From c66d5d324fda7e2eead13e910f0a4dfd35299d14 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Tue, 18 Aug 2026 18:49:47 +0530 Subject: [PATCH 01/14] feat: add scenario switcher foundations --- .../settings/domain/SettingsRepository.kt | 6 +- .../settings/domain/model/ScenarioSortItem.kt | 48 + .../settings/engine/SettingsRepositoryImpl.kt | 16 +- .../engine/data/SettingsDataSource.kt | 12 +- .../24.json | 857 ++++++++++++++++++ .../core/database/DatabaseInfo.kt | 2 +- .../core/database/dao/ScenarioDao.kt | 22 +- .../smartautoclicker/core/database/di/Hilt.kt | 4 +- .../database/entity/ScenarioStatsEntity.kt | 4 +- .../database/migrations/Migration23to24.kt | 50 + .../core/domain/data/ScenarioDataSource.kt | 32 +- .../domain/model/ScenarioSwitchResult.kt | 20 + .../list/FilteredScenarioListUseCase.kt | 26 +- .../settings/SettingsFragment.kt | 9 +- .../settings/SettingsViewModel.kt | 9 +- .../src/main/res/layout/fragment_settings.xml | 11 + .../src/main/res/values/strings.xml | 5 +- 17 files changed, 1095 insertions(+), 38 deletions(-) create mode 100644 core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItem.kt create mode 100644 core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json create mode 100644 core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt create mode 100644 core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/model/ScenarioSwitchResult.kt diff --git a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/SettingsRepository.kt b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/SettingsRepository.kt index 97df086e6..4c8a2fc63 100644 --- a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/SettingsRepository.kt +++ b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/SettingsRepository.kt @@ -37,6 +37,10 @@ interface SettingsRepository { val isFilterScenarioUiEnabledFlow: Flow fun toggleFilterScenarioUi() + val isScenarioSwitcherEnabledFlow: Flow + suspend fun isScenarioSwitcherEnabled(): Boolean + fun toggleScenarioSwitcher() + val isInputBlockWorkaroundEnabledFlow: Flow fun isInputBlockWorkaroundEnabled(): Boolean fun toggleInputBlockWorkaround() @@ -47,4 +51,4 @@ interface SettingsRepository { fun setScenarioSortOrder(invertSortOrder: Boolean) fun setScenarioSortShowDumb(show: Boolean) fun setScenarioSortShowSmart(show: Boolean) -} \ No newline at end of file +} diff --git a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItem.kt b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItem.kt new file mode 100644 index 000000000..d50fdb545 --- /dev/null +++ b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItem.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.settings.domain.model + +/** The values used by the scenario lists to apply the shared sort preference. */ +data class ScenarioSortItem( + val id: Long, + val name: String, + val lastStartTimestamp: Long, + val startCount: Long, +) + +/** + * Sorts any scenario-list representation using the same rules as the home screen. + * + * Ties are resolved by case-insensitive name and then database id so that a list does not + * jump around when two scenarios have the same primary sort value. + */ +fun Iterable.sortedByScenarioSortSettings( + settings: ScenarioSortSettings, + item: (T) -> ScenarioSortItem, +): List = sortedWith { left, right -> + val leftItem = item(left) + val rightItem = item(right) + + val primaryComparison = when (settings.type) { + ScenarioSortType.NAME -> compareValues(leftItem.name, rightItem.name) + ScenarioSortType.RECENT -> compareValues(leftItem.lastStartTimestamp, rightItem.lastStartTimestamp) + ScenarioSortType.MOST_USED -> compareValues(leftItem.startCount, rightItem.startCount) + } + + val orderedPrimaryComparison = when (settings.type) { + ScenarioSortType.NAME -> if (settings.inverted) -primaryComparison else primaryComparison + ScenarioSortType.RECENT, + ScenarioSortType.MOST_USED, + -> if (settings.inverted) primaryComparison else -primaryComparison + } + + orderedPrimaryComparison + .takeIf { it != 0 } + ?: compareValuesBy(leftItem, rightItem, { it.name.lowercase() }, { it.id }) +} diff --git a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/SettingsRepositoryImpl.kt b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/SettingsRepositoryImpl.kt index b674b653f..938ae34bb 100644 --- a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/SettingsRepositoryImpl.kt +++ b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/SettingsRepositoryImpl.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn @@ -62,6 +63,13 @@ internal class SettingsRepositoryImpl @Inject constructor( .stateIn(coroutineScope, SharingStarted.Eagerly, false) override val isFilterScenarioUiEnabledFlow: Flow = _isFilterScenarioUiEnabled + private val _isScenarioSwitcherEnabled: StateFlow = dataSource.isScenarioSwitcherEnabled() + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + override val isScenarioSwitcherEnabledFlow: Flow = _isScenarioSwitcherEnabled + + override suspend fun isScenarioSwitcherEnabled(): Boolean = + dataSource.isScenarioSwitcherEnabled().first() + private val _isInputBlockWorkaroundEnabledFlow: StateFlow = dataSource.isInputBlockWorkaroundEnabled() .stateIn(coroutineScope, SharingStarted.Eagerly, false) override val isInputBlockWorkaroundEnabledFlow: Flow = _isInputBlockWorkaroundEnabledFlow @@ -76,6 +84,12 @@ internal class SettingsRepositoryImpl @Inject constructor( } } + override fun toggleScenarioSwitcher() { + coroutineScope.launch { + dataSource.toggleScenarioSwitcher() + } + } + override fun isLegacyActionUiEnabled(): Boolean = _isLegacyActionUiEnabledFlow.value @@ -130,4 +144,4 @@ internal class SettingsRepositoryImpl @Inject constructor( override fun setScenarioSortShowSmart(show: Boolean) { coroutineScope.launch { scenarioSortSettingsDatasource.setShowSmart(show) } } -} \ No newline at end of file +} diff --git a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/data/SettingsDataSource.kt b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/data/SettingsDataSource.kt index 20e0b6a9b..29cc7164e 100644 --- a/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/data/SettingsDataSource.kt +++ b/core/common/settings/src/main/java/com/buzbuz/smartautoclicker/core/settings/engine/data/SettingsDataSource.kt @@ -45,6 +45,8 @@ internal class SettingsDataSource @Inject constructor( val KEY_IS_FILTER_SCENARIO_UI_ENABLED: Preferences.Key = booleanPreferencesKey("isFilterScenarioUiEnabled") + val KEY_IS_SCENARIO_SWITCHER_ENABLED: Preferences.Key = + booleanPreferencesKey("isScenarioSwitcherEnabled") val KEY_IS_LEGACY_ACTION_UI: Preferences.Key = booleanPreferencesKey("isLegacyActionUiEnabled") val KEY_IS_LEGACY_NOTIFICATION_UI: Preferences.Key = @@ -71,6 +73,14 @@ internal class SettingsDataSource @Inject constructor( preferences[KEY_IS_FILTER_SCENARIO_UI_ENABLED] = !(preferences[KEY_IS_FILTER_SCENARIO_UI_ENABLED] ?: true) } + internal fun isScenarioSwitcherEnabled(): Flow = + dataStore.data.map { preferences -> preferences[KEY_IS_SCENARIO_SWITCHER_ENABLED] ?: false } + + internal suspend fun toggleScenarioSwitcher() = + dataStore.edit { preferences -> + preferences[KEY_IS_SCENARIO_SWITCHER_ENABLED] = !(preferences[KEY_IS_SCENARIO_SWITCHER_ENABLED] ?: false) + } + internal fun isLegacyActionUiEnabled(): Flow = dataStore.data.map { preferences -> preferences[KEY_IS_LEGACY_ACTION_UI] ?: false } @@ -104,4 +114,4 @@ internal class SettingsDataSource @Inject constructor( preferences[KEY_INPUT_BLOCK_WORKAROUND] = !(preferences[KEY_INPUT_BLOCK_WORKAROUND] ?: false) } } -} \ No newline at end of file +} diff --git a/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json b/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json new file mode 100644 index 000000000..14d88ad56 --- /dev/null +++ b/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json @@ -0,0 +1,857 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "5e0f09c16babd60e1624c70d240b0380", + "entities": [ + { + "tableName": "action_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `clickPositionType` TEXT, `x` INTEGER, `y` INTEGER, `clickOnConditionId` INTEGER, `pressDuration` INTEGER, `clickOffsetX` INTEGER, `clickOffsetY` INTEGER, `fromX` INTEGER, `fromY` INTEGER, `toX` INTEGER, `toY` INTEGER, `swipeDuration` INTEGER, `pauseDuration` INTEGER, `isAdvanced` INTEGER, `isBroadcast` INTEGER, `intent_action` TEXT, `component_name` TEXT, `flags` INTEGER, `toggle_all` INTEGER, `toggle_all_type` TEXT, `counter_name` TEXT, `counter_operation` TEXT, `counter_operation_value_type` TEXT, `counter_operation_value` REAL, `counter_operation_counter_name` TEXT, `notification_message_text` TEXT, `notification_importance` INTEGER, `system_action_type` TEXT, `text_value` TEXT, `text_validate_input` INTEGER, FOREIGN KEY(`eventId`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`clickOnConditionId`) REFERENCES `condition_table`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clickPositionType", + "columnName": "clickPositionType", + "affinity": "TEXT" + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER" + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOnConditionId", + "columnName": "clickOnConditionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "pressDuration", + "columnName": "pressDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOffsetX", + "columnName": "clickOffsetX", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOffsetY", + "columnName": "clickOffsetY", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromX", + "columnName": "fromX", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromY", + "columnName": "fromY", + "affinity": "INTEGER" + }, + { + "fieldPath": "toX", + "columnName": "toX", + "affinity": "INTEGER" + }, + { + "fieldPath": "toY", + "columnName": "toY", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeDuration", + "columnName": "swipeDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "pauseDuration", + "columnName": "pauseDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAdvanced", + "columnName": "isAdvanced", + "affinity": "INTEGER" + }, + { + "fieldPath": "isBroadcast", + "columnName": "isBroadcast", + "affinity": "INTEGER" + }, + { + "fieldPath": "intentAction", + "columnName": "intent_action", + "affinity": "TEXT" + }, + { + "fieldPath": "componentName", + "columnName": "component_name", + "affinity": "TEXT" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER" + }, + { + "fieldPath": "toggleAll", + "columnName": "toggle_all", + "affinity": "INTEGER" + }, + { + "fieldPath": "toggleAllType", + "columnName": "toggle_all_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterName", + "columnName": "counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperation", + "columnName": "counter_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValueType", + "columnName": "counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValue", + "columnName": "counter_operation_value", + "affinity": "REAL" + }, + { + "fieldPath": "counterOperationCounterName", + "columnName": "counter_operation_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationMessageText", + "columnName": "notification_message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationImportance", + "columnName": "notification_importance", + "affinity": "INTEGER" + }, + { + "fieldPath": "systemActionType", + "columnName": "system_action_type", + "affinity": "TEXT" + }, + { + "fieldPath": "textValue", + "columnName": "text_value", + "affinity": "TEXT" + }, + { + "fieldPath": "textValidateInput", + "columnName": "text_validate_input", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_action_table_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_action_table_eventId` ON `${TABLE_NAME}` (`eventId`)" + }, + { + "name": "index_action_table_clickOnConditionId", + "unique": false, + "columnNames": [ + "clickOnConditionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_action_table_clickOnConditionId` ON `${TABLE_NAME}` (`clickOnConditionId`)" + } + ], + "foreignKeys": [ + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "condition_table", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "clickOnConditionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scenario_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `operator` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `enabled_on_start` INTEGER NOT NULL DEFAULT 1, `type` TEXT NOT NULL, `keep_detecting` INTEGER, `detecetion_cooldown_ms` INTEGER, FOREIGN KEY(`scenario_id`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenario_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conditionOperator", + "columnName": "operator", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledOnStart", + "columnName": "enabled_on_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keepDetecting", + "columnName": "keep_detecting", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionCooldownMs", + "columnName": "detecetion_cooldown_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_table_scenario_id", + "unique": false, + "columnNames": [ + "scenario_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_table_scenario_id` ON `${TABLE_NAME}` (`scenario_id`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenario_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scenario_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `detection_quality` INTEGER NOT NULL, `compute_rate` REAL NOT NULL DEFAULT 0.0, `randomize` INTEGER NOT NULL DEFAULT 0, `keep_screen_on` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detectionQuality", + "columnName": "detection_quality", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "computeRate", + "columnName": "compute_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "randomize", + "columnName": "randomize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepScreenOn", + "columnName": "keep_screen_on", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "condition_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `priority` INTEGER NOT NULL DEFAULT 0, `shouldBeDetected` INTEGER, `path` TEXT, `area_left` INTEGER, `area_top` INTEGER, `area_right` INTEGER, `area_bottom` INTEGER, `threshold` INTEGER, `detection_type` INTEGER, `detection_area_left` INTEGER, `detection_area_top` INTEGER, `detection_area_right` INTEGER, `detection_area_bottom` INTEGER, `broadcast_action` TEXT, `counter_name` TEXT, `counter_comparison_operation` TEXT, `counter_operation_value_type` TEXT, `counter_value` REAL, `counter_value_counter_name` TEXT, `timer_value_ms` INTEGER, `timer_restart_when_reached` INTEGER, `color_rgba` INTEGER, `number_counter_comparison_operation` TEXT, `number_counter_operation_value_type` TEXT, `number_counter_value` REAL, `number_counter_value_counter_name` TEXT, `number_format_type` TEXT, `text_to_detect` TEXT, `text_alphabet` TEXT, FOREIGN KEY(`eventId`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shouldBeDetected", + "columnName": "shouldBeDetected", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "areaLeft", + "columnName": "area_left", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaTop", + "columnName": "area_top", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaRight", + "columnName": "area_right", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaBottom", + "columnName": "area_bottom", + "affinity": "INTEGER" + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionType", + "columnName": "detection_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaLeft", + "columnName": "detection_area_left", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaTop", + "columnName": "detection_area_top", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaRight", + "columnName": "detection_area_right", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaBottom", + "columnName": "detection_area_bottom", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastAction", + "columnName": "broadcast_action", + "affinity": "TEXT" + }, + { + "fieldPath": "counterName", + "columnName": "counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "counterComparisonOperation", + "columnName": "counter_comparison_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValueType", + "columnName": "counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterValue", + "columnName": "counter_value", + "affinity": "REAL" + }, + { + "fieldPath": "counterOperationCounterName", + "columnName": "counter_value_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "timerValueMs", + "columnName": "timer_value_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "restartWhenReached", + "columnName": "timer_restart_when_reached", + "affinity": "INTEGER" + }, + { + "fieldPath": "colorRgba", + "columnName": "color_rgba", + "affinity": "INTEGER" + }, + { + "fieldPath": "numberCounterComparisonOperation", + "columnName": "number_counter_comparison_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "numberCounterOperationValueType", + "columnName": "number_counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "numberCounterValue", + "columnName": "number_counter_value", + "affinity": "REAL" + }, + { + "fieldPath": "numberCounterOperationCounterName", + "columnName": "number_counter_value_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "numberFormatType", + "columnName": "number_format_type", + "affinity": "TEXT" + }, + { + "fieldPath": "textToDetect", + "columnName": "text_to_detect", + "affinity": "TEXT" + }, + { + "fieldPath": "textAlphabet", + "columnName": "text_alphabet", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_condition_table_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_condition_table_eventId` ON `${TABLE_NAME}` (`eventId`)" + } + ], + "foreignKeys": [ + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "intent_extra_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `action_id` INTEGER NOT NULL, `type` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`action_id`) REFERENCES `action_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionId", + "columnName": "action_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_intent_extra_table_action_id", + "unique": false, + "columnNames": [ + "action_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_intent_extra_table_action_id` ON `${TABLE_NAME}` (`action_id`)" + } + ], + "foreignKeys": [ + { + "table": "action_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "action_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_toggle_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `action_id` INTEGER NOT NULL, `toggle_type` TEXT NOT NULL, `toggle_event_id` INTEGER NOT NULL, FOREIGN KEY(`action_id`) REFERENCES `action_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`toggle_event_id`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionId", + "columnName": "action_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "toggle_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toggleEventId", + "columnName": "toggle_event_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_toggle_table_action_id", + "unique": false, + "columnNames": [ + "action_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_toggle_table_action_id` ON `${TABLE_NAME}` (`action_id`)" + }, + { + "name": "index_event_toggle_table_toggle_event_id", + "unique": false, + "columnNames": [ + "toggle_event_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_toggle_table_toggle_event_id` ON `${TABLE_NAME}` (`toggle_event_id`)" + } + ], + "foreignKeys": [ + { + "table": "action_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "action_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "toggle_event_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scenario_usage_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scenario_id` INTEGER NOT NULL, `last_start_timestamp_ms` INTEGER NOT NULL, `start_count` INTEGER NOT NULL, FOREIGN KEY(`scenario_id`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenario_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStartTimestampMs", + "columnName": "last_start_timestamp_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startCount", + "columnName": "start_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scenario_usage_table_scenario_id", + "unique": true, + "columnNames": [ + "scenario_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scenario_usage_table_scenario_id` ON `${TABLE_NAME}` (`scenario_id`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenario_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "counters_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`counterName` TEXT NOT NULL, `scenarioId` INTEGER NOT NULL, `startingValue` REAL NOT NULL, PRIMARY KEY(`counterName`, `scenarioId`), FOREIGN KEY(`scenarioId`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "counterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenarioId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startingValue", + "columnName": "startingValue", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "counterName", + "scenarioId" + ] + }, + "indices": [ + { + "name": "index_counters_table_scenarioId", + "unique": false, + "columnNames": [ + "scenarioId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_counters_table_scenarioId` ON `${TABLE_NAME}` (`scenarioId`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenarioId" + ], + "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, '5e0f09c16babd60e1624c70d240b0380')" + ] + } +} \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt index 229562b08..26ad0da78 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt @@ -40,4 +40,4 @@ internal const val COUNTERS_TABLE = "counters_table" internal const val END_CONDITION_TABLE = "end_condition_table" /** Current version of the database. */ -const val DATABASE_VERSION = 23 \ No newline at end of file +const val DATABASE_VERSION = 24 diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ScenarioDao.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ScenarioDao.kt index 5d7af591c..798c97966 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ScenarioDao.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ScenarioDao.kt @@ -103,8 +103,24 @@ interface ScenarioDao { * * @return the scenario stats. */ - @Query("SELECT * FROM $SCENARIO_USAGE_TABLE WHERE scenario_id=:scenarioId") - suspend fun getScenarioStats(scenarioId: Long): ScenarioStatsEntity? + @Query("SELECT * FROM $SCENARIO_USAGE_TABLE WHERE scenario_id=:scenarioId ORDER BY id ASC") + suspend fun getScenarioStats(scenarioId: Long): List + + /** + * Increment the usage statistics for a scenario without a read-modify-write race. + * + * @return the number of statistics rows updated. + */ + @Query( + "UPDATE $SCENARIO_USAGE_TABLE " + + "SET last_start_timestamp_ms = :timestampMs, start_count = start_count + 1 " + + "WHERE scenario_id = :scenarioId" + ) + suspend fun incrementScenarioStats(scenarioId: Long, timestampMs: Long): Int + + /** Remove duplicate statistics rows after their totals have been merged into the first row. */ + @Query("DELETE FROM $SCENARIO_USAGE_TABLE WHERE id IN (:ids)") + suspend fun deleteScenarioStats(ids: List) /** * Add the stats for a scenario. @@ -121,4 +137,4 @@ interface ScenarioDao { */ @Update suspend fun updateScenarioStats(stats: ScenarioStatsEntity) -} \ No newline at end of file +} diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/di/Hilt.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/di/Hilt.kt index 0ac0a6baf..47892d7e7 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/di/Hilt.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/di/Hilt.kt @@ -24,6 +24,7 @@ import com.buzbuz.smartautoclicker.core.database.migrations.Migration10to11 import com.buzbuz.smartautoclicker.core.database.migrations.Migration12to13 import com.buzbuz.smartautoclicker.core.database.migrations.Migration19to20 import com.buzbuz.smartautoclicker.core.database.migrations.Migration21to22 +import com.buzbuz.smartautoclicker.core.database.migrations.Migration23to24 import com.buzbuz.smartautoclicker.core.database.migrations.Migration1to2 import com.buzbuz.smartautoclicker.core.database.migrations.Migration2to3 import com.buzbuz.smartautoclicker.core.database.migrations.Migration3to4 @@ -63,6 +64,7 @@ internal object SmartDatabaseModule { Migration12to13, Migration19to20, Migration21to22, + Migration23to24, ).build() -} \ No newline at end of file +} diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ScenarioStatsEntity.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ScenarioStatsEntity.kt index eb9e38c6c..e4798656d 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ScenarioStatsEntity.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ScenarioStatsEntity.kt @@ -37,7 +37,7 @@ import com.buzbuz.smartautoclicker.core.database.SCENARIO_USAGE_TABLE */ @Entity( tableName = SCENARIO_USAGE_TABLE, - indices = [Index("scenario_id")], + indices = [Index(value = ["scenario_id"], unique = true)], foreignKeys = [ForeignKey( entity = ScenarioEntity::class, parentColumns = ["id"], @@ -50,4 +50,4 @@ data class ScenarioStatsEntity( @ColumnInfo(name = "scenario_id") val scenarioId: Long, @ColumnInfo(name = "last_start_timestamp_ms") val lastStartTimestampMs: Long, @ColumnInfo(name = "start_count") val startCount: Long, -) : EntityWithId \ No newline at end of file +) : EntityWithId diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt new file mode 100644 index 000000000..e10819f5d --- /dev/null +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +import com.buzbuz.smartautoclicker.core.database.SCENARIO_USAGE_TABLE + +/** + * Migration from database v23 to v24. + * + * Scenario usage is a one-to-one relation. Older versions only indexed the scenario id, which allowed duplicate + * rows to be created by racing usage updates. Merge those rows deterministically, then enforce the relation in the + * schema so future starts and scenario switches have one authoritative statistic record. + */ +object Migration23to24 : Migration(23, 24) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "UPDATE `$SCENARIO_USAGE_TABLE` " + + "SET `start_count` = (" + + "SELECT SUM(`start_count`) FROM `$SCENARIO_USAGE_TABLE` AS duplicate " + + "WHERE duplicate.`scenario_id` = `$SCENARIO_USAGE_TABLE`.`scenario_id`" + + "), " + + "`last_start_timestamp_ms` = (" + + "SELECT MAX(`last_start_timestamp_ms`) FROM `$SCENARIO_USAGE_TABLE` AS duplicate " + + "WHERE duplicate.`scenario_id` = `$SCENARIO_USAGE_TABLE`.`scenario_id`" + + ") " + + "WHERE `id` IN (" + + "SELECT MIN(`id`) FROM `$SCENARIO_USAGE_TABLE` GROUP BY `scenario_id`" + + ")" + ) + db.execSQL( + "DELETE FROM `$SCENARIO_USAGE_TABLE` " + + "WHERE `id` NOT IN (SELECT MIN(`id`) FROM `$SCENARIO_USAGE_TABLE` GROUP BY `scenario_id`)" + ) + db.execSQL("DROP INDEX IF EXISTS `index_${SCENARIO_USAGE_TABLE}_scenario_id`") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_${SCENARIO_USAGE_TABLE}_scenario_id` " + + "ON `$SCENARIO_USAGE_TABLE` (`scenario_id`)" + ) + } +} diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSource.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSource.kt index dc43ac63e..588367e1c 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSource.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSource.kt @@ -232,24 +232,32 @@ internal class ScenarioDataSource @Inject constructor( } suspend fun markAsUsed(scenarioDbId: Long) { - database.scenarioDao().let { scenarioDao -> - val previousStats = scenarioDao.getScenarioStats(scenarioDbId) - if (previousStats != null) { - scenarioDao.updateScenarioStats( - previousStats.copy( - lastStartTimestampMs = System.currentTimeMillis(), - startCount = previousStats.startCount + 1, - ) - ) - } else { + val timestampMs = System.currentTimeMillis() + database.withTransaction { + val scenarioDao = database.scenarioDao() + val statistics = scenarioDao.getScenarioStats(scenarioDbId) + if (statistics.isEmpty()) { scenarioDao.addScenarioStats( ScenarioStatsEntity( id = DATABASE_ID_INSERTION, scenarioId = scenarioDbId, - lastStartTimestampMs = System.currentTimeMillis(), + lastStartTimestampMs = timestampMs, startCount = 1, ) ) + } else if (statistics.size > 1) { + val primary = statistics.first() + scenarioDao.updateScenarioStats( + primary.copy( + lastStartTimestampMs = timestampMs, + startCount = statistics.sumOf { it.startCount } + 1, + ) + ) + scenarioDao.deleteScenarioStats(statistics.drop(1).map { it.id }) + } else { + check(scenarioDao.incrementScenarioStats(scenarioDbId, timestampMs) == 1) { + "Scenario $scenarioDbId was deleted before its usage could be recorded" + } } } } @@ -508,4 +516,4 @@ internal class ScenarioDataSource @Inject constructor( } /** Tag for logs. */ -private const val TAG = "ScenarioDataSource" \ No newline at end of file +private const val TAG = "ScenarioDataSource" diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/model/ScenarioSwitchResult.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/model/ScenarioSwitchResult.kt new file mode 100644 index 000000000..f135a3800 --- /dev/null +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/model/ScenarioSwitchResult.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.processing.domain.model + +/** Result of attempting to change the Smart scenario loaded by the paused processor. */ +sealed interface ScenarioSwitchResult { + data object Success : ScenarioSwitchResult + data object ServiceUnavailable : ScenarioSwitchResult + data object InvalidProcessingState : ScenarioSwitchResult + data object ProjectionUnavailable : ScenarioSwitchResult + data object CurrentScenario : ScenarioSwitchResult + data object ScenarioUnavailable : ScenarioSwitchResult + data object PersistenceFailure : ScenarioSwitchResult +} diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/FilteredScenarioListUseCase.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/FilteredScenarioListUseCase.kt index 3e753c363..f6ab9bdcb 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/FilteredScenarioListUseCase.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/FilteredScenarioListUseCase.kt @@ -25,8 +25,9 @@ import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbAction import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario import com.buzbuz.smartautoclicker.core.dumb.domain.model.Repeatable import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository +import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortItem import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortSettings -import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortType +import com.buzbuz.smartautoclicker.core.settings.domain.model.sortedByScenarioSortSettings import com.buzbuz.smartautoclicker.core.ui.utils.formatDuration import com.buzbuz.smartautoclicker.scenarios.list.model.ScenarioListUiState @@ -151,17 +152,16 @@ private fun Collection.sortAndFilter( (sortConfig.showDumbScenario && item.scenario is DumbScenario) } - return when (sortConfig.type) { - ScenarioSortType.NAME -> - if (sortConfig.inverted) filteredList.sortedByDescending { it.displayName } - else filteredList.sortedBy { it.displayName } - - ScenarioSortType.RECENT -> - if (sortConfig.inverted) filteredList.sortedBy { it.lastStartTimestamp } - else filteredList.sortedByDescending { it.lastStartTimestamp } - - ScenarioSortType.MOST_USED -> - if (sortConfig.inverted) filteredList.sortedBy { it.startCount } - else filteredList.sortedByDescending { it.startCount } + return filteredList.sortedByScenarioSortSettings(sortConfig) { scenario -> + ScenarioSortItem( + id = when (val sourceScenario = scenario.scenario) { + is Scenario -> sourceScenario.id.databaseId + is DumbScenario -> sourceScenario.id.databaseId + else -> error("Unsupported scenario type: ${sourceScenario::class}") + }, + name = scenario.displayName, + lastStartTimestamp = scenario.lastStartTimestamp, + startCount = scenario.startCount, + ) } } diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt index 982d87bc4..84c815466 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt @@ -57,6 +57,12 @@ class SettingsFragment : Fragment() { setOnClickListener(viewModel::toggleScenarioFiltersUi) } + viewBinding.fieldScenarioSwitcher.apply { + setTitle(requireContext().getString(R.string.field_scenario_switcher_title)) + setDescription(requireContext().getString(R.string.field_scenario_switcher_desc)) + setOnClickListener(viewModel::toggleScenarioSwitcher) + } + viewBinding.fieldLegacyActionsUi.apply { setTitle(requireContext().getString(R.string.field_legacy_action_ui_title)) setDescription(requireContext().getString(R.string.field_legacy_action_ui_desc)) @@ -99,6 +105,7 @@ class SettingsFragment : Fragment() { lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.isScenarioFiltersUiEnabled.collect(viewBinding.fieldShowScenarioFilters::setChecked) } + launch { viewModel.isScenarioSwitcherEnabled.collect(viewBinding.fieldScenarioSwitcher::setChecked) } launch { viewModel.isLegacyActionUiEnabled.collect(viewBinding.fieldLegacyActionsUi::setChecked) } launch { viewModel.isLegacyNotificationUiEnabled.collect(viewBinding.fieldLegacyNotificationUi::setChecked) } launch { viewModel.isEntireScreenCaptureForced.collect(viewBinding.fieldForceEntireScreen::setChecked) } @@ -150,4 +157,4 @@ class SettingsFragment : Fragment() { viewBinding.fieldRemoveAds.root.visibility = View.GONE } } -} \ No newline at end of file +} diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt index 546c9488f..84a2e6cb5 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt @@ -43,6 +43,9 @@ class SettingsViewModel @Inject constructor( val isScenarioFiltersUiEnabled: Flow = settingsRepository.isFilterScenarioUiEnabledFlow + val isScenarioSwitcherEnabled: Flow = + settingsRepository.isScenarioSwitcherEnabledFlow + val isLegacyActionUiEnabled: Flow = settingsRepository.isLegacyActionUiEnabledFlow @@ -74,6 +77,10 @@ class SettingsViewModel @Inject constructor( settingsRepository.toggleFilterScenarioUi() } + fun toggleScenarioSwitcher() { + settingsRepository.toggleScenarioSwitcher() + } + fun toggleLegacyActionUi() { settingsRepository.toggleLegacyActionUi() } @@ -101,4 +108,4 @@ class SettingsViewModel @Inject constructor( fun showTroubleshootingDialog(activity: FragmentActivity) { qualityRepository.startTroubleshootingUiFlow(activity) } -} \ No newline at end of file +} diff --git a/smartautoclicker/src/main/res/layout/fragment_settings.xml b/smartautoclicker/src/main/res/layout/fragment_settings.xml index cefd6620e..373b5b7b6 100644 --- a/smartautoclicker/src/main/res/layout/fragment_settings.xml +++ b/smartautoclicker/src/main/res/layout/fragment_settings.xml @@ -40,6 +40,17 @@ android:layout_width="match_parent" android:layout_height="1dp"/> + + + + Show Scenarios filters Display or hide the filters in the scenario list screen. + Show scenario switcher + Shows the scenario switcher in the floating toolbar for quick swaps. + Legacy Action UI Change the action display by a list. Useful for complex scenarios. @@ -153,4 +156,4 @@ Import Export - \ No newline at end of file + From d82e81c48ca9b09701585f4b7b04a5bc504ef7a1 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Tue, 18 Aug 2026 18:49:57 +0530 Subject: [PATCH 02/14] feat: add responsive scenario switcher dialog --- .../feature/smart/config/di/Hilt.kt | 4 +- .../switcher/ScenarioSwitchAdapter.kt | 80 ++++++++ .../scenario/switcher/ScenarioSwitchDialog.kt | 182 ++++++++++++++++++ .../switcher/ScenarioSwitchViewModel.kt | 78 ++++++++ .../src/main/res/drawable/ic_smart.xml | 24 +++ .../res/layout/dialog_scenario_switch.xml | 65 +++++++ .../main/res/layout/item_scenario_switch.xml | 53 +++++ .../src/main/res/values/strings.xml | 18 +- 8 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchAdapter.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchDialog.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModel.kt create mode 100644 feature/smart-config/src/main/res/drawable/ic_smart.xml create mode 100644 feature/smart-config/src/main/res/layout/dialog_scenario_switch.xml create mode 100644 feature/smart-config/src/main/res/layout/item_scenario_switch.xml diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt index aa20a6aad..0967de6e7 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt @@ -63,6 +63,7 @@ import com.buzbuz.smartautoclicker.feature.smart.config.ui.counter.selection.Cou import com.buzbuz.smartautoclicker.feature.smart.config.ui.event.EventDialogViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.mainmenu.debugging.LiveDebuggingViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.ScenarioDialogViewModel +import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher.ScenarioSwitchViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.config.ScenarioConfigViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.imageevents.ImageEventListViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.more.MoreViewModel @@ -113,6 +114,7 @@ interface ScenarioConfigViewModelsEntryPoint { fun pauseViewModel(): PauseViewModel fun scenarioConfigViewModel(): ScenarioConfigViewModel fun scenarioDialogViewModel(): ScenarioDialogViewModel + fun scenarioSwitchViewModel(): ScenarioSwitchViewModel fun screenConditionSelectionViewModel(): ScreenConditionSelectionViewModel fun screenConditionTypeSelectionViewModel(): ScreenConditionTypeSelectionViewModel fun setTextViewModel(): SetTextViewModel @@ -125,4 +127,4 @@ interface ScenarioConfigViewModelsEntryPoint { fun triggerConditionTypeSelectionViewModel(): TriggerConditionTypeSelectionViewModel fun triggerConditionsViewModel(): TriggerConditionListViewModel fun triggerEventListViewModel(): TriggerEventListViewModel -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchAdapter.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchAdapter.kt new file mode 100644 index 000000000..1f8502483 --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchAdapter.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher + +import android.view.LayoutInflater +import android.view.ViewGroup + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.core.view.isVisible + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.feature.smart.config.R +import com.buzbuz.smartautoclicker.feature.smart.config.databinding.ItemScenarioSwitchBinding + +class ScenarioSwitchAdapter( + private val onScenarioClicked: (Scenario) -> Unit, +) : ListAdapter(DIFF_CALLBACK) { + + private var isSelectionEnabled = true + private var switchingScenarioId: Identifier? = null + + fun setSelectionEnabled(enabled: Boolean) { + if (isSelectionEnabled == enabled) return + isSelectionEnabled = enabled + notifyDataSetChanged() + } + + fun setSwitchingScenario(scenarioId: Identifier?) { + if (switchingScenarioId == scenarioId) return + switchingScenarioId = scenarioId + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder( + ItemScenarioSwitchBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ViewHolder( + private val binding: ItemScenarioSwitchBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(scenario: Scenario) { + binding.scenarioName.text = scenario.name + binding.root.isEnabled = isSelectionEnabled + binding.root.alpha = if (isSelectionEnabled) 1f else 0.6f + binding.progressSwitching.isVisible = scenario.id == switchingScenarioId + binding.progressSwitching.contentDescription = binding.root.context.getString( + R.string.scenario_switcher_switching, + scenario.name, + ) + binding.root.contentDescription = scenario.name + binding.root.setOnClickListener { + if (isSelectionEnabled) onScenarioClicked(scenario) + } + } + } + + private companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Scenario, newItem: Scenario): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Scenario, newItem: Scenario): Boolean = + oldItem == newItem + } + } +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchDialog.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchDialog.kt new file mode 100644 index 000000000..a6e9227e5 --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchDialog.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher + +import android.content.res.Configuration +import android.text.TextUtils +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView + +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager + +import com.buzbuz.smartautoclicker.core.common.overlays.base.viewModels +import com.buzbuz.smartautoclicker.core.common.overlays.dialog.OverlayDialog +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.processing.domain.model.ScenarioSwitchResult +import com.buzbuz.smartautoclicker.feature.smart.config.R +import com.buzbuz.smartautoclicker.feature.smart.config.databinding.DialogScenarioSwitchBinding +import com.buzbuz.smartautoclicker.feature.smart.config.di.ScenarioConfigViewModelsEntryPoint +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.snackbar.Snackbar + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +class ScenarioSwitchDialog( + private val onScenarioSelected: suspend (Scenario) -> ScenarioSwitchResult, +) : OverlayDialog(R.style.ScenarioConfigTheme) { + + private val viewModel: ScenarioSwitchViewModel by viewModels( + entryPoint = ScenarioConfigViewModelsEntryPoint::class.java, + creator = { scenarioSwitchViewModel() }, + ) + + private lateinit var binding: DialogScenarioSwitchBinding + private lateinit var adapter: ScenarioSwitchAdapter + private var isSwitching = false + private var failedScenario: Scenario? = null + + override fun onCreateView(): ViewGroup { + binding = DialogScenarioSwitchBinding.inflate(LayoutInflater.from(context)).apply { + toolbar.setNavigationOnClickListener { debounceUserInteraction { if (!isSwitching) back() } } + } + + adapter = ScenarioSwitchAdapter(::onScenarioClicked) + binding.list.adapter = adapter + if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + binding.list.layoutManager = GridLayoutManager(context, 2) + } + return binding.root + } + + override fun onDialogCreated(dialog: com.google.android.material.bottomsheet.BottomSheetDialog) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::updateUi) + } + } + } + + override fun back() { + if (!isSwitching) super.back() + } + + override fun onStop() { + // OverlayDialog recreates this object during rotation. A cancelled selection must not leave the recreated + // picker permanently disabled or showing a spinner for a job that no longer exists. + isSwitching = false + failedScenario = null + super.onStop() + } + + private fun updateUi(state: ScenarioSwitchUiState) { + binding.toolbar.setAutoSizedSubtitle( + context.getString( + R.string.scenario_switcher_current, + state.currentScenario?.name ?: context.getString(R.string.scenario_switcher_current_unknown), + ), + ) + + adapter.submitList(state.alternatives) + adapter.setSwitchingScenario(if (isSwitching) failedScenario?.id else null) + adapter.setSelectionEnabled(!state.isLoading && !isSwitching && state.isPaused && state.currentScenario != null) + binding.progressLoading.isVisible = state.isLoading + binding.emptyMessage.isVisible = !state.isLoading && state.alternatives.isEmpty() + binding.list.isVisible = !state.isLoading && state.alternatives.isNotEmpty() + binding.list.isEnabled = !isSwitching && state.isPaused + } + + private fun onScenarioClicked(scenario: Scenario) { + if (isSwitching) return + val state = viewModel.uiState.value + if (!state.isPaused || state.currentScenario == null) { + showError(R.string.scenario_switcher_error_paused) + return + } + if (state.alternatives.none { it.id == scenario.id }) { + showError(R.string.scenario_switcher_error_unavailable) + return + } + startSwitch(scenario) + } + + private fun startSwitch(scenario: Scenario) { + isSwitching = true + failedScenario = scenario + updateUi(viewModel.uiState.value) + + lifecycleScope.launch { + try { + when (onScenarioSelected(scenario)) { + ScenarioSwitchResult.Success -> dismissAfterSuccessfulSwitch() + ScenarioSwitchResult.ServiceUnavailable -> showError(R.string.scenario_switcher_error_service) + ScenarioSwitchResult.InvalidProcessingState -> showError(R.string.scenario_switcher_error_paused) + ScenarioSwitchResult.ProjectionUnavailable -> showError(R.string.scenario_switcher_error_projection) + ScenarioSwitchResult.CurrentScenario -> showError(R.string.scenario_switcher_error_current) + ScenarioSwitchResult.ScenarioUnavailable -> showError(R.string.scenario_switcher_error_unavailable) + ScenarioSwitchResult.PersistenceFailure -> showError(R.string.scenario_switcher_error_persistence) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + showError(R.string.scenario_switcher_error_unknown) + } + } + } + + private fun showError(messageRes: Int) { + isSwitching = false + updateUi(viewModel.uiState.value) + + Snackbar.make(binding.root, messageRes, Snackbar.LENGTH_LONG).apply { + failedScenario + ?.let { failed -> viewModel.uiState.value.alternatives.firstOrNull { it.id == failed.id } } + ?.let { availableScenario -> + setAction(R.string.scenario_switcher_retry) { startSwitch(availableScenario) } + } + }.show() + } + + private fun dismissAfterSuccessfulSwitch() { + super.back() + } + + private fun MaterialToolbar.setAutoSizedSubtitle(text: CharSequence) { + subtitle = text + children + .filterIsInstance() + .firstOrNull { it.text == text } + ?.apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + this, + SUBTITLE_MIN_TEXT_SIZE_SP, + SUBTITLE_MAX_TEXT_SIZE_SP, + SUBTITLE_TEXT_SIZE_STEP_SP, + TypedValue.COMPLEX_UNIT_SP, + ) + } + } + + private companion object { + const val SUBTITLE_MIN_TEXT_SIZE_SP = 12 + const val SUBTITLE_MAX_TEXT_SIZE_SP = 14 + const val SUBTITLE_TEXT_SIZE_STEP_SP = 1 + } +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModel.kt new file mode 100644 index 000000000..8cd9dc15d --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModel.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope + +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository +import com.buzbuz.smartautoclicker.core.processing.domain.model.DetectionState +import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository +import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortItem +import com.buzbuz.smartautoclicker.core.settings.domain.model.sortedByScenarioSortSettings + +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +import javax.inject.Inject + +data class ScenarioSwitchUiState( + val currentScenario: Scenario?, + val alternatives: List, + val isPaused: Boolean, + val isLoading: Boolean, +) + +class ScenarioSwitchViewModel @Inject constructor( + smartRepository: IRepository, + smartProcessingRepository: SmartProcessingRepository, + settingsRepository: SettingsRepository, +) : ViewModel() { + + val uiState: StateFlow = combine( + smartRepository.scenarios, + smartProcessingRepository.scenarioId, + smartProcessingRepository.detectionState, + settingsRepository.scenarioSortSettings, + ) { scenarios, currentScenarioId, detectionState, sortSettings -> + val currentScenario = scenarios.firstOrNull { it.id == currentScenarioId } + val alternatives = currentScenario?.let { + scenarios + .filterNot { scenario -> scenario.id == currentScenarioId } + .sortedByScenarioSortSettings(sortSettings) { scenario -> + ScenarioSortItem( + id = scenario.id.databaseId, + name = scenario.name, + lastStartTimestamp = scenario.stats?.lastStartTimestampMs ?: 0L, + startCount = scenario.stats?.startCount ?: 0L, + ) + } + } ?: emptyList() + + ScenarioSwitchUiState( + currentScenario = currentScenario, + alternatives = alternatives, + isPaused = currentScenario != null && detectionState == DetectionState.RECORDING, + isLoading = false, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = ScenarioSwitchUiState( + currentScenario = null, + alternatives = emptyList(), + isPaused = false, + isLoading = true, + ), + ) +} diff --git a/feature/smart-config/src/main/res/drawable/ic_smart.xml b/feature/smart-config/src/main/res/drawable/ic_smart.xml new file mode 100644 index 000000000..899ca1f04 --- /dev/null +++ b/feature/smart-config/src/main/res/drawable/ic_smart.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/feature/smart-config/src/main/res/layout/dialog_scenario_switch.xml b/feature/smart-config/src/main/res/layout/dialog_scenario_switch.xml new file mode 100644 index 000000000..d9687842a --- /dev/null +++ b/feature/smart-config/src/main/res/layout/dialog_scenario_switch.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/feature/smart-config/src/main/res/layout/item_scenario_switch.xml b/feature/smart-config/src/main/res/layout/item_scenario_switch.xml new file mode 100644 index 000000000..bd73f496e --- /dev/null +++ b/feature/smart-config/src/main/res/layout/item_scenario_switch.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/feature/smart-config/src/main/res/values/strings.xml b/feature/smart-config/src/main/res/values/strings.xml index 365b16da0..c6d1e9df3 100644 --- a/feature/smart-config/src/main/res/values/strings.xml +++ b/feature/smart-config/src/main/res/values/strings.xml @@ -565,4 +565,20 @@ Open the event list The icon of the application to start. - \ No newline at end of file + Switch scenario + Close scenario switcher + Switch scenario + Current: %1$s + Unknown + No other Smart scenarios available + The scenario service is no longer available. + Pause the scenario before switching. + Screen recording is unavailable. Restart it before switching. + This scenario is already loaded. + That scenario is no longer available. + The scenario could not be saved. Try again. + The scenario could not be switched. Try again. + Retry + Switching to %1$s + + From af28b202eafb1a245f06bd06155d7ae85c241211 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Tue, 18 Aug 2026 18:50:11 +0530 Subject: [PATCH 03/14] feat: integrate pause-safe scenario switching --- .../src/main/res/drawable/ic_swap_horiz.xml | 10 ++ .../domain/SmartProcessingRepository.kt | 10 +- .../domain/SmartProcessingRepositoryImpl.kt | 13 +- .../ServiceNotificationController.kt | 15 +- .../ServiceNotificationListener.kt | 4 +- .../model/ServiceNotificationAction.kt | 11 +- .../ui/CustomLayoutNotificationBuilder.kt | 3 +- .../ui/LegacyNotificationBuilder.kt | 1 + .../drawable/ic_notification_swap_horiz.xml | 10 ++ .../res/layout/notification_service_big.xml | 7 +- .../src/main/res/values/strings.xml | 3 +- .../smart/config/ui/mainmenu/MainMenu.kt | 39 +++++- .../smart/config/ui/mainmenu/MainMenuModel.kt | 12 +- .../src/main/res/layout/overlay_menu.xml | 10 +- .../SmartAutoClickerService.kt | 10 +- .../localservice/LocalService.kt | 128 ++++++++++++++++-- .../localservice/SmartScenarioSwitcher.kt | 112 +++++++++++++++ 17 files changed, 369 insertions(+), 29 deletions(-) create mode 100644 core/common/ui/src/main/res/drawable/ic_swap_horiz.xml create mode 100644 feature/notifications/src/main/res/drawable/ic_notification_swap_horiz.xml create mode 100644 smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcher.kt diff --git a/core/common/ui/src/main/res/drawable/ic_swap_horiz.xml b/core/common/ui/src/main/res/drawable/ic_swap_horiz.xml new file mode 100644 index 000000000..6097ff5c8 --- /dev/null +++ b/core/common/ui/src/main/res/drawable/ic_swap_horiz.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt index bbea1ae76..d66a96c4e 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt @@ -60,6 +60,14 @@ interface SmartProcessingRepository : Dumpable { */ fun setScenarioId(identifier: Identifier, markAsUsed: Boolean = false) + /** + * Persist usage and set the current scenario as one operation. + * + * This is used for scenario changes where reporting success before the usage count has been saved would be + * misleading to the user. + */ + suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier) + /** * Set the callback upon Android Media Projection errors. * @@ -127,4 +135,4 @@ interface SmartProcessingRepository : Dumpable { * @param action the action to be tested. */ suspend fun tryAction(context: Context, scenario: Scenario, action: Action) -} \ No newline at end of file +} diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt index a2ad48cee..ba9494dc3 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -63,6 +64,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.PrintWriter import javax.inject.Inject @@ -138,6 +140,15 @@ internal class SmartProcessingRepositoryImpl @Inject constructor( } } + override suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier): Unit = withContext(NonCancellable) { + // Persist before exposing the new scenario as loaded. This makes a successful switch mean that its usage + // count and last-used timestamp have already been accepted by the database. Once that transaction begins, + // complete the in-memory part too: cancelling between the two would otherwise record a use for a scenario + // that was never made current. + scenarioRepository.markAsUsed(identifier) + _scenarioId.value = identifier + } + override fun setProjectionErrorHandler(handler: () -> Unit) { projectionErrorHandler = handler } @@ -259,4 +270,4 @@ internal class SmartProcessingRepositoryImpl @Inject constructor( private const val TAG = "SmartProcessingRepository" /** The minimum detection quality for the algorithm. */ -const val DETECTION_QUALITY_MIN = com.buzbuz.smartautoclicker.core.detection.DETECTION_QUALITY_MIN \ No newline at end of file +const val DETECTION_QUALITY_MIN = com.buzbuz.smartautoclicker.core.detection.DETECTION_QUALITY_MIN diff --git a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationController.kt b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationController.kt index c07030929..e25c813f7 100644 --- a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationController.kt +++ b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationController.kt @@ -94,6 +94,15 @@ class ServiceNotificationController( ) } + fun updateScenarioName(context: Context, scenarioName: String) { + val state = notificationState ?: return + + updateNotificationState( + context, + state.copy(scenarioName = scenarioName), + ) + } + private fun updateNotification(context: Context, isNightModeEnabled: Boolean) { val state = notificationState ?: return @@ -105,12 +114,14 @@ class ServiceNotificationController( @SuppressLint("MissingPermission") private fun updateNotificationState(context: Context, state: ServiceNotificationState) { + // Keep the model current even when Android currently suppresses notification delivery. If permission is later + // restored, the next regular update must use the scenario name and state that were last selected. + notificationState = state val builder = notificationBuilder ?: return if (!PermissionPostNotification().checkIfGranted(context)) return Log.i(TAG, "Updating notification: $state") - notificationState = state builder.updateState(context, state) notificationManager.notify(NotificationIds.FOREGROUND_SERVICE_NOTIFICATION_ID, builder.build()) } @@ -125,4 +136,4 @@ class ServiceNotificationController( } /** Tag for logs. */ -private const val TAG = "ServiceNotificationManager" \ No newline at end of file +private const val TAG = "ServiceNotificationManager" diff --git a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationListener.kt b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationListener.kt index 2b40c4e3a..687ac1554 100644 --- a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationListener.kt +++ b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ServiceNotificationListener.kt @@ -24,6 +24,7 @@ interface ServiceNotificationListener { fun onShow(): Unit? fun onHide(): Unit? fun onStop(): Unit? + fun onSwitch(): Unit? } internal fun ServiceNotificationListener.notifyAction(action: ServiceNotificationAction) = @@ -32,6 +33,7 @@ internal fun ServiceNotificationListener.notifyAction(action: ServiceNotificatio ServiceNotificationAction.Pause -> onPause() ServiceNotificationAction.Show -> onShow() ServiceNotificationAction.Hide -> onHide() + ServiceNotificationAction.Switch -> onSwitch() ServiceNotificationAction.Stop -> onStop() ServiceNotificationAction.Config -> Unit - } \ No newline at end of file + } diff --git a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/model/ServiceNotificationAction.kt b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/model/ServiceNotificationAction.kt index 2fc81e133..5bd7453d4 100644 --- a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/model/ServiceNotificationAction.kt +++ b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/model/ServiceNotificationAction.kt @@ -60,6 +60,11 @@ internal sealed class ServiceNotificationAction { } + data object Switch : ServiceNotificationAction() { + override val textRes: Int = R.string.notification_button_switch + override val iconRes: Int = R.drawable.ic_notification_swap_horiz + } + data object Stop : ServiceNotificationAction() { override val textRes: Int = R.string.notification_button_stop override val iconRes: Int = R.drawable.ic_notification_cancel @@ -78,6 +83,7 @@ internal fun getAllActionsBroadcastIntentFilter(): IntentFilter = addAction(ServiceNotificationAction.Pause.getBroadcastAction()) addAction(ServiceNotificationAction.Show.getBroadcastAction()) addAction(ServiceNotificationAction.Hide.getBroadcastAction()) + addAction(ServiceNotificationAction.Switch.getBroadcastAction()) addAction(ServiceNotificationAction.Stop.getBroadcastAction()) } @@ -87,6 +93,7 @@ internal fun Intent.toServiceNotificationAction(): ServiceNotificationAction? = ServiceNotificationAction.Pause.getBroadcastAction() -> ServiceNotificationAction.Pause ServiceNotificationAction.Show.getBroadcastAction() -> ServiceNotificationAction.Show ServiceNotificationAction.Hide.getBroadcastAction() -> ServiceNotificationAction.Hide + ServiceNotificationAction.Switch.getBroadcastAction() -> ServiceNotificationAction.Switch ServiceNotificationAction.Stop.getBroadcastAction() -> ServiceNotificationAction.Stop else -> null } @@ -113,6 +120,7 @@ private fun ServiceNotificationAction.getIntent(appComponentsProvider: AppCompon ServiceNotificationAction.Show -> NotificationActionPendingIntent.Broadcast(getBroadcastAction()) ServiceNotificationAction.Hide -> NotificationActionPendingIntent.Broadcast(getBroadcastAction()) ServiceNotificationAction.Stop -> NotificationActionPendingIntent.Broadcast(getBroadcastAction()) + ServiceNotificationAction.Switch -> NotificationActionPendingIntent.Broadcast(getBroadcastAction()) ServiceNotificationAction.Config -> NotificationActionPendingIntent.Activity(appComponentsProvider.scenarioActivityComponentName) } @@ -122,6 +130,7 @@ private fun ServiceNotificationAction.getBroadcastAction(): String = ServiceNotificationAction.Pause -> "com.buzbuz.smartautoclicker.PAUSE" ServiceNotificationAction.Show -> "com.buzbuz.smartautoclicker.SHOW" ServiceNotificationAction.Hide -> "com.buzbuz.smartautoclicker.HIDE" + ServiceNotificationAction.Switch -> "com.buzbuz.smartautoclicker.SWITCH" ServiceNotificationAction.Stop -> "com.buzbuz.smartautoclicker.STOP" ServiceNotificationAction.Config -> throw IllegalArgumentException("This action doesn't use broadcasts") - } \ No newline at end of file + } diff --git a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/CustomLayoutNotificationBuilder.kt b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/CustomLayoutNotificationBuilder.kt index 4e3e0cfb6..79a8b28d8 100644 --- a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/CustomLayoutNotificationBuilder.kt +++ b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/CustomLayoutNotificationBuilder.kt @@ -41,6 +41,7 @@ internal class CustomLayoutNotificationBuilder( setOngoing(true) setLocalOnly(true) setStyle(NotificationCompat.DecoratedCustomViewStyle()) + setContentIntent(ServiceNotificationAction.Config.getPendingIntent(context, appComponentsProvider)) updateState(context, initialState) } @@ -83,7 +84,7 @@ internal class CustomLayoutNotificationBuilder( R.id.button_show_hide, if (state.isMenuVisible) ServiceNotificationAction.Hide else ServiceNotificationAction.Show ) - addAction(context, R.id.button_config, ServiceNotificationAction.Config) + addAction(context, R.id.button_switch, ServiceNotificationAction.Switch) addAction(context, R.id.button_exit, ServiceNotificationAction.Stop) } diff --git a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/LegacyNotificationBuilder.kt b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/LegacyNotificationBuilder.kt index 58561a99f..d65306011 100644 --- a/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/LegacyNotificationBuilder.kt +++ b/feature/notifications/src/main/java/com/buzbuz/smartautoclicker/feature/notifications/ui/LegacyNotificationBuilder.kt @@ -46,6 +46,7 @@ internal class LegacyNotificationBuilder( } override fun updateState(context: Context, state: ServiceNotificationState) { + setContentTitle(context.getString(R.string.notification_title, state.scenarioName)) clearActions() addServiceNotificationAction( diff --git a/feature/notifications/src/main/res/drawable/ic_notification_swap_horiz.xml b/feature/notifications/src/main/res/drawable/ic_notification_swap_horiz.xml new file mode 100644 index 000000000..ebba5b4c0 --- /dev/null +++ b/feature/notifications/src/main/res/drawable/ic_notification_swap_horiz.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/notifications/src/main/res/layout/notification_service_big.xml b/feature/notifications/src/main/res/layout/notification_service_big.xml index 79e55de2b..615d671d1 100644 --- a/feature/notifications/src/main/res/layout/notification_service_big.xml +++ b/feature/notifications/src/main/res/layout/notification_service_big.xml @@ -76,12 +76,13 @@ android:layout_height="wrap_content"> + android:src="@drawable/ic_notification_swap_horiz" + android:contentDescription="@string/notification_button_switch"/> @@ -103,4 +104,4 @@ - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values/strings.xml b/feature/notifications/src/main/res/values/strings.xml index 095f06106..c596bc0da 100644 --- a/feature/notifications/src/main/res/values/strings.xml +++ b/feature/notifications/src/main/res/values/strings.xml @@ -27,5 +27,6 @@ Hide Stop Config + Switch scenario - \ No newline at end of file + diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenu.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenu.kt index 5a25985a7..eb1cbcb5b 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenu.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenu.kt @@ -39,6 +39,7 @@ import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.Tip import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredOverlayType import com.buzbuz.smartautoclicker.core.ui.utils.AnimatedStatesImageButtonController import com.buzbuz.smartautoclicker.core.ui.utils.getDynamicColorsContext +import com.buzbuz.smartautoclicker.core.ui.R as CoreUiR import com.buzbuz.smartautoclicker.feature.smart.config.R import com.buzbuz.smartautoclicker.feature.smart.config.databinding.OverlayMenuBinding import com.buzbuz.smartautoclicker.feature.smart.config.di.ScenarioConfigViewModelsEntryPoint @@ -65,7 +66,11 @@ import kotlinx.coroutines.launch * There is no overlay views attached to this overlay menu, meaning that the user will always be able to clicks on the * Activities displayed below it. */ -class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { +class MainMenu( + private val onStopClicked: () -> Unit, + private val onSwitchScenarioClicked: () -> Unit, + private val isSwitchButtonInitiallyVisible: Boolean, +) : OverlayMenu() { override fun tutorialMonitoringTag(): String = MonitoredOverlayType.MAIN_MENU.name @@ -86,6 +91,7 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { } private var isHiddenForPaywall: Boolean = false + private var hasReceivedSwitchVisibility = false /** View binding for the content of the overlay. */ private lateinit var viewBinding: OverlayMenuBinding @@ -112,6 +118,7 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { state2to1AnimationRes = R.drawable.anim_pause_play, ) viewBinding = OverlayMenuBinding.inflate(layoutInflater) + viewBinding.btnSwitchScenario.isVisible = isSwitchButtonInitiallyVisible playPauseButtonController.attachView(viewBinding.btnPlay) return viewBinding.root @@ -136,6 +143,7 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.isStartButtonEnabled.collect(::updatePlayPauseButtonEnabledState) } + launch { viewModel.isSwitchButtonVisible.collect(::updateSwitchButtonVisibility) } launch { viewModel.isMediaProjectionStarted.collect(::updateProjectionErrorBadge) } launch { viewModel.detectionState.collect(::updateDetectionState) } launch { viewModel.nativeLibError.collect(::showNativeLibErrorDialogIfNeeded) } @@ -194,14 +202,20 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { when (viewId) { R.id.btn_play -> onPlayPauseClicked() R.id.btn_click_list -> onConfigureClicked() + R.id.btn_switch_scenario -> onSwitchScenarioClicked() R.id.btn_stop -> onStopClicked() } } override fun getWindowMaximumSize(backgroundView: ViewGroup): Size { val bgSize = super.getWindowMaximumSize(backgroundView) + val switchButtonWidth = if (viewBinding.btnSwitchScenario.isVisible) { + 0 + } else { + context.resources.getDimensionPixelSize(CoreUiR.dimen.overlay_menu_btn_size) + } return Size( - bgSize.width + context.resources.getDimensionPixelSize(R.dimen.overlay_debug_panel_width), + bgSize.width + switchButtonWidth + context.resources.getDimensionPixelSize(R.dimen.overlay_debug_panel_width), bgSize.height, ) } @@ -247,6 +261,23 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { private fun updatePlayPauseButtonEnabledState(canStartDetection: Boolean) = setMenuItemViewEnabled(viewBinding.btnPlay, canStartDetection) + private fun updateSwitchButtonVisibility(isVisible: Boolean) { + if (!hasReceivedSwitchVisibility) { + hasReceivedSwitchVisibility = true + if (isVisible != isSwitchButtonInitiallyVisible) return + } + if (viewBinding.btnSwitchScenario.isVisible == isVisible) return + + if (viewBinding.btnPlay.tag == null) { + viewBinding.btnSwitchScenario.visibility = if (isVisible) View.VISIBLE else View.GONE + return + } + + animateLayoutChanges { + setMenuItemVisibility(viewBinding.btnSwitchScenario, isVisible) + } + } + /** Refresh the menu layout according to the detection state. */ private fun updateDetectionState(newState: UiState) { val currentState = viewBinding.btnPlay.tag @@ -258,11 +289,13 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { if (currentState == null) { viewBinding.btnStop.isVisible = true viewBinding.btnClickList.isVisible = true + viewBinding.btnSwitchScenario.isVisible = isSwitchButtonInitiallyVisible playPauseButtonController.toState1(false) } else { animateLayoutChanges { setMenuItemVisibility(viewBinding.btnStop, true) setMenuItemVisibility(viewBinding.btnClickList, true) + setMenuItemVisibility(viewBinding.btnSwitchScenario, viewModel.isSwitchButtonVisible.value) playPauseButtonController.toState1(true) } } @@ -272,11 +305,13 @@ class MainMenu(private val onStopClicked: () -> Unit) : OverlayMenu() { if (currentState == null) { viewBinding.btnStop.isVisible = false viewBinding.btnClickList.isVisible = false + viewBinding.btnSwitchScenario.isVisible = false playPauseButtonController.toState2(false) } else { animateLayoutChanges { setMenuItemVisibility(viewBinding.btnStop, false) setMenuItemVisibility(viewBinding.btnClickList, false) + setMenuItemVisibility(viewBinding.btnSwitchScenario, false) playPauseButtonController.toState2(true) } } diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenuModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenuModel.kt index 2c53d760c..000c3a8ae 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenuModel.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/mainmenu/MainMenuModel.kt @@ -27,6 +27,7 @@ import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepositor import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.Tip import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository import com.buzbuz.smartautoclicker.core.processing.domain.model.DetectionState +import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository import com.buzbuz.smartautoclicker.core.smart.debugging.domain.DebuggingRepository import com.buzbuz.smartautoclicker.core.common.tutorial.domain.MonitoredViewsManager import com.buzbuz.smartautoclicker.core.common.tutorial.impl.monitoring.ViewPositioningType @@ -57,6 +58,7 @@ import javax.inject.Inject /** View model for the [MainMenu]. */ class MainMenuModel @Inject constructor( private val smartProcessingRepository: SmartProcessingRepository, + settingsRepository: SettingsRepository, private val editionRepository: EditionRepository, private val tutorialRepository: TutorialRepository, private val revenueRepository: IRevenueRepository, @@ -97,6 +99,14 @@ class MainMenuModel @Inject constructor( .map { it == DetectionState.RECORDING || it == DetectionState.DETECTING } .stateIn(viewModelScope, SharingStarted.Eagerly, true) + val isSwitchButtonVisible: StateFlow = combine( + detectionState, + isMediaProjectionStarted, + settingsRepository.isScenarioSwitcherEnabledFlow, + ) { state, isProjectionStarted, isEnabled -> + state == UiState.Idle && isProjectionStarted && isEnabled + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + /** The condition being configured by the user. */ @OptIn(ExperimentalCoroutinesApi::class) val allModelsInstalled: StateFlow = scenarioDbId @@ -237,4 +247,4 @@ sealed class UiState { data object Idle: UiState() } -private const val TAG = "MainMenuViewModel" \ No newline at end of file +private const val TAG = "MainMenuViewModel" diff --git a/feature/smart-config/src/main/res/layout/overlay_menu.xml b/feature/smart-config/src/main/res/layout/overlay_menu.xml index 8e272e0c9..0cbdc7f71 100644 --- a/feature/smart-config/src/main/res/layout/overlay_menu.xml +++ b/feature/smart-config/src/main/res/layout/overlay_menu.xml @@ -75,6 +75,14 @@ android:src="@drawable/ic_settings_filled" android:contentDescription="@string/content_desc_open_event_list" /> + + - \ No newline at end of file + diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index 3fe45d914..5441672d2 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -36,6 +36,7 @@ import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityMetricsMoni import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository import com.buzbuz.smartautoclicker.core.display.config.DisplayConfigManager +import com.buzbuz.smartautoclicker.core.domain.IRepository import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario import com.buzbuz.smartautoclicker.core.dumb.engine.DumbEngine @@ -75,6 +76,7 @@ class SmartAutoClickerService : AccessibilityService() { @Inject lateinit var overlayManager: OverlayManager @Inject lateinit var displayConfigManager: DisplayConfigManager @Inject lateinit var smartProcessingRepository: SmartProcessingRepository + @Inject lateinit var smartRepository: IRepository @Inject lateinit var dumbEngine: DumbEngine @Inject lateinit var bitmapManager: BitmapRepository @Inject lateinit var qualityRepository: QualityRepository @@ -115,12 +117,14 @@ class SmartAutoClickerService : AccessibilityService() { overlayManager = overlayManager, appComponentsProvider = appComponentsProvider, smartProcessingRepository = smartProcessingRepository, + smartRepository = smartRepository, dumbEngine = dumbEngine, revenueRepository = revenueRepository, settingsRepository = settingsRepository, debuggingRepository = debuggingRepository, tutorialRepository = tutorialRepository, onStart = ::onLocalServiceStarted, + onScenarioChanged = ::onLocalScenarioChanged, onStop = ::onLocalServiceStopped, ) ) @@ -172,6 +176,10 @@ class SmartAutoClickerService : AccessibilityService() { bitmapManager.clearCache() } + private fun onLocalScenarioChanged(scenarioId: Long, isSmart: Boolean) { + tileRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart) + } + override fun onKeyEvent(event: KeyEvent?): Boolean = (localServiceConnection.getLocalService() as? LocalService)?.onKeyEvent(event) ?: super.onKeyEvent(event) @@ -204,4 +212,4 @@ class SmartAutoClickerService : AccessibilityService() { } /** Tag for the logs. */ -private const val TAG = "SmartAutoClickerService" \ No newline at end of file +private const val TAG = "SmartAutoClickerService" diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt index 220aabc62..6e80ab511 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt @@ -20,12 +20,14 @@ import android.app.Notification import android.content.Context import android.content.Intent import android.media.projection.MediaProjectionManager +import android.util.Log import android.view.KeyEvent import com.buzbuz.smartautoclicker.core.base.data.AppComponentsProvider import com.buzbuz.smartautoclicker.core.common.accessibility.domain.LocalAccessibilityService import com.buzbuz.smartautoclicker.core.common.overlays.manager.OverlayManager import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository +import com.buzbuz.smartautoclicker.core.domain.IRepository import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario import com.buzbuz.smartautoclicker.core.dumb.engine.DumbEngine @@ -39,6 +41,7 @@ import com.buzbuz.smartautoclicker.feature.notifications.ServiceNotificationCont import com.buzbuz.smartautoclicker.feature.notifications.ServiceNotificationListener import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState +import com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher.ScenarioSwitchDialog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,21 +50,26 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeoutOrNull class LocalService( private val context: Context, private val overlayManager: OverlayManager, private val appComponentsProvider: AppComponentsProvider, private val settingsRepository: SettingsRepository, + private val smartRepository: IRepository, private val smartProcessingRepository: SmartProcessingRepository, private val dumbEngine: DumbEngine, private val tutorialRepository: TutorialRepository, private val revenueRepository: IRevenueRepository, private val debuggingRepository: DebuggingRepository, private val onStart: (scenarioId: Long, isSmart: Boolean, foregroundNotification: Notification?) -> Unit, + private val onScenarioChanged: (scenarioId: Long, isSmart: Boolean) -> Unit, private val onStop: () -> Unit, ) : LocalAccessibilityService { @@ -71,6 +79,23 @@ class LocalService( private var startJob: Job? = null /** Coroutine job for the paywall result upon start from notification. */ private var paywallResultJob: Job? = null + /** Prevents repeated notification taps from opening multiple switcher pickers during pause settling. */ + private var scenarioSwitcherOpeningJob: Job? = null + /** Serializes a scenario switch against starting detection for the same Smart service session. */ + private val smartScenarioTransitionMutex = Mutex() + /** Changes for every service start so stale picker work cannot affect a later session. */ + private var nextServiceSessionId: Long = 0L + + private val smartScenarioSwitcher: SmartScenarioSwitcher by lazy { + SmartScenarioSwitcher( + smartProcessingRepository = smartProcessingRepository, + smartRepository = smartRepository, + isServiceAvailable = { state.isStarted && state.isSmartLoaded }, + serviceSessionId = { state.takeIf { it.isStarted && it.isSmartLoaded }?.sessionId }, + scenarioTransitionMutex = smartScenarioTransitionMutex, + onScenarioChanged = ::synchronizeScenarioChanged, + ) + } /** Controls the notifications for the foreground service. */ private val notificationController: ServiceNotificationController by lazy { @@ -84,12 +109,13 @@ class LocalService( override fun onShow() = showMenu() override fun onHide() = hideMenu() override fun onStop() = stopScenario() + override fun onSwitch() = openScenarioSwitcherAfterPause() } ) } /** State of this LocalService. */ - private var state: LocalServiceState = LocalServiceState(isStarted = false, isSmartLoaded = false) + private var state: LocalServiceState = LocalServiceState(isStarted = false, isSmartLoaded = false, sessionId = 0L) /** True if the overlay is started, false if not. */ internal val isStarted: Boolean get() = state.isStarted @@ -114,7 +140,7 @@ class LocalService( override fun startDumbScenario(dumbScenario: DumbScenario) { if (state.isStarted) return - state = LocalServiceState(isStarted = true, isSmartLoaded = false) + state = LocalServiceState(isStarted = true, isSmartLoaded = false, sessionId = ++nextServiceSessionId) onStart(dumbScenario.id.databaseId, false, null) startJob = serviceScope.launch { @@ -145,7 +171,7 @@ class LocalService( */ override fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { if (isStarted) return - state = LocalServiceState(isStarted = true, isSmartLoaded = true) + state = LocalServiceState(isStarted = true, isSmartLoaded = true, sessionId = ++nextServiceSessionId) onStart( scenario.id.databaseId, @@ -159,7 +185,12 @@ class LocalService( ) startJob = serviceScope.launch { - val mainMenu = MainMenu { stopScenario() } + val isScenarioSwitcherEnabled = settingsRepository.isScenarioSwitcherEnabled() + val mainMenu = MainMenu( + onStopClicked = { stopScenario() }, + onSwitchScenarioClicked = ::openScenarioSwitcher, + isSwitchButtonInitiallyVisible = isScenarioSwitcherEnabled, + ) smartProcessingRepository.apply { setScenarioId(scenario.id, markAsUsed = true) @@ -180,7 +211,9 @@ class LocalService( override fun stopScenario() { if (!isStarted) return - state = LocalServiceState(isStarted = false, isSmartLoaded = false) + state = state.copy(isStarted = false, isSmartLoaded = false) + scenarioSwitcherOpeningJob?.cancel() + scenarioSwitcherOpeningJob = null serviceScope.launch { startJob?.join() @@ -242,12 +275,34 @@ class LocalService( private fun startSmartScenario() { serviceScope.launch { - smartProcessingRepository.startDetection( - context = context, - autoStopDuration = revenueRepository.consumeTrial(), - liveDebugging = debuggingRepository.isDebugViewEnabled(), - generateReport = debuggingRepository.isDebugReportEnabled(), - ) + // Ignore Play while a switch owns this transition. Starting afterward could silently start detection on a + // scenario different from the one the user saw when they pressed Play. + if (!smartScenarioTransitionMutex.tryLock()) return@launch + try { + if (!state.isSmartLoaded || smartProcessingRepository.isRunning()) return@launch + + smartProcessingRepository.startDetection( + context = context, + autoStopDuration = revenueRepository.consumeTrial(), + liveDebugging = debuggingRepository.isDebugViewEnabled(), + generateReport = debuggingRepository.isDebugReportEnabled(), + ) + } finally { + smartScenarioTransitionMutex.unlock() + } + } + } + + private fun synchronizeScenarioChanged(scenario: Scenario) { + try { + notificationController.updateScenarioName(context, scenario.name) + } catch (error: Exception) { + Log.w(TAG, "Unable to update the notification after switching scenario", error) + } + try { + onScenarioChanged(scenario.id.databaseId, true) + } catch (error: Exception) { + Log.w(TAG, "Unable to update the quick-settings tile after switching scenario", error) } } @@ -258,9 +313,56 @@ class LocalService( private fun showMenu() { overlayManager.restoreVisibility() } + + private fun openScenarioSwitcherAfterPause() { + if (scenarioSwitcherOpeningJob?.isActive == true) return + + scenarioSwitcherOpeningJob = serviceScope.launch { + startJob?.join() + if (!state.isStarted || !state.isSmartLoaded) return@launch + + if (smartProcessingRepository.detectionState.first() == DetectionState.DETECTING) { + smartProcessingRepository.stopDetection() + } + + val pausedState = withTimeoutOrNull(SCENARIO_SWITCHER_PAUSE_TIMEOUT_MS) { + smartProcessingRepository.detectionState.first { detectionState -> + detectionState == DetectionState.RECORDING || detectionState != DetectionState.DETECTING + } + } + if (pausedState != DetectionState.RECORDING) return@launch + if (!state.isStarted || !state.isSmartLoaded || smartProcessingRepository.getScenarioId() == null) return@launch + + openScenarioSwitcher() + }.also { openingJob -> + openingJob.invokeOnCompletion { + if (scenarioSwitcherOpeningJob === openingJob) { + scenarioSwitcherOpeningJob = null + } + } + } + } + + private fun openScenarioSwitcher() { + if (!state.isStarted || !state.isSmartLoaded) return + if (smartProcessingRepository.getScenarioId() == null) return + if (overlayManager.getBackStackTop() is ScenarioSwitchDialog) return + + overlayManager.navigateTo( + context = context, + newOverlay = ScenarioSwitchDialog( + onScenarioSelected = smartScenarioSwitcher::switchTo, + ), + hideCurrent = false, + ) + } } +private const val SCENARIO_SWITCHER_PAUSE_TIMEOUT_MS = 5_000L +private const val TAG = "LocalService" + private data class LocalServiceState( val isStarted: Boolean, - val isSmartLoaded: Boolean -) \ No newline at end of file + val isSmartLoaded: Boolean, + val sessionId: Long, +) diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcher.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcher.kt new file mode 100644 index 000000000..afe6f7ae2 --- /dev/null +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcher.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.localservice + +import android.util.Log + +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository +import com.buzbuz.smartautoclicker.core.processing.domain.model.DetectionState +import com.buzbuz.smartautoclicker.core.processing.domain.model.ScenarioSwitchResult + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.CancellationException + +/** Coordinates all scenario changes made while a Smart service session is paused. */ +internal class SmartScenarioSwitcher( + private val smartProcessingRepository: SmartProcessingRepository, + private val smartRepository: IRepository, + private val isServiceAvailable: () -> Boolean, + private val serviceSessionId: () -> Long?, + private val scenarioTransitionMutex: Mutex, + private val onScenarioChanged: (Scenario) -> Unit, +) { + + private val switchMutex = Mutex() + + suspend fun switchTo(scenario: Scenario): ScenarioSwitchResult = switchMutex.withLock switcherLock@{ + val switchingSessionId = serviceSessionId() + ?: return@switcherLock ScenarioSwitchResult.ServiceUnavailable + + scenarioTransitionMutex.withLock transitionLock@{ + if (!isServiceAvailable() || serviceSessionId() != switchingSessionId) { + return@transitionLock ScenarioSwitchResult.ServiceUnavailable + } + validatePausedRecordingState()?.let { return@transitionLock it } + + val currentScenarioId = smartProcessingRepository.getScenarioId() + ?: return@transitionLock ScenarioSwitchResult.InvalidProcessingState + + if (currentScenarioId == scenario.id) { + return@transitionLock ScenarioSwitchResult.CurrentScenario + } + + val storedScenario = try { + smartRepository.getScenario(scenario.id.databaseId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + return@transitionLock ScenarioSwitchResult.PersistenceFailure + } + ?: return@transitionLock ScenarioSwitchResult.ScenarioUnavailable + + if (storedScenario.id == currentScenarioId) { + return@transitionLock ScenarioSwitchResult.CurrentScenario + } + + // The database lookup above suspends. Revalidate both the session and projection afterwards so neither a + // quick service restart nor a projection loss can install a scenario into an unrelated session. + if (!isServiceAvailable() || serviceSessionId() != switchingSessionId) { + return@transitionLock ScenarioSwitchResult.ServiceUnavailable + } + validatePausedRecordingState()?.let { return@transitionLock it } + + try { + // This is the only usage-marking call for a switch. It completes before Success is returned and + // leaves the active screen projection untouched. + smartProcessingRepository.setScenarioIdAndMarkAsUsed(storedScenario.id) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + return@transitionLock ScenarioSwitchResult.PersistenceFailure + } + + // A service stop can race the final persistence call. The scenario and its usage have already been saved, + // so report that successful choice accurately, but do not send callbacks into a torn-down service. + if (isServiceAvailable() && serviceSessionId() == switchingSessionId) { + try { + onScenarioChanged(storedScenario) + } catch (error: Exception) { + // Reporting is best-effort as well: neither a notification failure nor a logging failure may + // change the result of an already committed scenario switch. + runCatching { + Log.w(TAG, "Scenario was switched but post-switch UI synchronization failed", error) + } + } + } + + ScenarioSwitchResult.Success + } + } + + private suspend fun validatePausedRecordingState(): ScenarioSwitchResult? = when ( + smartProcessingRepository.detectionState.first() + ) { + DetectionState.RECORDING -> null + DetectionState.INACTIVE, + DetectionState.ERROR_SCREEN_IMAGE_CAPTURE_FAILED, + -> ScenarioSwitchResult.ProjectionUnavailable + else -> ScenarioSwitchResult.InvalidProcessingState + } +} + +private const val TAG = "SmartScenarioSwitcher" From 44478d86410dbfa087c56affdd2b8bbcd8d70d90 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Tue, 18 Aug 2026 18:50:20 +0530 Subject: [PATCH 04/14] test: cover scenario switching and usage state --- core/common/settings/build.gradle.kts | 4 +- .../domain/model/ScenarioSortItemTest.kt | 84 ++++++ .../migrations/Migration23to24Tests.kt | 147 +++++++++++ core/smart/domain/build.gradle.kts | 3 +- .../data/ScenarioDataSourceUsageTests.kt | 92 +++++++ .../switcher/ScenarioSwitchViewModelTest.kt | 199 +++++++++++++++ .../localservice/SmartScenarioSwitcherTest.kt | 240 ++++++++++++++++++ 7 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 core/common/settings/src/test/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItemTest.kt create mode 100644 core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt create mode 100644 core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSourceUsageTests.kt create mode 100644 feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt create mode 100644 smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt diff --git a/core/common/settings/build.gradle.kts b/core/common/settings/build.gradle.kts index 34bbf2195..2a6162c16 100644 --- a/core/common/settings/build.gradle.kts +++ b/core/common/settings/build.gradle.kts @@ -31,4 +31,6 @@ dependencies { implementation(libs.androidx.datastore) implementation(project(":core:common:base")) -} \ No newline at end of file + + testImplementation(libs.junit) +} diff --git a/core/common/settings/src/test/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItemTest.kt b/core/common/settings/src/test/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItemTest.kt new file mode 100644 index 000000000..aa9c3935c --- /dev/null +++ b/core/common/settings/src/test/java/com/buzbuz/smartautoclicker/core/settings/domain/model/ScenarioSortItemTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.settings.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScenarioSortItemTest { + + @Test + fun `name sorting uses ascending and descending order`() { + val scenarios = listOf( + item(id = 1, name = "Bravo"), + item(id = 2, name = "Alpha"), + ) + + assertEquals( + listOf(2L, 1L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.NAME, inverted = false)) { it }.map { it.id }, + ) + assertEquals( + listOf(1L, 2L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.NAME, inverted = true)) { it }.map { it.id }, + ) + } + + @Test + fun `recent and most used sorting keep their homepage direction`() { + val scenarios = listOf( + item(id = 1, name = "Old", lastStartTimestamp = 10, startCount = 20), + item(id = 2, name = "Recent", lastStartTimestamp = 20, startCount = 5), + ) + + assertEquals( + listOf(2L, 1L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.RECENT, inverted = false)) { it }.map { it.id }, + ) + assertEquals( + listOf(1L, 2L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.RECENT, inverted = true)) { it }.map { it.id }, + ) + assertEquals( + listOf(1L, 2L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.MOST_USED, inverted = false)) { it }.map { it.id }, + ) + assertEquals( + listOf(2L, 1L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.MOST_USED, inverted = true)) { it }.map { it.id }, + ) + } + + @Test + fun `equal primary values use name and id as stable tie breakers`() { + val scenarios = listOf( + item(id = 2, name = "Same"), + item(id = 1, name = "Same"), + ) + + assertEquals( + listOf(1L, 2L), + scenarios.sortedByScenarioSortSettings(settings(ScenarioSortType.NAME, inverted = false)) { it }.map { it.id }, + ) + } + + private fun settings(type: ScenarioSortType, inverted: Boolean) = ScenarioSortSettings( + type = type, + inverted = inverted, + showSmartScenario = true, + showDumbScenario = true, + ) + + private fun item( + id: Long, + name: String, + lastStartTimestamp: Long = 0, + startCount: Long = 0, + ) = ScenarioSortItem(id, name, lastStartTimestamp, startCount) +} diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt new file mode 100644 index 000000000..f980241c6 --- /dev/null +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.database.migrations + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.os.Build + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry + +import com.buzbuz.smartautoclicker.core.database.ClickDatabase +import com.buzbuz.smartautoclicker.core.database.SCENARIO_TABLE +import com.buzbuz.smartautoclicker.core.database.SCENARIO_USAGE_TABLE + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Tests the usage-statistics repair performed by [Migration23to24]. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class Migration23to24Tests { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + ClickDatabase::class.java, + ) + + private lateinit var dbPath: String + + @Before + fun setUp() { + dbPath = ApplicationProvider.getApplicationContext() + .getDatabasePath("migration-test").path + } + + @Test + fun migrate_duplicateStatistics_mergesCountAndLatestTimestamp_thenEnforcesUniqueness() { + helper.createDatabase(dbPath, 23).use { db -> + db.insertTestScenario(1L) + db.insertTestStats(id = 10L, scenarioId = 1L, timestampMs = 100L, startCount = 2L) + db.insertTestStats(id = 11L, scenarioId = 1L, timestampMs = 300L, startCount = 4L) + } + + helper.runMigrationsAndValidate(dbPath, 24, true, Migration23to24).use { db -> + db.query("SELECT id, last_start_timestamp_ms, start_count FROM $SCENARIO_USAGE_TABLE WHERE scenario_id = 1").use { cursor -> + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + assertEquals(10L, cursor.getLong(0)) + assertEquals(300L, cursor.getLong(1)) + assertEquals(6L, cursor.getLong(2)) + } + + val insertResult = db.insert( + SCENARIO_USAGE_TABLE, + SQLiteDatabase.CONFLICT_IGNORE, + ContentValues().apply { + put("scenario_id", 1L) + put("last_start_timestamp_ms", 400L) + put("start_count", 1L) + }, + ) + assertEquals(-1L, insertResult) + } + } + + @Test + fun migrate_v15Database_throughTheRegisteredUpgradeChain() { + val lowerVersionDbPath = ApplicationProvider.getApplicationContext() + .getDatabasePath("migration-v15-to-v24-test").path + + helper.createDatabase(lowerVersionDbPath, 15).use { db -> + db.insertV15Scenario(2L) + } + + val database = Room.databaseBuilder( + ApplicationProvider.getApplicationContext(), + ClickDatabase::class.java, + lowerVersionDbPath, + ).addMigrations( + Migration19to20, + Migration21to22, + Migration23to24, + ).build() + try { + database.openHelper.writableDatabase.query( + "SELECT name FROM $SCENARIO_TABLE WHERE id = 2", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("Scenario 2", cursor.getString(0)) + } + } finally { + database.close() + } + } + + private fun SupportSQLiteDatabase.insertV15Scenario(id: Long) { + insert(SCENARIO_TABLE, 0, ContentValues().apply { + put("id", id) + put("name", "Scenario $id") + put("detection_quality", 1200) + put("randomize", 0) + }) + } + + private fun SupportSQLiteDatabase.insertTestScenario(id: Long) { + insert(SCENARIO_TABLE, 0, ContentValues().apply { + put("id", id) + put("name", "Scenario $id") + put("detection_quality", 1200) + put("compute_rate", 0.0) + put("randomize", 0) + put("keep_screen_on", 0) + }) + } + + private fun SupportSQLiteDatabase.insertTestStats( + id: Long, + scenarioId: Long, + timestampMs: Long, + startCount: Long, + ) { + insert(SCENARIO_USAGE_TABLE, 0, ContentValues().apply { + put("id", id) + put("scenario_id", scenarioId) + put("last_start_timestamp_ms", timestampMs) + put("start_count", startCount) + }) + } +} diff --git a/core/smart/domain/build.gradle.kts b/core/smart/domain/build.gradle.kts index 7dc31307c..9cb5baa00 100644 --- a/core/smart/domain/build.gradle.kts +++ b/core/smart/domain/build.gradle.kts @@ -38,4 +38,5 @@ dependencies { implementation(project(":core:smart:detection-models")) testImplementation(libs.kotlinx.coroutines.test) -} \ No newline at end of file + testImplementation(libs.androidx.room.testing) +} diff --git a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSourceUsageTests.kt b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSourceUsageTests.kt new file mode 100644 index 000000000..df6f8628f --- /dev/null +++ b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioDataSourceUsageTests.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.domain.data + +import android.os.Build + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider + +import com.buzbuz.smartautoclicker.core.base.identifier.DATABASE_ID_INSERTION +import com.buzbuz.smartautoclicker.core.database.ClickDatabase +import com.buzbuz.smartautoclicker.core.database.entity.ScenarioEntity + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Database-backed tests for durable scenario usage accounting. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class ScenarioDataSourceUsageTests { + + private lateinit var database: ClickDatabase + private lateinit var dataSource: ScenarioDataSource + private var scenarioId: Long = DATABASE_ID_INSERTION + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + ClickDatabase::class.java, + ).allowMainThreadQueries().build() + dataSource = ScenarioDataSource(database) + runTest { + scenarioId = database.scenarioDao().add( + ScenarioEntity( + id = DATABASE_ID_INSERTION, + name = "Usage test", + detectionQuality = 1200, + ) + ) + } + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun markAsUsed_twice_persistsOneAccumulatedStatistic() = runTest { + val startedAt = System.currentTimeMillis() + + dataSource.markAsUsed(scenarioId) + dataSource.markAsUsed(scenarioId) + + val statistics = database.scenarioDao().getScenarioStats(scenarioId) + assertEquals(1, statistics.size) + assertEquals(2L, statistics.single().startCount) + assertTrue(statistics.single().lastStartTimestampMs >= startedAt) + } + + @Test + fun markAsUsed_concurrently_preservesEveryIncrement() = runTest { + coroutineScope { + List(16) { + async(Dispatchers.Default) { dataSource.markAsUsed(scenarioId) } + }.awaitAll() + } + + val statistics = database.scenarioDao().getScenarioStats(scenarioId) + assertEquals(1, statistics.size) + assertEquals(16L, statistics.single().startCount) + } +} diff --git a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt new file mode 100644 index 000000000..d64a7d94f --- /dev/null +++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.scenario.switcher + +import android.content.Context +import android.content.Intent + +import com.buzbuz.smartautoclicker.core.base.ScenarioStats +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.action.Action +import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition +import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.counter.Counter +import com.buzbuz.smartautoclicker.core.domain.model.event.Event +import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.domain.model.event.TriggerEvent +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository +import com.buzbuz.smartautoclicker.core.processing.domain.model.DetectionState +import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository +import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortSettings +import com.buzbuz.smartautoclicker.core.settings.domain.model.ScenarioSortType + +import io.mockk.mockk + +import java.io.PrintWriter + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.time.Duration + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ScenarioSwitchViewModelTest { + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `state excludes current scenario and follows shared sort settings`() = runTest { + val currentId = Identifier(databaseId = 1L) + val scenarios = MutableStateFlow( + listOf( + scenario(1L, "Current", lastStart = 100L), + scenario(2L, "Recent", lastStart = 300L), + scenario(3L, "Old", lastStart = 200L), + ), + ) + val processingRepository = TestProcessingRepository( + scenarioId = MutableStateFlow(currentId), + detectionState = MutableStateFlow(DetectionState.RECORDING), + ) + val settingsRepository = TestSettingsRepository( + ScenarioSortSettings( + type = ScenarioSortType.RECENT, + inverted = false, + showSmartScenario = false, + showDumbScenario = false, + ), + ) + + val viewModel = ScenarioSwitchViewModel( + smartRepository = TestScenarioRepository(scenarios), + smartProcessingRepository = processingRepository, + settingsRepository = settingsRepository, + ) + + val state = viewModel.uiState.first { it.currentScenario != null } + + assertEquals("Current", state.currentScenario?.name) + assertEquals(listOf(2L, 3L), state.alternatives.map { it.id.databaseId }) + assertEquals(true, state.isPaused) + assertEquals(false, state.isLoading) + } + + @Test + fun `missing current scenario produces no switch targets`() = runTest { + val scenarios = MutableStateFlow(listOf(scenario(2L, "Target", lastStart = 300L))) + val viewModel = ScenarioSwitchViewModel( + smartRepository = TestScenarioRepository(scenarios), + smartProcessingRepository = TestProcessingRepository( + scenarioId = MutableStateFlow(null), + detectionState = MutableStateFlow(DetectionState.RECORDING), + ), + settingsRepository = TestSettingsRepository( + ScenarioSortSettings( + type = ScenarioSortType.NAME, + inverted = false, + showSmartScenario = true, + showDumbScenario = true, + ), + ), + ) + + val state = viewModel.uiState.first { !it.isLoading } + + assertEquals(null, state.currentScenario) + assertEquals(emptyList(), state.alternatives) + assertEquals(false, state.isPaused) + } + + private fun scenario(id: Long, name: String, lastStart: Long) = Scenario( + id = Identifier(databaseId = id), + name = name, + detectionQuality = 0, + stats = ScenarioStats(lastStartTimestampMs = lastStart, startCount = 0), + ) +} + +private class TestSettingsRepository(settings: ScenarioSortSettings) : SettingsRepository { + override val isLegacyActionUiEnabledFlow: Flow = flowOf(false) + override val isLegacyNotificationUiEnabledFlow: Flow = flowOf(false) + override val isEntireScreenCaptureForcedFlow: Flow = flowOf(false) + override val isFilterScenarioUiEnabledFlow: Flow = flowOf(false) + override val isScenarioSwitcherEnabledFlow: Flow = flowOf(false) + override suspend fun isScenarioSwitcherEnabled(): Boolean = false + override val isInputBlockWorkaroundEnabledFlow: Flow = flowOf(false) + override val scenarioSortSettings: Flow = MutableStateFlow(settings) + + override fun isLegacyActionUiEnabled() = false + override fun toggleLegacyActionUi() = Unit + override fun isLegacyNotificationUiEnabled() = false + override fun toggleLegacyNotificationUi() = Unit + override fun isEntireScreenCaptureForced() = false + override fun toggleForceEntireScreenCapture() = Unit + override fun toggleFilterScenarioUi() = Unit + override fun toggleScenarioSwitcher() = Unit + override fun isInputBlockWorkaroundEnabled() = false + override fun toggleInputBlockWorkaround() = Unit + override fun setScenarioSortType(type: ScenarioSortType) = Unit + override fun setScenarioSortOrder(invertSortOrder: Boolean) = Unit + override fun setScenarioSortShowDumb(show: Boolean) = Unit + override fun setScenarioSortShowSmart(show: Boolean) = Unit +} + +private class TestProcessingRepository( + override val scenarioId: StateFlow, + override val detectionState: Flow, +) : SmartProcessingRepository { + override val canStartDetection: Flow = emptyFlow() + override fun getScenarioId() = scenarioId.value + override fun isRunning() = false + override fun setScenarioId(identifier: Identifier, markAsUsed: Boolean) = Unit + override suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier) = Unit + override fun setProjectionErrorHandler(handler: () -> Unit) = Unit + override fun startScreenRecord(resultCode: Int, data: Intent) = Unit + override suspend fun startDetection(context: Context, liveDebugging: Boolean, generateReport: Boolean, autoStopDuration: Duration?) = Unit + override fun stopDetection() = Unit + override fun stopScreenRecord() = Unit + override suspend fun tryEvent(context: Context, scenario: Scenario, event: ScreenEvent) = Unit + override suspend fun tryScreenCondition(context: Context, scenario: Scenario, condition: ScreenCondition) = Unit + override suspend fun tryAction(context: Context, scenario: Scenario, action: Action) = Unit + override fun dump(writer: PrintWriter, prefix: CharSequence) = Unit +} + +private class TestScenarioRepository( + override val scenarios: Flow>, +) : IRepository by mockk(relaxed = true) { + override val allScreenEvents: Flow> = emptyFlow() + override val allTriggerEvents: Flow> = emptyFlow() + override val allConditions: Flow> = emptyFlow() + override val allActions: Flow> = emptyFlow() + override val screenEventsCount: Flow = emptyFlow() + override val triggerEventsCount: Flow = emptyFlow() + override val screenConditionsCount: Flow = emptyFlow() + override val triggerConditionsCount: Flow = emptyFlow() + override val actionsCount: Flow = emptyFlow() + override val legacyConditionsCount: Flow = emptyFlow() + + override suspend fun getScenario(scenarioId: Long) = scenarios.first().firstOrNull { it.id.databaseId == scenarioId } +} diff --git a/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt new file mode 100644 index 000000000..5d9d3b100 --- /dev/null +++ b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.localservice + +import android.content.Context +import android.content.Intent + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.action.Action +import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition +import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.counter.Counter +import com.buzbuz.smartautoclicker.core.domain.model.event.Event +import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.domain.model.event.TriggerEvent +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository +import com.buzbuz.smartautoclicker.core.processing.domain.model.DetectionState +import com.buzbuz.smartautoclicker.core.processing.domain.model.ScenarioSwitchResult + +import io.mockk.mockk + +import java.io.PrintWriter + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test + +class SmartScenarioSwitcherTest { + + private val currentScenarioId = Identifier(databaseId = 1L) + private val targetScenario = scenario(2L, "Target") + private lateinit var processingRepository: TestProcessingRepository + private lateinit var scenarioRepository: TestScenarioRepository + private lateinit var switcher: SmartScenarioSwitcher + private var serviceAvailable = true + private var serviceSessionId = 1L + private val changedScenarios = mutableListOf() + private var onScenarioChanged: (Scenario) -> Unit = changedScenarios::add + + @Before + fun setUp() { + serviceAvailable = true + serviceSessionId = 1L + changedScenarios.clear() + onScenarioChanged = changedScenarios::add + processingRepository = TestProcessingRepository(currentScenarioId) + scenarioRepository = TestScenarioRepository(listOf(targetScenario)) + + switcher = SmartScenarioSwitcher( + smartProcessingRepository = processingRepository, + smartRepository = scenarioRepository, + isServiceAvailable = { serviceAvailable }, + serviceSessionId = { serviceSessionId.takeIf { serviceAvailable } }, + scenarioTransitionMutex = Mutex(), + onScenarioChanged = { onScenarioChanged(it) }, + ) + } + + @Test + fun `paused switch marks target used exactly once and notifies once`() = runTest { + assertEquals(ScenarioSwitchResult.Success, switcher.switchTo(targetScenario)) + + assertEquals(listOf(targetScenario.id), processingRepository.markedAsUsed) + assertEquals(listOf(targetScenario), changedScenarios) + assertEquals(0, processingRepository.stopScreenRecordCalls) + assertEquals(0, processingRepository.startScreenRecordCalls) + } + + @Test + fun `duplicate switch requests are serialized and mark target once`() = runTest { + val setStarted = CompletableDeferred() + val releaseSet = CompletableDeferred() + processingRepository.onMarkAsUsed = { + setStarted.complete(Unit) + releaseSet.await() + } + + val firstRequest = async { switcher.switchTo(targetScenario) } + setStarted.await() + val secondRequest = async { switcher.switchTo(targetScenario) } + runCurrent() + + assertFalse(secondRequest.isCompleted) + releaseSet.complete(Unit) + val results = listOf(firstRequest.await(), secondRequest.await()) + + assertEquals(1, results.count { it == ScenarioSwitchResult.Success }) + assertEquals(1, results.count { it == ScenarioSwitchResult.CurrentScenario }) + assertEquals(listOf(targetScenario.id), processingRepository.markedAsUsed) + } + + @Test + fun `current stale and invalid targets never mark usage`() = runTest { + assertEquals(ScenarioSwitchResult.CurrentScenario, switcher.switchTo(scenario(1L, "Current"))) + + assertEquals(ScenarioSwitchResult.ScenarioUnavailable, switcher.switchTo(scenario(3L, "Deleted"))) + + processingRepository.detectionStateValue.value = DetectionState.DETECTING + assertEquals(ScenarioSwitchResult.InvalidProcessingState, switcher.switchTo(targetScenario)) + + assertEquals(emptyList(), processingRepository.markedAsUsed) + } + + @Test + fun `projection loss is reported without changing the loaded scenario`() = runTest { + processingRepository.detectionStateValue.value = DetectionState.INACTIVE + + assertEquals(ScenarioSwitchResult.ProjectionUnavailable, switcher.switchTo(targetScenario)) + assertEquals(emptyList(), processingRepository.markedAsUsed) + } + + @Test + fun `projection loss during scenario lookup prevents a late switch`() = runTest { + scenarioRepository.onGetScenario = { + processingRepository.detectionStateValue.value = DetectionState.INACTIVE + targetScenario + } + + assertEquals(ScenarioSwitchResult.ProjectionUnavailable, switcher.switchTo(targetScenario)) + assertEquals(emptyList(), processingRepository.markedAsUsed) + assertEquals(currentScenarioId, processingRepository.getScenarioId()) + } + + @Test + fun `usage persistence failure does not change the loaded scenario`() = runTest { + processingRepository.onMarkAsUsed = { throw IllegalStateException("database") } + + assertEquals(ScenarioSwitchResult.PersistenceFailure, switcher.switchTo(targetScenario)) + assertEquals(currentScenarioId, processingRepository.getScenarioId()) + assertEquals(emptyList(), changedScenarios) + } + + @Test + fun `service stop during persistence keeps the successful switch but skips the callback`() = runTest { + processingRepository.onMarkAsUsed = { serviceAvailable = false } + + assertEquals(ScenarioSwitchResult.Success, switcher.switchTo(targetScenario)) + assertEquals(targetScenario.id, processingRepository.getScenarioId()) + assertEquals(emptyList(), changedScenarios) + } + + @Test + fun `service restart during scenario lookup prevents a stale switch`() = runTest { + scenarioRepository.onGetScenario = { + serviceSessionId++ + targetScenario + } + + assertEquals(ScenarioSwitchResult.ServiceUnavailable, switcher.switchTo(targetScenario)) + assertEquals(emptyList(), processingRepository.markedAsUsed) + assertEquals(currentScenarioId, processingRepository.getScenarioId()) + } + + @Test + fun `post switch callback failure does not turn a completed switch into a failure`() = runTest { + onScenarioChanged = { throw IllegalStateException("notification") } + + assertEquals(ScenarioSwitchResult.Success, switcher.switchTo(targetScenario)) + assertEquals(targetScenario.id, processingRepository.getScenarioId()) + } + + private fun scenario(id: Long, name: String) = Scenario( + id = Identifier(databaseId = id), + name = name, + detectionQuality = 0, + ) +} + +private class TestProcessingRepository(initialScenarioId: Identifier) : SmartProcessingRepository { + private val scenarioIdValue = MutableStateFlow(initialScenarioId) + val detectionStateValue = MutableStateFlow(DetectionState.RECORDING) + val markedAsUsed = mutableListOf() + var onMarkAsUsed: suspend () -> Unit = {} + var startScreenRecordCalls = 0 + var stopScreenRecordCalls = 0 + + override val scenarioId: StateFlow = scenarioIdValue + override val canStartDetection: Flow = emptyFlow() + override val detectionState: Flow = detectionStateValue + + override fun getScenarioId() = scenarioIdValue.value + override fun isRunning() = detectionStateValue.value == DetectionState.DETECTING + override fun setScenarioId(identifier: Identifier, markAsUsed: Boolean) { scenarioIdValue.value = identifier } + override suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier) { + onMarkAsUsed() + markedAsUsed += identifier + scenarioIdValue.value = identifier + } + override fun setProjectionErrorHandler(handler: () -> Unit) = Unit + override fun startScreenRecord(resultCode: Int, data: Intent) { startScreenRecordCalls++ } + override suspend fun startDetection(context: Context, liveDebugging: Boolean, generateReport: Boolean, autoStopDuration: Duration?) = Unit + override fun stopDetection() = Unit + override fun stopScreenRecord() { stopScreenRecordCalls++ } + override suspend fun tryEvent(context: Context, scenario: Scenario, event: ScreenEvent) = Unit + override suspend fun tryScreenCondition(context: Context, scenario: Scenario, condition: ScreenCondition) = Unit + override suspend fun tryAction(context: Context, scenario: Scenario, action: Action) = Unit + override fun dump(writer: PrintWriter, prefix: CharSequence) = Unit +} + +private class TestScenarioRepository(initialScenarios: List) : IRepository by mockk(relaxed = true) { + private val scenariosValue = MutableStateFlow(initialScenarios) + var onGetScenario: suspend (Long) -> Scenario? = { scenarioId -> + scenariosValue.value.firstOrNull { it.id.databaseId == scenarioId } + } + + override val scenarios: Flow> = scenariosValue + override val allScreenEvents: Flow> = emptyFlow() + override val allTriggerEvents: Flow> = emptyFlow() + override val allConditions: Flow> = emptyFlow() + override val allActions: Flow> = emptyFlow() + override val screenEventsCount: Flow = emptyFlow() + override val triggerEventsCount: Flow = emptyFlow() + override val screenConditionsCount: Flow = emptyFlow() + override val triggerConditionsCount: Flow = emptyFlow() + override val actionsCount: Flow = emptyFlow() + override val legacyConditionsCount: Flow = emptyFlow() + + override suspend fun getScenario(scenarioId: Long) = onGetScenario(scenarioId) +} From 099fb1434a67a467b97aabe2dbf448c9e34bddbc Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Tue, 18 Aug 2026 18:50:27 +0530 Subject: [PATCH 05/14] feat: translate scenario switcher interface --- .../src/main/res/values-ar/strings.xml | 3 ++- .../src/main/res/values-es/strings.xml | 3 ++- .../src/main/res/values-fr/strings.xml | 3 ++- .../src/main/res/values-it/strings.xml | 1 + .../src/main/res/values-ja/strings.xml | 1 + .../src/main/res/values-pt-rBR/strings.xml | 1 + .../src/main/res/values-ru/strings.xml | 3 ++- .../src/main/res/values-uk/strings.xml | 3 ++- .../src/main/res/values-zh-rCN/strings.xml | 1 + .../src/main/res/values-zh-rTW/strings.xml | 1 + .../src/main/res/values-ar/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-es/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-fr/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-it/strings.xml | 15 +++++++++++++++ .../src/main/res/values-ja/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-pt-rBR/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-ru/strings.xml | 15 +++++++++++++++ .../src/main/res/values-uk/strings.xml | 15 +++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-zh-rTW/strings.xml | 17 ++++++++++++++++- .../src/main/res/values-ar/strings.xml | 3 +++ .../src/main/res/values-es/strings.xml | 5 ++++- .../src/main/res/values-fr/strings.xml | 5 ++++- .../src/main/res/values-it/strings.xml | 3 +++ .../src/main/res/values-ja/strings.xml | 3 +++ .../src/main/res/values-pt-rBR/strings.xml | 3 +++ .../src/main/res/values-ru/strings.xml | 5 ++++- .../src/main/res/values-uk/strings.xml | 5 ++++- .../src/main/res/values-zh-rCN/strings.xml | 3 +++ .../src/main/res/values-zh-rTW/strings.xml | 3 +++ 30 files changed, 206 insertions(+), 16 deletions(-) diff --git a/feature/notifications/src/main/res/values-ar/strings.xml b/feature/notifications/src/main/res/values-ar/strings.xml index 7816ea28d..9be9bf5e0 100644 --- a/feature/notifications/src/main/res/values-ar/strings.xml +++ b/feature/notifications/src/main/res/values-ar/strings.xml @@ -25,5 +25,6 @@ يخفي قف التكوين + تبديل السيناريو - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values-es/strings.xml b/feature/notifications/src/main/res/values-es/strings.xml index 3da1a5765..0b7eaf827 100644 --- a/feature/notifications/src/main/res/values-es/strings.xml +++ b/feature/notifications/src/main/res/values-es/strings.xml @@ -25,5 +25,6 @@ Esconder Detener configuración + Cambiar escenario - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values-fr/strings.xml b/feature/notifications/src/main/res/values-fr/strings.xml index 361127948..b79c3afc2 100644 --- a/feature/notifications/src/main/res/values-fr/strings.xml +++ b/feature/notifications/src/main/res/values-fr/strings.xml @@ -25,5 +25,6 @@ Masquer Stop Config + Changer de scénario - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values-it/strings.xml b/feature/notifications/src/main/res/values-it/strings.xml index 3864cfc4f..5651edf34 100644 --- a/feature/notifications/src/main/res/values-it/strings.xml +++ b/feature/notifications/src/main/res/values-it/strings.xml @@ -25,5 +25,6 @@ Nascondere Stop Configurazione + Cambia scenario diff --git a/feature/notifications/src/main/res/values-ja/strings.xml b/feature/notifications/src/main/res/values-ja/strings.xml index 4496e24e8..c8c6d063b 100644 --- a/feature/notifications/src/main/res/values-ja/strings.xml +++ b/feature/notifications/src/main/res/values-ja/strings.xml @@ -8,4 +8,5 @@ 非表示 停止 設定 + シナリオを切り替え diff --git a/feature/notifications/src/main/res/values-pt-rBR/strings.xml b/feature/notifications/src/main/res/values-pt-rBR/strings.xml index 5e3ddbcc9..48ac3d50b 100644 --- a/feature/notifications/src/main/res/values-pt-rBR/strings.xml +++ b/feature/notifications/src/main/res/values-pt-rBR/strings.xml @@ -25,5 +25,6 @@ Esconder Parar Configuração + Mudar de cenário diff --git a/feature/notifications/src/main/res/values-ru/strings.xml b/feature/notifications/src/main/res/values-ru/strings.xml index c37df4453..476451917 100644 --- a/feature/notifications/src/main/res/values-ru/strings.xml +++ b/feature/notifications/src/main/res/values-ru/strings.xml @@ -25,5 +25,6 @@ Скрывать Останавливаться Конфигурация + Переключить сценарий - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values-uk/strings.xml b/feature/notifications/src/main/res/values-uk/strings.xml index cd6ebe981..e2add767c 100644 --- a/feature/notifications/src/main/res/values-uk/strings.xml +++ b/feature/notifications/src/main/res/values-uk/strings.xml @@ -25,5 +25,6 @@ Сховати Стоп Налаштування + Перемкнути сценарій - \ No newline at end of file + diff --git a/feature/notifications/src/main/res/values-zh-rCN/strings.xml b/feature/notifications/src/main/res/values-zh-rCN/strings.xml index 5be7fb4db..2992d55f5 100644 --- a/feature/notifications/src/main/res/values-zh-rCN/strings.xml +++ b/feature/notifications/src/main/res/values-zh-rCN/strings.xml @@ -8,4 +8,5 @@ 隐藏 停止 配置 + 切换场景 diff --git a/feature/notifications/src/main/res/values-zh-rTW/strings.xml b/feature/notifications/src/main/res/values-zh-rTW/strings.xml index fb95dcad0..f58a4116b 100644 --- a/feature/notifications/src/main/res/values-zh-rTW/strings.xml +++ b/feature/notifications/src/main/res/values-zh-rTW/strings.xml @@ -8,4 +8,5 @@ 隱藏 停止 設定 + 切換情境 diff --git a/feature/smart-config/src/main/res/values-ar/strings.xml b/feature/smart-config/src/main/res/values-ar/strings.xml index 93b3e7764..39e73dd10 100644 --- a/feature/smart-config/src/main/res/values-ar/strings.xml +++ b/feature/smart-config/src/main/res/values-ar/strings.xml @@ -487,4 +487,19 @@ أنت على وشك حذف هذا العنصر، هل أنت متأكد؟ - \ No newline at end of file + إغلاق مبدّل السيناريو + تبديل السيناريو + الحالي: %1$s + غير معروف + لا توجد سيناريوهات ذكية أخرى متاحة + لم تعد خدمة السيناريو متاحة. + أوقف السيناريو مؤقتًا قبل التبديل. + تسجيل الشاشة غير متاح. أعد تشغيله قبل التبديل. + هذا السيناريو محمّل بالفعل. + لم يعد هذا السيناريو متاحًا. + تعذّر حفظ السيناريو. حاول مرة أخرى. + تعذّر تبديل السيناريو. حاول مرة أخرى. + إعادة المحاولة + جارٍ التبديل إلى %1$s + + diff --git a/feature/smart-config/src/main/res/values-es/strings.xml b/feature/smart-config/src/main/res/values-es/strings.xml index 36edd07fc..07bf87957 100644 --- a/feature/smart-config/src/main/res/values-es/strings.xml +++ b/feature/smart-config/src/main/res/values-es/strings.xml @@ -487,4 +487,19 @@ Estás a punto de eliminar este elemento, ¿estás seguro? - \ No newline at end of file + Cerrar el selector de escenarios + Cambiar escenario + Actual: %1$s + Desconocido + No hay otros escenarios inteligentes disponibles + El servicio de escenarios ya no está disponible. + Pausa el escenario antes de cambiar. + La grabación de pantalla no está disponible. Reiníciala antes de cambiar. + Este escenario ya está cargado. + Ese escenario ya no está disponible. + No se pudo guardar el escenario. Inténtalo de nuevo. + No se pudo cambiar el escenario. Inténtalo de nuevo. + Reintentar + Cambiando a %1$s + + diff --git a/feature/smart-config/src/main/res/values-fr/strings.xml b/feature/smart-config/src/main/res/values-fr/strings.xml index 3dce2fb16..cef3590b4 100644 --- a/feature/smart-config/src/main/res/values-fr/strings.xml +++ b/feature/smart-config/src/main/res/values-fr/strings.xml @@ -467,4 +467,19 @@ Vous êtes sur le point de supprimer cet élément, êtes-vous sûr ? - \ No newline at end of file + Fermer le sélecteur de scénario + Changer de scénario + Actuel : %1$s + Inconnu + Aucun autre scénario intelligent disponible + Le service de scénarios n\'est plus disponible. + Mettez le scénario en pause avant d\'en changer. + L\'enregistrement de l\'écran n\'est pas disponible. Redémarrez-le avant de changer de scénario. + Ce scénario est déjà chargé. + Ce scénario n\'est plus disponible. + Impossible d\'enregistrer le scénario. Réessayez. + Impossible de changer de scénario. Réessayez. + Réessayer + Passage à %1$s + + diff --git a/feature/smart-config/src/main/res/values-it/strings.xml b/feature/smart-config/src/main/res/values-it/strings.xml index 076b8d557..3525e00d0 100644 --- a/feature/smart-config/src/main/res/values-it/strings.xml +++ b/feature/smart-config/src/main/res/values-it/strings.xml @@ -466,4 +466,19 @@ Stai per eliminare questo elemento, sei sicuro? + Chiudi il selettore di scenario + Cambia scenario + Attuale: %1$s + Sconosciuto + Nessun altro scenario intelligente disponibile + Il servizio degli scenari non è più disponibile. + Metti in pausa lo scenario prima di cambiarlo. + La registrazione dello schermo non è disponibile. Riavviala prima di cambiare scenario. + Questo scenario è già caricato. + Quello scenario non è più disponibile. + Non è stato possibile salvare lo scenario. Riprova. + Non è stato possibile cambiare scenario. Riprova. + Riprova + Passaggio a %1$s + diff --git a/feature/smart-config/src/main/res/values-ja/strings.xml b/feature/smart-config/src/main/res/values-ja/strings.xml index 37a1adae2..27e6b4433 100644 --- a/feature/smart-config/src/main/res/values-ja/strings.xml +++ b/feature/smart-config/src/main/res/values-ja/strings.xml @@ -326,4 +326,19 @@ このアイテムを削除しようとしています。よろしいですか? - \ No newline at end of file + シナリオ切り替えを閉じる + シナリオを切り替え + 現在:%1$s + 不明 + 他に利用可能なスマートシナリオはありません + シナリオサービスは利用できなくなりました。 + 切り替える前にシナリオを一時停止してください。 + 画面録画を利用できません。切り替える前に再起動してください。 + このシナリオはすでに読み込まれています。 + そのシナリオは利用できなくなりました。 + シナリオを保存できませんでした。もう一度お試しください。 + シナリオを切り替えられませんでした。もう一度お試しください。 + 再試行 + %1$s に切り替え中 + + diff --git a/feature/smart-config/src/main/res/values-pt-rBR/strings.xml b/feature/smart-config/src/main/res/values-pt-rBR/strings.xml index 6aa70e51a..abb9148aa 100644 --- a/feature/smart-config/src/main/res/values-pt-rBR/strings.xml +++ b/feature/smart-config/src/main/res/values-pt-rBR/strings.xml @@ -483,4 +483,19 @@ Você está prestes a excluir este item, tem certeza? - \ No newline at end of file + Fechar o seletor de cenários + Mudar de cenário + Atual: %1$s + Desconhecido + Nenhum outro cenário inteligente disponível + O serviço de cenários não está mais disponível. + Pause o cenário antes de trocar. + A gravação da tela não está disponível. Reinicie-a antes de trocar. + Este cenário já está carregado. + Esse cenário não está mais disponível. + Não foi possível salvar o cenário. Tente novamente. + Não foi possível trocar de cenário. Tente novamente. + Tentar novamente + Mudando para %1$s + + diff --git a/feature/smart-config/src/main/res/values-ru/strings.xml b/feature/smart-config/src/main/res/values-ru/strings.xml index c77668ed9..29241061e 100644 --- a/feature/smart-config/src/main/res/values-ru/strings.xml +++ b/feature/smart-config/src/main/res/values-ru/strings.xml @@ -487,4 +487,19 @@ Вы собираетесь удалить этот элемент, вы уверены? + Закрыть переключатель сценариев + Переключить сценарий + Текущий: %1$s + Неизвестно + Нет других доступных умных сценариев + Служба сценариев больше недоступна. + Приостановите сценарий перед переключением. + Запись экрана недоступна. Перезапустите её перед переключением. + Этот сценарий уже загружен. + Этот сценарий больше недоступен. + Не удалось сохранить сценарий. Повторите попытку. + Не удалось переключить сценарий. Повторите попытку. + Повторить + Переключение на %1$s + diff --git a/feature/smart-config/src/main/res/values-uk/strings.xml b/feature/smart-config/src/main/res/values-uk/strings.xml index 81142eea1..1929ddacc 100644 --- a/feature/smart-config/src/main/res/values-uk/strings.xml +++ b/feature/smart-config/src/main/res/values-uk/strings.xml @@ -490,4 +490,19 @@ Ви збираєтесь видалити цей елемент, ви впевнені? + Закрити перемикач сценаріїв + Перемкнути сценарій + Поточний: %1$s + Невідомо + Немає інших доступних розумних сценаріїв + Служба сценаріїв більше недоступна. + Призупиніть сценарій перед перемиканням. + Запис екрана недоступний. Перезапустіть його перед перемиканням. + Цей сценарій уже завантажено. + Цей сценарій більше недоступний. + Не вдалося зберегти сценарій. Спробуйте ще раз. + Не вдалося перемкнути сценарій. Спробуйте ще раз. + Спробувати ще раз + Перемикання на %1$s + diff --git a/feature/smart-config/src/main/res/values-zh-rCN/strings.xml b/feature/smart-config/src/main/res/values-zh-rCN/strings.xml index 646a2a6c8..46f2ea327 100644 --- a/feature/smart-config/src/main/res/values-zh-rCN/strings.xml +++ b/feature/smart-config/src/main/res/values-zh-rCN/strings.xml @@ -463,4 +463,19 @@ 您即将删除此项目,确定吗? - \ No newline at end of file + 关闭场景切换器 + 切换场景 + 当前:%1$s + 未知 + 没有其他可用的智能场景 + 场景服务已不可用。 + 切换前请暂停场景。 + 屏幕录制不可用。请先重新启动屏幕录制,再切换场景。 + 此场景已加载。 + 该场景已不可用。 + 无法保存场景。请重试。 + 无法切换场景。请重试。 + 重试 + 正在切换到 %1$s + + diff --git a/feature/smart-config/src/main/res/values-zh-rTW/strings.xml b/feature/smart-config/src/main/res/values-zh-rTW/strings.xml index b7bf06108..27ce59e69 100644 --- a/feature/smart-config/src/main/res/values-zh-rTW/strings.xml +++ b/feature/smart-config/src/main/res/values-zh-rTW/strings.xml @@ -463,4 +463,19 @@ 您即將刪除此項目,確定嗎? - \ No newline at end of file + 關閉情境切換器 + 切換情境 + 目前:%1$s + 未知 + 沒有其他可用的智慧情境 + 情境服務已無法使用。 + 切換前請暫停情境。 + 螢幕錄製無法使用。請先重新啟動螢幕錄製,再切換情境。 + 此情境已載入。 + 該情境已無法使用。 + 無法儲存情境。請重試。 + 無法切換情境。請重試。 + 重試 + 正在切換至 %1$s + + diff --git a/smartautoclicker/src/main/res/values-ar/strings.xml b/smartautoclicker/src/main/res/values-ar/strings.xml index 6226d66d5..0136e9843 100644 --- a/smartautoclicker/src/main/res/values-ar/strings.xml +++ b/smartautoclicker/src/main/res/values-ar/strings.xml @@ -132,6 +132,9 @@ عرض فلاتر السيناريو عرض أو إخفاء الفلاتر في شاشة قائمة السيناريوهات. + عرض مبدّل السيناريو + يظهر مبدّل السيناريو في شريط الأدوات العائم للتبديل السريع. + واجهة الإجراءات القديمة تغيير عرض الإجراءات إلى قائمة. مفيد للسيناريوهات المعقدة. diff --git a/smartautoclicker/src/main/res/values-es/strings.xml b/smartautoclicker/src/main/res/values-es/strings.xml index c096fd501..84a11a7bf 100644 --- a/smartautoclicker/src/main/res/values-es/strings.xml +++ b/smartautoclicker/src/main/res/values-es/strings.xml @@ -125,6 +125,9 @@ Mostrar filtros de escenarios Muestra u oculta los filtros en la pantalla de lista de escenarios. + Mostrar selector de escenarios + Muestra el selector de escenarios en la barra de herramientas flotante para cambios rápidos. + IU de acción heredada Cambie la visualización de acciones por una lista. Útil para escenarios complejos. @@ -160,4 +163,4 @@ Importar Exportar - \ No newline at end of file + diff --git a/smartautoclicker/src/main/res/values-fr/strings.xml b/smartautoclicker/src/main/res/values-fr/strings.xml index 2e515c2cb..5281a7493 100644 --- a/smartautoclicker/src/main/res/values-fr/strings.xml +++ b/smartautoclicker/src/main/res/values-fr/strings.xml @@ -125,6 +125,9 @@ Afficher les filtres Afficher ou cacher les filtres sur l\'écran des Scénarios. + Afficher le sélecteur de scénario + Affiche le sélecteur de scénario dans la barre d\'outils flottante pour changer rapidement de scénario. + Legacy Action UI Modifie l\'affichage de l\'action par une liste. Utile pour les scénarios complexes. @@ -161,4 +164,4 @@ Importer Exporter - \ No newline at end of file + diff --git a/smartautoclicker/src/main/res/values-it/strings.xml b/smartautoclicker/src/main/res/values-it/strings.xml index 8ebab815f..dcfe8f31c 100644 --- a/smartautoclicker/src/main/res/values-it/strings.xml +++ b/smartautoclicker/src/main/res/values-it/strings.xml @@ -123,6 +123,9 @@ Mostra i filtri degli scenari Visualizza o nasconde i filtri nella schermata dell\'elenco degli scenari. + Mostra il selettore di scenario + Mostra il selettore di scenario nella barra degli strumenti flottante per cambi rapidi. + Interfaccia utente dell\'azione legacy Modifica la visualizzazione delle azioni tramite un elenco. Utile per scenari complessi. diff --git a/smartautoclicker/src/main/res/values-ja/strings.xml b/smartautoclicker/src/main/res/values-ja/strings.xml index 8ed63ee59..69ca0f1c6 100644 --- a/smartautoclicker/src/main/res/values-ja/strings.xml +++ b/smartautoclicker/src/main/res/values-ja/strings.xml @@ -72,6 +72,9 @@ シナリオのフィルターを表示 シナリオ一覧画面でフィルターの表示/非表示を切り替えます。 + シナリオ切り替えを表示 + フローティングツールバーにシナリオ切り替えを表示し、すばやく切り替えられるようにします。 + 従来の操作 UI 操作を一覧で表示します。複雑なシナリオに適しています。 diff --git a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml index e1cfc4585..bfe8ed088 100644 --- a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml +++ b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml @@ -124,6 +124,9 @@ Mostrar filtros de cenários Exibir ou ocultar os filtros na tela da lista de cenários. + Mostrar seletor de cenários + Exibe o seletor de cenários na barra de ferramentas flutuante para trocas rápidas. + IU de ação herdada Altere a exibição da ação por uma lista. Útil para cenários complexos. diff --git a/smartautoclicker/src/main/res/values-ru/strings.xml b/smartautoclicker/src/main/res/values-ru/strings.xml index 34f92ac70..6caf14363 100644 --- a/smartautoclicker/src/main/res/values-ru/strings.xml +++ b/smartautoclicker/src/main/res/values-ru/strings.xml @@ -122,6 +122,9 @@ Показать фильтры сценариев Отобразите или скройте фильтры на экране списка сценариев. + Показать переключатель сценариев + Показывает переключатель сценариев на плавающей панели инструментов для быстрой смены. + Устаревший пользовательский интерфейс действий Измените отображение действий списком. Полезно для сложных сценариев. @@ -157,4 +160,4 @@ Импортировать Экспортировать - \ No newline at end of file + diff --git a/smartautoclicker/src/main/res/values-uk/strings.xml b/smartautoclicker/src/main/res/values-uk/strings.xml index db81624f5..ced4f04ae 100644 --- a/smartautoclicker/src/main/res/values-uk/strings.xml +++ b/smartautoclicker/src/main/res/values-uk/strings.xml @@ -125,6 +125,9 @@ Показувати фільтри сценаріїв Відображати або приховувати фільтри на екрані списку сценаріїв. + Показувати перемикач сценаріїв + Показує перемикач сценаріїв на плаваючій панелі інструментів для швидкої зміни. + Застарілий інтерфейс дій Змінити відображення дії списком. Корисно для складних сценаріїв. @@ -160,4 +163,4 @@ Імпорт Експорт - \ No newline at end of file + diff --git a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml index 60abe00c7..5f6de9d2c 100644 --- a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml @@ -72,6 +72,9 @@ 显示场景筛选 在场景列表界面显示或隐藏筛选器。 + 显示场景切换器 + 在悬浮工具栏中显示场景切换器,方便快速切换。 + 经典操作界面 以列表方式显示操作,适用于复杂场景。 diff --git a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml index baa18a1ca..2e85b7de2 100644 --- a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml @@ -72,6 +72,9 @@ 顯示情境篩選 於情境清單畫面顯示或隱藏篩選器。 + 顯示情境切換器 + 於浮動工具列顯示情境切換器,方便快速切換。 + 經典操作介面 以清單方式顯示操作,適用於複雜情境。 From bad03a227503f456b5ac4c8a9e418c180f4701b5 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 11 Jul 2026 12:49:02 +0530 Subject: [PATCH 06/14] refactor(external-launch): generalize Quick Settings module --- .../.gitignore | 0 .../build.gradle.kts | 8 +- .../consumer-rules.pro | 0 .../proguard-rules.pro | 0 .../qstile/data/QSTileConfigData.kt | 2 +- .../qstile/data/QSTileConfigDataSource.kt | 2 +- .../qstile/domain/QSTileDisplayInfo.kt | 2 +- .../qstile/ui/QSTileLauncherActivity.kt | 20 +-- .../qstile/ui/QSTileLauncherViewModel.kt | 16 +- .../qstile/ui/QSTileService.kt | 12 +- .../res/layout/activity_qstile_launcher.xml | 0 .../src/main/AndroidManifest.xml | 49 ------ .../qstile/domain/QSTileActionHandler.kt | 28 --- .../feature/qstile/domain/QSTileRepository.kt | 159 ------------------ .../src/main/res/values-ar/strings.xml | 22 --- .../src/main/res/values-es/strings.xml | 22 --- .../src/main/res/values-fr/strings.xml | 22 --- .../src/main/res/values-it/strings.xml | 22 --- .../src/main/res/values-ja/strings.xml | 6 - .../src/main/res/values-pt-rBR/strings.xml | 22 --- .../src/main/res/values-ru/strings.xml | 22 --- .../src/main/res/values-uk/strings.xml | 22 --- .../src/main/res/values-zh-rCN/strings.xml | 6 - .../src/main/res/values-zh-rTW/strings.xml | 6 - .../src/main/res/values/strings.xml | 22 --- settings.gradle.kts | 2 +- 26 files changed, 35 insertions(+), 459 deletions(-) rename feature/{quick-settings-tile => external-launch}/.gitignore (100%) rename feature/{quick-settings-tile => external-launch}/build.gradle.kts (80%) rename feature/{quick-settings-tile => external-launch}/consumer-rules.pro (100%) rename feature/{quick-settings-tile => external-launch}/proguard-rules.pro (100%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/data/QSTileConfigData.kt (91%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/data/QSTileConfigDataSource.kt (97%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/domain/QSTileDisplayInfo.kt (92%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/ui/QSTileLauncherActivity.kt (83%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/ui/QSTileLauncherViewModel.kt (87%) rename feature/{quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature => external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch}/qstile/ui/QSTileService.kt (92%) rename feature/{quick-settings-tile => external-launch}/src/main/res/layout/activity_qstile_launcher.xml (100%) delete mode 100644 feature/quick-settings-tile/src/main/AndroidManifest.xml delete mode 100644 feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt delete mode 100644 feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt delete mode 100644 feature/quick-settings-tile/src/main/res/values-ar/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-es/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-fr/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-it/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-ja/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-pt-rBR/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-ru/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-uk/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-zh-rCN/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values-zh-rTW/strings.xml delete mode 100644 feature/quick-settings-tile/src/main/res/values/strings.xml diff --git a/feature/quick-settings-tile/.gitignore b/feature/external-launch/.gitignore similarity index 100% rename from feature/quick-settings-tile/.gitignore rename to feature/external-launch/.gitignore diff --git a/feature/quick-settings-tile/build.gradle.kts b/feature/external-launch/build.gradle.kts similarity index 80% rename from feature/quick-settings-tile/build.gradle.kts rename to feature/external-launch/build.gradle.kts index d736d8fd2..5dfb444ab 100644 --- a/feature/quick-settings-tile/build.gradle.kts +++ b/feature/external-launch/build.gradle.kts @@ -16,21 +16,26 @@ */ plugins { alias(libs.plugins.buzbuz.androidLibrary) + alias(libs.plugins.buzbuz.androidUnitTest) alias(libs.plugins.buzbuz.flavour) alias(libs.plugins.buzbuz.hilt) + alias(libs.plugins.buzbuz.kotlinSerialization) } android { - namespace = "com.buzbuz.smartautoclicker.feature.qstile" + namespace = "com.buzbuz.smartautoclicker.feature.externallaunch" buildFeatures.viewBinding = true } dependencies { implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.datastore) implementation(libs.androidx.appCompat) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.google.material) implementation(project(":core:common:base")) @@ -41,4 +46,5 @@ dependencies { implementation(project(":core:smart:domain")) implementation(project(":core:smart:processing")) implementation(project(":core:common:permissions")) + implementation(project(":core:common:actions")) } diff --git a/feature/quick-settings-tile/consumer-rules.pro b/feature/external-launch/consumer-rules.pro similarity index 100% rename from feature/quick-settings-tile/consumer-rules.pro rename to feature/external-launch/consumer-rules.pro diff --git a/feature/quick-settings-tile/proguard-rules.pro b/feature/external-launch/proguard-rules.pro similarity index 100% rename from feature/quick-settings-tile/proguard-rules.pro rename to feature/external-launch/proguard-rules.pro diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigData.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigData.kt similarity index 91% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigData.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigData.kt index 50a04de1a..c8bf6ce6d 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigData.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigData.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.data +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.data internal data class QSTileScenarioInfo( val id: Long, diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigDataSource.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigDataSource.kt similarity index 97% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigDataSource.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigDataSource.kt index b3707bbe3..f271a9937 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/data/QSTileConfigDataSource.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/data/QSTileConfigDataSource.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.data +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.data import android.content.Context import androidx.datastore.preferences.core.Preferences diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileDisplayInfo.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/domain/QSTileDisplayInfo.kt similarity index 92% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileDisplayInfo.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/domain/QSTileDisplayInfo.kt index ca5450198..d4acf0277 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileDisplayInfo.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/domain/QSTileDisplayInfo.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.domain +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.domain internal data class QSTileDisplayInfo( val tileState: Int, diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherActivity.kt similarity index 83% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherActivity.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherActivity.kt index c80fecef6..3a9c67226 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherActivity.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherActivity.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.ui +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui import android.content.Context import android.content.Intent @@ -26,7 +26,7 @@ import androidx.appcompat.app.AppCompatActivity import com.buzbuz.smartautoclicker.core.display.recorder.MediaProjectionRequest import com.buzbuz.smartautoclicker.core.ui.errors.createNoMediaProjectionDialog -import com.buzbuz.smartautoclicker.feature.qstile.R +import com.buzbuz.smartautoclicker.feature.externallaunch.R import dagger.hilt.android.AndroidEntryPoint @@ -36,11 +36,11 @@ class QSTileLauncherActivity : AppCompatActivity() { companion object { private const val EXTRA_SCENARIO_ID = - "com.buzbuz.smartautoclicker.feature.qstile.ui.EXTRA_SCENARIO_ID" + "com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui.EXTRA_SCENARIO_ID" private const val EXTRA_IS_SMART_SCENARIO = - "com.buzbuz.smartautoclicker.feature.qstile.ui.EXTRA_IS_SMART_SCENARIO" + "com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui.EXTRA_IS_SMART_SCENARIO" - fun getStartIntent(context: Context, scenarioId: Long, isSmartScenario: Boolean): Intent = + fun getLaunchIntent(context: Context, scenarioId: Long, isSmartScenario: Boolean): Intent = Intent(context, QSTileLauncherActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) .putExtra(EXTRA_SCENARIO_ID, scenarioId) @@ -75,7 +75,7 @@ class QSTileLauncherActivity : AppCompatActivity() { onMandatoryDenied = ::finish, onAllGranted = { Log.i(TAG, "All permissions are granted, start scenario") - viewModel.startDumbScenario(scenarioId) + viewModel.launchDumbScenario(scenarioId) finish() } ) @@ -98,14 +98,14 @@ class QSTileLauncherActivity : AppCompatActivity() { mediaProjectionRequest.showMediaProjectionWarning( context = this, forceEntireScreen = viewModel.isEntireScreenCaptureForced(), - onSuccess = { resultCode, data -> startScenario(resultCode, data, scenarioId) }, + onSuccess = { resultCode, data -> launchScenario(resultCode, data, scenarioId) }, onFailure = { showProjectionDeniedToast() }, onError = { showUnsupportedDeviceDialog() }, ) } - private fun startScenario(resultCode: Int, data: Intent, scenarioId: Long) { - viewModel.startSmartScenario(resultCode, data, scenarioId) + private fun launchScenario(resultCode: Int, data: Intent, scenarioId: Long) { + viewModel.launchSmartScenario(resultCode, data, scenarioId) finish() } @@ -119,4 +119,4 @@ class QSTileLauncherActivity : AppCompatActivity() { } } -private const val TAG = "QSTileLauncherActivity" \ No newline at end of file +private const val TAG = "QSTileLauncherActivity" diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherViewModel.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherViewModel.kt similarity index 87% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherViewModel.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherViewModel.kt index 5b3c0a7bb..49688f4b8 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileLauncherViewModel.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileLauncherViewModel.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.ui +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui import android.content.Intent import androidx.appcompat.app.AppCompatActivity @@ -31,7 +31,7 @@ import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionAcces import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionOverlay import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionPostNotification import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository -import com.buzbuz.smartautoclicker.feature.qstile.domain.QSTileRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -42,7 +42,7 @@ import javax.inject.Inject @HiltViewModel class QSTileLauncherViewModel @Inject constructor( @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - private val qsTileRepository: QSTileRepository, + private val qsTileRepository: ExternalLaunchRepository, private val permissionController: PermissionsController, private val smartRepository: IRepository, private val dumbRepository: DumbRepository, @@ -67,20 +67,20 @@ class QSTileLauncherViewModel @Inject constructor( ) } - fun startSmartScenario(resultCode: Int, data: Intent, scenarioId: Long) { + fun launchSmartScenario(resultCode: Int, data: Intent, scenarioId: Long) { viewModelScope.launch(ioDispatcher) { val scenario = smartRepository.getScenario(scenarioId) ?: return@launch - qsTileRepository.startSmartScenario(resultCode, data, scenario) + qsTileRepository.launchSmartScenario(resultCode, data, scenario) } } - fun startDumbScenario(scenarioId: Long) { + fun launchDumbScenario(scenarioId: Long) { viewModelScope.launch(ioDispatcher) { val scenario = dumbRepository.getDumbScenario(scenarioId) ?: return@launch - qsTileRepository.startDumbScenario(scenario) + qsTileRepository.launchDumbScenario(scenario) } } fun isEntireScreenCaptureForced(): Boolean = settingsRepository.isEntireScreenCaptureForced() -} \ No newline at end of file +} diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileService.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileService.kt similarity index 92% rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileService.kt rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileService.kt index fe67992d4..7ab43a42a 100644 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/ui/QSTileService.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/qstile/ui/QSTileService.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.qstile.ui +package com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui import android.content.ComponentName import android.content.Context @@ -25,8 +25,8 @@ import android.util.Log import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers import com.buzbuz.smartautoclicker.core.base.extensions.startActivityAndCollapseCompat -import com.buzbuz.smartautoclicker.feature.qstile.domain.QSTileDisplayInfo -import com.buzbuz.smartautoclicker.feature.qstile.domain.QSTileRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.qstile.domain.QSTileDisplayInfo +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -57,7 +57,7 @@ class QSTileService : TileService() { } } - @Inject internal lateinit var qsTileRepository: QSTileRepository + @Inject internal lateinit var qsTileRepository: ExternalLaunchRepository @Inject @Dispatcher(HiltCoroutineDispatchers.Main) internal lateinit var mainDispatcher: CoroutineDispatcher private var mainCoroutineScope: CoroutineScope? = null @@ -109,7 +109,7 @@ class QSTileService : TileService() { Tile.STATE_ACTIVE -> qsTileRepository.stopScenarios() Tile.STATE_INACTIVE -> - startActivityAndCollapseCompat(QSTileLauncherActivity.getStartIntent(this, scenarioId, isSmart)) + startActivityAndCollapseCompat(QSTileLauncherActivity.getLaunchIntent(this, scenarioId, isSmart)) } } @@ -145,4 +145,4 @@ class QSTileService : TileService() { } } -private const val TAG = "QSTileService" \ No newline at end of file +private const val TAG = "QSTileService" diff --git a/feature/quick-settings-tile/src/main/res/layout/activity_qstile_launcher.xml b/feature/external-launch/src/main/res/layout/activity_qstile_launcher.xml similarity index 100% rename from feature/quick-settings-tile/src/main/res/layout/activity_qstile_launcher.xml rename to feature/external-launch/src/main/res/layout/activity_qstile_launcher.xml diff --git a/feature/quick-settings-tile/src/main/AndroidManifest.xml b/feature/quick-settings-tile/src/main/AndroidManifest.xml deleted file mode 100644 index 43b2a5758..000000000 --- a/feature/quick-settings-tile/src/main/AndroidManifest.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt b/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt deleted file mode 100644 index bba18a337..000000000 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2024 Kevin Buzeau - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.buzbuz.smartautoclicker.feature.qstile.domain - -import android.content.Intent -import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario -import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario - -interface QSTileActionHandler { - fun isRunning() : Boolean - fun startDumbScenario(dumbScenario: DumbScenario) - fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) - fun stop() -} \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt b/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt deleted file mode 100644 index 0f92a00f2..000000000 --- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (C) 2024 Kevin Buzeau - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.buzbuz.smartautoclicker.feature.qstile.domain - -import android.content.Context -import android.content.Intent -import android.service.quicksettings.Tile - -import com.buzbuz.smartautoclicker.core.base.di.Dispatcher -import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO -import com.buzbuz.smartautoclicker.core.domain.IRepository -import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario -import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository -import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario -import com.buzbuz.smartautoclicker.core.dumb.engine.DumbEngine -import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository -import com.buzbuz.smartautoclicker.feature.qstile.R -import com.buzbuz.smartautoclicker.feature.qstile.data.QSTileScenarioInfo -import com.buzbuz.smartautoclicker.feature.qstile.data.QsTileConfigDataSource -import com.buzbuz.smartautoclicker.feature.qstile.ui.QSTileService - -import dagger.hilt.android.qualifiers.ApplicationContext - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -import javax.inject.Inject -import javax.inject.Singleton - -@OptIn(ExperimentalCoroutinesApi::class) -@Singleton -class QSTileRepository @Inject constructor( - @ApplicationContext context: Context, - @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - private val dumbRepository: DumbRepository, - private val dumbEngine: DumbEngine, - private val smartRepository: IRepository, - private val smartProcessingRepository: SmartProcessingRepository, - private val qsTileConfigDataSource: QsTileConfigDataSource, -) { - - private val coroutineScopeIo: CoroutineScope = - CoroutineScope(SupervisorJob() + ioDispatcher) - - private var qsTileActionHandler: QSTileActionHandler? = null - - private val tileDisplayInfo: Flow = qsTileConfigDataSource.getQSTileScenarioInfo() - .flatMapLatest { scenarioInfo -> - scenarioInfo ?: return@flatMapLatest flowOf(context.getTileDisplayInfo(false, null, null, null)) - - if (scenarioInfo.isSmart) { - combine(smartRepository.getScenarioFlow(scenarioInfo.id), smartProcessingRepository.scenarioId) { scenario, runningId -> - context.getTileDisplayInfo( - isSmart = true, - runningId = runningId?.databaseId, - scenarioId = scenario?.id?.databaseId, - scenarioName = scenario?.name, - ) - } - } else { - combine(dumbRepository.getDumbScenarioFlow(scenarioInfo.id), dumbEngine.dumbScenario) { scenario, runningScenario -> - context.getTileDisplayInfo( - isSmart = false, - runningId = runningScenario?.getDatabaseId(), - scenarioId = scenario?.id?.databaseId, - scenarioName = scenario?.name, - ) - } - } - } - - internal val qsTileDisplayInfo: StateFlow = tileDisplayInfo - .distinctUntilChanged() - .stateIn(coroutineScopeIo, SharingStarted.Eagerly, null) - - init { - qsTileDisplayInfo - .onEach { QSTileService.requestTileUpdate(context) } - .launchIn(coroutineScopeIo) - } - - fun setTileScenario(scenarioId: Long, isSmart: Boolean) { - coroutineScopeIo.launch { - qsTileConfigDataSource.putQSTileScenarioInfo(QSTileScenarioInfo(scenarioId, isSmart)) - } - } - - fun setTileActionHandler(actionHandler: QSTileActionHandler) { - qsTileActionHandler = actionHandler - } - - internal fun getLastScenarioDetails(): Pair = - qsTileDisplayInfo.value?.scenarioId to qsTileDisplayInfo.value?.isSmart - - internal fun isAccessibilityServiceStarted(): Boolean = - qsTileActionHandler?.isRunning() ?: false - - internal fun startDumbScenario(scenario: DumbScenario) = - qsTileActionHandler?.startDumbScenario(scenario) - - internal fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) = - qsTileActionHandler?.startSmartScenario(resultCode, data, scenario) - - internal fun stopScenarios() = - qsTileActionHandler?.stop() - - private fun Context.getTileDisplayInfo(isSmart: Boolean, runningId: Long?, scenarioId: Long?, scenarioName: String?): QSTileDisplayInfo { - val state = when { - scenarioId == null || scenarioName == null -> Tile.STATE_UNAVAILABLE - runningId == null -> Tile.STATE_INACTIVE - scenarioId == runningId -> Tile.STATE_ACTIVE - else -> Tile.STATE_UNAVAILABLE - } - - return QSTileDisplayInfo( - tileState = state, - tileTitle = getString( - when (state) { - Tile.STATE_INACTIVE -> R.string.tile_label_start_scenario - Tile.STATE_ACTIVE -> R.string.tile_label_stop_scenario - else -> R.string.tile_label_start_scenario - } - ), - tileSubTitle = - if (state == Tile.STATE_UNAVAILABLE) getString(R.string.tile_subtext_unavailable) - else scenarioName, - scenarioId = scenarioId, - isSmart = isSmart, - ) - } -} - diff --git a/feature/quick-settings-tile/src/main/res/values-ar/strings.xml b/feature/quick-settings-tile/src/main/res/values-ar/strings.xml deleted file mode 100644 index a3d9cdcbd..000000000 --- a/feature/quick-settings-tile/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - ابدأ بالنقر - أوقف النقر - لم يتم تحديد أي سيناريو - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-es/strings.xml b/feature/quick-settings-tile/src/main/res/values-es/strings.xml deleted file mode 100644 index 6a6b62ca2..000000000 --- a/feature/quick-settings-tile/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Iniciar Click\'r - Detener Klick\'r - Ningún escenario definido - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-fr/strings.xml b/feature/quick-settings-tile/src/main/res/values-fr/strings.xml deleted file mode 100644 index ced44f425..000000000 --- a/feature/quick-settings-tile/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Démarrer Klick\'r - Stop Klick\'r - Aucun Scenario défini - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-it/strings.xml b/feature/quick-settings-tile/src/main/res/values-it/strings.xml deleted file mode 100644 index 5e3fc14af..000000000 --- a/feature/quick-settings-tile/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Avviare Klick\'r - Fermare Klick\'r - Nessuno scenario definito - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-ja/strings.xml b/feature/quick-settings-tile/src/main/res/values-ja/strings.xml deleted file mode 100644 index 9ee882eed..000000000 --- a/feature/quick-settings-tile/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Klick\'r を開始 - Klick\'r を停止 - シナリオが定義されていません - diff --git a/feature/quick-settings-tile/src/main/res/values-pt-rBR/strings.xml b/feature/quick-settings-tile/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index 75b7f4789..000000000 --- a/feature/quick-settings-tile/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Iniciar Klick\'r - Parar Klick\'r - Nenhum cenário definido - diff --git a/feature/quick-settings-tile/src/main/res/values-ru/strings.xml b/feature/quick-settings-tile/src/main/res/values-ru/strings.xml deleted file mode 100644 index 0619405c5..000000000 --- a/feature/quick-settings-tile/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Запустить Klick\'r - Остановить Klick\'r - Сценарий не был определён - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-uk/strings.xml b/feature/quick-settings-tile/src/main/res/values-uk/strings.xml deleted file mode 100644 index c13036c73..000000000 --- a/feature/quick-settings-tile/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Запустити Klick\'r - Зупинити Klick\'r - Сценарій не визначено - \ No newline at end of file diff --git a/feature/quick-settings-tile/src/main/res/values-zh-rCN/strings.xml b/feature/quick-settings-tile/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 0a98c1961..000000000 --- a/feature/quick-settings-tile/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - 启动 Klick\'r - 停止 Klick\'r - 尚未定义场景 - diff --git a/feature/quick-settings-tile/src/main/res/values-zh-rTW/strings.xml b/feature/quick-settings-tile/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index fc2761a29..000000000 --- a/feature/quick-settings-tile/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - 啟動 Klick\'r - 停止 Klick\'r - 尚未定義情境 - diff --git a/feature/quick-settings-tile/src/main/res/values/strings.xml b/feature/quick-settings-tile/src/main/res/values/strings.xml deleted file mode 100644 index e533ab183..000000000 --- a/feature/quick-settings-tile/src/main/res/values/strings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Start Klick\'r - Stop Klick\'r - No Scenario defined - \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 324b8d25e..3d623c85a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,7 +64,7 @@ include(":core:smart:processing") include(":feature:backup") include(":feature:dumb-config") include(":feature:notifications") -include(":feature:quick-settings-tile") +include(":feature:external-launch") include(":feature:revenue") include(":feature:review") include(":feature:smart-config") From 8c4040c29979f262948d6480ca81c38f011aefff Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 11 Jul 2026 12:49:52 +0530 Subject: [PATCH 07/14] feat(actions): add external actions to smart scenarios --- .../common/actions/AndroidActionExecutor.kt | 5 +- .../actions/AndroidActionExecutorImpl.kt | 13 +- .../external/ExternalActionEventContract.kt | 68 ++ .../25.json | 862 ++++++++++++++++++ .../core/database/ClickDatabase.kt | 1 + .../core/database/DatabaseInfo.kt | 2 +- .../core/database/entity/ActionEntity.kt | 7 +- .../core/database/entity/DatabaseEnums.kt | 4 +- .../compat/CompatDeserializer.kt | 21 +- .../GetDebugLiveDetectionResultUseCase.kt | 4 +- .../core/domain/model/action/Action.kt | 1 + .../domain/model/action/ExternalAction.kt | 39 + .../model/action/mapper/ActionDomainMapper.kt | 12 +- .../model/action/mapper/ActionEntityMapper.kt | 12 + .../domain/model/action/ActionMapperTests.kt | 18 +- .../domain/model/action/ActionTestsData.kt | 31 +- .../data/processor/ActionExecutor.kt | 6 + .../domain/SmartProcessingRepository.kt | 3 + .../domain/SmartProcessingRepositoryImpl.kt | 4 + .../processing/tests/ActionExecutorTests.kt | 18 +- .../feature/smart/config/di/Hilt.kt | 2 + .../smart/config/domain/EditedItemsBuilder.kt | 21 +- .../config/domain/EditionDefaultValues.kt | 5 +- .../GetActionMissingReferencesUseCase.kt | 2 + .../ReplaceMissingCounterReferenceUseCase.kt | 2 + ...eMissingScreenConditionReferenceUseCase.kt | 4 +- ...IsActionRelatedToUnreachableItemUseCase.kt | 4 +- .../GetCounterReadReferencesUseCase.kt | 2 + .../GetCounterWriteReferencesUseCase.kt | 2 + .../usecase/counter/ReplaceCounterUseCase.kt | 4 +- .../ui/action/brief/BaseSmartActionUiFlow.kt | 5 +- .../brief/SmartActionsBriefViewModel.kt | 4 +- .../action/external/ExternalActionDialog.kt | 151 +++ .../external/ExternalActionSelectionDialog.kt | 113 +++ .../action/external/ExternalActionUiState.kt | 18 + .../external/ExternalActionViewModel.kt | 84 ++ .../ui/action/selection/ActionTypeChoices.kt | 10 +- .../selection/ActionTypeSelectionDialog.kt | 1 + .../config/ui/common/model/action/UiAction.kt | 3 + .../common/model/action/UiExternalAction.kt | 30 + .../dialog_config_action_external_action.xml | 76 ++ .../main/res/values/event_default_config.xml | 3 +- .../src/main/res/values/strings.xml | 18 + .../switcher/ScenarioSwitchViewModelTest.kt | 1 + .../dialog/live/eventtry/TryEventViewModel.kt | 4 +- .../localservice/SmartScenarioSwitcherTest.kt | 1 + 46 files changed, 1680 insertions(+), 21 deletions(-) create mode 100644 core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/external/ExternalActionEventContract.kt create mode 100644 core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/25.json create mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/ExternalAction.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionDialog.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionSelectionDialog.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionUiState.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionViewModel.kt create mode 100644 feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiExternalAction.kt create mode 100644 feature/smart-config/src/main/res/layout/dialog_config_action_external_action.xml diff --git a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutor.kt b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutor.kt index 39ae1dc9a..5ca28689d 100644 --- a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutor.kt +++ b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutor.kt @@ -110,7 +110,10 @@ interface AndroidActionExecutor: Dumpable { * due to the queuing system). */ fun postNotification(notificationRequest: ActionNotificationRequest) + + /** Fire a named external automation event. */ + fun fireExternalAction(externalActionName: String) } /** The maximum supported duration for a gesture. This limitation comes from Android GestureStroke API. */ -const val GESTURE_DURATION_MAX_VALUE = 59_999L \ No newline at end of file +const val GESTURE_DURATION_MAX_VALUE = 59_999L diff --git a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutorImpl.kt b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutorImpl.kt index ba2b67930..c2088e2f9 100644 --- a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutorImpl.kt +++ b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/AndroidActionExecutorImpl.kt @@ -24,6 +24,7 @@ import android.util.AndroidRuntimeException import android.util.Log import com.buzbuz.smartautoclicker.core.common.actions.gesture.GestureExecutor +import com.buzbuz.smartautoclicker.core.common.actions.external.ExternalActionEventContract import com.buzbuz.smartautoclicker.core.common.actions.model.ActionNotificationRequest import com.buzbuz.smartautoclicker.core.common.actions.notification.NotificationRequestExecutor import com.buzbuz.smartautoclicker.core.common.actions.text.TextExecutor @@ -129,9 +130,19 @@ internal class AndroidActionExecutorImpl @Inject constructor( notificationRequestExecutor.postNotification(notificationRequest) } + override fun fireExternalAction(externalActionName: String) { + val service = accessibilityService ?: return + + try { + service.sendBroadcast(ExternalActionEventContract.createRequestQueryIntent(externalActionName)) + } catch (iaex: IllegalArgumentException) { + Log.w(TAG, "Can't fire external action, Intent is invalid.", iaex) + } + } + override fun dump(writer: PrintWriter, prefix: CharSequence) { gestureExecutor.dump(writer, prefix) } } -private const val TAG = "ServiceActionExecutor" \ No newline at end of file +private const val TAG = "ServiceActionExecutor" diff --git a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/external/ExternalActionEventContract.kt b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/external/ExternalActionEventContract.kt new file mode 100644 index 000000000..b6dbe36fe --- /dev/null +++ b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/external/ExternalActionEventContract.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.common.actions.external + +import android.content.Intent +import android.os.Bundle + +object ExternalActionEventContract { + + const val ACTION_EDIT_EVENT = "net.dinglisch.android.tasker.ACTION_EDIT_EVENT" + const val ACTION_QUERY_CONDITION = "com.twofortyfouram.locale.intent.action.QUERY_CONDITION" + const val ACTION_REQUEST_QUERY = "com.twofortyfouram.locale.intent.action.REQUEST_QUERY" + + const val EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE" + const val EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB" + const val EXTRA_STRING_JSON = "com.twofortyfouram.locale.intent.extra.STRING_JSON" + const val EXTRA_STRING_ACTIVITY_CLASS_NAME = "com.twofortyfouram.locale.intent.extra.ACTIVITY" + + const val RESULT_CONDITION_SATISFIED = 16 + const val RESULT_CONDITION_UNSATISFIED = 17 + const val RESULT_CONDITION_UNKNOWN = 18 + + private const val EXTRA_REQUEST_QUERY_PASS_THROUGH_DATA = + "net.dinglisch.android.tasker.extras.PASS_THROUGH_DATA" + + private const val EXTRA_FIRED_ACTION_NAME = + "com.buzbuz.smartautoclicker.extra.EXTERNAL_ACTION_NAME" + + const val EVENT_CONFIGURATION_ACTIVITY_CLASS_NAME = + "com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui.ExternalActionEventConfigurationActivity" + + fun createConfigurationResult(configurationJson: String, blurb: String): Intent = + Intent() + .putExtra(EXTRA_BUNDLE, Bundle().apply { putString(EXTRA_STRING_JSON, configurationJson) }) + .putExtra(EXTRA_STRING_BLURB, blurb) + + fun readConfigurationJson(intent: Intent?): String? = + intent?.getBundleExtra(EXTRA_BUNDLE)?.getString(EXTRA_STRING_JSON) + + fun createRequestQueryIntent(externalActionName: String): Intent = + Intent(ACTION_REQUEST_QUERY) + .putExtra(EXTRA_STRING_ACTIVITY_CLASS_NAME, EVENT_CONFIGURATION_ACTIVITY_CLASS_NAME) + .putExtra( + EXTRA_REQUEST_QUERY_PASS_THROUGH_DATA, + Bundle().apply { putString(EXTRA_FIRED_ACTION_NAME, externalActionName.trim()) }, + ) + + fun readFiredExternalActionName(intent: Intent?): String? = + intent + ?.getBundleExtra(EXTRA_REQUEST_QUERY_PASS_THROUGH_DATA) + ?.getString(EXTRA_FIRED_ACTION_NAME) + ?.trim() + ?.takeIf { it.isNotEmpty() } +} diff --git a/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/25.json b/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/25.json new file mode 100644 index 000000000..fafafd23b --- /dev/null +++ b/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/25.json @@ -0,0 +1,862 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "ad37c524212c623f1b9c8eeb69f65c2c", + "entities": [ + { + "tableName": "action_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `clickPositionType` TEXT, `x` INTEGER, `y` INTEGER, `clickOnConditionId` INTEGER, `pressDuration` INTEGER, `clickOffsetX` INTEGER, `clickOffsetY` INTEGER, `fromX` INTEGER, `fromY` INTEGER, `toX` INTEGER, `toY` INTEGER, `swipeDuration` INTEGER, `pauseDuration` INTEGER, `isAdvanced` INTEGER, `isBroadcast` INTEGER, `intent_action` TEXT, `component_name` TEXT, `flags` INTEGER, `toggle_all` INTEGER, `toggle_all_type` TEXT, `counter_name` TEXT, `counter_operation` TEXT, `counter_operation_value_type` TEXT, `counter_operation_value` REAL, `counter_operation_counter_name` TEXT, `notification_message_text` TEXT, `notification_importance` INTEGER, `system_action_type` TEXT, `text_value` TEXT, `text_validate_input` INTEGER, `external_action_name` TEXT, FOREIGN KEY(`eventId`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`clickOnConditionId`) REFERENCES `condition_table`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clickPositionType", + "columnName": "clickPositionType", + "affinity": "TEXT" + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER" + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOnConditionId", + "columnName": "clickOnConditionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "pressDuration", + "columnName": "pressDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOffsetX", + "columnName": "clickOffsetX", + "affinity": "INTEGER" + }, + { + "fieldPath": "clickOffsetY", + "columnName": "clickOffsetY", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromX", + "columnName": "fromX", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromY", + "columnName": "fromY", + "affinity": "INTEGER" + }, + { + "fieldPath": "toX", + "columnName": "toX", + "affinity": "INTEGER" + }, + { + "fieldPath": "toY", + "columnName": "toY", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeDuration", + "columnName": "swipeDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "pauseDuration", + "columnName": "pauseDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAdvanced", + "columnName": "isAdvanced", + "affinity": "INTEGER" + }, + { + "fieldPath": "isBroadcast", + "columnName": "isBroadcast", + "affinity": "INTEGER" + }, + { + "fieldPath": "intentAction", + "columnName": "intent_action", + "affinity": "TEXT" + }, + { + "fieldPath": "componentName", + "columnName": "component_name", + "affinity": "TEXT" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER" + }, + { + "fieldPath": "toggleAll", + "columnName": "toggle_all", + "affinity": "INTEGER" + }, + { + "fieldPath": "toggleAllType", + "columnName": "toggle_all_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterName", + "columnName": "counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperation", + "columnName": "counter_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValueType", + "columnName": "counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValue", + "columnName": "counter_operation_value", + "affinity": "REAL" + }, + { + "fieldPath": "counterOperationCounterName", + "columnName": "counter_operation_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationMessageText", + "columnName": "notification_message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationImportance", + "columnName": "notification_importance", + "affinity": "INTEGER" + }, + { + "fieldPath": "systemActionType", + "columnName": "system_action_type", + "affinity": "TEXT" + }, + { + "fieldPath": "textValue", + "columnName": "text_value", + "affinity": "TEXT" + }, + { + "fieldPath": "textValidateInput", + "columnName": "text_validate_input", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalActionName", + "columnName": "external_action_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_action_table_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_action_table_eventId` ON `${TABLE_NAME}` (`eventId`)" + }, + { + "name": "index_action_table_clickOnConditionId", + "unique": false, + "columnNames": [ + "clickOnConditionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_action_table_clickOnConditionId` ON `${TABLE_NAME}` (`clickOnConditionId`)" + } + ], + "foreignKeys": [ + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "condition_table", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "clickOnConditionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scenario_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `operator` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `enabled_on_start` INTEGER NOT NULL DEFAULT 1, `type` TEXT NOT NULL, `keep_detecting` INTEGER, `detecetion_cooldown_ms` INTEGER, FOREIGN KEY(`scenario_id`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenario_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conditionOperator", + "columnName": "operator", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledOnStart", + "columnName": "enabled_on_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keepDetecting", + "columnName": "keep_detecting", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionCooldownMs", + "columnName": "detecetion_cooldown_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_table_scenario_id", + "unique": false, + "columnNames": [ + "scenario_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_table_scenario_id` ON `${TABLE_NAME}` (`scenario_id`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenario_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scenario_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `detection_quality` INTEGER NOT NULL, `compute_rate` REAL NOT NULL DEFAULT 0.0, `randomize` INTEGER NOT NULL DEFAULT 0, `keep_screen_on` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detectionQuality", + "columnName": "detection_quality", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "computeRate", + "columnName": "compute_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "randomize", + "columnName": "randomize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepScreenOn", + "columnName": "keep_screen_on", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "condition_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `priority` INTEGER NOT NULL DEFAULT 0, `shouldBeDetected` INTEGER, `path` TEXT, `area_left` INTEGER, `area_top` INTEGER, `area_right` INTEGER, `area_bottom` INTEGER, `threshold` INTEGER, `detection_type` INTEGER, `detection_area_left` INTEGER, `detection_area_top` INTEGER, `detection_area_right` INTEGER, `detection_area_bottom` INTEGER, `broadcast_action` TEXT, `counter_name` TEXT, `counter_comparison_operation` TEXT, `counter_operation_value_type` TEXT, `counter_value` REAL, `counter_value_counter_name` TEXT, `timer_value_ms` INTEGER, `timer_restart_when_reached` INTEGER, `color_rgba` INTEGER, `number_counter_comparison_operation` TEXT, `number_counter_operation_value_type` TEXT, `number_counter_value` REAL, `number_counter_value_counter_name` TEXT, `number_format_type` TEXT, `text_to_detect` TEXT, `text_alphabet` TEXT, FOREIGN KEY(`eventId`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shouldBeDetected", + "columnName": "shouldBeDetected", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "areaLeft", + "columnName": "area_left", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaTop", + "columnName": "area_top", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaRight", + "columnName": "area_right", + "affinity": "INTEGER" + }, + { + "fieldPath": "areaBottom", + "columnName": "area_bottom", + "affinity": "INTEGER" + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionType", + "columnName": "detection_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaLeft", + "columnName": "detection_area_left", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaTop", + "columnName": "detection_area_top", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaRight", + "columnName": "detection_area_right", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectionAreaBottom", + "columnName": "detection_area_bottom", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastAction", + "columnName": "broadcast_action", + "affinity": "TEXT" + }, + { + "fieldPath": "counterName", + "columnName": "counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "counterComparisonOperation", + "columnName": "counter_comparison_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "counterOperationValueType", + "columnName": "counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "counterValue", + "columnName": "counter_value", + "affinity": "REAL" + }, + { + "fieldPath": "counterOperationCounterName", + "columnName": "counter_value_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "timerValueMs", + "columnName": "timer_value_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "restartWhenReached", + "columnName": "timer_restart_when_reached", + "affinity": "INTEGER" + }, + { + "fieldPath": "colorRgba", + "columnName": "color_rgba", + "affinity": "INTEGER" + }, + { + "fieldPath": "numberCounterComparisonOperation", + "columnName": "number_counter_comparison_operation", + "affinity": "TEXT" + }, + { + "fieldPath": "numberCounterOperationValueType", + "columnName": "number_counter_operation_value_type", + "affinity": "TEXT" + }, + { + "fieldPath": "numberCounterValue", + "columnName": "number_counter_value", + "affinity": "REAL" + }, + { + "fieldPath": "numberCounterOperationCounterName", + "columnName": "number_counter_value_counter_name", + "affinity": "TEXT" + }, + { + "fieldPath": "numberFormatType", + "columnName": "number_format_type", + "affinity": "TEXT" + }, + { + "fieldPath": "textToDetect", + "columnName": "text_to_detect", + "affinity": "TEXT" + }, + { + "fieldPath": "textAlphabet", + "columnName": "text_alphabet", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_condition_table_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_condition_table_eventId` ON `${TABLE_NAME}` (`eventId`)" + } + ], + "foreignKeys": [ + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "intent_extra_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `action_id` INTEGER NOT NULL, `type` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`action_id`) REFERENCES `action_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionId", + "columnName": "action_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_intent_extra_table_action_id", + "unique": false, + "columnNames": [ + "action_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_intent_extra_table_action_id` ON `${TABLE_NAME}` (`action_id`)" + } + ], + "foreignKeys": [ + { + "table": "action_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "action_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_toggle_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `action_id` INTEGER NOT NULL, `toggle_type` TEXT NOT NULL, `toggle_event_id` INTEGER NOT NULL, FOREIGN KEY(`action_id`) REFERENCES `action_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`toggle_event_id`) REFERENCES `event_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionId", + "columnName": "action_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "toggle_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toggleEventId", + "columnName": "toggle_event_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_toggle_table_action_id", + "unique": false, + "columnNames": [ + "action_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_toggle_table_action_id` ON `${TABLE_NAME}` (`action_id`)" + }, + { + "name": "index_event_toggle_table_toggle_event_id", + "unique": false, + "columnNames": [ + "toggle_event_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_toggle_table_toggle_event_id` ON `${TABLE_NAME}` (`toggle_event_id`)" + } + ], + "foreignKeys": [ + { + "table": "action_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "action_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "toggle_event_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scenario_usage_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scenario_id` INTEGER NOT NULL, `last_start_timestamp_ms` INTEGER NOT NULL, `start_count` INTEGER NOT NULL, FOREIGN KEY(`scenario_id`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenario_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStartTimestampMs", + "columnName": "last_start_timestamp_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startCount", + "columnName": "start_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scenario_usage_table_scenario_id", + "unique": true, + "columnNames": [ + "scenario_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scenario_usage_table_scenario_id` ON `${TABLE_NAME}` (`scenario_id`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenario_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "counters_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`counterName` TEXT NOT NULL, `scenarioId` INTEGER NOT NULL, `startingValue` REAL NOT NULL, PRIMARY KEY(`counterName`, `scenarioId`), FOREIGN KEY(`scenarioId`) REFERENCES `scenario_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "counterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scenarioId", + "columnName": "scenarioId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startingValue", + "columnName": "startingValue", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "counterName", + "scenarioId" + ] + }, + "indices": [ + { + "name": "index_counters_table_scenarioId", + "unique": false, + "columnNames": [ + "scenarioId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_counters_table_scenarioId` ON `${TABLE_NAME}` (`scenarioId`)" + } + ], + "foreignKeys": [ + { + "table": "scenario_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scenarioId" + ], + "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, 'ad37c524212c623f1b9c8eeb69f65c2c')" + ] + } +} \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/ClickDatabase.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/ClickDatabase.kt index bb6353623..72279cf47 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/ClickDatabase.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/ClickDatabase.kt @@ -57,6 +57,7 @@ import javax.inject.Singleton AutoMigration (from = 18, to = 19), AutoMigration (from = 20, to = 21), AutoMigration (from = 22, to = 23), + AutoMigration (from = 24, to = 25), ] ) abstract class ClickDatabase : ScenarioDatabase() diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt index 26ad0da78..552b3980a 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/DatabaseInfo.kt @@ -40,4 +40,4 @@ internal const val COUNTERS_TABLE = "counters_table" internal const val END_CONDITION_TABLE = "end_condition_table" /** Current version of the database. */ -const val DATABASE_VERSION = 24 +const val DATABASE_VERSION = 25 diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ActionEntity.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ActionEntity.kt index 0fc9af121..3aae4aa1c 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ActionEntity.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/ActionEntity.kt @@ -88,6 +88,8 @@ import kotlinx.serialization.Serializable * * @param textValue [ActionType.TEXT] only: the text to type in the focused view * @param textValidateInput [ActionType.TEXT] only: the type of system action to execute. + * + * @param externalActionName [ActionType.EXTERNAL_ACTION] only: the global name fired to automation plugins. */ @Entity( tableName = ACTION_TABLE, @@ -162,6 +164,9 @@ data class ActionEntity( // ActionType.TEXT @ColumnInfo(name = "text_value") val textValue: String? = null, @ColumnInfo(name = "text_validate_input") val textValidateInput: Boolean? = null, + + // ActionType.EXTERNAL_ACTION + @ColumnInfo(name = "external_action_name") val externalActionName: String? = null, ) : EntityWithId /** @@ -183,4 +188,4 @@ data class CompleteActionEntity( entityColumn = "action_id" ) val eventsToggle: List, -) \ No newline at end of file +) diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/DatabaseEnums.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/DatabaseEnums.kt index 68b8cf1d5..53c237934 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/DatabaseEnums.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/DatabaseEnums.kt @@ -53,6 +53,8 @@ enum class ActionType { SYSTEM, /** Set the text of a focused view on the screen. */ TEXT, + /** Fire a named external automation plugin event. */ + EXTERNAL_ACTION, } @@ -185,4 +187,4 @@ enum class NumberFormatType { enum class NotificationMessageType { TEXT, COUNTER_VALUE; -} \ No newline at end of file +} diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt index 227563f88..45c3af85d 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt @@ -482,6 +482,7 @@ internal open class CompatDeserializer : Deserializer { ActionType.INTENT -> deserializeActionIntent(jsonAction) ActionType.TOGGLE_EVENT -> deserializeActionToggleEvent(jsonAction) ActionType.CHANGE_COUNTER -> deserializeActionChangeCounter(jsonAction) + ActionType.EXTERNAL_ACTION -> deserializeActionExternalAction(jsonAction) ActionType.NOTIFICATION -> deserializeActionNotification(jsonAction) ActionType.SYSTEM -> deserializeActionSystem(jsonAction) ActionType.TEXT -> deserializeActionSetText(jsonAction) @@ -649,6 +650,24 @@ internal open class CompatDeserializer : Deserializer { ) } + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + open fun deserializeActionExternalAction(jsonExternalAction: JsonObject): ActionEntity? { + val id = jsonExternalAction.getLong("id", true) ?: return null + val eventId = jsonExternalAction.getLong("eventId", true) ?: return null + val externalActionName = jsonExternalAction.getString("externalActionName", true)?.trim() + ?.takeIf { it.isNotEmpty() } + ?: return null + + return ActionEntity( + id = id, + eventId = eventId, + name = jsonExternalAction.getString("name") ?: "", + priority = jsonExternalAction.getInt("priority")?.coerceAtLeast(0) ?: 0, + type = ActionType.EXTERNAL_ACTION, + externalActionName = externalActionName, + ) + } + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) open fun deserializeActionNotification(jsonNotification: JsonObject): ActionEntity? { val id = jsonNotification.getLong("id", true) ?: return null @@ -747,4 +766,4 @@ internal open class CompatDeserializer : Deserializer { open fun deserializeCounterActionValue(jsonCounterCondition: JsonObject): Double = jsonCounterCondition.getDouble("counterOperationValue") ?: 0.0 -} \ No newline at end of file +} diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/usecase/GetDebugLiveDetectionResultUseCase.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/usecase/GetDebugLiveDetectionResultUseCase.kt index 477711f4b..382c88f5f 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/usecase/GetDebugLiveDetectionResultUseCase.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/usecase/GetDebugLiveDetectionResultUseCase.kt @@ -19,6 +19,7 @@ package com.buzbuz.smartautoclicker.core.smart.debugging.domain.usecase import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -81,6 +82,7 @@ class GetDebugLiveDetectionResultUseCase @Inject constructor( is Swipe -> action.swipeDuration ?: 0 is Pause -> action.pauseDuration ?: 0 is ChangeCounter, + is ExternalAction, is Intent, is Notification, is SetText, @@ -88,4 +90,4 @@ class GetDebugLiveDetectionResultUseCase @Inject constructor( is ToggleEvent -> 0 } } -} \ No newline at end of file +} diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Action.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Action.kt index 721b73c80..88c842a74 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Action.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Action.kt @@ -46,6 +46,7 @@ sealed class Action : Identifiable, Completable, Prioritizable { when (this) { is Click -> copy(id = id, eventId = eventId, name = name, priority = priority) is ChangeCounter -> copy(id = id, eventId = eventId, name = name, priority = priority) + is ExternalAction -> copy(id = id, eventId = eventId, name = name, priority = priority) is Intent -> copy(id = id, eventId = eventId, name = name, priority = priority) is Pause -> copy(id = id, eventId = eventId, name = name, priority = priority) is Swipe -> copy(id = id, eventId = eventId, name = name, priority = priority) diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/ExternalAction.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/ExternalAction.kt new file mode 100644 index 000000000..5b6647d3e --- /dev/null +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/ExternalAction.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.domain.model.action + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier + +data class ExternalAction( + override val id: Identifier, + override val eventId: Identifier, + override val name: String? = null, + override var priority: Int, + val externalActionName: String, +) : Action() { + + override fun isComplete(): Boolean = + super.isComplete() && externalActionName.isNotBlank() + + override fun hashCodeNoIds(): Int = + name.hashCode() + externalActionName.hashCode() + + override fun deepCopy(): ExternalAction = copy( + name = "" + name, + externalActionName = "" + externalActionName, + ) +} diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionDomainMapper.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionDomainMapper.kt index c645c04ea..b392669bb 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionDomainMapper.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionDomainMapper.kt @@ -13,6 +13,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationVal import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -31,6 +32,7 @@ internal fun CompleteActionEntity.toDomain(cleanIds: Boolean = false): Action = ActionType.INTENT -> toDomainIntent(cleanIds) ActionType.TOGGLE_EVENT -> toDomainToggleEvent(cleanIds) ActionType.CHANGE_COUNTER -> toDomainChangeCounter(cleanIds) + ActionType.EXTERNAL_ACTION -> toDomainExternalAction(cleanIds) ActionType.NOTIFICATION -> toDomainNotification(cleanIds) ActionType.SYSTEM -> toDomainSystem(cleanIds) ActionType.TEXT -> toDomainSetText(cleanIds) @@ -105,6 +107,14 @@ private fun CompleteActionEntity.toDomainChangeCounter(cleanIds: Boolean = false ), ) +private fun CompleteActionEntity.toDomainExternalAction(cleanIds: Boolean = false) = ExternalAction( + id = Identifier(id = action.id, asTemporary = cleanIds), + eventId = Identifier(id = action.eventId, asTemporary = cleanIds), + name = action.name, + priority = action.priority, + externalActionName = action.externalActionName ?: "", +) + private fun CompleteActionEntity.toDomainNotification(cleanIds: Boolean = false) = Notification( id = Identifier(id = action.id, asTemporary = cleanIds), eventId = Identifier(id = action.eventId, asTemporary = cleanIds), @@ -148,4 +158,4 @@ private fun String?.toComponentName(): ComponentName? = this?.let { } private fun getPositionIfValid(x: Int?, y: Int?): Point? = - if (x != null && y != null) Point(x, y) else null \ No newline at end of file + if (x != null && y != null) Point(x, y) else null diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionEntityMapper.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionEntityMapper.kt index feee13f42..5167ee32e 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionEntityMapper.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/mapper/ActionEntityMapper.kt @@ -23,6 +23,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationVal import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -42,6 +43,7 @@ internal fun Action.toEntity(): ActionEntity { is Intent -> toIntentEntity() is ToggleEvent -> toToggleEventEntity() is ChangeCounter -> toChangeCounterEntity() + is ExternalAction -> toExternalActionEntity() is Notification -> toNotificationEntity() is SystemAction -> toSystemActionEntity() is SetText -> toSetTextEntity() @@ -130,6 +132,16 @@ private fun ChangeCounter.toChangeCounterEntity(): ActionEntity { ) } +private fun ExternalAction.toExternalActionEntity(): ActionEntity = + ActionEntity( + id = id.databaseId, + eventId = eventId.databaseId, + priority = priority, + name = name!!, + type = ActionType.EXTERNAL_ACTION, + externalActionName = externalActionName.trim(), + ) + private fun Notification.toNotificationEntity(): ActionEntity = ActionEntity( id = id.databaseId, diff --git a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionMapperTests.kt b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionMapperTests.kt index 5f0bf8f0b..3b1ec1188 100644 --- a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionMapperTests.kt +++ b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionMapperTests.kt @@ -175,4 +175,20 @@ class ActionMapperTests { ActionTestsData.getNewSetTextEntity(eventId = ActionTestsData.ACTION_EVENT_ID).toDomain(), ) } -} \ No newline at end of file + + @Test + fun externalAction_toEntity() { + assertEquals( + ActionTestsData.getNewExternalActionEntity(eventId = ActionTestsData.ACTION_EVENT_ID).action, + ActionTestsData.getNewExternalAction(eventId = ActionTestsData.ACTION_EVENT_ID).toEntity(), + ) + } + + @Test + fun externalAction_toDomain() { + assertEquals( + ActionTestsData.getNewExternalAction(eventId = ActionTestsData.ACTION_EVENT_ID), + ActionTestsData.getNewExternalActionEntity(eventId = ActionTestsData.ACTION_EVENT_ID).toDomain(), + ) + } +} diff --git a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionTestsData.kt b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionTestsData.kt index 728a45d65..c5dd00408 100644 --- a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionTestsData.kt +++ b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/ActionTestsData.kt @@ -404,6 +404,35 @@ internal object ActionTestsData { eventId: Long, ) = SetText(id.asIdentifier(), eventId.asIdentifier(), name, priority, text, validateInput) + /* ------- External Action Data ------- */ + + private const val EXTERNAL_ACTION_ID = 51L + private const val EXTERNAL_ACTION_NAME = "External action name" + private const val EXTERNAL_ACTION_LINK_NAME = "Open xyz game intent" + + fun getNewExternalActionEntity( + id: Long = EXTERNAL_ACTION_ID, + name: String = EXTERNAL_ACTION_NAME, + priority: Int = 0, + externalActionName: String = EXTERNAL_ACTION_LINK_NAME, + eventId: Long, + ) = CompleteActionEntity( + action = ActionEntity( + id, eventId, priority, name, ActionType.EXTERNAL_ACTION, + externalActionName = externalActionName, + ), + intentExtras = emptyList(), + eventsToggle = emptyList(), + ) + + fun getNewExternalAction( + id: Long = EXTERNAL_ACTION_ID, + name: String? = EXTERNAL_ACTION_NAME, + priority: Int = 0, + externalActionName: String = EXTERNAL_ACTION_LINK_NAME, + eventId: Long, + ) = ExternalAction(id.asIdentifier(), eventId.asIdentifier(), name, priority, externalActionName) + fun getNewEventToggleExtra( id: Long = EVENT_TOGGLE_ID, @@ -411,4 +440,4 @@ internal object ActionTestsData { targetEventId: Long = EVENT_TOGGLE_TARGET_ID, type: ToggleEvent.ToggleType = EVENT_TOGGLE_TYPE, ) = EventToggle(id.asIdentifier(), actionId.asIdentifier(), targetEventId.asIdentifier(), type) -} \ No newline at end of file +} diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ActionExecutor.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ActionExecutor.kt index 16a07cefb..23d311112 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ActionExecutor.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ActionExecutor.kt @@ -41,6 +41,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.action.Pause import com.buzbuz.smartautoclicker.core.domain.model.action.Swipe import com.buzbuz.smartautoclicker.core.domain.model.action.ToggleEvent import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.SetText import com.buzbuz.smartautoclicker.core.domain.model.action.SystemAction @@ -98,6 +99,7 @@ internal class ActionExecutor( is Intent -> executeIntent(action) is ToggleEvent -> executeToggleEvent(action) is ChangeCounter -> executeChangeCounter(action) + is ExternalAction -> executeExternalAction(action) is Notification -> executeNotification(event, action) is SystemAction -> executeSystemAction(action) is SetText -> executeSetText(action) @@ -255,6 +257,10 @@ internal class ActionExecutor( ) } + private fun executeExternalAction(externalAction: ExternalAction) { + androidExecutor.fireExternalAction(externalAction.externalActionName) + } + private fun executeNotification(event: Event, notification: Notification) { val counters = buildMap { notification.messageText.findCounterReferences().forEach { counterName -> diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt index d66a96c4e..01df20ef5 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepository.kt @@ -51,6 +51,9 @@ interface SmartProcessingRepository : Dumpable { /** @return true if the processing is currently running ([DetectionState.DETECTING]), false if not. */ fun isRunning(): Boolean + /** @return true if screen capture is currently active, whether detection is running or only loaded. */ + fun isScreenRecordActive(): Boolean + /** * Set the scenario to be processed. * diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt index ba9494dc3..4f056e592 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingRepositoryImpl.kt @@ -158,6 +158,10 @@ internal class SmartProcessingRepositoryImpl @Inject constructor( override fun isRunning(): Boolean = detectorEngine.state.value == DetectorState.DETECTING + override fun isScreenRecordActive(): Boolean = + detectorEngine.state.value == DetectorState.RECORDING || + detectorEngine.state.value == DetectorState.DETECTING + override fun startScreenRecord(resultCode: Int, data: Intent) { detectorEngine.startScreenRecord(resultCode, data) { coroutineScopeMain.launch { projectionErrorHandler?.invoke() } diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ActionExecutorTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ActionExecutorTests.kt index 83d20baf2..1adaf3888 100644 --- a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ActionExecutorTests.kt +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ActionExecutorTests.kt @@ -30,6 +30,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.EXACT import com.buzbuz.smartautoclicker.core.domain.model.OR import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Pause import com.buzbuz.smartautoclicker.core.domain.model.action.Swipe import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition @@ -89,6 +90,8 @@ class ActionExecutorTests { )) fun getNewDefaultPause(id: Long) = Pause(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 3, TEST_DURATION) + fun getNewDefaultExternalAction(id: Long, externalActionName: String) = + ExternalAction(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 4, externalActionName) fun getNewDefaultCondition(id: Long) = ScreenCondition.Image(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 0, true, 10, "path", Rect(), EXACT, null) @@ -240,6 +243,19 @@ class ActionExecutorTests { verify(mockAndroidExecutor, never()).dispatchGesture(anyNotNull()) } + @Test + fun execute_oneExternalAction() = runTest { + val externalAction = getNewDefaultExternalAction(1, "Open xyz game intent") + + actionExecutor.executeActions( + event = getNewDefaultEvent(actions = listOf(externalAction)), + results = ConditionsResults(), + ) + + verify(mockAndroidExecutor).fireExternalAction("Open xyz game intent") + verify(mockAndroidExecutor, never()).dispatchGesture(anyNotNull()) + } + @Test fun execute_mixed() = runTest { val click = getNewDefaultClickUserPos(1) @@ -280,4 +296,4 @@ class ActionExecutorTests { assertTrue("Action execution have not completed yet", isCompleted) }.join() } -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt index 0967de6e7..cc679fb9a 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/di/Hilt.kt @@ -23,6 +23,7 @@ import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.brief.SmartAct import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.changecounter.ChangeCounterViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.click.offset.ClickOffsetViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.click.ClickViewModel +import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external.ExternalActionViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.intent.IntentViewModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.intent.activities.ActivitySelectionModel import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.intent.component.ComponentSelectionModel @@ -96,6 +97,7 @@ interface ScenarioConfigViewModelsEntryPoint { fun eventCopyModel(): EventCopyViewModel fun eventDialogViewModel(): EventDialogViewModel fun eventTogglesViewModel(): EventTogglesViewModel + fun externalActionViewModel(): ExternalActionViewModel fun extraConfigViewModel(): ExtraConfigModel fun fixEventChildrenCopyViewModel(): FixEventChildrenCopyViewModel fun fixEventsCopyViewModel(): FixEventsCopyViewModel diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditedItemsBuilder.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditedItemsBuilder.kt index 6e1846589..d3f3f101d 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditedItemsBuilder.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditedItemsBuilder.kt @@ -33,6 +33,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click import com.buzbuz.smartautoclicker.core.domain.model.action.Click.PositionType +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -379,6 +380,15 @@ class EditedItemsBuilder internal constructor( priority = 0, ) + fun createNewExternalAction(context: Context): ExternalAction = + ExternalAction( + id = actionsIdCreator.generateNewIdentifier(), + eventId = getEditedEventIdOrThrow(), + name = defaultValues.externalActionName(context), + externalActionName = "", + priority = 0, + ) + fun createNewNotification(context: Context): Notification = Notification( id = actionsIdCreator.generateNewIdentifier(), @@ -415,6 +425,7 @@ class EditedItemsBuilder internal constructor( is Intent -> createNewIntentFrom(from, eventId) is ToggleEvent -> createNewToggleEventFrom(from, eventId) is ChangeCounter -> createNewChangeCounterFrom(from, eventId) + is ExternalAction -> createNewExternalActionFrom(from, eventId) is Notification -> createNewNotificationFrom(from, eventId) is SystemAction -> createNewSystemActionFrom(from, eventId) is SetText -> createNewSetTextFrom(from, eventId) @@ -501,6 +512,14 @@ class EditedItemsBuilder internal constructor( ) } + private fun createNewExternalActionFrom(from: ExternalAction, eventId: Identifier): ExternalAction = + from.copy( + id = actionsIdCreator.generateNewIdentifier(), + eventId = eventId, + name = "" + from.name, + externalActionName = "" + from.externalActionName, + ) + private fun createNewNotificationFrom(from: Notification, eventId: Identifier): Notification { val actionId = actionsIdCreator.generateNewIdentifier() @@ -548,4 +567,4 @@ class EditedItemsBuilder internal constructor( ?: throw IllegalStateException("Can't create items without an edited action") private fun getEditedImageEventsCountOrThrow(): Int = editor.getEditedImageEventsCount() -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditionDefaultValues.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditionDefaultValues.kt index 4289e68d1..f476b0d94 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditionDefaultValues.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/EditionDefaultValues.kt @@ -77,6 +77,9 @@ internal class EditionDefaultValues { fun changeCounterName(context: Context): String = context.getString(R.string.default_change_counter_name) + fun externalActionName(context: Context): String = + context.getString(R.string.default_external_action_name) + fun notificationName(context: Context): String = context.getString(R.string.default_notification_name) @@ -88,4 +91,4 @@ internal class EditionDefaultValues { fun counterComparisonOperation(): ComparisonOperation = ComparisonOperation.EQUALS -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/GetActionMissingReferencesUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/GetActionMissingReferencesUseCase.kt index 670ec4aa9..0fbab309c 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/GetActionMissingReferencesUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/GetActionMissingReferencesUseCase.kt @@ -22,6 +22,7 @@ import com.buzbuz.smartautoclicker.core.domain.IRepository import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -65,6 +66,7 @@ class GetActionMissingReferencesUseCase @Inject constructor( is ToggleEvent -> action.getMissingReferences(copyResultEvents) // Nothing is referenced in those actions + is ExternalAction, is Intent, is Pause, is Swipe, diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingCounterReferenceUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingCounterReferenceUseCase.kt index ae470341f..f4cb371d4 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingCounterReferenceUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingCounterReferenceUseCase.kt @@ -21,6 +21,7 @@ import android.util.Log import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -103,6 +104,7 @@ class ReplaceMissingCounterReferenceUseCase @Inject constructor() { is SetText -> replaceCounterReference(oldName, newName) is Click, + is ExternalAction, is Intent, is Pause, is Swipe, diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingScreenConditionReferenceUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingScreenConditionReferenceUseCase.kt index 14f3da089..67a59e44f 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingScreenConditionReferenceUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/references/ReplaceMissingScreenConditionReferenceUseCase.kt @@ -20,6 +20,7 @@ import android.util.Log import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -76,6 +77,7 @@ class ReplaceMissingScreenConditionReferenceUseCase @Inject constructor() { } is ChangeCounter, + is ExternalAction, is Intent, is Notification, is Pause, @@ -90,4 +92,4 @@ class ReplaceMissingScreenConditionReferenceUseCase @Inject constructor() { } } -private const val TAG = "ReplaceMissingScreenConditionReferenceUseCase" \ No newline at end of file +private const val TAG = "ReplaceMissingScreenConditionReferenceUseCase" diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/unreachable/IsActionRelatedToUnreachableItemUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/unreachable/IsActionRelatedToUnreachableItemUseCase.kt index 05691a8a3..5b4f90fef 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/unreachable/IsActionRelatedToUnreachableItemUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/copy/unreachable/IsActionRelatedToUnreachableItemUseCase.kt @@ -21,6 +21,7 @@ import com.buzbuz.smartautoclicker.core.common.actions.text.findCounterReference import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -55,6 +56,7 @@ class IsActionRelatedToUnreachableItemUseCase @Inject constructor( // Nothing is referenced in those actions is Pause, is Swipe, + is ExternalAction, is Intent, is SystemAction -> false } @@ -108,4 +110,4 @@ class IsActionRelatedToUnreachableItemUseCase @Inject constructor( private fun EditionRepository.counterIsUnreachable(counterName: String): Boolean = editionState.getCounter(counterName) == null -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterReadReferencesUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterReadReferencesUseCase.kt index a1521d8f7..e4d5a7fb9 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterReadReferencesUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterReadReferencesUseCase.kt @@ -20,6 +20,7 @@ import com.buzbuz.smartautoclicker.core.common.actions.text.findCounterReference import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -112,6 +113,7 @@ class GetCounterReadReferencesUseCase @Inject constructor( } is Click, + is ExternalAction, is Intent, is Pause, is SystemAction, diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterWriteReferencesUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterWriteReferencesUseCase.kt index ceb5bcf3b..56894356f 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterWriteReferencesUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/GetCounterWriteReferencesUseCase.kt @@ -19,6 +19,7 @@ package com.buzbuz.smartautoclicker.feature.smart.config.domain.usecase.counter import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -62,6 +63,7 @@ class GetCounterWriteReferencesUseCase @Inject constructor( is Notification, is SetText, is Click, + is ExternalAction, is Intent, is Pause, is SystemAction, diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/ReplaceCounterUseCase.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/ReplaceCounterUseCase.kt index 54d27a8c2..6ef51b62c 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/ReplaceCounterUseCase.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/domain/usecase/counter/ReplaceCounterUseCase.kt @@ -20,6 +20,7 @@ import com.buzbuz.smartautoclicker.core.common.actions.text.findCounterReference import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -123,6 +124,7 @@ class ReplaceCounterUseCase @Inject constructor( } is Click, + is ExternalAction, is Intent, is Pause, is SystemAction, @@ -130,4 +132,4 @@ class ReplaceCounterUseCase @Inject constructor( is ToggleEvent -> Unit } } -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/BaseSmartActionUiFlow.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/BaseSmartActionUiFlow.kt index 1bd9bc916..bec1c8edc 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/BaseSmartActionUiFlow.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/BaseSmartActionUiFlow.kt @@ -22,6 +22,7 @@ import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionPostN import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -32,6 +33,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.action.ToggleEvent import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.OnActionConfigCompleteListener import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.changecounter.ChangeCounterDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.click.ClickDialog +import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external.ExternalActionDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.intent.IntentDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.notification.NotificationDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.pause.PauseDialog @@ -102,6 +104,7 @@ internal fun BaseOverlay.showActionConfigDialog(configurator: ActionConfigurator is SystemAction -> SystemActionDialog(actionConfigDialogListener) is ToggleEvent -> ToggleEventDialog(actionConfigDialogListener) is ChangeCounter -> ChangeCounterDialog(actionConfigDialogListener) + is ExternalAction -> ExternalActionDialog(actionConfigDialogListener) is SetText -> SetTextDialog(actionConfigDialogListener) is Notification -> { if (PermissionPostNotification().checkIfGranted(context)) NotificationDialog(actionConfigDialogListener) @@ -115,4 +118,4 @@ internal fun BaseOverlay.showActionConfigDialog(configurator: ActionConfigurator newOverlay = overlay, hideCurrent = true, ) -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/SmartActionsBriefViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/SmartActionsBriefViewModel.kt index 2f44d0fb5..b26bc0dd4 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/SmartActionsBriefViewModel.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/brief/SmartActionsBriefViewModel.kt @@ -135,6 +135,7 @@ class SmartActionsBriefViewModel @Inject constructor( add(ActionTypeChoice.SetText) add(ActionTypeChoice.System) add(ActionTypeChoice.ChangeCounter) + add(ActionTypeChoice.ExternalAction) add(ActionTypeChoice.ToggleEvent) add(ActionTypeChoice.Notification) add(ActionTypeChoice.Intent) @@ -179,6 +180,7 @@ class SmartActionsBriefViewModel @Inject constructor( ActionTypeChoice.Intent -> editionRepository.editedItemsBuilder.createNewIntent(context) ActionTypeChoice.ToggleEvent -> editionRepository.editedItemsBuilder.createNewToggleEvent(context) ActionTypeChoice.ChangeCounter -> editionRepository.editedItemsBuilder.createNewChangeCounter(context) + ActionTypeChoice.ExternalAction -> editionRepository.editedItemsBuilder.createNewExternalAction(context) ActionTypeChoice.Notification -> editionRepository.editedItemsBuilder.createNewNotification(context) ActionTypeChoice.System -> editionRepository.editedItemsBuilder.createNewSystemAction(context) ActionTypeChoice.SetText -> editionRepository.editedItemsBuilder.createNewSetText(context) @@ -341,4 +343,4 @@ class SmartActionsBriefViewModel @Inject constructor( private data class BriefVisualizationState( val focusedIndex: Int, val gestureCaptureStarted: Boolean, -) \ No newline at end of file +) diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionDialog.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionDialog.kt new file mode 100644 index 000000000..3ab9b76bf --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionDialog.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external + +import android.text.InputFilter +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.core.common.overlays.base.viewModels +import com.buzbuz.smartautoclicker.core.common.overlays.dialog.OverlayDialog +import com.buzbuz.smartautoclicker.core.ui.bindings.dialogs.DialogNavigationButton +import com.buzbuz.smartautoclicker.core.ui.bindings.dialogs.setButtonEnabledState +import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setError +import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setLabel +import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setOnTextChangedListener +import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setText +import com.buzbuz.smartautoclicker.feature.smart.config.R +import com.buzbuz.smartautoclicker.feature.smart.config.databinding.DialogConfigActionExternalActionBinding +import com.buzbuz.smartautoclicker.feature.smart.config.di.ScenarioConfigViewModelsEntryPoint +import com.buzbuz.smartautoclicker.feature.smart.config.ui.action.OnActionConfigCompleteListener +import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.dialogs.showCloseWithoutSavingDialog +import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.coroutines.launch + +class ExternalActionDialog( + private val listener: OnActionConfigCompleteListener, +) : OverlayDialog(R.style.ScenarioConfigTheme) { + + private val viewModel: ExternalActionViewModel by viewModels( + entryPoint = ScenarioConfigViewModelsEntryPoint::class.java, + creator = { externalActionViewModel() }, + ) + private lateinit var viewBinding: DialogConfigActionExternalActionBinding + + override fun onCreateView(): ViewGroup { + viewBinding = DialogConfigActionExternalActionBinding.inflate(LayoutInflater.from(context)).apply { + layoutTopBar.apply { + dialogTitle.setText(R.string.dialog_title_external_action) + buttonDismiss.setDebouncedOnClickListener { back() } + buttonSave.apply { + visibility = View.VISIBLE + setDebouncedOnClickListener { onSaveButtonClicked() } + } + buttonDelete.apply { + visibility = View.VISIBLE + setDebouncedOnClickListener { onDeleteButtonClicked() } + } + } + + fieldName.apply { + setLabel(R.string.generic_name) + setOnTextChangedListener { viewModel.setName(it.toString()) } + textField.filters = arrayOf( + InputFilter.LengthFilter(context.resources.getInteger(R.integer.name_max_length)) + ) + } + hideSoftInputOnFocusLoss(fieldName.textField) + + fieldExternalActionName.apply { + setLabel(R.string.field_external_action_name_label) + setOnTextChangedListener { viewModel.setExternalActionName(it.toString()) } + textField.filters = arrayOf( + InputFilter.LengthFilter(context.resources.getInteger(R.integer.name_max_length)) + ) + } + hideSoftInputOnFocusLoss(fieldExternalActionName.textField) + + buttonSelectExternalAction.setDebouncedOnClickListener { showExternalActionSelectionDialog() } + } + + return viewBinding.root + } + + override fun onDialogCreated(dialog: BottomSheetDialog) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { viewModel.isEditingAction.collect(::onActionEditingStateChanged) } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect(::updateUiState) } + } + } + } + + override fun back() { + if (viewModel.hasUnsavedModifications()) { + context.showCloseWithoutSavingDialog { + listener.onDismissClicked() + super.back() + } + return + } + + listener.onDismissClicked() + super.back() + } + + private fun onSaveButtonClicked() { + listener.onConfirmClicked() + super.back() + } + + private fun onDeleteButtonClicked() { + listener.onDeleteClicked() + super.back() + } + + private fun updateUiState(state: ExternalActionUiState?) { + state ?: return + + viewBinding.apply { + layoutTopBar.setButtonEnabledState(DialogNavigationButton.SAVE, state.canBeSaved) + fieldName.setText(state.name) + fieldName.setError(state.nameError) + fieldExternalActionName.setText(state.externalActionName) + fieldExternalActionName.setError(state.externalActionNameError) + } + } + + private fun showExternalActionSelectionDialog() { + overlayManager.navigateTo( + context = context, + newOverlay = ExternalActionSelectionDialog { selectedName -> + viewModel.setExternalActionName(selectedName) + }, + hideCurrent = true, + ) + } + + private fun onActionEditingStateChanged(isEditingAction: Boolean) { + if (!isEditingAction) { + Log.e(TAG, "Closing ExternalActionDialog because there is no action edited") + finish() + } + } +} + +private const val TAG = "ExternalActionDialog" diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionSelectionDialog.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionSelectionDialog.kt new file mode 100644 index 000000000..1b2391996 --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionSelectionDialog.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.buzbuz.smartautoclicker.core.common.overlays.base.viewModels +import com.buzbuz.smartautoclicker.core.common.overlays.dialog.OverlayDialog +import com.buzbuz.smartautoclicker.core.ui.bindings.lists.setEmptyText +import com.buzbuz.smartautoclicker.core.ui.bindings.lists.updateState +import com.buzbuz.smartautoclicker.feature.smart.config.R +import com.buzbuz.smartautoclicker.feature.smart.config.databinding.DialogBaseListBinding +import com.buzbuz.smartautoclicker.feature.smart.config.databinding.ItemCounterNameBinding +import com.buzbuz.smartautoclicker.feature.smart.config.di.ScenarioConfigViewModelsEntryPoint +import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.coroutines.launch + +class ExternalActionSelectionDialog( + private val onExternalActionSelected: (String) -> Unit, +) : OverlayDialog(R.style.ScenarioConfigTheme) { + + private val viewModel: ExternalActionViewModel by viewModels( + entryPoint = ScenarioConfigViewModelsEntryPoint::class.java, + creator = { externalActionViewModel() }, + ) + private lateinit var viewBinding: DialogBaseListBinding + private lateinit var adapter: ExternalActionSelectionAdapter + + override fun onCreateView(): ViewGroup { + viewBinding = DialogBaseListBinding.inflate(LayoutInflater.from(context)).apply { + layoutTopBar.apply { + dialogTitle.setText(R.string.dialog_title_external_action_selection) + buttonDismiss.setDebouncedOnClickListener { back() } + } + + floatingButtonsLayout.visibility = View.GONE + + adapter = ExternalActionSelectionAdapter { selectedName -> + debounceUserInteraction { + onExternalActionSelected(selectedName) + back() + } + } + + layoutLoadableList.apply { + setEmptyText(R.string.message_empty_external_action_list_title, R.string.message_empty_external_action_list_desc) + list.adapter = adapter + list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } + + return viewBinding.root + } + + override fun onDialogCreated(dialog: BottomSheetDialog) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.knownExternalActionNames.collect(::updateExternalActionNames) } + } + } + } + + private fun updateExternalActionNames(names: List) { + viewBinding.layoutLoadableList.updateState(names) + adapter.submitList(names) + } +} + +private class ExternalActionSelectionAdapter( + private val onExternalActionSelected: (String) -> Unit, +) : ListAdapter(ExternalActionSelectionDiffUtilCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExternalActionSelectionViewHolder = + ExternalActionSelectionViewHolder( + ItemCounterNameBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onExternalActionSelected, + ) + + override fun onBindViewHolder(holder: ExternalActionSelectionViewHolder, position: Int) { + holder.onBind(getItem(position)) + } +} + +private object ExternalActionSelectionDiffUtilCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem +} + +private class ExternalActionSelectionViewHolder( + private val viewBinding: ItemCounterNameBinding, + private val onExternalActionSelected: (String) -> Unit, +) : RecyclerView.ViewHolder(viewBinding.root) { + + fun onBind(name: String) { + viewBinding.title.text = name + viewBinding.description.setText(R.string.field_external_action_selection_desc) + viewBinding.root.setOnClickListener { onExternalActionSelected(name) } + } +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionUiState.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionUiState.kt new file mode 100644 index 000000000..f8b5e51e0 --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionUiState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external + +data class ExternalActionUiState( + val canBeSaved: Boolean, + val hasUnsavedModifications: Boolean, + val name: String?, + val nameError: Boolean, + val externalActionName: String, + val externalActionNameError: Boolean, +) diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionViewModel.kt new file mode 100644 index 000000000..ba898eeba --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/external/ExternalActionViewModel.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.action.external + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction +import com.buzbuz.smartautoclicker.feature.smart.config.domain.EditionRepository +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@OptIn(FlowPreview::class) +class ExternalActionViewModel @Inject constructor( + private val editionRepository: EditionRepository, +) : ViewModel() { + + private val configuredExternalAction = editionRepository.editionState.editedActionState + .mapNotNull { action -> action.value } + .filterIsInstance() + + val isEditingAction: Flow = editionRepository.isEditingAction + .distinctUntilChanged() + .debounce(1000) + + val knownExternalActionNames: Flow> = + editionRepository.editionState.allEditedEventsFlow + .map { events -> + events + .flatMap { event -> event.actions } + .filterIsInstance() + .map { action -> action.externalActionName.trim() } + .filter { name -> name.isNotEmpty() } + .distinct() + .sortedBy { name -> name.lowercase() } + } + + val uiState: StateFlow = combine( + configuredExternalAction, + editionRepository.editionState.editedActionState.map { it.hasChanged }, + editionRepository.editionState.editedActionState.map { it.canBeSaved }, + ) { action, hasChanged, canBeSaved -> + ExternalActionUiState( + canBeSaved = canBeSaved, + hasUnsavedModifications = hasChanged, + name = action.name, + nameError = action.name?.isEmpty() ?: true, + externalActionName = action.externalActionName, + externalActionNameError = action.externalActionName.isBlank(), + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun hasUnsavedModifications(): Boolean = + uiState.value?.hasUnsavedModifications == true + + fun setName(name: String) { + updateEditedExternalAction { old -> old.copy(name = "" + name) } + } + + fun setExternalActionName(name: String) { + updateEditedExternalAction { old -> old.copy(externalActionName = name.trim()) } + } + + private fun updateEditedExternalAction(closure: (old: ExternalAction) -> ExternalAction) { + editionRepository.editionState.getEditedAction()?.let { old -> + editionRepository.updateEditedAction(closure(old)) + } + } +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeChoices.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeChoices.kt index 262fbc3f5..cde80a110 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeChoices.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeChoices.kt @@ -20,6 +20,7 @@ import com.buzbuz.smartautoclicker.core.common.overlays.dialog.implementation.Di import com.buzbuz.smartautoclicker.feature.smart.config.R import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getChangeCounterIconRes import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getClickIconRes +import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getExternalActionIconRes import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getIntentIconRes import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getNotificationIconRes import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action.getPauseIconRes @@ -84,6 +85,13 @@ sealed class ActionTypeChoice( getChangeCounterIconRes(), ) + /** External Action choice. */ + data object ExternalAction : ActionTypeChoice( + R.string.item_external_action_title, + R.string.item_external_action_desc, + getExternalActionIconRes(), + ) + /** Notification Action choice. */ data object Notification : ActionTypeChoice( R.string.item_notification_title, @@ -104,4 +112,4 @@ sealed class ActionTypeChoice( R.string.item_set_text_desc, getSetTextIconRes(), ) -} \ No newline at end of file +} diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeSelectionDialog.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeSelectionDialog.kt index 053e41da2..5b555ebbc 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeSelectionDialog.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/action/selection/ActionTypeSelectionDialog.kt @@ -66,6 +66,7 @@ class ActionTypeSelectionDialog( else viewModel.stopViewCounterMonitoring() ActionTypeChoice.Copy, + ActionTypeChoice.ExternalAction, ActionTypeChoice.Intent, ActionTypeChoice.Notification, ActionTypeChoice.Pause, diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiAction.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiAction.kt index 8016393b6..866cf71ed 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiAction.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiAction.kt @@ -21,6 +21,7 @@ import androidx.annotation.DrawableRes import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -56,6 +57,7 @@ internal fun Action.getIconRes(): Int = when (this) { is Intent -> getIntentIconRes() is ToggleEvent -> getToggleEventIconRes() is ChangeCounter -> getChangeCounterIconRes() + is ExternalAction -> getExternalActionIconRes() is Notification -> getNotificationIconRes() is SystemAction -> getSystemActionIconRes() is SetText -> getSetTextIconRes() @@ -68,6 +70,7 @@ internal fun Action.getActionDescription(context: Context, parent: Event?, inErr is Intent -> getDescription(context, inError) is ToggleEvent -> getDescription(context, inError) is ChangeCounter -> getDescription(context, inError) + is ExternalAction -> getDescription(context, inError) is Notification -> getDescription(context, inError) is SystemAction -> getDescription(context, inError) is SetText -> getDescription(context, inError) diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiExternalAction.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiExternalAction.kt new file mode 100644 index 000000000..5e263570e --- /dev/null +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/common/model/action/UiExternalAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.common.model.action + +import android.content.Context +import androidx.annotation.DrawableRes +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction +import com.buzbuz.smartautoclicker.feature.smart.config.R + +@DrawableRes +internal fun getExternalActionIconRes(): Int = + R.drawable.ic_external_action + +internal fun ExternalAction.getDescription(context: Context, inError: Boolean): String = + if (inError) context.getString(R.string.item_external_action_details_error) + else context.getString(R.string.item_external_action_details, externalActionName) diff --git a/feature/smart-config/src/main/res/layout/dialog_config_action_external_action.xml b/feature/smart-config/src/main/res/layout/dialog_config_action_external_action.xml new file mode 100644 index 000000000..ae471e558 --- /dev/null +++ b/feature/smart-config/src/main/res/layout/dialog_config_action_external_action.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/smart-config/src/main/res/values/event_default_config.xml b/feature/smart-config/src/main/res/values/event_default_config.xml index c4fee35b5..ef5ebedac 100644 --- a/feature/smart-config/src/main/res/values/event_default_config.xml +++ b/feature/smart-config/src/main/res/values/event_default_config.xml @@ -36,10 +36,11 @@ 4 Change counter + External Action Notification System action Write text - \ No newline at end of file + diff --git a/feature/smart-config/src/main/res/values/strings.xml b/feature/smart-config/src/main/res/values/strings.xml index c6d1e9df3..e8f441877 100644 --- a/feature/smart-config/src/main/res/values/strings.xml +++ b/feature/smart-config/src/main/res/values/strings.xml @@ -89,6 +89,11 @@ %1$s %2$s %3$s Counter is invalid + External Action + Trigger an external automation app + Fire \"%1$s\" + External action name is invalid + Change event state Change the enabled state of an event Enable all @@ -432,6 +437,19 @@ %1$s = %1$s %2$s %3$s + + + External Action + External action name + Use this name in your automation app\'s Klick\'r external action trigger. + No external action selected + Click here to reuse an external action name + Reusable automation trigger name + External actions + No external action found + Type a new external action name in a smart scenario first. + + Event state diff --git a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt index d64a7d94f..fb1fa138c 100644 --- a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt +++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt @@ -168,6 +168,7 @@ private class TestProcessingRepository( override val canStartDetection: Flow = emptyFlow() override fun getScenarioId() = scenarioId.value override fun isRunning() = false + override fun isScreenRecordActive() = false override fun setScenarioId(identifier: Identifier, markAsUsed: Boolean) = Unit override suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier) = Unit override fun setProjectionErrorHandler(handler: () -> Unit) = Unit diff --git a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/eventtry/TryEventViewModel.kt b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/eventtry/TryEventViewModel.kt index 920222596..b19315f0b 100644 --- a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/eventtry/TryEventViewModel.kt +++ b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/eventtry/TryEventViewModel.kt @@ -27,6 +27,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.OR import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -130,6 +131,7 @@ private fun Event.getDebugIcon(): Int = private fun Action.getDebugIcon(): Int = when (this) { is ChangeCounter -> R.drawable.ic_change_counter + is ExternalAction -> R.drawable.ic_external_action is Click -> R.drawable.ic_click is Intent -> R.drawable.ic_intent is Notification -> R.drawable.ic_action_notification @@ -151,4 +153,4 @@ private fun Context.getDurationText(durationMs: Long): String = "$durationMs${getString(R.string.dropdown_label_time_unit_ms)}" /** Delay before removing the last positive result display in debug. */ -private val POSITIVE_VALUE_DISPLAY_TIMEOUT_MS = 1500.milliseconds \ No newline at end of file +private val POSITIVE_VALUE_DISPLAY_TIMEOUT_MS = 1500.milliseconds diff --git a/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt index 5d9d3b100..9c759fa68 100644 --- a/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt +++ b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/SmartScenarioSwitcherTest.kt @@ -201,6 +201,7 @@ private class TestProcessingRepository(initialScenarioId: Identifier) : SmartPro override fun getScenarioId() = scenarioIdValue.value override fun isRunning() = detectionStateValue.value == DetectionState.DETECTING + override fun isScreenRecordActive() = true override fun setScenarioId(identifier: Identifier, markAsUsed: Boolean) { scenarioIdValue.value = identifier } override suspend fun setScenarioIdAndMarkAsUsed(identifier: Identifier) { onMarkAsUsed() From 5df96a5642d72d437cc110c4e1afa109af4abf24 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 11 Jul 2026 12:50:21 +0530 Subject: [PATCH 08/14] feat(locale): add signed scenario-control plugin configuration --- .../src/main/AndroidManifest.xml | 113 +++++++++++ .../domain/ExternalLaunchActionHandler.kt | 35 ++++ .../domain/ExternalLaunchRepository.kt | 182 ++++++++++++++++++ .../data/AndroidKeystoreLocalePluginSigner.kt | 63 ++++++ .../externallaunch/localeplugin/di/Hilt.kt | 27 +++ .../domain/LocalePluginConfiguration.kt | 38 ++++ .../domain/LocalePluginConfigurationCodec.kt | 57 ++++++ .../domain/LocalePluginConfigurationSigner.kt | 14 ++ .../domain/LocalePluginContract.kt | 28 +++ .../ui/LocalePluginConfigurationActivity.kt | 127 ++++++++++++ .../ui/LocalePluginConfigurationViewModel.kt | 71 +++++++ .../ui/LocalePluginScenarioAdapter.kt | 54 ++++++ .../LocalePluginStopConfigurationActivity.kt | 43 +++++ ...ty_external_action_event_configuration.xml | 72 +++++++ .../activity_locale_plugin_configuration.xml | 74 +++++++ .../activity_locale_plugin_execution.xml | 5 + .../layout/item_locale_plugin_scenario.xml | 37 ++++ .../src/main/res/values-ar/strings.xml | 54 ++++++ .../src/main/res/values-es/strings.xml | 54 ++++++ .../src/main/res/values-fr/strings.xml | 54 ++++++ .../src/main/res/values-it/strings.xml | 54 ++++++ .../src/main/res/values-ja/strings.xml | 38 ++++ .../src/main/res/values-pt-rBR/strings.xml | 54 ++++++ .../src/main/res/values-ru/strings.xml | 54 ++++++ .../src/main/res/values-uk/strings.xml | 54 ++++++ .../src/main/res/values-zh-rCN/strings.xml | 38 ++++ .../src/main/res/values-zh-rTW/strings.xml | 38 ++++ .../src/main/res/values/strings.xml | 56 ++++++ .../LocalePluginConfigurationCodecTest.kt | 103 ++++++++++ .../LocalePluginConfigurationViewModelTest.kt | 54 ++++++ 30 files changed, 1745 insertions(+) create mode 100644 feature/external-launch/src/main/AndroidManifest.xml create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/data/AndroidKeystoreLocalePluginSigner.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/di/Hilt.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodec.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationSigner.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginContract.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationActivity.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModel.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginScenarioAdapter.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginStopConfigurationActivity.kt create mode 100644 feature/external-launch/src/main/res/layout/activity_external_action_event_configuration.xml create mode 100644 feature/external-launch/src/main/res/layout/activity_locale_plugin_configuration.xml create mode 100644 feature/external-launch/src/main/res/layout/activity_locale_plugin_execution.xml create mode 100644 feature/external-launch/src/main/res/layout/item_locale_plugin_scenario.xml create mode 100644 feature/external-launch/src/main/res/values-ar/strings.xml create mode 100644 feature/external-launch/src/main/res/values-es/strings.xml create mode 100644 feature/external-launch/src/main/res/values-fr/strings.xml create mode 100644 feature/external-launch/src/main/res/values-it/strings.xml create mode 100644 feature/external-launch/src/main/res/values-ja/strings.xml create mode 100644 feature/external-launch/src/main/res/values-pt-rBR/strings.xml create mode 100644 feature/external-launch/src/main/res/values-ru/strings.xml create mode 100644 feature/external-launch/src/main/res/values-uk/strings.xml create mode 100644 feature/external-launch/src/main/res/values-zh-rCN/strings.xml create mode 100644 feature/external-launch/src/main/res/values-zh-rTW/strings.xml create mode 100644 feature/external-launch/src/main/res/values/strings.xml create mode 100644 feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt create mode 100644 feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModelTest.kt diff --git a/feature/external-launch/src/main/AndroidManifest.xml b/feature/external-launch/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9653fa879 --- /dev/null +++ b/feature/external-launch/src/main/AndroidManifest.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt new file mode 100644 index 000000000..f2c3961e7 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.domain + +import android.content.Intent +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario + +interface ExternalLaunchActionHandler { + fun isRunning(): Boolean + fun isScenarioConfigurationOpen(): Boolean + fun isSmartScreenRecordActive(): Boolean + fun getSmartScenarioId(): Long? + fun getDumbScenarioId(): Long? + fun launchDumbScenario(dumbScenario: DumbScenario) + fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) + fun replaceDumbScenario(dumbScenario: DumbScenario) + fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) + fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) + fun stop() +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt new file mode 100644 index 000000000..adc354dbc --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.domain + +import android.content.Context +import android.content.Intent +import android.service.quicksettings.Tile + +import com.buzbuz.smartautoclicker.core.base.di.Dispatcher +import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository +import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario +import com.buzbuz.smartautoclicker.core.dumb.engine.DumbEngine +import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.qstile.data.QSTileScenarioInfo +import com.buzbuz.smartautoclicker.feature.externallaunch.qstile.data.QsTileConfigDataSource +import com.buzbuz.smartautoclicker.feature.externallaunch.qstile.domain.QSTileDisplayInfo +import com.buzbuz.smartautoclicker.feature.externallaunch.qstile.ui.QSTileService + +import dagger.hilt.android.qualifiers.ApplicationContext + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +import javax.inject.Inject +import javax.inject.Singleton + +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class ExternalLaunchRepository @Inject constructor( + @ApplicationContext context: Context, + @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val dumbRepository: DumbRepository, + private val dumbEngine: DumbEngine, + private val smartRepository: IRepository, + private val smartProcessingRepository: SmartProcessingRepository, + private val qsTileConfigDataSource: QsTileConfigDataSource, +) { + + private val coroutineScopeIo: CoroutineScope = + CoroutineScope(SupervisorJob() + ioDispatcher) + + private var actionHandler: ExternalLaunchActionHandler? = null + + private val tileDisplayInfo: Flow = qsTileConfigDataSource.getQSTileScenarioInfo() + .flatMapLatest { scenarioInfo -> + scenarioInfo ?: return@flatMapLatest flowOf(context.getTileDisplayInfo(false, null, null, null)) + + if (scenarioInfo.isSmart) { + combine(smartRepository.getScenarioFlow(scenarioInfo.id), smartProcessingRepository.scenarioId) { scenario, runningId -> + context.getTileDisplayInfo( + isSmart = true, + runningId = runningId?.databaseId, + scenarioId = scenario?.id?.databaseId, + scenarioName = scenario?.name, + ) + } + } else { + combine(dumbRepository.getDumbScenarioFlow(scenarioInfo.id), dumbEngine.dumbScenario) { scenario, runningScenario -> + context.getTileDisplayInfo( + isSmart = false, + runningId = runningScenario?.getDatabaseId(), + scenarioId = scenario?.id?.databaseId, + scenarioName = scenario?.name, + ) + } + } + } + + internal val qsTileDisplayInfo: StateFlow = tileDisplayInfo + .distinctUntilChanged() + .stateIn(coroutineScopeIo, SharingStarted.Eagerly, null) + + init { + qsTileDisplayInfo + .onEach { QSTileService.requestTileUpdate(context) } + .launchIn(coroutineScopeIo) + } + + fun setTileScenario(scenarioId: Long, isSmart: Boolean) { + coroutineScopeIo.launch { + qsTileConfigDataSource.putQSTileScenarioInfo(QSTileScenarioInfo(scenarioId, isSmart)) + } + } + + fun setActionHandler(actionHandler: ExternalLaunchActionHandler) { + this.actionHandler = actionHandler + } + + internal fun getLastScenarioDetails(): Pair = + qsTileDisplayInfo.value?.scenarioId to qsTileDisplayInfo.value?.isSmart + + internal fun isAccessibilityServiceStarted(): Boolean = + actionHandler?.isRunning() ?: false + + /** True while the user is editing the currently loaded scenario. */ + internal fun isScenarioConfigurationOpen(): Boolean = + actionHandler?.isScenarioConfigurationOpen() ?: false + + internal fun isSmartScreenRecordActive(): Boolean = + actionHandler?.isSmartScreenRecordActive() ?: false + + internal fun getSmartScenarioId(): Long? = + actionHandler?.getSmartScenarioId() + + internal fun isDumbScenarioRunning(scenarioId: Long): Boolean = + actionHandler?.isRunning() == true && actionHandler?.getDumbScenarioId() == scenarioId + + internal fun launchDumbScenario(scenario: DumbScenario) = + actionHandler?.launchDumbScenario(scenario) + + internal fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) = + actionHandler?.launchSmartScenario(resultCode, data, scenario) + + internal fun replaceDumbScenario(scenario: DumbScenario) = + actionHandler?.replaceDumbScenario(scenario) + + internal fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) = + actionHandler?.replaceSmartScenario(resultCode, data, scenario) + + internal fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) = + actionHandler?.replaceSmartScenarioWithCurrentProjection(scenario) + + internal fun stopScenarios() = + actionHandler?.stop() + + private fun Context.getTileDisplayInfo(isSmart: Boolean, runningId: Long?, scenarioId: Long?, scenarioName: String?): QSTileDisplayInfo { + val state = when { + scenarioId == null || scenarioName == null -> Tile.STATE_UNAVAILABLE + runningId == null -> Tile.STATE_INACTIVE + scenarioId == runningId -> Tile.STATE_ACTIVE + else -> Tile.STATE_UNAVAILABLE + } + + return QSTileDisplayInfo( + tileState = state, + tileTitle = getString( + when (state) { + Tile.STATE_INACTIVE -> R.string.tile_label_launch_scenario + Tile.STATE_ACTIVE -> R.string.tile_label_stop_scenario + else -> R.string.tile_label_launch_scenario + } + ), + tileSubTitle = + if (state == Tile.STATE_UNAVAILABLE) getString(R.string.tile_subtext_unavailable) + else scenarioName, + scenarioId = scenarioId, + isSmart = isSmart, + ) + } +} + diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/data/AndroidKeystoreLocalePluginSigner.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/data/AndroidKeystoreLocalePluginSigner.kt new file mode 100644 index 000000000..6552c8f87 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/data/AndroidKeystoreLocalePluginSigner.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.data + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationSigner +import java.security.KeyStore +import java.security.MessageDigest +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AndroidKeystoreLocalePluginSigner @Inject constructor() : LocalePluginConfigurationSigner { + + override fun sign(payload: String): String = hmac(payload).toHex() + + override fun verify(payload: String, signature: String): Boolean { + val signatureBytes = signature.hexToByteArrayOrNull() ?: return false + return MessageDigest.isEqual(hmac(payload), signatureBytes) + } + + private fun hmac(payload: String): ByteArray = + Mac.getInstance(HMAC_ALGORITHM).run { + init(getOrCreateKey()) + doFinal(payload.toByteArray(Charsets.UTF_8)) + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance(HMAC_ALGORITHM, KEYSTORE_PROVIDER).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, + ).setDigests(KeyProperties.DIGEST_SHA256).build() + ) + generateKey() + } + } +} + +private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) } + +private fun String.hexToByteArrayOrNull(): ByteArray? { + if (length % 2 != 0 || any { it.digitToIntOrNull(16) == null }) return null + return ByteArray(length / 2) { index -> substring(index * 2, index * 2 + 2).toInt(16).toByte() } +} + +private const val KEYSTORE_PROVIDER = "AndroidKeyStore" +private const val HMAC_ALGORITHM = "HmacSHA256" +private const val KEY_ALIAS = "klickr_locale_plugin_hmac_v1" diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/di/Hilt.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/di/Hilt.kt new file mode 100644 index 000000000..c3628bb8b --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/di/Hilt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.di + +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.data.AndroidKeystoreLocalePluginSigner +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationSigner +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class LocalePluginModule { + @Binds + @Singleton + abstract fun bindConfigurationSigner( + signer: AndroidKeystoreLocalePluginSigner, + ): LocalePluginConfigurationSigner +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt new file mode 100644 index 000000000..b27135446 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import kotlinx.serialization.Serializable + +@Serializable +internal enum class LocalePluginOperation { LAUNCH, STOP } + +@Serializable +internal data class LocalePluginConfiguration( + val version: Int = CURRENT_VERSION, + val operation: LocalePluginOperation, + val scenarioId: Long? = null, + val isSmart: Boolean? = null, +) { + fun isValid(): Boolean = when (operation) { + LocalePluginOperation.LAUNCH -> scenarioId != null && scenarioId > 0L && isSmart != null + LocalePluginOperation.STOP -> scenarioId == null && isSmart == null + } +} + +@Serializable +internal data class SignedLocalePluginConfiguration( + val version: Int, + val operation: LocalePluginOperation, + val scenarioId: Long? = null, + val isSmart: Boolean? = null, + val signature: String, +) + +internal const val CURRENT_VERSION = 1 diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodec.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodec.kt new file mode 100644 index 000000000..fcce25fad --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodec.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class LocalePluginConfigurationCodec @Inject constructor( + private val signer: LocalePluginConfigurationSigner, +) { + private val json = Json { + encodeDefaults = true + explicitNulls = true + ignoreUnknownKeys = false + } + + fun encode(configuration: LocalePluginConfiguration): String { + require(configuration.version == CURRENT_VERSION && configuration.isValid()) + val payload = json.encodeToString(configuration) + return json.encodeToString( + SignedLocalePluginConfiguration( + version = configuration.version, + operation = configuration.operation, + scenarioId = configuration.scenarioId, + isSmart = configuration.isSmart, + signature = signer.sign(payload), + ) + ) + } + + fun decode(value: String?): LocalePluginConfiguration? { + if (value.isNullOrBlank()) return null + + return runCatching { + val signed = json.decodeFromString(value) + val configuration = LocalePluginConfiguration( + version = signed.version, + operation = signed.operation, + scenarioId = signed.scenarioId, + isSmart = signed.isSmart, + ) + if (configuration.version != CURRENT_VERSION || !configuration.isValid()) return null + + val payload = json.encodeToString(configuration) + configuration.takeIf { signer.verify(payload, signed.signature) } + }.getOrNull() + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationSigner.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationSigner.kt new file mode 100644 index 000000000..8409a0362 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationSigner.kt @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +internal interface LocalePluginConfigurationSigner { + fun sign(payload: String): String + fun verify(payload: String, signature: String): Boolean +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginContract.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginContract.kt new file mode 100644 index 000000000..72bc11d04 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginContract.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import android.content.Intent +import android.os.Bundle + +internal object LocalePluginContract { + const val ACTION_EDIT_SETTING = "com.twofortyfouram.locale.intent.action.EDIT_SETTING" + const val ACTION_FIRE_SETTING = "com.twofortyfouram.locale.intent.action.FIRE_SETTING" + const val EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE" + const val EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB" + const val EXTRA_STRING_JSON = "com.twofortyfouram.locale.intent.extra.STRING_JSON" + + fun readConfigurationJson(intent: Intent?): String? = + intent?.getBundleExtra(EXTRA_BUNDLE)?.getString(EXTRA_STRING_JSON) + + fun createResult(configurationJson: String, blurb: String): Intent = + Intent() + .putExtra(EXTRA_BUNDLE, Bundle().apply { putString(EXTRA_STRING_JSON, configurationJson) }) + .putExtra(EXTRA_STRING_BLURB, blurb) +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationActivity.kt new file mode 100644 index 000000000..bc401caf4 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationActivity.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.app.Activity +import android.os.Bundle +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.databinding.ActivityLocalePluginConfigurationBinding +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfiguration +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginOperation +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class LocalePluginConfigurationActivity : AppCompatActivity() { + + private val viewModel: LocalePluginConfigurationViewModel by viewModels() + private lateinit var binding: ActivityLocalePluginConfigurationBinding + private lateinit var scenarioAdapter: LocalePluginScenarioAdapter + private var selectedScenario: LocalePluginScenarioItem? = null + private var restoredConfiguration: LocalePluginConfiguration? = null + private var hasAppliedRestore = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action != LocalePluginContract.ACTION_EDIT_SETTING) { + finish() + return + } + + binding = ActivityLocalePluginConfigurationBinding.inflate(layoutInflater) + setContentView(binding.root) + scenarioAdapter = LocalePluginScenarioAdapter(this) + binding.scenario.setAdapter(scenarioAdapter) + restoredConfiguration = viewModel.decodeConfiguration(LocalePluginContract.readConfigurationJson(intent)) + + binding.scenario.setOnItemClickListener { _, _, position, _ -> + selectedScenario = scenarioAdapter.getItem(position) + render() + } + binding.cancel.setOnClickListener { + setResult(Activity.RESULT_CANCELED) + finish() + } + binding.save.setOnClickListener { + viewModel.requestFallbackNotificationPermission(this, ::saveConfiguration) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.scenarios.collect(::updateScenarios) + } + } + } + + private fun updateScenarios(scenarios: List) { + scenarioAdapter.replace(scenarios) + if (!hasAppliedRestore) { + hasAppliedRestore = true + restoredConfiguration?.let { restored -> + selectedScenario = scenarios.find { + it.id == restored.scenarioId && it.isSmart == restored.isSmart + } + } + if (restoredConfiguration == null) selectedScenario = scenarios.firstOrNull() + } else { + selectedScenario = selectedScenario?.let { selected -> + scenarios.find { it.id == selected.id && it.isSmart == selected.isSmart } + } + } + binding.scenario.setText(selectedScenario?.name.orEmpty(), false) + render() + } + + private fun render() { + binding.save.isEnabled = selectedScenario != null + + val message = when { + scenarioAdapter.isEmpty -> getString(R.string.locale_plugin_no_scenarios) + restoredConfiguration?.operation == LocalePluginOperation.LAUNCH && + restoredConfiguration?.scenarioId != null && selectedScenario == null -> + getString(R.string.locale_plugin_deleted_scenario) + selectedScenario?.isSmart == true -> getString(R.string.locale_plugin_smart_note) + else -> null + } + binding.message.text = message + binding.message.visibility = if (message == null) View.GONE else View.VISIBLE + } + + private fun saveConfiguration() { + if (selectedScenario != null) finishSavingConfiguration() + } + + private fun finishSavingConfiguration() { + val scenario = selectedScenario ?: return + val configuration = LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = scenario.id, + isSmart = scenario.isSmart, + ) + val blurb = getString( + R.string.locale_plugin_blurb_launch, + scenario.name, + getString( + if (scenario.isSmart) R.string.locale_plugin_type_smart else R.string.locale_plugin_type_dumb + ), + ) + setResult( + Activity.RESULT_OK, + LocalePluginContract.createResult(viewModel.encodeConfiguration(configuration), blurb), + ) + finish() + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModel.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModel.kt new file mode 100644 index 000000000..cddc01209 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModel.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.common.permissions.PermissionsController +import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionPostNotification +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfiguration +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationCodec +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +internal class LocalePluginConfigurationViewModel @Inject constructor( + smartRepository: IRepository, + dumbRepository: DumbRepository, + private val codec: LocalePluginConfigurationCodec, + private val permissionController: PermissionsController, +) : ViewModel() { + + val scenarios: StateFlow> = combine( + smartRepository.scenarios, + dumbRepository.dumbScenarios, + ) { smartScenarios, dumbScenarios -> + (smartScenarios.map { scenario -> + LocalePluginScenarioItem(scenario.id.databaseId, scenario.name, isSmart = true) + } + dumbScenarios.map { scenario -> + LocalePluginScenarioItem(scenario.id.databaseId, scenario.name, isSmart = false) + }).sortedBy { it.name.lowercase() } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun decodeConfiguration(value: String?): LocalePluginConfiguration? = codec.decode(value) + + fun encodeConfiguration(configuration: LocalePluginConfiguration): String = codec.encode(configuration) + + fun requestFallbackNotificationPermission(activity: AppCompatActivity, onGranted: () -> Unit) { + permissionController.startPermissionsUiFlow( + activity = activity, + permissions = listOf( + PermissionPostNotification( + optional = true, + purpose = PermissionPostNotification.Purpose.EXTERNAL_LAUNCH_FALLBACK, + ), + ), + onAllGranted = onGranted, + ) + } + +} + +internal data class LocalePluginScenarioItem( + val id: Long, + val name: String, + val isSmart: Boolean, +) { + override fun toString(): String = name +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginScenarioAdapter.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginScenarioAdapter.kt new file mode 100644 index 000000000..82aa0ceed --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginScenarioAdapter.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.databinding.ItemLocalePluginScenarioBinding + +internal class LocalePluginScenarioAdapter(context: Context) : + ArrayAdapter(context, R.layout.item_locale_plugin_scenario, mutableListOf()) { + + fun replace(items: List) { + clear() + addAll(items) + notifyDataSetChanged() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View = + bind(position, convertView, parent) + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View = + bind(position, convertView, parent) + + private fun bind(position: Int, convertView: View?, parent: ViewGroup): View { + val binding = if (convertView == null) { + ItemLocalePluginScenarioBinding.inflate(LayoutInflater.from(context), parent, false) + } else { + ItemLocalePluginScenarioBinding.bind(convertView) + } + val item = getItem(position) ?: return binding.root + binding.name.text = item.name + binding.type.setText( + if (item.isSmart) R.string.locale_plugin_type_smart else R.string.locale_plugin_type_dumb + ) + binding.icon.setImageResource( + if (item.isSmart) { + com.buzbuz.smartautoclicker.core.ui.R.drawable.ic_screen_event + } else { + com.buzbuz.smartautoclicker.core.ui.R.drawable.ic_click + } + ) + return binding.root + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginStopConfigurationActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginStopConfigurationActivity.kt new file mode 100644 index 000000000..ff548f8b7 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginStopConfigurationActivity.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.app.Activity +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfiguration +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginOperation +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class LocalePluginStopConfigurationActivity : AppCompatActivity() { + + private val viewModel: LocalePluginConfigurationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action != LocalePluginContract.ACTION_EDIT_SETTING) { + finish() + return + } + + val configuration = LocalePluginConfiguration(operation = LocalePluginOperation.STOP) + setResult( + Activity.RESULT_OK, + LocalePluginContract.createResult( + viewModel.encodeConfiguration(configuration), + getString(R.string.locale_plugin_blurb_stop), + ) + ) + finish() + } +} diff --git a/feature/external-launch/src/main/res/layout/activity_external_action_event_configuration.xml b/feature/external-launch/src/main/res/layout/activity_external_action_event_configuration.xml new file mode 100644 index 000000000..45d205348 --- /dev/null +++ b/feature/external-launch/src/main/res/layout/activity_external_action_event_configuration.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/external-launch/src/main/res/layout/activity_locale_plugin_configuration.xml b/feature/external-launch/src/main/res/layout/activity_locale_plugin_configuration.xml new file mode 100644 index 000000000..efad5612e --- /dev/null +++ b/feature/external-launch/src/main/res/layout/activity_locale_plugin_configuration.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/external-launch/src/main/res/layout/activity_locale_plugin_execution.xml b/feature/external-launch/src/main/res/layout/activity_locale_plugin_execution.xml new file mode 100644 index 000000000..f2646b4e7 --- /dev/null +++ b/feature/external-launch/src/main/res/layout/activity_locale_plugin_execution.xml @@ -0,0 +1,5 @@ + + diff --git a/feature/external-launch/src/main/res/layout/item_locale_plugin_scenario.xml b/feature/external-launch/src/main/res/layout/item_locale_plugin_scenario.xml new file mode 100644 index 000000000..e2d1165c4 --- /dev/null +++ b/feature/external-launch/src/main/res/layout/item_locale_plugin_scenario.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/feature/external-launch/src/main/res/values-ar/strings.xml b/feature/external-launch/src/main/res/values-ar/strings.xml new file mode 100644 index 000000000..473acebae --- /dev/null +++ b/feature/external-launch/src/main/res/values-ar/strings.xml @@ -0,0 +1,54 @@ + + + + تشغيل Klick\'r + أوقف النقر + لم يتم تحديد أي سيناريو + تشغيل سيناريو Klick\'r + إيقاف Klick\'r + تشغيل سيناريو Klick\'r + اختر السيناريو الذي سيجهزه Klick\'r. اضغط تشغيل في Klick\'r عندما تكون مستعدًا. + السيناريو + أنشئ سيناريو في Klick\'r قبل إعداد هذا الإجراء. + لم يعد السيناريو المحدد سابقًا موجودًا. اختر سيناريو آخر. + تطلب السيناريوهات الذكية إذن تسجيل الشاشة عند تشغيلها ما لم تكن جلسة الالتقاط نشطة في Klick\'r. + حفظ + إلغاء + تشغيل %1$s (%2$s) + إيقاف Klick\'r + ذكي + بسيط + نوع السيناريو + تشغيل الأتمتة + إكمال تشغيل Klick\'r + اضغط لتشغيل %1$s + تحتاج أتمتة Klick\'r إلى انتباه + لم يعد هذا الإجراء المحفوظ صالحًا. افتحه واحفظه مجددًا في تطبيق الأتمتة. + لم يعد السيناريو المحدد موجودًا. عدّل هذا الإجراء في تطبيق الأتمتة. + لم يتم منح أذونات Klick\'r المطلوبة. + تعذر على Klick\'r الفتح مباشرةً، كما أن الإشعارات معطلة للبديل. + لم يتم منح إذن تسجيل الشاشة. + إجراء Klick\'r خارجي + اختر إجراءً خارجيًا + شغّل هذه الأتمتة عندما ينفذ سيناريو ذكي إجراءً خارجيًا محددًا. + الإجراء الخارجي + يجب أن يطابق الاسم إجراءً خارجيًا محفوظًا في سيناريو ذكي في Klick\'r. الأسماء ليست سرية، لذا لا تستخدمها لحماية عمليات الأتمتة الحساسة. + أنشئ إجراءً خارجيًا داخل سيناريو ذكي في Klick\'r أولًا، ثم عد إلى هنا. + الاسم المحفوظ غير مستخدم حاليًا في Klick\'r. يمكنك حفظه كما هو أو اختيار اسم آخر. + إجراء خارجي: %1$s + diff --git a/feature/external-launch/src/main/res/values-es/strings.xml b/feature/external-launch/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..b2d06d693 --- /dev/null +++ b/feature/external-launch/src/main/res/values-es/strings.xml @@ -0,0 +1,54 @@ + + + + Iniciar Klick\'r + Detener Klick\'r + Ningún escenario definido + Iniciar escenario de Klick\'r + Detener Klick\'r + Iniciar escenario de Klick\'r + Elige el escenario que Klick\'r debe preparar. Pulsa Reproducir en Klick\'r cuando estés listo. + Escenario + Crea un escenario en Klick\'r antes de configurar esta acción. + El escenario seleccionado anteriormente ya no existe. Elige otro. + Los escenarios inteligentes solicitan permiso para capturar la pantalla al iniciarse, salvo que Klick\'r ya tenga una sesión de captura activa. + Guardar + Cancelar + Iniciar %1$s (%2$s) + Detener Klick\'r + Inteligente + Simple + Tipo de escenario + Inicios de automatización + Terminar de iniciar Klick\'r + Toca para iniciar %1$s + La automatización de Klick\'r necesita atención + Esta acción guardada ya no es válida. Ábrela y guárdala de nuevo en tu aplicación de automatización. + El escenario seleccionado ya no existe. Edita esta acción en tu aplicación de automatización. + No se concedieron los permisos necesarios de Klick\'r. + Klick\'r no pudo abrirse directamente y las notificaciones están desactivadas para el recurso alternativo. + No se concedió el permiso para capturar la pantalla. + Acción externa de Klick\'r + Elegir acción externa + Ejecuta esta automatización cuando un escenario inteligente active la acción externa seleccionada. + Acción externa + El nombre debe coincidir con una acción externa guardada en un escenario inteligente de Klick\'r. Los nombres no son secretos, así que no los uses para proteger automatizaciones sensibles. + Crea primero una acción externa dentro de un escenario inteligente de Klick\'r y vuelve aquí. + Este nombre guardado no se usa actualmente en Klick\'r. Puedes guardarlo sin cambios o elegir otro. + Acción externa: %1$s + diff --git a/feature/external-launch/src/main/res/values-fr/strings.xml b/feature/external-launch/src/main/res/values-fr/strings.xml new file mode 100644 index 000000000..940523d3c --- /dev/null +++ b/feature/external-launch/src/main/res/values-fr/strings.xml @@ -0,0 +1,54 @@ + + + + Lancer Klick\'r + Stop Klick\'r + Aucun Scenario défini + Lancer un scénario Klick\'r + Arrêter Klick\'r + Lancer un scénario Klick\'r + Choisissez le scénario que Klick\'r doit préparer. Appuyez sur Lecture dans Klick\'r lorsque vous êtes prêt. + Scénario + Créez un scénario dans Klick\'r avant de configurer cette action. + Le scénario sélectionné précédemment n\'existe plus. Choisissez-en un autre. + Les scénarios intelligents demandent l\'autorisation de capture d\'écran au lancement, sauf si Klick\'r dispose déjà d\'une session active. + Enregistrer + Annuler + Lancer %1$s (%2$s) + Arrêter Klick\'r + Intelligent + Simple + Type de scénario + Lancements d\'automatisation + Terminer le lancement de Klick\'r + Appuyez pour lancer %1$s + L\'automatisation Klick\'r nécessite votre attention + Cette action enregistrée n\'est plus valide. Ouvrez-la et enregistrez-la à nouveau dans votre application d\'automatisation. + Le scénario sélectionné n\'existe plus. Modifiez cette action dans votre application d\'automatisation. + Les autorisations Klick\'r requises n\'ont pas été accordées. + Klick\'r n\'a pas pu s\'ouvrir directement et les notifications sont désactivées pour le mode de secours. + L\'autorisation de capture d\'écran n\'a pas été accordée. + Action externe Klick\'r + Choisir une action externe + Exécutez cette automatisation lorsqu\'un scénario intelligent déclenche l\'action externe sélectionnée. + Action externe + Le nom doit correspondre à une action externe enregistrée dans un scénario intelligent Klick\'r. Les noms ne sont pas secrets : ne les utilisez pas pour protéger des automatisations sensibles. + Créez d\'abord une action externe dans un scénario intelligent Klick\'r, puis revenez ici. + Ce nom enregistré n\'est pas utilisé actuellement dans Klick\'r. Vous pouvez l\'enregistrer tel quel ou en choisir un autre. + Action externe : %1$s + diff --git a/feature/external-launch/src/main/res/values-it/strings.xml b/feature/external-launch/src/main/res/values-it/strings.xml new file mode 100644 index 000000000..e3d74e49d --- /dev/null +++ b/feature/external-launch/src/main/res/values-it/strings.xml @@ -0,0 +1,54 @@ + + + + Avvia Klick\'r + Fermare Klick\'r + Nessuno scenario definito + Avvia scenario Klick\'r + Arresta Klick\'r + Avvia scenario Klick\'r + Scegli lo scenario che Klick\'r deve preparare. Premi Riproduci in Klick\'r quando sei pronto. + Scenario + Crea uno scenario in Klick\'r prima di configurare questa azione. + Lo scenario selezionato in precedenza non esiste più. Scegline un altro. + Gli scenari smart richiedono l\'autorizzazione per la cattura dello schermo all\'avvio, a meno che Klick\'r non abbia già una sessione attiva. + Salva + Annulla + Avvia %1$s (%2$s) + Arresta Klick\'r + Smart + Semplice + Tipo di scenario + Avvii dell\'automazione + Completa l\'avvio di Klick\'r + Tocca per avviare %1$s + L\'automazione Klick\'r richiede attenzione + Questa azione salvata non è più valida. Aprila e salvala di nuovo nell\'app di automazione. + Lo scenario selezionato non esiste più. Modifica questa azione nell\'app di automazione. + Le autorizzazioni richieste di Klick\'r non sono state concesse. + Klick\'r non ha potuto aprirsi direttamente e le notifiche sono disattivate per il ripiego. + L\'autorizzazione per la cattura dello schermo non è stata concessa. + Azione esterna di Klick\'r + Scegli azione esterna + Esegui questa automazione quando uno scenario smart attiva l\'azione esterna selezionata. + Azione esterna + Il nome deve corrispondere a un\'azione esterna salvata in uno scenario smart di Klick\'r. I nomi non sono segreti, quindi non usarli per proteggere automazioni sensibili. + Crea prima un\'azione esterna in uno scenario smart di Klick\'r, poi torna qui. + Questo nome salvato non è attualmente usato in Klick\'r. Puoi salvarlo invariato o sceglierne un altro. + Azione esterna: %1$s + diff --git a/feature/external-launch/src/main/res/values-ja/strings.xml b/feature/external-launch/src/main/res/values-ja/strings.xml new file mode 100644 index 000000000..27d360998 --- /dev/null +++ b/feature/external-launch/src/main/res/values-ja/strings.xml @@ -0,0 +1,38 @@ + + + Klick\'r を起動 + Klick\'r を停止 + シナリオが定義されていません + Klick\'r シナリオを起動 + Klick\'r を停止 + Klick\'r シナリオを起動 + Klick\'r で準備するシナリオを選択します。準備ができたら Klick\'r で再生を押してください。 + シナリオ + このアクションを設定する前に Klick\'r でシナリオを作成してください。 + 以前選択したシナリオは存在しません。別のシナリオを選択してください。 + スマートシナリオは、Klick\'r に有効なキャプチャセッションがない場合、起動時に画面キャプチャ権限を求めます。 + 保存 + キャンセル + %1$s を起動(%2$s) + Klick\'r を停止 + スマート + シンプル + シナリオの種類 + 自動化の起動 + Klick\'r の起動を完了 + タップして %1$s を起動 + Klick\'r の自動化に対応が必要です + 保存したアクションは無効です。自動化アプリで開いて再度保存してください。 + 選択したシナリオは存在しません。自動化アプリでこのアクションを編集してください。 + Klick\'r に必要な権限が許可されていません。 + Klick\'r を直接開けませんでした。代替手段の通知も無効です。 + 画面キャプチャ権限が許可されていません。 + Klick\'r 外部アクション + 外部アクションを選択 + スマートシナリオで選択した外部アクションが実行されたときに、この自動化を実行します。 + 外部アクション + 名前は Klick\'r のスマートシナリオに保存された外部アクションと一致する必要があります。名前は秘密ではないため、機密性の高い自動化の保護には使用しないでください。 + まず Klick\'r のスマートシナリオに外部アクションを作成してから、ここに戻ってください。 + この保存名は現在 Klick\'r で使われていません。そのまま保存するか、別の名前を選べます。 + 外部アクション: %1$s + diff --git a/feature/external-launch/src/main/res/values-pt-rBR/strings.xml b/feature/external-launch/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 000000000..d942c45b5 --- /dev/null +++ b/feature/external-launch/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,54 @@ + + + + Iniciar Klick\'r + Parar Klick\'r + Nenhum cenário definido + Iniciar cenário do Klick\'r + Parar o Klick\'r + Iniciar cenário do Klick\'r + Escolha o cenário que o Klick\'r deve preparar. Pressione Reproduzir no Klick\'r quando estiver pronto. + Cenário + Crie um cenário no Klick\'r antes de configurar esta ação. + O cenário selecionado anteriormente não existe mais. Escolha outro. + Cenários inteligentes pedem permissão de captura de tela ao iniciar, a menos que o Klick\'r já tenha uma sessão de captura ativa. + Salvar + Cancelar + Iniciar %1$s (%2$s) + Parar o Klick\'r + Inteligente + Simples + Tipo de cenário + Inicializações de automação + Concluir inicialização do Klick\'r + Toque para iniciar %1$s + A automação do Klick\'r precisa de atenção + Esta ação salva não é mais válida. Abra-a e salve-a novamente no seu app de automação. + O cenário selecionado não existe mais. Edite esta ação no seu app de automação. + As permissões necessárias do Klick\'r não foram concedidas. + O Klick\'r não pôde abrir diretamente e as notificações estão desativadas para o fallback. + A permissão de captura de tela não foi concedida. + Ação externa do Klick\'r + Escolher ação externa + Execute esta automação quando um cenário inteligente disparar a Ação externa selecionada. + Ação externa + O nome deve corresponder a uma Ação externa salva em um cenário inteligente do Klick\'r. Os nomes não são secretos, portanto não os use para proteger automações sensíveis. + Crie primeiro uma Ação externa dentro de um cenário inteligente do Klick\'r e volte aqui. + Este nome salvo não é usado atualmente no Klick\'r. Você pode salvá-lo sem alterações ou escolher outro. + Ação externa: %1$s + diff --git a/feature/external-launch/src/main/res/values-ru/strings.xml b/feature/external-launch/src/main/res/values-ru/strings.xml new file mode 100644 index 000000000..c65297da6 --- /dev/null +++ b/feature/external-launch/src/main/res/values-ru/strings.xml @@ -0,0 +1,54 @@ + + + + Запустить Klick\'r + Остановить Klick\'r + Сценарий не был определён + Запустить сценарий Klick\'r + Остановить Klick\'r + Запустить сценарий Klick\'r + Выберите сценарий, который должен подготовить Klick\'r. Когда будете готовы, нажмите «Воспроизвести» в Klick\'r. + Сценарий + Создайте сценарий в Klick\'r перед настройкой этого действия. + Ранее выбранный сценарий больше не существует. Выберите другой. + При запуске умных сценариев требуется разрешение на запись экрана, если в Klick\'r ещё нет активного сеанса захвата. + Сохранить + Отмена + Запустить %1$s (%2$s) + Остановить Klick\'r + Умный + Простой + Тип сценария + Запуск автоматизации + Завершить запуск Klick\'r + Нажмите, чтобы запустить %1$s + Автоматизации Klick\'r требуется внимание + Сохранённое действие больше недействительно. Откройте и сохраните его заново в приложении автоматизации. + Выбранный сценарий больше не существует. Измените это действие в приложении автоматизации. + Не предоставлены необходимые разрешения Klick\'r. + Klick\'r не удалось открыть напрямую, а уведомления для запасного варианта отключены. + Разрешение на запись экрана не предоставлено. + Внешнее действие Klick\'r + Выберите внешнее действие + Запускайте эту автоматизацию, когда умный сценарий выполняет выбранное внешнее действие. + Внешнее действие + Имя должно совпадать с внешним действием, сохранённым в умном сценарии Klick\'r. Имена не являются секретными, поэтому не используйте их для защиты важных автоматизаций. + Сначала создайте внешнее действие в умном сценарии Klick\'r, затем вернитесь сюда. + Это сохранённое имя сейчас не используется в Klick\'r. Можно сохранить его или выбрать другое. + Внешнее действие: %1$s + diff --git a/feature/external-launch/src/main/res/values-uk/strings.xml b/feature/external-launch/src/main/res/values-uk/strings.xml new file mode 100644 index 000000000..03a8a3266 --- /dev/null +++ b/feature/external-launch/src/main/res/values-uk/strings.xml @@ -0,0 +1,54 @@ + + + + Запустити Klick\'r + Зупинити Klick\'r + Сценарій не визначено + Запустити сценарій Klick\'r + Зупинити Klick\'r + Запустити сценарій Klick\'r + Виберіть сценарій, який має підготувати Klick\'r. Коли будете готові, натисніть «Відтворити» в Klick\'r. + Сценарій + Створіть сценарій у Klick\'r перед налаштуванням цієї дії. + Раніше вибраний сценарій більше не існує. Виберіть інший. + Розумні сценарії запитують дозвіл на запис екрана під час запуску, якщо Klick\'r ще не має активного сеансу захоплення. + Зберегти + Скасувати + Запустити %1$s (%2$s) + Зупинити Klick\'r + Розумний + Простий + Тип сценарію + Запуски автоматизації + Завершити запуск Klick\'r + Торкніться, щоб запустити %1$s + Автоматизація Klick\'r потребує уваги + Збережена дія більше недійсна. Відкрийте її та збережіть знову в застосунку автоматизації. + Вибраний сценарій більше не існує. Відредагуйте цю дію в застосунку автоматизації. + Не надано необхідні дозволи Klick\'r. + Klick\'r не вдалося відкрити безпосередньо, а сповіщення для запасного варіанту вимкнено. + Дозвіл на запис екрана не надано. + Зовнішня дія Klick\'r + Виберіть зовнішню дію + Запускайте цю автоматизацію, коли розумний сценарій виконує вибрану зовнішню дію. + Зовнішня дія + Назва має збігатися із зовнішньою дією, збереженою в розумному сценарії Klick\'r. Назви не є секретними, тому не використовуйте їх для захисту важливої автоматизації. + Спочатку створіть зовнішню дію в розумному сценарії Klick\'r, а потім поверніться сюди. + Ця збережена назва зараз не використовується в Klick\'r. Її можна зберегти або вибрати іншу. + Зовнішня дія: %1$s + diff --git a/feature/external-launch/src/main/res/values-zh-rCN/strings.xml b/feature/external-launch/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 000000000..52c211e39 --- /dev/null +++ b/feature/external-launch/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,38 @@ + + + 启动 Klick\'r + 停止 Klick\'r + 尚未定义场景 + 启动 Klick\'r 场景 + 停止 Klick\'r + 启动 Klick\'r 场景 + 选择 Klick\'r 要准备的场景。准备好后,在 Klick\'r 中点击播放。 + 场景 + 请先在 Klick\'r 中创建场景,再配置此操作。 + 之前选择的场景已不存在。请选择其他场景。 + 启动智能场景时会请求屏幕捕获权限,除非 Klick\'r 已有活动的捕获会话。 + 保存 + 取消 + 启动 %1$s(%2$s) + 停止 Klick\'r + 智能 + 简单 + 场景类型 + 自动化启动 + 完成 Klick\'r 启动 + 点按以启动 %1$s + Klick\'r 自动化需要处理 + 保存的操作已无效。请在自动化应用中重新打开并保存。 + 所选场景已不存在。请在自动化应用中编辑此操作。 + 未授予 Klick\'r 所需的权限。 + Klick\'r 无法直接打开,且备用通知已停用。 + 未授予屏幕捕获权限。 + Klick\'r 外部操作 + 选择外部操作 + 当智能场景触发所选外部操作时运行此自动化。 + 外部操作 + 名称必须与 Klick\'r 智能场景中保存的外部操作一致。名称并非秘密,请勿用它来保护敏感自动化操作。 + 请先在 Klick\'r 智能场景中创建外部操作,然后返回此处。 + 此保存的名称目前未在 Klick\'r 中使用。可以原样保存,也可以选择其他名称。 + 外部操作:%1$s + diff --git a/feature/external-launch/src/main/res/values-zh-rTW/strings.xml b/feature/external-launch/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..b2c8f10c3 --- /dev/null +++ b/feature/external-launch/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,38 @@ + + + 啟動 Klick\'r + 停止 Klick\'r + 尚未定義情境 + 啟動 Klick\'r 情境 + 停止 Klick\'r + 啟動 Klick\'r 情境 + 選擇 Klick\'r 要準備的情境。準備好後,在 Klick\'r 中按下播放。 + 情境 + 請先在 Klick\'r 中建立情境,再設定此動作。 + 先前選取的情境已不存在。請選擇其他情境。 + 啟動智慧情境時會要求螢幕擷取權限,除非 Klick\'r 已有作用中的擷取工作階段。 + 儲存 + 取消 + 啟動 %1$s(%2$s) + 停止 Klick\'r + 智慧 + 簡單 + 情境類型 + 自動化啟動 + 完成 Klick\'r 啟動 + 點按以啟動 %1$s + Klick\'r 自動化需要處理 + 儲存的動作已無效。請在自動化應用程式中重新開啟並儲存。 + 所選情境已不存在。請在自動化應用程式中編輯此動作。 + 未授予 Klick\'r 所需的權限。 + Klick\'r 無法直接開啟,且備用通知已停用。 + 未授予螢幕擷取權限。 + Klick\'r 外部動作 + 選擇外部動作 + 當智慧情境觸發所選外部動作時執行此自動化。 + 外部動作 + 名稱必須與 Klick\'r 智慧情境中儲存的外部動作一致。名稱並非秘密,請勿用它來保護敏感的自動化操作。 + 請先在 Klick\'r 智慧情境中建立外部動作,然後返回此處。 + 此儲存名稱目前未在 Klick\'r 中使用。可以原樣儲存,也可以選擇其他名稱。 + 外部動作:%1$s + diff --git a/feature/external-launch/src/main/res/values/strings.xml b/feature/external-launch/src/main/res/values/strings.xml new file mode 100644 index 000000000..92653344f --- /dev/null +++ b/feature/external-launch/src/main/res/values/strings.xml @@ -0,0 +1,56 @@ + + + + Launch Klick\'r + Stop Klick\'r + No Scenario defined + + Launch Klick\'r scenario + Stop Klick\'r + Launch Klick\'r scenario + Choose the scenario Klick\'r should prepare. Press Play in Klick\'r when ready. + Scenario + Create a scenario in Klick\'r before configuring this action. + The previously selected scenario no longer exists. Choose another scenario. + Smart scenarios ask for screen-capture permission when launched unless Klick\'r already has an active capture session. + Save + Cancel + Launch %1$s (%2$s) + Stop Klick\'r + Smart + Dumb + Scenario type + Automation launches + Finish launching Klick\'r + Tap to launch %1$s + Klick\'r automation needs attention + This saved action is no longer valid. Open and save it again in your automation app. + The selected scenario no longer exists. Edit this action in your automation app. + Required Klick\'r permissions were not granted. + Klick\'r could not open directly, and notifications are disabled for fallback. + Screen-capture permission was not granted. + + Klick\'r external action + Choose external action + Run this automation when a smart scenario fires the selected External Action. + External action + The name must match an External Action saved in a Klick\'r smart scenario. Names are not secret, so do not use them to protect sensitive automations. + Create an External Action inside a Klick\'r smart scenario first, then come back here. + This saved name is not currently used in Klick\'r. You can save unchanged, or choose another name. + External action: %1$s + diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt new file mode 100644 index 000000000..0ffab6880 --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class LocalePluginConfigurationCodecTest { + + private val codec = LocalePluginConfigurationCodec(TestSigner("current-key")) + + @Test + fun `smart launch round trips`() { + val configuration = LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 42L, + isSmart = true, + ) + + assertEquals(configuration, codec.decode(codec.encode(configuration))) + } + + @Test + fun `dumb launch round trips`() { + val configuration = LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 7L, + isSmart = false, + ) + + assertEquals(configuration, codec.decode(codec.encode(configuration))) + } + + @Test + fun `stop round trips without scenario fields`() { + val configuration = LocalePluginConfiguration(operation = LocalePluginOperation.STOP) + + assertEquals(configuration, codec.decode(codec.encode(configuration))) + } + + @Test + fun `tampered payload is rejected`() { + val encoded = codec.encode( + LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 42L, + isSmart = true, + ) + ) + + assertNull(codec.decode(encoded.replace("\"scenarioId\":42", "\"scenarioId\":43"))) + } + + @Test + fun `configuration signed by an old install key is rejected`() { + val oldCodec = LocalePluginConfigurationCodec(TestSigner("old-key")) + val encoded = oldCodec.encode(LocalePluginConfiguration(operation = LocalePluginOperation.STOP)) + + assertNull(codec.decode(encoded)) + } + + @Test + fun `malformed and missing fields are rejected`() { + assertNull(codec.decode(null)) + assertNull(codec.decode("not-json")) + assertNull(codec.decode("""{"version":1,"operation":"LAUNCH","signature":"00"}""")) + } + + @Test + fun `invalid configuration cannot be encoded`() { + assertThrows(IllegalArgumentException::class.java) { + codec.encode(LocalePluginConfiguration(operation = LocalePluginOperation.LAUNCH)) + } + } +} + +private class TestSigner(secret: String) : LocalePluginConfigurationSigner { + private val key = SecretKeySpec(secret.toByteArray(), "HmacSHA256") + + override fun sign(payload: String): String = hmac(payload).toHex() + + override fun verify(payload: String, signature: String): Boolean = + MessageDigest.isEqual(hmac(payload), signature.hexToBytes()) + + private fun hmac(payload: String): ByteArray = Mac.getInstance("HmacSHA256").run { + init(key) + doFinal(payload.toByteArray()) + } +} + +private fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) } +private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModelTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModelTest.kt new file mode 100644 index 000000000..ff019171f --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginConfigurationViewModelTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import androidx.appcompat.app.AppCompatActivity +import com.buzbuz.smartautoclicker.core.common.permissions.PermissionsController +import com.buzbuz.smartautoclicker.core.common.permissions.model.Permission +import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionPostNotification +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationCodec +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocalePluginConfigurationViewModelTest { + + private val smartRepository = mockk(relaxed = true) + private val dumbRepository = mockk(relaxed = true) + private val codec = mockk() + private val permissionsController = mockk(relaxed = true) + private val viewModel = LocalePluginConfigurationViewModel( + smartRepository = smartRepository, + dumbRepository = dumbRepository, + codec = codec, + permissionController = permissionsController, + ) + + @Test + fun `saving a Locale launch setup requests notification fallback permission`() { + val permissions = slot>() + + viewModel.requestFallbackNotificationPermission(mockk(), onGranted = {}) + + verify { + permissionsController.startPermissionsUiFlow( + activity = any(), + permissions = capture(permissions), + onAllGranted = any(), + onMandatoryDenied = null, + ) + } + val permission = permissions.captured.single() as PermissionPostNotification + assertEquals(PermissionPostNotification.Purpose.EXTERNAL_LAUNCH_FALLBACK, permission.purpose) + } +} From 9f5bd74951b77c3192b80389c1f9810942832e23 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 11 Jul 2026 12:50:55 +0530 Subject: [PATCH 09/14] feat(locale): add external-action event plugin --- .../main/res/drawable/ic_external_action.xml | 10 ++ .../ExternalActionEventConfiguration.kt | 25 ++++ .../ExternalActionEventConfigurationCodec.kt | 40 ++++++ ...ternalActionEventConfigurationViewModel.kt | 45 +++++++ ...xternalActionEventConfigurationActivity.kt | 116 ++++++++++++++++++ ...ternalActionEventConfigurationCodecTest.kt | 54 ++++++++ 6 files changed, 290 insertions(+) create mode 100644 core/common/ui/src/main/res/drawable/ic_external_action.xml create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfiguration.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodec.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationViewModel.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ExternalActionEventConfigurationActivity.kt create mode 100644 feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodecTest.kt diff --git a/core/common/ui/src/main/res/drawable/ic_external_action.xml b/core/common/ui/src/main/res/drawable/ic_external_action.xml new file mode 100644 index 000000000..472060058 --- /dev/null +++ b/core/common/ui/src/main/res/drawable/ic_external_action.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfiguration.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfiguration.kt new file mode 100644 index 000000000..f6605e30e --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfiguration.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction + +import kotlinx.serialization.Serializable + +internal const val EXTERNAL_ACTION_EVENT_CONFIG_VERSION = 1 + +@Serializable +internal data class ExternalActionEventConfiguration( + val version: Int = EXTERNAL_ACTION_EVENT_CONFIG_VERSION, + val externalActionName: String, +) { + fun normalized(): ExternalActionEventConfiguration = + copy(externalActionName = externalActionName.trim()) + + fun isValid(): Boolean = + version == EXTERNAL_ACTION_EVENT_CONFIG_VERSION && externalActionName.trim().isNotEmpty() +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodec.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodec.kt new file mode 100644 index 000000000..19de3edeb --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodec.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ExternalActionEventConfigurationCodec @Inject constructor() { + + private val json = Json { + encodeDefaults = true + explicitNulls = true + ignoreUnknownKeys = false + } + + fun encode(configuration: ExternalActionEventConfiguration): String { + val normalized = configuration.normalized() + require(normalized.isValid()) + return json.encodeToString(normalized) + } + + fun decode(value: String?): ExternalActionEventConfiguration? { + if (value.isNullOrBlank()) return null + + return runCatching { + json.decodeFromString(value) + .normalized() + .takeIf { it.isValid() } + }.getOrNull() + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationViewModel.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationViewModel.kt new file mode 100644 index 000000000..893c6ada0 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationViewModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +internal class ExternalActionEventConfigurationViewModel @Inject constructor( + smartRepository: IRepository, + private val codec: ExternalActionEventConfigurationCodec, +) : ViewModel() { + + val knownExternalActionNames: StateFlow> = + smartRepository.allActions + .map { actions -> + actions.asSequence() + .filterIsInstance() + .map { it.externalActionName.trim() } + .filter { it.isNotEmpty() } + .distinct() + .sortedBy { it.lowercase() } + .toList() + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun decodeConfiguration(value: String?): ExternalActionEventConfiguration? = codec.decode(value) + + fun encodeConfiguration(externalActionName: String): String = + codec.encode(ExternalActionEventConfiguration(externalActionName = externalActionName)) +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ExternalActionEventConfigurationActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ExternalActionEventConfigurationActivity.kt new file mode 100644 index 000000000..31e84d5d6 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ExternalActionEventConfigurationActivity.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.app.Activity +import android.os.Bundle +import android.view.View +import android.widget.ArrayAdapter +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.core.common.actions.external.ExternalActionEventContract +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.databinding.ActivityExternalActionEventConfigurationBinding +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction.ExternalActionEventConfigurationViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ExternalActionEventConfigurationActivity : AppCompatActivity() { + + private val viewModel: ExternalActionEventConfigurationViewModel by viewModels() + private lateinit var binding: ActivityExternalActionEventConfigurationBinding + private lateinit var namesAdapter: ArrayAdapter + private var restoredName: String? = null + private var selectedName: String? = null + private var hasAppliedRestore = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action != ExternalActionEventContract.ACTION_EDIT_EVENT) { + finish() + return + } + + binding = ActivityExternalActionEventConfigurationBinding.inflate(layoutInflater) + setContentView(binding.root) + + namesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, mutableListOf()) + binding.externalActionName.setAdapter(namesAdapter) + restoredName = viewModel + .decodeConfiguration(ExternalActionEventContract.readConfigurationJson(intent)) + ?.externalActionName + + binding.externalActionName.setOnItemClickListener { _, _, position, _ -> + selectedName = namesAdapter.getItem(position) + render() + } + binding.cancel.setOnClickListener { + setResult(Activity.RESULT_CANCELED) + finish() + } + binding.save.setOnClickListener { saveConfiguration() } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.knownExternalActionNames.collect(::updateNames) + } + } + } + + private fun updateNames(knownNames: List) { + if (!hasAppliedRestore) { + hasAppliedRestore = true + selectedName = restoredName ?: knownNames.firstOrNull() + } else if (selectedName == null) { + selectedName = knownNames.firstOrNull() + } else if (selectedName != restoredName && selectedName !in knownNames) { + selectedName = knownNames.firstOrNull() + } + + val namesForDisplay = buildList { + if (restoredName != null && restoredName !in knownNames) add(restoredName!!) + addAll(knownNames) + } + + namesAdapter.clear() + namesAdapter.addAll(namesForDisplay) + binding.externalActionName.setText(selectedName.orEmpty(), false) + render() + } + + private fun render() { + val name = selectedName + val isMissingRestoredName = name != null && name == restoredName && namesAdapter.getPosition(name) == 0 && + viewModel.knownExternalActionNames.value.none { it == name } + + binding.save.isEnabled = !name.isNullOrBlank() + binding.message.text = when { + namesAdapter.isEmpty -> getString(R.string.external_action_event_empty) + isMissingRestoredName -> getString(R.string.external_action_event_missing_name) + else -> getString(R.string.external_action_event_description) + } + binding.message.visibility = View.VISIBLE + } + + private fun saveConfiguration() { + val name = selectedName?.trim()?.takeIf { it.isNotEmpty() } ?: return + setResult( + Activity.RESULT_OK, + ExternalActionEventContract.createConfigurationResult( + configurationJson = viewModel.encodeConfiguration(name), + blurb = getString(R.string.external_action_event_blurb, name), + ), + ) + finish() + } +} diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodecTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodecTest.kt new file mode 100644 index 000000000..039ffd4cd --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/externalaction/ExternalActionEventConfigurationCodecTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExternalActionEventConfigurationCodecTest { + + private val codec = ExternalActionEventConfigurationCodec() + + @Test + fun encodeDecode_validConfiguration() { + val decoded = codec.decode( + codec.encode(ExternalActionEventConfiguration(externalActionName = "Open xyz game intent")) + ) + + assertEquals( + ExternalActionEventConfiguration(externalActionName = "Open xyz game intent"), + decoded, + ) + } + + @Test + fun encodeDecode_trimmedName() { + val decoded = codec.decode( + codec.encode(ExternalActionEventConfiguration(externalActionName = " Open xyz game intent ")) + ) + + assertEquals("Open xyz game intent", decoded?.externalActionName) + } + + @Test + fun decode_rejectsEmptyName() { + assertNull(codec.decode("""{"version":1,"externalActionName":" "}""")) + } + + @Test + fun decode_rejectsUnknownVersion() { + assertNull(codec.decode("""{"version":2,"externalActionName":"Open xyz game intent"}""")) + } + + @Test + fun decode_rejectsMissingField() { + assertNull(codec.decode("""{"version":1}""")) + } +} From 5c62380fd2392e5787ac56c74f9348e13023b4fd Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 11 Jul 2026 12:51:13 +0530 Subject: [PATCH 10/14] feat(locale): execute scenario controls with safe launch fallback --- .../domain/LocalAccessibilityService.kt | 12 +- .../common/overlays/manager/OverlayManager.kt | 11 +- .../overlays/manager/OverlayManagerTests.kt | 11 +- .../model/PermissionPostNotification.kt | 9 +- .../ui/PermissionDialogViewModel.kt | 14 +- .../src/main/res/values-ar/strings.xml | 4 +- .../src/main/res/values-es/strings.xml | 4 +- .../src/main/res/values-fr/strings.xml | 4 +- .../src/main/res/values-it/strings.xml | 2 + .../src/main/res/values-ja/strings.xml | 2 + .../src/main/res/values-pt-rBR/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 4 +- .../src/main/res/values-uk/strings.xml | 4 +- .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + .../src/main/res/values/strings.xml | 4 +- .../ui/AccessibilityTroubleshootingDialog.kt | 4 +- .../BackgroundLaunchTroubleshootingDialog.kt | 53 ++++ .../tutorial/impl/TutorialRepositoryImpl.kt | 4 +- .../core/dumb/engine/DumbEngine.kt | 2 +- .../domain/LocalePluginActionExecutor.kt | 89 +++++++ .../domain/LocalePluginDeviceState.kt | 29 +++ .../domain/LocalePluginDirectLaunchTracker.kt | 92 +++++++ .../domain/LocalePluginLaunchFailureStore.kt | 136 +++++++++++ .../LocalePluginNotificationController.kt | 110 +++++++++ .../ExternalActionEventQueryReceiver.kt | 44 ++++ .../receiver/LocalePluginFireReceiver.kt | 228 ++++++++++++++++++ .../ui/LocalePluginExecutionActivity.kt | 205 ++++++++++++++++ .../ui/LocalePluginExecutionViewModel.kt | 74 ++++++ .../domain/LocalePluginActionExecutorTest.kt | 167 +++++++++++++ .../LocalePluginDirectLaunchTrackerTest.kt | 118 +++++++++ .../timeline/DebugReportTimelineViewModel.kt | 2 + smartautoclicker/build.gradle.kts | 2 +- .../SmartAutoClickerService.kt | 39 ++- .../localservice/LocalService.kt | 68 +++++- .../scenarios/ScenarioActivity.kt | 43 +++- .../scenarios/list/ScenarioListFragment.kt | 8 +- .../scenarios/list/adapter/ScenarioAdapter.kt | 10 +- .../list/adapter/ScenarioViewHolders.kt | 14 +- .../scenarios/viewmodel/ScenarioViewModel.kt | 4 +- .../src/main/res/values-ar/strings.xml | 2 + .../src/main/res/values-es/strings.xml | 2 + .../src/main/res/values-fr/strings.xml | 2 + .../src/main/res/values-it/strings.xml | 2 + .../src/main/res/values-ja/strings.xml | 2 + .../src/main/res/values-pt-rBR/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-uk/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + .../src/main/res/values/strings.xml | 7 + 51 files changed, 1596 insertions(+), 67 deletions(-) create mode 100644 core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/BackgroundLaunchTroubleshootingDialog.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDeviceState.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTracker.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginLaunchFailureStore.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/notification/LocalePluginNotificationController.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/ExternalActionEventQueryReceiver.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt create mode 100644 feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt create mode 100644 feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTrackerTest.kt diff --git a/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt b/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt index 9c8e4af22..0fdb24a42 100644 --- a/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt +++ b/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt @@ -22,9 +22,15 @@ import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario interface LocalAccessibilityService { - fun startDumbScenario(dumbScenario: DumbScenario) - fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) + fun isSmartScreenRecordActive(): Boolean + fun getSmartScenarioId(): Long? + fun getDumbScenarioId(): Long? + fun launchDumbScenario(dumbScenario: DumbScenario) + fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) + fun replaceDumbScenario(dumbScenario: DumbScenario) + fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) + fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) fun stopScenario() fun release() -} \ No newline at end of file +} diff --git a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManager.kt b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManager.kt index 77b8fd2cf..6b6ca26be 100644 --- a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManager.kt +++ b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManager.kt @@ -227,6 +227,15 @@ class OverlayManager @Inject internal constructor( fun isOverlayStackVisible(): Boolean = getBackStackTop()?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false + /** + * @return true when a visible child overlay is open above a scenario's main menu. + * + * The root overlay is the scenario menu. Any further overlay is an editor, dialog, or other + * foreground interaction that should not be interrupted by an external scenario replacement. + */ + fun hasVisibleOverlayAboveRoot(): Boolean = + !isOverlayStackHidden() && overlayBackStack.size > 1 + /** * Set an overlay as being shown above all overlays in the backstack. * It will not be added to the backstack, and can be seen as "an overlay for overlays". @@ -374,4 +383,4 @@ class OverlayManager @Inject internal constructor( } /** Tag for logs. */ -private const val TAG = "OverlayManager" \ No newline at end of file +private const val TAG = "OverlayManager" diff --git a/core/common/overlays/src/test/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManagerTests.kt b/core/common/overlays/src/test/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManagerTests.kt index 1e7765293..6bc45d99a 100644 --- a/core/common/overlays/src/test/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManagerTests.kt +++ b/core/common/overlays/src/test/java/com/buzbuz/smartautoclicker/core/common/overlays/manager/OverlayManagerTests.kt @@ -104,6 +104,15 @@ class OverlayManagerTests { Assert.assertEquals(mockOverlay2, overlayManager.getBackStackTop()) } + @Test + fun hasVisibleOverlayAboveRoot_returnsTrueOnlyForChildOverlays() { + overlayManager.navigateTo(mockContext, mockOverlay1) + Assert.assertFalse(overlayManager.hasVisibleOverlayAboveRoot()) + + overlayManager.navigateTo(mockContext, mockOverlay2) + Assert.assertTrue(overlayManager.hasVisibleOverlayAboveRoot()) + } + @Test fun stackTop_navigateUp_initial() { overlayManager.navigateUp(mockContext) @@ -234,4 +243,4 @@ class OverlayManagerTests { } Mockito.verifyNoMoreInteractions(mockOverlay1, mockOverlay2) } -} \ No newline at end of file +} diff --git a/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/model/PermissionPostNotification.kt b/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/model/PermissionPostNotification.kt index 90814fc4d..1137efa07 100644 --- a/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/model/PermissionPostNotification.kt +++ b/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/model/PermissionPostNotification.kt @@ -28,8 +28,15 @@ import com.buzbuz.smartautoclicker.core.base.data.getNotificationSettingsIntent @SuppressLint("InlinedApi") data class PermissionPostNotification( private val optional: Boolean = false, + val purpose: Purpose = Purpose.GENERAL, ) : Permission.Dangerous(optional), Permission.ForApiRange { + /** Explains why notifications are requested in the preparation dialog. */ + enum class Purpose { + GENERAL, + EXTERNAL_LAUNCH_FALLBACK, + } + override val fromApiLvl: Int get() = Build.VERSION_CODES.TIRAMISU @@ -41,4 +48,4 @@ data class PermissionPostNotification( override fun isGranted(context: Context): Boolean = context.getSystemService(NotificationManager::class.java).areNotificationsEnabled() -} \ No newline at end of file +} diff --git a/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/ui/PermissionDialogViewModel.kt b/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/ui/PermissionDialogViewModel.kt index 6518655fc..93126d68d 100644 --- a/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/ui/PermissionDialogViewModel.kt +++ b/core/common/permissions/src/main/java/com/buzbuz/smartautoclicker/core/common/permissions/ui/PermissionDialogViewModel.kt @@ -86,8 +86,16 @@ private fun Permission.toPermissionDialogUiState(): PermissionDialogUiState = is PermissionPostNotification -> PermissionDialogUiState( permission = this, - titleRes = R.string.dialog_title_permission_notification, - descriptionRes = R.string.message_permission_desc_notification, + titleRes = when (purpose) { + PermissionPostNotification.Purpose.GENERAL -> R.string.dialog_title_permission_notification + PermissionPostNotification.Purpose.EXTERNAL_LAUNCH_FALLBACK -> + R.string.dialog_title_permission_launch_fallback_notification + }, + descriptionRes = when (purpose) { + PermissionPostNotification.Purpose.GENERAL -> R.string.message_permission_desc_notification + PermissionPostNotification.Purpose.EXTERNAL_LAUNCH_FALLBACK -> + R.string.message_permission_desc_launch_fallback_notification + }, ) is PermissionAccessibilityService -> PermissionDialogUiState( @@ -95,4 +103,4 @@ private fun Permission.toPermissionDialogUiState(): PermissionDialogUiState = titleRes = R.string.dialog_title_permission_accessibility, descriptionRes = R.string.message_permission_desc_accessibility, ) - } \ No newline at end of file + } diff --git a/core/common/permissions/src/main/res/values-ar/strings.xml b/core/common/permissions/src/main/res/values-ar/strings.xml index 5e05a4eac..79ac5824c 100644 --- a/core/common/permissions/src/main/res/values-ar/strings.xml +++ b/core/common/permissions/src/main/res/values-ar/strings.xml @@ -49,4 +49,6 @@ طلب إذن ينكر - \ No newline at end of file + إشعارات بدء التشغيل الاحتياطية + اختياري: يسمح لـ Klick\'r بعرض إشعار لإكمال بدء سيناريو عندما لا يتمكن تطبيق الأتمتة من فتحه مباشرةً، مثلما يكون الهاتف مقفلاً. + diff --git a/core/common/permissions/src/main/res/values-es/strings.xml b/core/common/permissions/src/main/res/values-es/strings.xml index 3e3908539..3cca8104b 100644 --- a/core/common/permissions/src/main/res/values-es/strings.xml +++ b/core/common/permissions/src/main/res/values-es/strings.xml @@ -49,4 +49,6 @@ Solicitar permiso Denegar - \ No newline at end of file + Notificaciones de inicio alternativo + Opcional: permite que Klick\'r muestre una notificación para terminar de iniciar un escenario cuando la aplicación de automatización no puede abrirlo directamente, por ejemplo, mientras el teléfono está bloqueado. + diff --git a/core/common/permissions/src/main/res/values-fr/strings.xml b/core/common/permissions/src/main/res/values-fr/strings.xml index 35a43a641..46a9c7084 100644 --- a/core/common/permissions/src/main/res/values-fr/strings.xml +++ b/core/common/permissions/src/main/res/values-fr/strings.xml @@ -49,4 +49,6 @@ Requête de permission Refuser - \ No newline at end of file + Notifications de lancement de secours + Facultatif : permet à Klick\'r d\'afficher une notification pour terminer le lancement d\'un scénario lorsque l\'application d\'automatisation ne peut pas l\'ouvrir directement, par exemple lorsque le téléphone est verrouillé. + diff --git a/core/common/permissions/src/main/res/values-it/strings.xml b/core/common/permissions/src/main/res/values-it/strings.xml index 1fe798588..957ede212 100644 --- a/core/common/permissions/src/main/res/values-it/strings.xml +++ b/core/common/permissions/src/main/res/values-it/strings.xml @@ -49,4 +49,6 @@ Richiesta di permesso Negare + Notifiche di avvio di riserva + Facoltativo: consente a Klick\'r di mostrare una notifica per completare l\'avvio di uno scenario quando l\'app di automazione non può aprirlo direttamente, ad esempio mentre il telefono è bloccato. diff --git a/core/common/permissions/src/main/res/values-ja/strings.xml b/core/common/permissions/src/main/res/values-ja/strings.xml index ae66e416f..df136e18c 100644 --- a/core/common/permissions/src/main/res/values-ja/strings.xml +++ b/core/common/permissions/src/main/res/values-ja/strings.xml @@ -12,4 +12,6 @@ 権限をリクエスト 拒否 + 起動時の代替通知 + 任意:端末がロック中など、オートメーションアプリがシナリオを直接開けないときに、Klick\'r が起動を完了するための通知を表示できるようにします。 diff --git a/core/common/permissions/src/main/res/values-pt-rBR/strings.xml b/core/common/permissions/src/main/res/values-pt-rBR/strings.xml index f60c31913..fd0e53d9b 100644 --- a/core/common/permissions/src/main/res/values-pt-rBR/strings.xml +++ b/core/common/permissions/src/main/res/values-pt-rBR/strings.xml @@ -49,4 +49,6 @@ Solicitar permissão Negar + Notificações de inicialização alternativa + Opcional: permite que o Klick\'r mostre uma notificação para concluir a inicialização de um cenário quando o aplicativo de automação não puder abri-lo diretamente, como quando o telefone estiver bloqueado. diff --git a/core/common/permissions/src/main/res/values-ru/strings.xml b/core/common/permissions/src/main/res/values-ru/strings.xml index 2236d1c1b..8a44cd510 100644 --- a/core/common/permissions/src/main/res/values-ru/strings.xml +++ b/core/common/permissions/src/main/res/values-ru/strings.xml @@ -49,4 +49,6 @@ Запросить разрешение Отклонить - \ No newline at end of file + Уведомления для запасного запуска + Необязательно: позволяет Klick\'r показать уведомление, чтобы завершить запуск сценария, когда приложение автоматизации не может открыть его напрямую, например когда телефон заблокирован. + diff --git a/core/common/permissions/src/main/res/values-uk/strings.xml b/core/common/permissions/src/main/res/values-uk/strings.xml index 0f7e38567..7ab9b33f0 100644 --- a/core/common/permissions/src/main/res/values-uk/strings.xml +++ b/core/common/permissions/src/main/res/values-uk/strings.xml @@ -48,4 +48,6 @@ Запросити дозвіл Відхилити - \ No newline at end of file + Сповіщення для резервного запуску + Необов\'язково: дозволяє Klick\'r показати сповіщення для завершення запуску сценарію, коли застосунок автоматизації не може відкрити його безпосередньо, наприклад коли телефон заблоковано. + diff --git a/core/common/permissions/src/main/res/values-zh-rCN/strings.xml b/core/common/permissions/src/main/res/values-zh-rCN/strings.xml index fa11d209f..8feb4a58f 100644 --- a/core/common/permissions/src/main/res/values-zh-rCN/strings.xml +++ b/core/common/permissions/src/main/res/values-zh-rCN/strings.xml @@ -12,4 +12,6 @@ 请求权限 拒绝 + 备用启动通知 + 可选:当自动化应用无法直接打开场景(例如手机已锁定)时,允许 Klick\'r 显示通知以完成启动。 diff --git a/core/common/permissions/src/main/res/values-zh-rTW/strings.xml b/core/common/permissions/src/main/res/values-zh-rTW/strings.xml index 32a3fb663..3f578ee16 100644 --- a/core/common/permissions/src/main/res/values-zh-rTW/strings.xml +++ b/core/common/permissions/src/main/res/values-zh-rTW/strings.xml @@ -12,4 +12,6 @@ 請求權限 拒絕 + 備用啟動通知 + 選用:當自動化應用程式無法直接開啟情境(例如手機已鎖定)時,允許 Klick\'r 顯示通知以完成啟動。 diff --git a/core/common/permissions/src/main/res/values/strings.xml b/core/common/permissions/src/main/res/values/strings.xml index 285c341e4..aa0fbddb7 100644 --- a/core/common/permissions/src/main/res/values/strings.xml +++ b/core/common/permissions/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ --> Accessibility service Notification + Launch notifications Permission denied Overlay @@ -39,6 +40,7 @@ service permission, click on the button bellow. Optional: Shows the notification while the application is running in order to easily return to the scenario selection screen. + Optional: lets Klick\'r offer a tap-to-launch notification when Android blocks an automatic launch. This permission is mandatory for Klick\'r. It won\'t be able to work correctly without it. @@ -49,4 +51,4 @@ Request permission Deny - \ No newline at end of file + diff --git a/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/AccessibilityTroubleshootingDialog.kt b/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/AccessibilityTroubleshootingDialog.kt index 110e496d7..4c0bae4bf 100644 --- a/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/AccessibilityTroubleshootingDialog.kt +++ b/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/AccessibilityTroubleshootingDialog.kt @@ -58,6 +58,6 @@ class AccessibilityTroubleshootingDialog : DialogFragment() { } private fun showDontKillMyApp() { - context?.safeStartWebBrowserActivity("https://dontkillmyapp.com?app=Klick%27r") + context?.safeStartWebBrowserActivity("https://dontkillmyapp.com/?app=Klick%27r") } -} \ No newline at end of file +} diff --git a/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/BackgroundLaunchTroubleshootingDialog.kt b/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/BackgroundLaunchTroubleshootingDialog.kt new file mode 100644 index 000000000..a6a7908ea --- /dev/null +++ b/core/common/quality/src/main/java/com/buzbuz/smartautoclicker/core/common/quality/ui/BackgroundLaunchTroubleshootingDialog.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.common.quality.ui + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.buzbuz.smartautoclicker.core.base.extensions.safeStartWebBrowserActivity +import com.buzbuz.smartautoclicker.core.common.quality.databinding.DialogAccessibilityTroubleshootingBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** A generic, Don’t Kill My App-style explanation for an Android background-launch failure. */ +class BackgroundLaunchTroubleshootingDialog : DialogFragment() { + + companion object { + const val FRAGMENT_TAG = "BackgroundLaunchTroubleshootingDialog" + + private const val ARG_TITLE = "title" + private const val ARG_MESSAGE = "message" + private const val ARG_HELP_URL = "help_url" + + fun newInstance(title: String, message: String, helpUrl: String) = + BackgroundLaunchTroubleshootingDialog().apply { + arguments = Bundle().apply { + putString(ARG_TITLE, title) + putString(ARG_MESSAGE, message) + putString(ARG_HELP_URL, helpUrl) + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val args = requireArguments() + val binding = DialogAccessibilityTroubleshootingBinding.inflate(layoutInflater).apply { + titlePermission.text = args.getString(ARG_TITLE) + descPermission.text = args.getString(ARG_MESSAGE) + buttonOpenWebsite.setOnClickListener { + context?.safeStartWebBrowserActivity(args.getString(ARG_HELP_URL).orEmpty()) + } + buttonUnderstood.setOnClickListener { dismiss() } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .create() + } +} diff --git a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt index 1070081d2..f7c7eccd8 100644 --- a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt +++ b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt @@ -101,7 +101,7 @@ internal class TutorialRepositoryImpl @Inject constructor( val insertedScenario = scenario.copy(id = scenarioId) // Load the scenario - localService.startSmartScenario( + localService.launchSmartScenario( scenario = insertedScenario, resultCode = mpResultCode, data = mpData, @@ -164,4 +164,4 @@ internal class TutorialRepositoryImpl @Inject constructor( } } -private const val TAG = "TutorialRepositoryImpl" \ No newline at end of file +private const val TAG = "TutorialRepositoryImpl" diff --git a/core/dumb/src/main/java/com/buzbuz/smartautoclicker/core/dumb/engine/DumbEngine.kt b/core/dumb/src/main/java/com/buzbuz/smartautoclicker/core/dumb/engine/DumbEngine.kt index 525dce43c..9efdeb981 100644 --- a/core/dumb/src/main/java/com/buzbuz/smartautoclicker/core/dumb/engine/DumbEngine.kt +++ b/core/dumb/src/main/java/com/buzbuz/smartautoclicker/core/dumb/engine/DumbEngine.kt @@ -166,4 +166,4 @@ class DumbEngine @Inject constructor( } } -private const val TAG = "DumbEngine" \ No newline at end of file +private const val TAG = "DumbEngine" diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.kt new file mode 100644 index 000000000..05ef6249d --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import android.content.Context +import android.content.Intent +import com.buzbuz.smartautoclicker.core.base.di.Dispatcher +import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO +import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionOverlay +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository +import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class LocalePluginActionExecutor @Inject constructor( + @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val smartRepository: IRepository, + private val dumbRepository: DumbRepository, + private val externalLaunchRepository: ExternalLaunchRepository, +) { + suspend fun resolve(configuration: LocalePluginConfiguration): ResolvedLocalePluginAction? = + withContext(ioDispatcher) { + when (configuration.operation) { + LocalePluginOperation.STOP -> ResolvedLocalePluginAction.Stop + LocalePluginOperation.LAUNCH -> { + val id = configuration.scenarioId ?: return@withContext null + if (configuration.isSmart == true) { + smartRepository.getScenario(id)?.let(ResolvedLocalePluginAction::LaunchSmart) + } else { + dumbRepository.getDumbScenario(id)?.let(ResolvedLocalePluginAction::LaunchDumb) + } + } + } + } + + fun areBasePermissionsReady(context: Context): Boolean = + PermissionOverlay().checkIfGranted(context) && externalLaunchRepository.isAccessibilityServiceStarted() + + fun isScenarioConfigurationOpen(): Boolean = externalLaunchRepository.isScenarioConfigurationOpen() + + fun executeStop() = externalLaunchRepository.stopScenarios() + + fun launchDumb(action: ResolvedLocalePluginAction.LaunchDumb) { + if (externalLaunchRepository.isDumbScenarioRunning(action.scenario.id.databaseId)) return + externalLaunchRepository.replaceDumbScenario(action.scenario) + } + + fun launchSmart(resultCode: Int, data: Intent, action: ResolvedLocalePluginAction.LaunchSmart) = + externalLaunchRepository.replaceSmartScenario(resultCode, data, action.scenario) + + fun launchSmartWithCurrentProjection(action: ResolvedLocalePluginAction.LaunchSmart): Boolean { + if (!externalLaunchRepository.isSmartScreenRecordActive()) return false + + val currentScenarioId = externalLaunchRepository.getSmartScenarioId() + if (currentScenarioId == action.scenario.id.databaseId && + externalLaunchRepository.isAccessibilityServiceStarted() + ) { + return true + } + + externalLaunchRepository.replaceSmartScenarioWithCurrentProjection(action.scenario) + return true + } +} + +internal sealed interface ResolvedLocalePluginAction { + data object Stop : ResolvedLocalePluginAction + data class LaunchSmart(val scenario: Scenario) : ResolvedLocalePluginAction + data class LaunchDumb(val scenario: DumbScenario) : ResolvedLocalePluginAction + + val scenarioName: String? + get() = when (this) { + Stop -> null + is LaunchSmart -> scenario.name + is LaunchDumb -> scenario.name + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDeviceState.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDeviceState.kt new file mode 100644 index 000000000..ac5b0d7e9 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDeviceState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import android.app.KeyguardManager +import android.content.Context +import android.os.PowerManager +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class LocalePluginDeviceState @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun canAttemptDirectLaunch(): Boolean { + val powerManager = context.getSystemService(PowerManager::class.java) + val keyguardManager = context.getSystemService(KeyguardManager::class.java) + + return powerManager.isInteractive && !keyguardManager.isKeyguardLocked + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTracker.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTracker.kt new file mode 100644 index 000000000..a89a9d8ee --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTracker.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class LocalePluginDirectLaunchTracker @Inject constructor() { + + private val pendingRequests = ConcurrentHashMap.newKeySet() + private val openedRequests = ConcurrentHashMap.newKeySet() + private val latestRequestId = AtomicReference(null) + private val activeExecutionRequestId = AtomicReference(null) + + fun markPending(configurationJson: String) { + latestRequestId.set(configurationJson) + pendingRequests.add(configurationJson) + openedRequests.remove(configurationJson) + } + + /** + * Claims a direct background launch. A request which timed out, was replaced, or was stopped + * must never be allowed to revive when Android delivers its PendingIntent late. + */ + fun claimDirectLaunch(requestId: String): Boolean { + if (!isLatest(requestId)) return false + if (activeExecutionRequestId.get() == requestId) return true + if (!pendingRequests.remove(requestId)) return false + + activeExecutionRequestId.set(requestId) + if (!isLatest(requestId)) { + activeExecutionRequestId.compareAndSet(requestId, null) + return false + } + openedRequests.add(requestId) + return true + } + + /** Claims the latest notification continuation only when another launch is not already on screen. */ + fun claimFallbackLaunch(requestId: String): Boolean { + if (!isLatest(requestId)) return false + if (activeExecutionRequestId.get()?.let { it != requestId } == true) return false + activeExecutionRequestId.set(requestId) + if (!isLatest(requestId)) { + activeExecutionRequestId.compareAndSet(requestId, null) + return false + } + return true + } + + fun claimRecoveredLaunch(requestId: String) { + latestRequestId.set(requestId) + activeExecutionRequestId.set(requestId) + } + + fun consumeOpened(configurationJson: String): Boolean { + pendingRequests.remove(configurationJson) + return openedRequests.remove(configurationJson) + } + + fun abandon(requestId: String) { + pendingRequests.remove(requestId) + openedRequests.remove(requestId) + } + + /** + * A second background request must not be sent to the same translucent helper Activity while + * a first request is awaiting permission or being displayed. The caller should offer the + * notification continuation instead. + */ + fun hasAnotherInFlightRequest(requestId: String): Boolean = + (activeExecutionRequestId.get()?.let { it != requestId } == true) || + pendingRequests.any { it != requestId } + + fun markExecutionClosed(requestId: String?) { + if (requestId != null) activeExecutionRequestId.compareAndSet(requestId, null) + } + + fun isLatest(requestId: String): Boolean = latestRequestId.get() == requestId + + fun isCurrentExecution(requestId: String?): Boolean = + requestId != null && isLatest(requestId) && activeExecutionRequestId.get() == requestId +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginLaunchFailureStore.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginLaunchFailureStore.kt new file mode 100644 index 000000000..debbdce0a --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginLaunchFailureStore.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.buzbuz.smartautoclicker.core.base.PreferencesDataStore +import com.buzbuz.smartautoclicker.core.base.di.Dispatcher +import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps one pending explanation for a direct automation launch which did not open its Activity. + * + * A lock-screen launch deliberately uses a notification and must not create this state. The value is + * tied to its launch attempt so that a late Activity start, or a tap on its fallback notification, + * can remove only its own warning. + */ +@Singleton +class LocalePluginLaunchFailureStore @Inject constructor( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, +) { + + private val dataStore = PreferencesDataStore( + context = context, + dispatcher = ioDispatcher, + fileName = PREFERENCES_FILE_NAME, + ) + + internal suspend fun markDirectLaunchFailed(requestId: String) { + dataStore.edit { preferences -> + preferences[KEY_PENDING_FAILURE_REQUEST_ID] = requestId + } + } + + internal suspend fun clearDirectLaunchFailure(requestId: String) { + dataStore.edit { preferences -> + if (preferences[KEY_PENDING_FAILURE_REQUEST_ID] == requestId) { + preferences.remove(KEY_PENDING_FAILURE_REQUEST_ID) + } + } + } + + internal suspend fun markFallbackPending(requestId: String) { + dataStore.edit { preferences -> + preferences[KEY_PENDING_FALLBACK_REQUEST_ID] = requestId + } + } + + internal suspend fun markLaunchPending(requestId: String) { + dataStore.edit { preferences -> + preferences[KEY_PENDING_LAUNCH_REQUEST_ID] = requestId + preferences[KEY_PENDING_LAUNCH_TIMESTAMP] = System.currentTimeMillis() + } + } + + internal suspend fun clearLaunchPending() { + dataStore.edit { preferences -> + preferences.remove(KEY_PENDING_LAUNCH_REQUEST_ID) + preferences.remove(KEY_PENDING_LAUNCH_TIMESTAMP) + } + } + + internal suspend fun consumeLaunchPending(requestId: String): Boolean { + var matches = false + dataStore.edit { preferences -> + val requestMatches = preferences[KEY_PENDING_LAUNCH_REQUEST_ID] == requestId + val timestamp = preferences[KEY_PENDING_LAUNCH_TIMESTAMP] + val isRecent = timestamp != null && + System.currentTimeMillis() - timestamp <= PENDING_LAUNCH_MAX_AGE_MS + if (requestMatches && isRecent) matches = true + if (requestMatches) { + preferences.remove(KEY_PENDING_LAUNCH_REQUEST_ID) + preferences.remove(KEY_PENDING_LAUNCH_TIMESTAMP) + } + } + return matches + } + + internal suspend fun clearFallbackPending() { + dataStore.edit { preferences -> + preferences.remove(KEY_PENDING_FALLBACK_REQUEST_ID) + } + } + + internal suspend fun consumeFallbackPending(requestId: String): Boolean { + var matches = false + dataStore.edit { preferences -> + if (preferences[KEY_PENDING_FALLBACK_REQUEST_ID] == requestId) { + preferences.remove(KEY_PENDING_FALLBACK_REQUEST_ID) + matches = true + } + } + return matches + } + + /** + * Returns whether Klick'r should explain a failed direct launch when its normal UI is next opened. + * Reading and clearing are one transaction so the message is shown at most once per failed attempt. + */ + suspend fun consumePendingDirectLaunchFailure(): Boolean { + var hasPendingFailure = false + dataStore.edit { preferences -> + hasPendingFailure = preferences.remove(KEY_PENDING_FAILURE_REQUEST_ID) != null + } + return hasPendingFailure + } +} + +private const val PREFERENCES_FILE_NAME = "locale_plugin_launch_failures" + +private val KEY_PENDING_FAILURE_REQUEST_ID: Preferences.Key = + stringPreferencesKey("pendingDirectLaunchFailureRequestId") + +private val KEY_PENDING_FALLBACK_REQUEST_ID: Preferences.Key = + stringPreferencesKey("pendingFallbackRequestId") + +private val KEY_PENDING_LAUNCH_REQUEST_ID: Preferences.Key = + stringPreferencesKey("pendingLaunchRequestId") + +private val KEY_PENDING_LAUNCH_TIMESTAMP: Preferences.Key = + longPreferencesKey("pendingLaunchTimestamp") + +private const val PENDING_LAUNCH_MAX_AGE_MS = 10_000L diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/notification/LocalePluginNotificationController.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/notification/LocalePluginNotificationController.kt new file mode 100644 index 000000000..b41392bcd --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/notification/LocalePluginNotificationController.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.notification + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui.LocalePluginExecutionActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalePluginNotificationController @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val mainHandler = Handler(Looper.getMainLooper()) + + @SuppressLint("MissingPermission") + fun showLaunchFallback( + configurationJson: String, + scenarioName: String, + requestId: String, + ): Boolean { + ensureChannel() + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) { + Log.w(TAG, "Can't show launch fallback, notifications are disabled") + showToast(R.string.locale_plugin_error_fallback_unavailable) + return false + } + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_ID_LAUNCH, + LocalePluginExecutionActivity.createFallbackIntent(context, configurationJson, requestId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + NotificationManagerCompat.from(context).notify( + NOTIFICATION_ID_LAUNCH, + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(com.buzbuz.smartautoclicker.core.ui.R.drawable.ic_intent) + .setContentTitle(context.getString(R.string.locale_plugin_notification_launch_title)) + .setContentText(context.getString(R.string.locale_plugin_notification_launch_text, scenarioName)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build(), + ) + return true + } + + @SuppressLint("MissingPermission") + fun showError(messageRes: Int) { + ensureChannel() + showToast(messageRes) + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + NotificationManagerCompat.from(context).notify( + NOTIFICATION_ID_ERROR, + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(com.buzbuz.smartautoclicker.core.ui.R.drawable.ic_warning) + .setContentTitle(context.getString(R.string.locale_plugin_notification_error_title)) + .setContentText(context.getString(messageRes)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build(), + ) + } + + fun cancelLaunchFallback() { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID_LAUNCH) + } + + private fun showToast(messageRes: Int) { + mainHandler.post { + Toast.makeText(context, messageRes, Toast.LENGTH_LONG).show() + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.locale_plugin_notification_channel), + NotificationManager.IMPORTANCE_HIGH, + ) + ) + } + +} + +private const val CHANNEL_ID = "locale_plugin_launches" +private const val NOTIFICATION_ID_LAUNCH = 7101 +private const val NOTIFICATION_ID_ERROR = 7102 +private const val TAG = "LocalePluginNotification" diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/ExternalActionEventQueryReceiver.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/ExternalActionEventQueryReceiver.kt new file mode 100644 index 000000000..05787b8c3 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/ExternalActionEventQueryReceiver.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.buzbuz.smartautoclicker.core.common.actions.external.ExternalActionEventContract +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction.ExternalActionEventConfigurationCodec +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class ExternalActionEventQueryReceiver : BroadcastReceiver() { + + @Inject internal lateinit var codec: ExternalActionEventConfigurationCodec + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ExternalActionEventContract.ACTION_QUERY_CONDITION) { + resultCode = ExternalActionEventContract.RESULT_CONDITION_UNKNOWN + return + } + + val configuredName = codec + .decode(ExternalActionEventContract.readConfigurationJson(intent)) + ?.externalActionName + val firedName = ExternalActionEventContract.readFiredExternalActionName(intent) + + resultCode = when { + configuredName == null || firedName == null -> + ExternalActionEventContract.RESULT_CONDITION_UNKNOWN + configuredName == firedName -> + ExternalActionEventContract.RESULT_CONDITION_SATISFIED + else -> + ExternalActionEventContract.RESULT_CONDITION_UNSATISFIED + } + } +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt new file mode 100644 index 000000000..297f6aad8 --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.receiver + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import com.buzbuz.smartautoclicker.core.base.di.Dispatcher +import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginActionExecutor +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationCodec +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginDeviceState +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginDirectLaunchTracker +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginLaunchFailureStore +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.ResolvedLocalePluginAction +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.notification.LocalePluginNotificationController +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui.LocalePluginExecutionActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@AndroidEntryPoint +class LocalePluginFireReceiver : BroadcastReceiver() { + + @Inject internal lateinit var codec: LocalePluginConfigurationCodec + @Inject internal lateinit var executor: LocalePluginActionExecutor + @Inject internal lateinit var notifications: LocalePluginNotificationController + @Inject internal lateinit var deviceState: LocalePluginDeviceState + @Inject internal lateinit var directLaunchTracker: LocalePluginDirectLaunchTracker + @Inject internal lateinit var launchFailureStore: LocalePluginLaunchFailureStore + @Inject @Dispatcher(IO) internal lateinit var ioDispatcher: CoroutineDispatcher + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != LocalePluginContract.ACTION_FIRE_SETTING) return + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + ioDispatcher).launch { + try { + handleFire(context, intent) + } finally { + pendingResult.finish() + } + } + } + + private suspend fun handleFire(context: Context, intent: Intent) { + val configurationJson = LocalePluginContract.readConfigurationJson(intent) + val configuration = codec.decode(configurationJson) + if (configuration == null || configurationJson == null) { + notifications.showError(R.string.locale_plugin_error_invalid) + return + } + + val requestId = UUID.randomUUID().toString() + directLaunchTracker.markPending(requestId) + launchFailureStore.clearLaunchPending() + launchFailureStore.clearFallbackPending() + launchFailureStore.markLaunchPending(requestId) + + when (val action = executor.resolve(configuration)) { + null -> { + directLaunchTracker.abandon(requestId) + notifications.showError(R.string.locale_plugin_error_missing_scenario) + } + ResolvedLocalePluginAction.Stop -> { + directLaunchTracker.abandon(requestId) + notifications.cancelLaunchFallback() + executor.executeStop() + } + is ResolvedLocalePluginAction.LaunchDumb -> { + if (deferForOpenScenarioConfiguration(configurationJson, action.scenario.name, requestId)) { + return + } else if (executor.areBasePermissionsReady(context)) { + directLaunchTracker.abandon(requestId) + if (directLaunchTracker.isLatest(requestId)) notifications.cancelLaunchFallback() + executor.launchDumb(action) + } else requestUserCompletion(context, configurationJson, action.scenario.name, requestId) + } + is ResolvedLocalePluginAction.LaunchSmart -> { + if (deferForOpenScenarioConfiguration(configurationJson, action.scenario.name, requestId)) { + return + } + if (executor.launchSmartWithCurrentProjection(action)) { + directLaunchTracker.abandon(requestId) + if (directLaunchTracker.isLatest(requestId)) notifications.cancelLaunchFallback() + return + } + requestUserCompletion(context, configurationJson, action.scenario.name, requestId) + } + } + } + + /** + * Do not replace a scenario while the user is editing it. The notification is an explicit, + * user-controlled way to apply the requested launch after they finish their work. + */ + private suspend fun deferForOpenScenarioConfiguration( + configurationJson: String, + scenarioName: String, + requestId: String, + ): Boolean { + if (!executor.isScenarioConfigurationOpen()) return false + + Log.i(TAG, "Using notification fallback because the user is editing the current scenario") + directLaunchTracker.abandon(requestId) + launchFailureStore.markFallbackPending(requestId) + notifications.showLaunchFallback(configurationJson, scenarioName, requestId) + return true + } + + private suspend fun requestUserCompletion( + context: Context, + configurationJson: String, + scenarioName: String, + requestId: String, + ) { + if (directLaunchTracker.hasAnotherInFlightRequest(requestId)) { + Log.i(TAG, "Using notification fallback because another Locale launch is awaiting completion") + directLaunchTracker.abandon(requestId) + launchFailureStore.markFallbackPending(requestId) + notifications.showLaunchFallback(configurationJson, scenarioName, requestId) + return + } + + if (!deviceState.canAttemptDirectLaunch()) { + Log.i(TAG, "Using notification fallback because the device is locked or not interactive") + directLaunchTracker.abandon(requestId) + launchFailureStore.markFallbackPending(requestId) + notifications.showLaunchFallback(configurationJson, scenarioName, requestId) + return + } + + val activityStarted = tryStartExecutionActivity(context, configurationJson, requestId) + + if (!activityStarted) { + Log.w(TAG, "Direct Locale launch could not be sent; using notification fallback") + directLaunchTracker.abandon(requestId) + showDirectLaunchFallback(configurationJson, scenarioName, requestId) + return + } + + delay(DIRECT_LAUNCH_FALLBACK_DELAY_MS) + if (!directLaunchTracker.consumeOpened(requestId) && directLaunchTracker.isLatest(requestId)) { + Log.w(TAG, "Direct Locale launch did not open in time; using notification fallback") + showDirectLaunchFallback(configurationJson, scenarioName, requestId) + } + } + + private suspend fun showDirectLaunchFallback( + configurationJson: String, + scenarioName: String, + requestId: String, + ) { + if (!directLaunchTracker.isLatest(requestId)) return + Log.i(TAG, "Recording failed direct launch and showing notification fallback") + launchFailureStore.clearLaunchPending() + launchFailureStore.markDirectLaunchFailed(requestId) + launchFailureStore.markFallbackPending(requestId) + // No Activity acknowledgement will arrive for this attempt in the normal fallback case. + // Remove it from the tracker now so repeated blocked launches cannot grow the pending set. + // A late Activity can still clear the persisted warning using its request id. + directLaunchTracker.abandon(requestId) + notifications.showLaunchFallback(configurationJson, scenarioName, requestId) + } + + private fun tryStartExecutionActivity( + context: Context, + configurationJson: String, + requestId: String, + ): Boolean = + runCatching { + PendingIntent.getActivity( + context, + requestId.hashCode(), + LocalePluginExecutionActivity.createIntent(context, configurationJson, requestId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ).send( + context, + 0, + null, + null, + null, + null, + backgroundActivityStartOptions(), + ) + }.onFailure { throwable -> + Log.w(TAG, "Can't directly open Locale plugin execution activity", throwable) + }.isSuccess + + private fun backgroundActivityStartOptions(): Bundle? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return ActivityOptions.makeBasic().apply { + setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED, + ) + }.toBundle() + } + + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return ActivityOptions.makeBasic().apply { + setPendingIntentBackgroundActivityLaunchAllowed(true) + }.toBundle() + } + + return null + } +} + +private const val DIRECT_LAUNCH_FALLBACK_DELAY_MS = 1500L +private const val TAG = "LocalePluginFireReceiver" diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt new file mode 100644 index 000000000..7d572110b --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.buzbuz.smartautoclicker.core.display.recorder.MediaProjectionRequest +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginDirectLaunchTracker +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginLaunchFailureStore +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.ResolvedLocalePluginAction +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.notification.LocalePluginNotificationController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class LocalePluginExecutionActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_CONFIGURATION_JSON = + "com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.CONFIGURATION_JSON" + private const val EXTRA_REQUEST_ID = + "com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.REQUEST_ID" + private const val EXTRA_FROM_FALLBACK = + "com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.FROM_FALLBACK" + + fun createIntent( + context: Context, + configurationJson: String, + requestId: String? = null, + ): Intent = + Intent(context, LocalePluginExecutionActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_CONFIGURATION_JSON, configurationJson) + .apply { + requestId?.let { putExtra(EXTRA_REQUEST_ID, it) } + } + + fun createFallbackIntent( + context: Context, + configurationJson: String, + requestId: String, + ): Intent = + createIntent(context, configurationJson, requestId) + .putExtra(EXTRA_FROM_FALLBACK, true) + } + + @Inject internal lateinit var notifications: LocalePluginNotificationController + @Inject internal lateinit var directLaunchTracker: LocalePluginDirectLaunchTracker + @Inject internal lateinit var launchFailureStore: LocalePluginLaunchFailureStore + private val viewModel: LocalePluginExecutionViewModel by viewModels() + private val mediaProjectionRequest = MediaProjectionRequest() + private var smartAction: ResolvedLocalePluginAction.LaunchSmart? = null + private var configurationJson: String? = null + private var requestId: String? = null + private var launchedFromFallback = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_locale_plugin_execution) + mediaProjectionRequest.registerForActivityResult(this) + handleLaunchIntent(intent) + } + + private fun handleLaunchIntent(launchIntent: Intent) { + configurationJson = launchIntent.getStringExtra(EXTRA_CONFIGURATION_JSON) + requestId = launchIntent.getStringExtra(EXTRA_REQUEST_ID) + launchedFromFallback = launchIntent.getBooleanExtra(EXTRA_FROM_FALLBACK, false) + + lifecycleScope.launch { + val accepted = requestId?.let { id -> + if (!launchedFromFallback) { + directLaunchTracker.claimDirectLaunch(id) || + launchFailureStore.consumeLaunchPending(id).also { recovered -> + if (recovered) directLaunchTracker.claimRecoveredLaunch(id) + } + } else { + directLaunchTracker.claimFallbackLaunch(id) || + launchFailureStore.consumeFallbackPending(id).also { recovered -> + if (recovered) directLaunchTracker.claimRecoveredLaunch(id) + } || launchFailureStore.consumeLaunchPending(id).also { recovered -> + if (recovered) directLaunchTracker.claimRecoveredLaunch(id) + } + } + } ?: true + if (!accepted) { + finishAndRemoveTask() + return@launch + } + + requestId?.let { id -> + notifications.cancelLaunchFallback() + launchFailureStore.consumeLaunchPending(id) + launchFailureStore.consumeFallbackPending(id) + if (!launchedFromFallback) launchFailureStore.clearDirectLaunchFailure(id) + } + viewModel.resolve(configurationJson, ::handleResolvedAction) + } + } + + private fun handleResolvedAction(action: ResolvedLocalePluginAction?) { + if (!isCurrentRequest()) { + close() + return + } + when (action) { + null -> fail(R.string.locale_plugin_error_invalid) + ResolvedLocalePluginAction.Stop -> { + viewModel.executeStop() + close() + } + is ResolvedLocalePluginAction.LaunchDumb -> requestPermissions { + if (isCurrentRequest()) { + viewModel.launchDumb(action) + close() + } + } + is ResolvedLocalePluginAction.LaunchSmart -> { + smartAction = action + requestPermissions { + mediaProjectionRequest.showMediaProjectionWarning( + context = this, + forceEntireScreen = viewModel.isEntireScreenCaptureForced(), + onSuccess = success@{ resultCode, data -> + if (!isCurrentRequest()) { + close() + return@success + } + viewModel.launchSmart(resultCode, data, action) + close() + }, + onFailure = { + if (isCurrentRequest()) fail(R.string.locale_plugin_error_projection) + else close() + }, + onError = ::handleProjectionLaunchError, + ) + } + } + } + } + + private fun requestPermissions(onGranted: () -> Unit) { + viewModel.requestPermissions( + activity = this, + onAllGranted = { if (isCurrentRequest()) onGranted() else close() }, + onMandatoryDenied = { + if (isCurrentRequest()) fail(R.string.locale_plugin_error_permissions) + else close() + }, + ) + } + + private fun fail(messageRes: Int) { + notifications.showError(messageRes) + close() + } + + private fun handleProjectionLaunchError() { + val id = requestId + val configuration = configurationJson + val action = smartAction + if (!isCurrentRequest()) { + close() + return + } + if (launchedFromFallback || id == null || configuration == null || action == null) { + fail(R.string.locale_plugin_error_projection) + return + } + + lifecycleScope.launch { + launchFailureStore.markDirectLaunchFailed(id) + launchFailureStore.markFallbackPending(id) + notifications.showLaunchFallback(configuration, action.scenario.name, id) + close() + } + } + + override fun onDestroy() { + // A back press or system finish does not necessarily reach close(). Do not leave the + // process-wide launch serializer stuck after this translucent helper disappears. + if (!isChangingConfigurations) directLaunchTracker.markExecutionClosed(requestId) + super.onDestroy() + } + + private fun close() { + directLaunchTracker.markExecutionClosed(requestId) + finishAndRemoveTask() + } + + private fun isCurrentRequest(): Boolean = + requestId == null || directLaunchTracker.isCurrentExecution(requestId) +} diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt new file mode 100644 index 000000000..767e49edf --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.base.data.AppComponentsProvider +import com.buzbuz.smartautoclicker.core.common.permissions.PermissionsController +import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionAccessibilityService +import com.buzbuz.smartautoclicker.core.common.permissions.model.PermissionOverlay +import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginActionExecutor +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfigurationCodec +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.ResolvedLocalePluginAction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class LocalePluginExecutionViewModel @Inject constructor( + private val codec: LocalePluginConfigurationCodec, + private val executor: LocalePluginActionExecutor, + private val permissionController: PermissionsController, + private val externalLaunchRepository: ExternalLaunchRepository, + private val appComponentsProvider: AppComponentsProvider, + private val settingsRepository: SettingsRepository, +) : ViewModel() { + + fun resolve(configurationJson: String?, onResult: (ResolvedLocalePluginAction?) -> Unit) { + val configuration = codec.decode(configurationJson) + if (configuration == null) { + onResult(null) + return + } + viewModelScope.launch { onResult(executor.resolve(configuration)) } + } + + fun requestPermissions( + activity: AppCompatActivity, + onAllGranted: () -> Unit, + onMandatoryDenied: () -> Unit, + ) { + permissionController.startPermissionsUiFlow( + activity = activity, + permissions = listOf( + PermissionOverlay(), + PermissionAccessibilityService( + componentName = appComponentsProvider.klickrServiceComponentName, + isServiceRunning = { externalLaunchRepository.isAccessibilityServiceStarted() }, + ), + ), + onAllGranted = onAllGranted, + onMandatoryDenied = onMandatoryDenied, + ) + } + + fun launchDumb(action: ResolvedLocalePluginAction.LaunchDumb) = executor.launchDumb(action) + + fun launchSmart(resultCode: Int, data: Intent, action: ResolvedLocalePluginAction.LaunchSmart) = + executor.launchSmart(resultCode, data, action) + + fun executeStop() = executor.executeStop() + + fun isEntireScreenCaptureForced(): Boolean = settingsRepository.isEntireScreenCaptureForced() +} diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt new file mode 100644 index 000000000..23fedb0e0 --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import android.content.Intent +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.IRepository +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.core.dumb.domain.DumbRepository +import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalePluginActionExecutorTest { + + private val smartRepository = mockk() + private val dumbRepository = mockk() + private val externalLaunchRepository = mockk(relaxed = true) + private val executor = LocalePluginActionExecutor( + ioDispatcher = UnconfinedTestDispatcher(), + smartRepository = smartRepository, + dumbRepository = dumbRepository, + externalLaunchRepository = externalLaunchRepository, + ) + + @Test + fun `stop resolves without a scenario and executes stop`() = runTest { + val action = executor.resolve(LocalePluginConfiguration(operation = LocalePluginOperation.STOP)) + + assertSame(ResolvedLocalePluginAction.Stop, action) + executor.executeStop() + verify(exactly = 1) { externalLaunchRepository.stopScenarios() } + } + + @Test + fun `smart launch resolves and uses atomic replacement with fresh projection`() = runTest { + val scenario = mockk() + coEvery { smartRepository.getScenario(42L) } returns scenario + + val action = executor.resolve( + LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 42L, + isSmart = true, + ) + ) + + assertTrue(action is ResolvedLocalePluginAction.LaunchSmart) + assertSame(scenario, (action as ResolvedLocalePluginAction.LaunchSmart).scenario) + val projectionData = mockk() + executor.launchSmart(RESULT_OK, projectionData, action) + verify(exactly = 1) { + externalLaunchRepository.replaceSmartScenario(RESULT_OK, projectionData, scenario) + } + } + + @Test + fun `smart launch reuses active projection`() = runTest { + val scenario = scenario(42L) + every { externalLaunchRepository.isSmartScreenRecordActive() } returns true + every { externalLaunchRepository.getSmartScenarioId() } returns 7L + + val action = ResolvedLocalePluginAction.LaunchSmart(scenario) + + assertTrue(executor.launchSmartWithCurrentProjection(action)) + verify(exactly = 1) { externalLaunchRepository.replaceSmartScenarioWithCurrentProjection(scenario) } + } + + @Test + fun `smart launch leaves the already active scenario untouched`() = runTest { + val scenario = scenario(42L) + every { externalLaunchRepository.isSmartScreenRecordActive() } returns true + every { externalLaunchRepository.getSmartScenarioId() } returns 42L + every { externalLaunchRepository.isAccessibilityServiceStarted() } returns true + + assertTrue(executor.launchSmartWithCurrentProjection(ResolvedLocalePluginAction.LaunchSmart(scenario))) + verify(exactly = 0) { externalLaunchRepository.replaceSmartScenarioWithCurrentProjection(any()) } + } + + @Test + fun `smart launch does not reuse missing projection`() = runTest { + val scenario = scenario(42L) + every { externalLaunchRepository.isSmartScreenRecordActive() } returns false + + val action = ResolvedLocalePluginAction.LaunchSmart(scenario) + + assertNull(executor.launchSmartWithCurrentProjection(action).takeIf { it }) + verify(exactly = 0) { externalLaunchRepository.replaceSmartScenarioWithCurrentProjection(any()) } + } + + @Test + fun `reports when the user is editing the current scenario`() { + every { externalLaunchRepository.isScenarioConfigurationOpen() } returns true + + assertTrue(executor.isScenarioConfigurationOpen()) + } + + @Test + fun `dumb launch resolves and uses atomic replacement`() = runTest { + val scenario = mockk() + every { scenario.id } returns Identifier(databaseId = 7L) + coEvery { dumbRepository.getDumbScenario(7L) } returns scenario + + val action = executor.resolve( + LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 7L, + isSmart = false, + ) + ) + + assertTrue(action is ResolvedLocalePluginAction.LaunchDumb) + executor.launchDumb(action as ResolvedLocalePluginAction.LaunchDumb) + verify(exactly = 1) { externalLaunchRepository.replaceDumbScenario(scenario) } + } + + @Test + fun `dumb launch leaves the already running scenario untouched`() { + val scenario = mockk() + every { scenario.id } returns Identifier(databaseId = 7L) + every { externalLaunchRepository.isDumbScenarioRunning(7L) } returns true + + executor.launchDumb(ResolvedLocalePluginAction.LaunchDumb(scenario)) + + verify(exactly = 0) { externalLaunchRepository.replaceDumbScenario(any()) } + } + + @Test + fun `deleted scenarios do not resolve`() = runTest { + coEvery { smartRepository.getScenario(99L) } returns null + + assertNull( + executor.resolve( + LocalePluginConfiguration( + operation = LocalePluginOperation.LAUNCH, + scenarioId = 99L, + isSmart = true, + ) + ) + ) + } +} + +private const val RESULT_OK = -1 + +private fun scenario(id: Long) = Scenario( + id = Identifier(databaseId = id), + name = "Scenario $id", + detectionQuality = 100, +) diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTrackerTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTrackerTest.kt new file mode 100644 index 000000000..2fc5916aa --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginDirectLaunchTrackerTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalePluginDirectLaunchTrackerTest { + + private val tracker = LocalePluginDirectLaunchTracker() + + @Test + fun `opening one request does not acknowledge another pending request`() { + tracker.markPending("first") + assertTrue(tracker.claimDirectLaunch("first")) + tracker.markPending("second") + + assertTrue(tracker.consumeOpened("first")) + assertFalse(tracker.consumeOpened("second")) + } + + @Test + fun `an acknowledgement is consumed only once`() { + tracker.markPending("request") + tracker.claimDirectLaunch("request") + + assertTrue(tracker.consumeOpened("request")) + assertFalse(tracker.consumeOpened("request")) + } + + @Test + fun `a fallback notification acknowledgement does not remain in memory`() { + tracker.markPending("request") + tracker.abandon("request") + assertFalse(tracker.claimDirectLaunch("request")) + + assertFalse(tracker.consumeOpened("request")) + } + + @Test + fun `a newer request supersedes an older request`() { + tracker.markPending("first") + tracker.markPending("second") + + assertFalse(tracker.isLatest("first")) + assertTrue(tracker.isLatest("second")) + } + + @Test + fun `a late direct launch cannot revive a superseded request`() { + tracker.markPending("first") + tracker.markPending("second") + + assertFalse(tracker.claimDirectLaunch("first")) + assertTrue(tracker.claimDirectLaunch("second")) + } + + @Test + fun `only the latest fallback request can be claimed`() { + tracker.markPending("first") + tracker.abandon("first") + tracker.markPending("second") + tracker.abandon("second") + + assertFalse(tracker.claimFallbackLaunch("first")) + assertTrue(tracker.claimFallbackLaunch("second")) + } + + @Test + fun `recovered fallback claim restores process state`() { + tracker.claimRecoveredLaunch("recovered") + + assertTrue(tracker.isLatest("recovered")) + assertTrue(tracker.isCurrentExecution("recovered")) + } + + @Test + fun `a pending request makes a later request in flight`() { + tracker.markPending("first") + tracker.markPending("second") + + assertTrue(tracker.hasAnotherInFlightRequest("second")) + + tracker.abandon("first") + assertFalse(tracker.hasAnotherInFlightRequest("second")) + } + + @Test + fun `an open execution makes a later request in flight until it closes`() { + tracker.markPending("first") + tracker.claimDirectLaunch("first") + tracker.markPending("second") + + assertTrue(tracker.hasAnotherInFlightRequest("second")) + + tracker.markExecutionClosed("first") + assertFalse(tracker.hasAnotherInFlightRequest("second")) + } + + @Test + fun `fallback claim does not replace an active execution`() { + tracker.markPending("first") + assertTrue(tracker.claimDirectLaunch("first")) + tracker.markPending("second") + tracker.abandon("second") + + assertFalse(tracker.claimFallbackLaunch("second")) + assertTrue(tracker.hasAnotherInFlightRequest("second")) + assertFalse(tracker.isCurrentExecution("second")) + } +} diff --git a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/report/timeline/DebugReportTimelineViewModel.kt b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/report/timeline/DebugReportTimelineViewModel.kt index d035f4981..871331e30 100644 --- a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/report/timeline/DebugReportTimelineViewModel.kt +++ b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/report/timeline/DebugReportTimelineViewModel.kt @@ -25,6 +25,7 @@ import com.buzbuz.smartautoclicker.core.domain.IRepository import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.ExternalAction import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Notification import com.buzbuz.smartautoclicker.core.domain.model.action.Pause @@ -187,6 +188,7 @@ class DebugReportTimelineViewModel @Inject constructor( is Intent -> R.drawable.ic_intent is ToggleEvent -> R.drawable.ic_toggle_event is ChangeCounter -> R.drawable.ic_change_counter + is ExternalAction -> R.drawable.ic_external_action is Notification -> R.drawable.ic_action_notification is SetText -> R.drawable.ic_action_set_text is SystemAction -> R.drawable.ic_action_system diff --git a/smartautoclicker/build.gradle.kts b/smartautoclicker/build.gradle.kts index 26e7905a5..f46973909 100644 --- a/smartautoclicker/build.gradle.kts +++ b/smartautoclicker/build.gradle.kts @@ -176,7 +176,7 @@ dependencies { implementation(project(":feature:backup")) implementation(project(":feature:notifications")) - implementation(project(":feature:quick-settings-tile")) + implementation(project(":feature:external-launch")) implementation(project(":feature:revenue")) implementation(project(":feature:review")) implementation(project(":feature:smart-config")) diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index 5441672d2..9134a0fa8 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -43,8 +43,8 @@ import com.buzbuz.smartautoclicker.core.dumb.engine.DumbEngine import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingRepository import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository import com.buzbuz.smartautoclicker.core.smart.debugging.domain.DebuggingRepository -import com.buzbuz.smartautoclicker.feature.qstile.domain.QSTileActionHandler -import com.buzbuz.smartautoclicker.feature.qstile.domain.QSTileRepository +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchActionHandler +import com.buzbuz.smartautoclicker.feature.externallaunch.domain.ExternalLaunchRepository import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository import com.buzbuz.smartautoclicker.feature.review.ReviewRepository import com.buzbuz.smartautoclicker.localservice.LocalService @@ -83,7 +83,7 @@ class SmartAutoClickerService : AccessibilityService() { @Inject lateinit var qualityMetricsMonitor: QualityMetricsMonitor @Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var revenueRepository: IRevenueRepository - @Inject lateinit var tileRepository: QSTileRepository + @Inject lateinit var externalLaunchRepository: ExternalLaunchRepository @Inject lateinit var reviewRepository: ReviewRepository @Inject lateinit var appComponentsProvider: AppComponentsProvider @Inject lateinit var actionExecutor: AndroidActionExecutor @@ -96,14 +96,31 @@ class SmartAutoClickerService : AccessibilityService() { qualityMetricsMonitor.onServiceConnected() actionExecutor.init(this) - tileRepository.setTileActionHandler( - object : QSTileActionHandler { + externalLaunchRepository.setActionHandler( + object : ExternalLaunchActionHandler { override fun isRunning(): Boolean = localServiceConnection.isServiceStarted() - override fun startDumbScenario(dumbScenario: DumbScenario) { - localServiceConnection.getLocalService()?.startDumbScenario(dumbScenario) + override fun isScenarioConfigurationOpen(): Boolean = + overlayManager.hasVisibleOverlayAboveRoot() + override fun isSmartScreenRecordActive(): Boolean = + localServiceConnection.getLocalService()?.isSmartScreenRecordActive() ?: false + override fun getSmartScenarioId(): Long? = + localServiceConnection.getLocalService()?.getSmartScenarioId() + override fun getDumbScenarioId(): Long? = + localServiceConnection.getLocalService()?.getDumbScenarioId() + override fun launchDumbScenario(dumbScenario: DumbScenario) { + localServiceConnection.getLocalService()?.launchDumbScenario(dumbScenario) } - override fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { - localServiceConnection.getLocalService()?.startSmartScenario(resultCode, data, scenario) + override fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { + localServiceConnection.getLocalService()?.launchSmartScenario(resultCode, data, scenario) + } + override fun replaceDumbScenario(dumbScenario: DumbScenario) { + localServiceConnection.getLocalService()?.replaceDumbScenario(dumbScenario) + } + override fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { + localServiceConnection.getLocalService()?.replaceSmartScenario(resultCode, data, scenario) + } + override fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) { + localServiceConnection.getLocalService()?.replaceSmartScenarioWithCurrentProjection(scenario) } override fun stop() { localServiceConnection.getLocalService()?.stopScenario() @@ -152,7 +169,7 @@ class SmartAutoClickerService : AccessibilityService() { requestFilterKeyEvents(true) displayConfigManager.startMonitoring(this) - tileRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart) + externalLaunchRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart) } private fun onLocalServiceStopped() { @@ -177,7 +194,7 @@ class SmartAutoClickerService : AccessibilityService() { } private fun onLocalScenarioChanged(scenarioId: Long, isSmart: Boolean) { - tileRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart) + externalLaunchRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart) } override fun onKeyEvent(event: KeyEvent?): Boolean = diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt index 6e80ab511..035fd7071 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt @@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull class LocalService( @@ -96,6 +97,9 @@ class LocalService( onScenarioChanged = ::synchronizeScenarioChanged, ) } + private val scenarioChangeMutex = Mutex() + /** Updated before the delayed Dumb-engine setup, so external launches always see the active selection. */ + private var loadedDumbScenarioId: Long? = null /** Controls the notifications for the foreground service. */ private val notificationController: ServiceNotificationController by lazy { @@ -120,6 +124,15 @@ class LocalService( internal val isStarted: Boolean get() = state.isStarted + override fun isSmartScreenRecordActive(): Boolean = + state.isStarted && state.isSmartLoaded && smartProcessingRepository.isScreenRecordActive() + + override fun getSmartScenarioId(): Long? = + if (state.isSmartLoaded) smartProcessingRepository.getScenarioId()?.databaseId else null + + override fun getDumbScenarioId(): Long? = + if (state.isStarted && !state.isSmartLoaded) loadedDumbScenarioId else null + init { combine(dumbEngine.isRunning, smartProcessingRepository.detectionState) { dumbIsRunning, smartState -> dumbIsRunning || smartState == DetectionState.DETECTING @@ -138,9 +151,10 @@ class LocalService( .launchIn(serviceScope) } - override fun startDumbScenario(dumbScenario: DumbScenario) { + override fun launchDumbScenario(dumbScenario: DumbScenario) { if (state.isStarted) return state = LocalServiceState(isStarted = true, isSmartLoaded = false, sessionId = ++nextServiceSessionId) + loadedDumbScenarioId = dumbScenario.id.databaseId onStart(dumbScenario.id.databaseId, false, null) startJob = serviceScope.launch { @@ -169,7 +183,7 @@ class LocalService( * [android.app.Activity.onActivityResult] * @param scenario the identifier of the scenario of clicks to be used for detection. */ - override fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { + override fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { if (isStarted) return state = LocalServiceState(isStarted = true, isSmartLoaded = true, sessionId = ++nextServiceSessionId) @@ -210,22 +224,54 @@ class LocalService( } override fun stopScenario() { + serviceScope.launch { scenarioChangeMutex.withLock { stopAndWait() } } + } + + override fun replaceDumbScenario(dumbScenario: DumbScenario) { + serviceScope.launch { + scenarioChangeMutex.withLock { + stopAndWait() + launchDumbScenario(dumbScenario) + } + } + } + + override fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { + serviceScope.launch { + scenarioChangeMutex.withLock { + stopAndWait() + launchSmartScenario(resultCode, data, scenario) + } + } + } + + override fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) { + serviceScope.launch { + scenarioChangeMutex.withLock { + if (!isSmartScreenRecordActive()) return@withLock + smartProcessingRepository.stopDetection() + smartScenarioSwitcher.switchTo(scenario) + if (overlayManager.isStackHidden.value) overlayManager.restoreVisibility() + } + } + } + + private suspend fun stopAndWait() { if (!isStarted) return state = state.copy(isStarted = false, isSmartLoaded = false) scenarioSwitcherOpeningJob?.cancel() scenarioSwitcherOpeningJob = null + loadedDumbScenarioId = null - serviceScope.launch { - startJob?.join() - startJob = null + startJob?.join() + startJob = null - dumbEngine.release() - overlayManager.closeAll(context) - smartProcessingRepository.stopScreenRecord() + dumbEngine.release() + overlayManager.closeAll(context) + smartProcessingRepository.stopScreenRecord() - onStop() - notificationController.destroyNotification() - } + onStop() + notificationController.destroyNotification() } override fun release() { diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt index 288bff0d7..44f1d01c9 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt @@ -23,19 +23,25 @@ import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.buzbuz.smartautoclicker.R import com.buzbuz.smartautoclicker.scenarios.list.ScenarioListFragment import com.buzbuz.smartautoclicker.scenarios.list.model.ScenarioListUiState import com.buzbuz.smartautoclicker.core.base.extensions.delayDrawUntil import com.buzbuz.smartautoclicker.core.display.recorder.MediaProjectionRequest +import com.buzbuz.smartautoclicker.core.common.quality.ui.BackgroundLaunchTroubleshootingDialog import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario import com.buzbuz.smartautoclicker.core.ui.errors.createNoMediaProjectionDialog import com.buzbuz.smartautoclicker.feature.revenue.UserConsentState +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginLaunchFailureStore +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.notification.LocalePluginNotificationController import com.buzbuz.smartautoclicker.scenarios.viewmodel.ScenarioViewModel import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject /** * Entry point activity for the application. @@ -47,6 +53,8 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { /** ViewModel providing the click scenarios data to the UI. */ private val scenarioViewModel: ScenarioViewModel by viewModels() + @Inject lateinit var localePluginLaunchFailureStore: LocalePluginLaunchFailureStore + @Inject lateinit var localePluginNotifications: LocalePluginNotificationController /** The result launcher for the projection permission dialog. */ private val mediaProjectionRequest: MediaProjectionRequest = MediaProjectionRequest() @@ -75,7 +83,25 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { scenarioViewModel.refreshPurchaseState() } - override fun startScenario(item: ScenarioListUiState.Item.ScenarioItem) { + override fun onPostResume() { + super.onPostResume() + lifecycleScope.launch { + if (localePluginLaunchFailureStore.consumePendingDirectLaunchFailure()) { + showLocalePluginBackgroundLaunchHelp() + } + } + } + + private fun showLocalePluginBackgroundLaunchHelp() { + if (supportFragmentManager.findFragmentByTag(BackgroundLaunchTroubleshootingDialog.FRAGMENT_TAG) != null) return + BackgroundLaunchTroubleshootingDialog.newInstance( + getString(R.string.dialog_title_locale_plugin_background_launch), + getString(R.string.message_locale_plugin_background_launch), + DONT_KILL_MY_APP_URL, + ).show(supportFragmentManager, BackgroundLaunchTroubleshootingDialog.FRAGMENT_TAG) + } + + override fun launchScenario(item: ScenarioListUiState.Item.ScenarioItem) { requestedItem = item scenarioViewModel.startPermissionFlowIfNeeded( @@ -87,11 +113,11 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { private fun onMandatoryPermissionsGranted() { scenarioViewModel.startTroubleshootingFlowIfNeeded(this) { when (val scenario = requestedItem?.scenario) { - is DumbScenario -> startDumbScenario(scenario) + is DumbScenario -> launchDumbScenario(scenario) is Scenario -> mediaProjectionRequest.showMediaProjectionWarning( context = this, forceEntireScreen = scenarioViewModel.isEntireScreenCaptureForced(), - onSuccess = { resultCode, data -> startSmartScenario(resultCode, data, scenario) }, + onSuccess = { resultCode, data -> launchSmartScenario(resultCode, data, scenario) }, onFailure = { showProjectionDeniedToast() }, onError = { showUnsupportedDeviceDialog() }, ) @@ -107,14 +133,14 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { createNoMediaProjectionDialog { finish() }.show() } - private fun startDumbScenario(scenario: DumbScenario) { + private fun launchDumbScenario(scenario: DumbScenario) { handleScenarioStartResult(scenarioViewModel.loadDumbScenario( context = this, scenario = scenario, )) } - private fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { + private fun launchSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) { handleScenarioStartResult(scenarioViewModel.loadSmartScenario( context = this, resultCode = resultCode, @@ -124,7 +150,10 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { } private fun handleScenarioStartResult(result: Boolean) { - if (result) finish() + if (result) { + localePluginNotifications.cancelLaunchFallback() + finish() + } else Toast.makeText(this, R.string.toast_denied_foreground_permission, Toast.LENGTH_SHORT).show() } @@ -132,3 +161,5 @@ class ScenarioActivity : AppCompatActivity(), ScenarioListFragment.Listener { Toast.makeText(this, R.string.toast_denied_screen_sharing_permission, Toast.LENGTH_SHORT).show() } } + +private const val DONT_KILL_MY_APP_URL = "https://dontkillmyapp.com/?app=Klick%27r" diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/ScenarioListFragment.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/ScenarioListFragment.kt index 53efa8c71..1c73e5101 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/ScenarioListFragment.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/ScenarioListFragment.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.launch class ScenarioListFragment : Fragment() { interface Listener { - fun startScenario(item: ScenarioListUiState.Item.ScenarioItem) + fun launchScenario(item: ScenarioListUiState.Item.ScenarioItem) } private val tutorialNavigator: TutorialNavigator by lazy { @@ -98,7 +98,7 @@ class ScenarioListFragment : Fragment() { scenariosAdapter = ScenarioAdapter( bitmapProvider = scenarioListViewModel::getConditionBitmap, - startScenarioListener = ::onStartClicked, + launchScenarioListener = ::onStartClicked, deleteScenarioListener = ::onDeleteClicked, exportClickListener = ::onExportClicked, copyClickedListener = ::showCopyScenarioDialog, @@ -273,7 +273,7 @@ class ScenarioListFragment : Fragment() { * @param scenario the scenario clicked. */ private fun onStartClicked(scenario: ScenarioListUiState.Item.ScenarioItem) { - (requireActivity() as? Listener)?.startScenario(scenario) + (requireActivity() as? Listener)?.launchScenario(scenario) } /** @@ -376,4 +376,4 @@ private fun MenuItem.bind(state: ScenarioListUiState.Menu.Item) { } /** Tag for logs. */ -private const val TAG = "ScenarioListFragment" \ No newline at end of file +private const val TAG = "ScenarioListFragment" diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioAdapter.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioAdapter.kt index a501e54db..4296db36b 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioAdapter.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioAdapter.kt @@ -37,13 +37,13 @@ import kotlinx.coroutines.Job /** * Adapter for the display of the click scenarios created by the user into a RecyclerView. * - * @param startScenarioListener listener upon the click on a scenario. + * @param launchScenarioListener listener upon the click on a scenario. * @param exportClickListener listener upon the export button of a scenario. * @param deleteScenarioListener listener upon the delete button of a scenario. */ class ScenarioAdapter( private val bitmapProvider: (ScreenCondition.Image, onBitmapLoaded: (Bitmap?) -> Unit) -> Job?, - private val startScenarioListener: ((ScenarioListUiState.Item.ScenarioItem) -> Unit), + private val launchScenarioListener: ((ScenarioListUiState.Item.ScenarioItem) -> Unit), private val expandCollapseListener: ((ScenarioListUiState.Item.ScenarioItem) -> Unit), private val exportClickListener: ((ScenarioListUiState.Item.ScenarioItem) -> Unit), private val copyClickedListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), @@ -66,13 +66,13 @@ class ScenarioAdapter( when (viewType) { R.layout.item_empty_scenario -> EmptyScenarioHolder( viewBinding = ItemEmptyScenarioBinding.inflate(LayoutInflater.from(parent.context), parent, false), - startScenarioListener = startScenarioListener, + launchScenarioListener = launchScenarioListener, deleteScenarioListener = deleteScenarioListener, ) R.layout.item_dumb_scenario -> DumbScenarioViewHolder( viewBinding = ItemDumbScenarioBinding.inflate(LayoutInflater.from(parent.context), parent, false), - startScenarioListener = startScenarioListener, + launchScenarioListener = launchScenarioListener, expandCollapseListener = expandCollapseListener, exportClickListener = exportClickListener, copyClickedListener = copyClickedListener, @@ -82,7 +82,7 @@ class ScenarioAdapter( R.layout.item_smart_scenario -> SmartScenarioViewHolder( viewBinding = ItemSmartScenarioBinding.inflate(LayoutInflater.from(parent.context), parent, false), bitmapProvider= bitmapProvider, - startScenarioListener = startScenarioListener, + launchScenarioListener = launchScenarioListener, expandCollapseListener = expandCollapseListener, exportClickListener = exportClickListener, copyClickedListener = copyClickedListener, diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioViewHolders.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioViewHolders.kt index d8461692c..b2a1376b4 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioViewHolders.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/list/adapter/ScenarioViewHolders.kt @@ -35,7 +35,7 @@ import java.util.Locale class EmptyScenarioHolder( private val viewBinding: ItemEmptyScenarioBinding, - private val startScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Empty) -> Unit), + private val launchScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Empty) -> Unit), private val deleteScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Empty) -> Unit), ): RecyclerView.ViewHolder(viewBinding.root) { @@ -46,7 +46,7 @@ class EmptyScenarioHolder( else R.drawable.ic_smart ) - buttonStart.setOnClickListener { startScenarioListener(scenarioItem) } + buttonStart.setOnClickListener { launchScenarioListener(scenarioItem) } buttonDelete.setOnClickListener { deleteScenarioListener(scenarioItem) } } } @@ -54,7 +54,7 @@ class EmptyScenarioHolder( /** ViewHolder for the [ScenarioAdapter]. */ class DumbScenarioViewHolder( private val viewBinding: ItemDumbScenarioBinding, - private val startScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), + private val launchScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val expandCollapseListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val exportClickListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val copyClickedListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), @@ -79,7 +79,7 @@ class DumbScenarioViewHolder( buttonExpandCollapse.isEnabled = true buttonExport.visibility = View.GONE topDivider.visibility = View.VISIBLE - root.setOnClickListener { startScenarioListener(scenarioItem) } + root.setOnClickListener { launchScenarioListener(scenarioItem) } } if (!scenarioItem.showExportCheckbox && scenarioItem.expanded) { @@ -110,7 +110,7 @@ class DumbScenarioViewHolder( class SmartScenarioViewHolder( private val viewBinding: ItemSmartScenarioBinding, bitmapProvider: (ScreenCondition.Image, onBitmapLoaded: (Bitmap?) -> Unit) -> Job?, - private val startScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), + private val launchScenarioListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val expandCollapseListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val exportClickListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), private val copyClickedListener: ((ScenarioListUiState.Item.ScenarioItem.Valid) -> Unit), @@ -140,7 +140,7 @@ class SmartScenarioViewHolder( buttonExpandCollapse.isEnabled = true buttonExport.visibility = View.GONE topDivider.visibility = View.VISIBLE - root.setOnClickListener { startScenarioListener(scenarioItem) } + root.setOnClickListener { launchScenarioListener(scenarioItem) } } if (!scenarioItem.showExportCheckbox && scenarioItem.expanded) { @@ -170,4 +170,4 @@ class SmartScenarioViewHolder( buttonDelete.setOnClickListener { deleteScenarioListener(scenarioItem) } buttonExport.setOnClickListener { exportClickListener(scenarioItem) } } -} \ No newline at end of file +} diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/viewmodel/ScenarioViewModel.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/viewmodel/ScenarioViewModel.kt index 6905325f9..7fd0a98b3 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/viewmodel/ScenarioViewModel.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/viewmodel/ScenarioViewModel.kt @@ -113,7 +113,7 @@ class ScenarioViewModel @Inject constructor( if (foregroundPermission != PermissionChecker.PERMISSION_GRANTED) return false } - serviceConnection.getLocalService()?.startSmartScenario(resultCode, data, scenario) + serviceConnection.getLocalService()?.launchSmartScenario(resultCode, data, scenario) return true } @@ -123,7 +123,7 @@ class ScenarioViewModel @Inject constructor( if (foregroundPermission != PermissionChecker.PERMISSION_GRANTED) return false } - serviceConnection.getLocalService()?.startDumbScenario(scenario) + serviceConnection.getLocalService()?.launchDumbScenario(scenario) return true } diff --git a/smartautoclicker/src/main/res/values-ar/strings.xml b/smartautoclicker/src/main/res/values-ar/strings.xml index 0136e9843..11f91c09f 100644 --- a/smartautoclicker/src/main/res/values-ar/strings.xml +++ b/smartautoclicker/src/main/res/values-ar/strings.xml @@ -170,4 +170,6 @@ استيراد تصدير + تم حظر التشغيل في الخلفية + تعذر على Klick\'r فتح نافذة تسجيل الشاشة تلقائيًا.\n\nلإكمال تشغيل السيناريو، عرض إشعارًا بدلًا من ذلك.\n\nتفرض بعض هواتف Android قيودًا أكبر على التطبيقات في الخلفية. راجع هذا الدليل لتحسين استقرار الأذونات. diff --git a/smartautoclicker/src/main/res/values-es/strings.xml b/smartautoclicker/src/main/res/values-es/strings.xml index 84a11a7bf..fe80ac360 100644 --- a/smartautoclicker/src/main/res/values-es/strings.xml +++ b/smartautoclicker/src/main/res/values-es/strings.xml @@ -163,4 +163,6 @@ Importar Exportar + Inicio en segundo plano bloqueado + Klick\'r no pudo abrir automáticamente la ventana de captura de pantalla.\n\nPara terminar de iniciar el escenario, mostró una notificación.\n\nAlgunos teléfonos Android restringen más las aplicaciones en segundo plano. Consulta esta guía para mejorar la estabilidad de los permisos. diff --git a/smartautoclicker/src/main/res/values-fr/strings.xml b/smartautoclicker/src/main/res/values-fr/strings.xml index 5281a7493..494ee80d8 100644 --- a/smartautoclicker/src/main/res/values-fr/strings.xml +++ b/smartautoclicker/src/main/res/values-fr/strings.xml @@ -164,4 +164,6 @@ Importer Exporter + Lancement en arrière-plan bloqué + Klick\'r n\'a pas pu ouvrir automatiquement la fenêtre de capture d\'écran.\n\nPour terminer le lancement du scénario, une notification a été affichée.\n\nCertains téléphones Android restreignent davantage les applications en arrière-plan. Consultez ce guide pour améliorer la stabilité des autorisations. diff --git a/smartautoclicker/src/main/res/values-it/strings.xml b/smartautoclicker/src/main/res/values-it/strings.xml index dcfe8f31c..4ac744013 100644 --- a/smartautoclicker/src/main/res/values-it/strings.xml +++ b/smartautoclicker/src/main/res/values-it/strings.xml @@ -162,4 +162,6 @@ Importa Esporta + Avvio in background bloccato + Klick\'r non ha potuto aprire automaticamente la finestra di cattura dello schermo.\n\nPer completare l\'avvio dello scenario, è stata mostrata una notifica.\n\nAlcuni telefoni Android impongono restrizioni maggiori alle app in background. Consulta questa guida per migliorare la stabilità delle autorizzazioni. diff --git a/smartautoclicker/src/main/res/values-ja/strings.xml b/smartautoclicker/src/main/res/values-ja/strings.xml index 69ca0f1c6..ddbe13ffd 100644 --- a/smartautoclicker/src/main/res/values-ja/strings.xml +++ b/smartautoclicker/src/main/res/values-ja/strings.xml @@ -108,4 +108,6 @@ インポート エクスポート + バックグラウンド起動がブロックされました + Klick\'r は画面キャプチャの確認画面を自動で開けませんでした。\n\nシナリオの起動を完了するため、代わりに通知を表示しました。\n\n一部の Android 端末では、バックグラウンドアプリにより厳しい制限があります。このガイドを確認して権限の安定性を改善してください。 diff --git a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml index bfe8ed088..b70f5783e 100644 --- a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml +++ b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml @@ -162,4 +162,6 @@ Importar Exportar + Inicialização em segundo plano bloqueada + O Klick\'r não pôde abrir automaticamente a janela de captura de tela.\n\nPara concluir a inicialização do cenário, uma notificação foi exibida.\n\nAlguns telefones Android restringem mais os apps em segundo plano. Consulte este guia para melhorar a estabilidade das permissões. diff --git a/smartautoclicker/src/main/res/values-ru/strings.xml b/smartautoclicker/src/main/res/values-ru/strings.xml index 6caf14363..7475d4da4 100644 --- a/smartautoclicker/src/main/res/values-ru/strings.xml +++ b/smartautoclicker/src/main/res/values-ru/strings.xml @@ -160,4 +160,6 @@ Импортировать Экспортировать + Фоновый запуск заблокирован + Klick\'r не удалось автоматически открыть окно записи экрана.\n\nЧтобы завершить запуск сценария, было показано уведомление.\n\nНекоторые телефоны Android сильнее ограничивают фоновые приложения. Обратитесь к этому руководству, чтобы повысить стабильность разрешений. diff --git a/smartautoclicker/src/main/res/values-uk/strings.xml b/smartautoclicker/src/main/res/values-uk/strings.xml index ced4f04ae..a72a3b062 100644 --- a/smartautoclicker/src/main/res/values-uk/strings.xml +++ b/smartautoclicker/src/main/res/values-uk/strings.xml @@ -163,4 +163,6 @@ Імпорт Експорт + Фоновий запуск заблоковано + Klick\'r не вдалося автоматично відкрити вікно запису екрана.\n\nЩоб завершити запуск сценарію, було показано сповіщення.\n\nДеякі телефони Android сильніше обмежують фонові застосунки. Перегляньте цей посібник, щоб підвищити стабільність дозволів. diff --git a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml index 5f6de9d2c..64184ebc0 100644 --- a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml @@ -108,4 +108,6 @@ 导入 导出 + 后台启动被阻止 + Klick\'r 无法自动打开屏幕捕获提示。\n\n为了完成场景启动,我们改为显示了一条通知。\n\n部分 Android 手机对后台应用限制更严格。请参考此指南以提高权限稳定性。 diff --git a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml index 2e85b7de2..58101a63b 100644 --- a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml @@ -108,4 +108,6 @@ 匯入 匯出 + 背景啟動遭到封鎖 + Klick\'r 無法自動開啟螢幕擷取提示。\n\n為了完成情境啟動,我們改為顯示通知。\n\n部分 Android 手機對背景應用程式限制更嚴格。請參考此指南以提高權限穩定性。 diff --git a/smartautoclicker/src/main/res/values/strings.xml b/smartautoclicker/src/main/res/values/strings.xml index db771cec4..120764d18 100644 --- a/smartautoclicker/src/main/res/values/strings.xml +++ b/smartautoclicker/src/main/res/values/strings.xml @@ -86,6 +86,10 @@ - Views handles any length here, so no limitations. --> This will delete %1$s. Are you sure ? + Background launch blocked + Klick\'r could not open the screen-capture prompt automatically.\n\n + To finish launching the scenario, it showed a notification instead.\n\n + Some Android phones restrict background apps more than standard Android. Refer to this guide to improve the permission stability. + Klick\'r Status + Provides the current Klick\'r scenario status Launch Klick\'r Stop Klick\'r No Scenario defined diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateTest.kt new file mode 100644 index 000000000..d6a354368 --- /dev/null +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScenarioStateTest { + + @Test + fun `public contract exposes only scenario name and state`() { + assertEquals(2, ScenarioStatePluginContract.relevantVariables.size) + assertTrue(ScenarioStatePluginContract.relevantVariables[0].startsWith("%klickr_scenario_name\n")) + assertTrue(ScenarioStatePluginContract.relevantVariables[1].startsWith("%klickr_scenario_state\n")) + } + + @Test + fun `no loaded scenario has none state`() { + assertEquals( + ScenarioState.NONE, + ScenarioState.from( + isScenarioOpen = false, + isRunning = true, + isOverlayHidden = true, + isSettingsOpen = true, + ), + ) + } + + @Test + fun `settings takes priority over every loaded scenario state`() { + assertEquals( + ScenarioState.SETTINGS, + ScenarioState.from( + isScenarioOpen = true, + isRunning = true, + isOverlayHidden = true, + isSettingsOpen = true, + ), + ) + } + + @Test + fun `hidden takes priority over running`() { + assertEquals( + ScenarioState.HIDDEN, + ScenarioState.from( + isScenarioOpen = true, + isRunning = true, + isOverlayHidden = true, + isSettingsOpen = false, + ), + ) + } + + @Test + fun `visible loaded scenario reports running or paused`() { + assertEquals(ScenarioState.RUNNING, visibleScenarioState(isRunning = true)) + assertEquals(ScenarioState.PAUSED, visibleScenarioState(isRunning = false)) + } + + @Test + fun `condition is satisfied for every loaded state`() { + ScenarioState.entries + .filterNot { it == ScenarioState.NONE } + .forEach { assertTrue(ScenarioStateSnapshot("Scenario", it).isScenarioOpen) } + + assertFalse(ScenarioStateSnapshot("", ScenarioState.NONE).isScenarioOpen) + } + + @Test + fun `state values form the documented stable vocabulary`() { + assertEquals( + listOf("running", "paused", "hidden", "settings", "none"), + ScenarioState.entries.map { it.value }, + ) + } + + private fun visibleScenarioState(isRunning: Boolean): ScenarioState = + ScenarioState.from( + isScenarioOpen = true, + isRunning = isRunning, + isOverlayHidden = false, + isSettingsOpen = false, + ) +} diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index 9134a0fa8..4b131a8cb 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -99,8 +99,12 @@ class SmartAutoClickerService : AccessibilityService() { externalLaunchRepository.setActionHandler( object : ExternalLaunchActionHandler { override fun isRunning(): Boolean = localServiceConnection.isServiceStarted() + override fun isScenarioRunning(): Boolean = + localServiceConnection.getLocalService()?.isScenarioRunning() ?: false + override fun isOverlayVisible(): Boolean = overlayManager.isOverlayStackVisible() + override fun isOverlayHidden(): Boolean = overlayManager.isOverlayStackHidden() override fun isScenarioConfigurationOpen(): Boolean = - overlayManager.hasVisibleOverlayAboveRoot() + overlayManager.hasOverlayAboveRoot() override fun isSmartScreenRecordActive(): Boolean = localServiceConnection.getLocalService()?.isSmartScreenRecordActive() ?: false override fun getSmartScenarioId(): Long? = @@ -142,6 +146,7 @@ class SmartAutoClickerService : AccessibilityService() { tutorialRepository = tutorialRepository, onStart = ::onLocalServiceStarted, onScenarioChanged = ::onLocalScenarioChanged, + onScenarioStateChanged = externalLaunchRepository::notifyScenarioStateChanged, onStop = ::onLocalServiceStopped, ) ) @@ -153,6 +158,7 @@ class SmartAutoClickerService : AccessibilityService() { release() } localServiceConnection.onAccessibilityServiceStopped() + externalLaunchRepository.notifyScenarioStateChanged() qualityMetricsMonitor.onServiceUnbind() actionExecutor.clear() diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt index 035fd7071..a16745f89 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt @@ -71,6 +71,7 @@ class LocalService( private val debuggingRepository: DebuggingRepository, private val onStart: (scenarioId: Long, isSmart: Boolean, foregroundNotification: Notification?) -> Unit, private val onScenarioChanged: (scenarioId: Long, isSmart: Boolean) -> Unit, + private val onScenarioStateChanged: () -> Unit, private val onStop: () -> Unit, ) : LocalAccessibilityService { @@ -133,11 +134,15 @@ class LocalService( override fun getDumbScenarioId(): Long? = if (state.isStarted && !state.isSmartLoaded) loadedDumbScenarioId else null + override fun isScenarioRunning(): Boolean = + dumbEngine.isRunning.value || smartProcessingRepository.isRunning() + init { combine(dumbEngine.isRunning, smartProcessingRepository.detectionState) { dumbIsRunning, smartState -> dumbIsRunning || smartState == DetectionState.DETECTING }.onEach { isRunning -> notificationController.updateNotification(context, isRunning, !overlayManager.isOverlayStackHidden()) + onScenarioStateChanged() }.launchIn(serviceScope) overlayManager.isStackHidden @@ -147,8 +152,13 @@ class LocalService( dumbEngine.isRunning.value || smartProcessingRepository.isRunning(), !isStackHidden ) + onScenarioStateChanged() } .launchIn(serviceScope) + + overlayManager.backStackTopFlow + .onEach { onScenarioStateChanged() } + .launchIn(serviceScope) } override fun launchDumbScenario(dumbScenario: DumbScenario) { @@ -262,6 +272,7 @@ class LocalService( scenarioSwitcherOpeningJob?.cancel() scenarioSwitcherOpeningJob = null loadedDumbScenarioId = null + onScenarioStateChanged() startJob?.join() startJob = null @@ -347,6 +358,7 @@ class LocalService( } try { onScenarioChanged(scenario.id.databaseId, true) + onScenarioStateChanged() } catch (error: Exception) { Log.w(TAG, "Unable to update the quick-settings tile after switching scenario", error) } From 11e489fa20112bf8590679f160474631337ee1d4 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 22 Aug 2026 15:41:54 +0530 Subject: [PATCH 12/14] feat(locale): run the current paused scenario --- .../domain/LocalAccessibilityService.kt | 1 + .../src/main/AndroidManifest.xml | 13 ++++++ .../domain/ExternalLaunchActionHandler.kt | 1 + .../domain/ExternalLaunchRepository.kt | 3 ++ .../domain/LocalePluginActionExecutor.kt | 5 +++ .../domain/LocalePluginConfiguration.kt | 3 +- .../receiver/LocalePluginFireReceiver.kt | 7 +++ .../ui/LocalePluginExecutionActivity.kt | 4 ++ .../ui/LocalePluginExecutionViewModel.kt | 2 + ...lePluginRunCurrentConfigurationActivity.kt | 43 +++++++++++++++++++ .../src/main/res/values/strings.xml | 2 + .../domain/LocalePluginActionExecutorTest.kt | 9 ++++ .../LocalePluginConfigurationCodecTest.kt | 7 +++ .../SmartAutoClickerService.kt | 3 ++ .../localservice/LocalService.kt | 40 +++++++++++++++-- .../localservice/RunCurrentScenarioTest.kt | 41 ++++++++++++++++++ 16 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginRunCurrentConfigurationActivity.kt create mode 100644 smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/RunCurrentScenarioTest.kt diff --git a/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt b/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt index ee3e3c5bb..ba30fc1c7 100644 --- a/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt +++ b/core/common/accessibility/src/main/java/com/buzbuz/smartautoclicker/core/common/accessibility/domain/LocalAccessibilityService.kt @@ -31,6 +31,7 @@ interface LocalAccessibilityService { fun replaceDumbScenario(dumbScenario: DumbScenario) fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) + fun runCurrentScenario() fun stopScenario() fun release() diff --git a/feature/external-launch/src/main/AndroidManifest.xml b/feature/external-launch/src/main/AndroidManifest.xml index 565eb3acd..d252ecad0 100644 --- a/feature/external-launch/src/main/AndroidManifest.xml +++ b/feature/external-launch/src/main/AndroidManifest.xml @@ -89,6 +89,19 @@ + + + + + + + ResolvedLocalePluginAction.Stop + LocalePluginOperation.RUN_CURRENT -> ResolvedLocalePluginAction.RunCurrent LocalePluginOperation.LAUNCH -> { val id = configuration.scenarioId ?: return@withContext null if (configuration.isSmart == true) { @@ -52,6 +53,8 @@ internal class LocalePluginActionExecutor @Inject constructor( fun executeStop() = externalLaunchRepository.stopScenarios() + fun executeRunCurrent() = externalLaunchRepository.runCurrentScenario() + fun launchDumb(action: ResolvedLocalePluginAction.LaunchDumb) { if (externalLaunchRepository.isDumbScenarioRunning(action.scenario.id.databaseId)) return externalLaunchRepository.replaceDumbScenario(action.scenario) @@ -77,12 +80,14 @@ internal class LocalePluginActionExecutor @Inject constructor( internal sealed interface ResolvedLocalePluginAction { data object Stop : ResolvedLocalePluginAction + data object RunCurrent : ResolvedLocalePluginAction data class LaunchSmart(val scenario: Scenario) : ResolvedLocalePluginAction data class LaunchDumb(val scenario: DumbScenario) : ResolvedLocalePluginAction val scenarioName: String? get() = when (this) { Stop -> null + RunCurrent -> null is LaunchSmart -> scenario.name is LaunchDumb -> scenario.name } diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt index b27135446..049d6cafb 100644 --- a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.kt @@ -11,7 +11,7 @@ package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain import kotlinx.serialization.Serializable @Serializable -internal enum class LocalePluginOperation { LAUNCH, STOP } +internal enum class LocalePluginOperation { LAUNCH, RUN_CURRENT, STOP } @Serializable internal data class LocalePluginConfiguration( @@ -22,6 +22,7 @@ internal data class LocalePluginConfiguration( ) { fun isValid(): Boolean = when (operation) { LocalePluginOperation.LAUNCH -> scenarioId != null && scenarioId > 0L && isSmart != null + LocalePluginOperation.RUN_CURRENT, LocalePluginOperation.STOP -> scenarioId == null && isSmart == null } } diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt index 297f6aad8..0f397c958 100644 --- a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt @@ -81,9 +81,16 @@ class LocalePluginFireReceiver : BroadcastReceiver() { } ResolvedLocalePluginAction.Stop -> { directLaunchTracker.abandon(requestId) + launchFailureStore.clearLaunchPending() notifications.cancelLaunchFallback() executor.executeStop() } + ResolvedLocalePluginAction.RunCurrent -> { + directLaunchTracker.abandon(requestId) + launchFailureStore.clearLaunchPending() + notifications.cancelLaunchFallback() + executor.executeRunCurrent() + } is ResolvedLocalePluginAction.LaunchDumb -> { if (deferForOpenScenarioConfiguration(configurationJson, action.scenario.name, requestId)) { return diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt index 7d572110b..78bd78dc6 100644 --- a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt @@ -120,6 +120,10 @@ class LocalePluginExecutionActivity : AppCompatActivity() { viewModel.executeStop() close() } + ResolvedLocalePluginAction.RunCurrent -> { + viewModel.executeRunCurrent() + close() + } is ResolvedLocalePluginAction.LaunchDumb -> requestPermissions { if (isCurrentRequest()) { viewModel.launchDumb(action) diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt index 767e49edf..e3efd1ed1 100644 --- a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt @@ -70,5 +70,7 @@ internal class LocalePluginExecutionViewModel @Inject constructor( fun executeStop() = executor.executeStop() + fun executeRunCurrent() = executor.executeRunCurrent() + fun isEntireScreenCaptureForced(): Boolean = settingsRepository.isEntireScreenCaptureForced() } diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginRunCurrentConfigurationActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginRunCurrentConfigurationActivity.kt new file mode 100644 index 000000000..01384af5c --- /dev/null +++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginRunCurrentConfigurationActivity.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui + +import android.app.Activity +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.buzbuz.smartautoclicker.feature.externallaunch.R +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginConfiguration +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract +import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginOperation +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class LocalePluginRunCurrentConfigurationActivity : AppCompatActivity() { + + private val viewModel: LocalePluginConfigurationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action != LocalePluginContract.ACTION_EDIT_SETTING) { + finish() + return + } + + val configuration = LocalePluginConfiguration(operation = LocalePluginOperation.RUN_CURRENT) + setResult( + Activity.RESULT_OK, + LocalePluginContract.createResult( + viewModel.encodeConfiguration(configuration), + getString(R.string.locale_plugin_blurb_run_current), + ) + ) + finish() + } +} diff --git a/feature/external-launch/src/main/res/values/strings.xml b/feature/external-launch/src/main/res/values/strings.xml index 7df767364..c38b4984a 100644 --- a/feature/external-launch/src/main/res/values/strings.xml +++ b/feature/external-launch/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Launch Klick\'r scenario Stop Klick\'r + Run current Klick\'r scenario Launch Klick\'r scenario Choose the scenario Klick\'r should prepare. Press Play in Klick\'r when ready. Scenario @@ -34,6 +35,7 @@ Cancel Launch %1$s (%2$s) Stop Klick\'r + Run current Klick\'r scenario Smart Dumb Scenario type diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt index 23fedb0e0..e14f0b7df 100644 --- a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt @@ -49,6 +49,15 @@ class LocalePluginActionExecutorTest { verify(exactly = 1) { externalLaunchRepository.stopScenarios() } } + @Test + fun `run current resolves without a scenario and delegates to the live session`() = runTest { + val action = executor.resolve(LocalePluginConfiguration(operation = LocalePluginOperation.RUN_CURRENT)) + + assertSame(ResolvedLocalePluginAction.RunCurrent, action) + executor.executeRunCurrent() + verify(exactly = 1) { externalLaunchRepository.runCurrentScenario() } + } + @Test fun `smart launch resolves and uses atomic replacement with fresh projection`() = runTest { val scenario = mockk() diff --git a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt index 0ffab6880..918b0e4f8 100644 --- a/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt +++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt @@ -49,6 +49,13 @@ class LocalePluginConfigurationCodecTest { assertEquals(configuration, codec.decode(codec.encode(configuration))) } + @Test + fun `run current round trips without scenario fields`() { + val configuration = LocalePluginConfiguration(operation = LocalePluginOperation.RUN_CURRENT) + + assertEquals(configuration, codec.decode(codec.encode(configuration))) + } + @Test fun `tampered payload is rejected`() { val encoded = codec.encode( diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index 4b131a8cb..b58301ae9 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -126,6 +126,9 @@ class SmartAutoClickerService : AccessibilityService() { override fun replaceSmartScenarioWithCurrentProjection(scenario: Scenario) { localServiceConnection.getLocalService()?.replaceSmartScenarioWithCurrentProjection(scenario) } + override fun runCurrentScenario() { + localServiceConnection.getLocalService()?.runCurrentScenario() + } override fun stop() { localServiceConnection.getLocalService()?.stopScenario() } diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt index a16745f89..7e8c4c471 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt @@ -305,6 +305,30 @@ class LocalService( } } + /** + * Runs the loaded scenario only when the root overlay is visible and unobstructed. + * Locale actions are intentionally ignored in every other state so they cannot disturb + * a running session or UI containing unsaved user changes. + */ + override fun runCurrentScenario() { + serviceScope.launch { + if (!canRunCurrentScenario( + isLoaded = state.isStarted, + isRunning = isScenarioRunning(), + isHidden = overlayManager.isOverlayStackHidden(), + hasOverlayAboveRoot = overlayManager.hasOverlayAboveRoot(), + ) + ) return@launch + + if (state.isSmartLoaded) { + if (shouldStartPaywall()) startPaywall(onlyIfRootVisible = true) + else startSmartScenario(onlyIfRootVisible = true) + } else { + dumbEngine.startDumbScenario() + } + } + } + private fun pause() { serviceScope.launch { when { @@ -318,25 +342,28 @@ class LocalService( revenueRepository.userBillingState.value == UserBillingState.AD_REQUESTED && !tutorialRepository.isTutorialStarted() - private fun startPaywall() { + private fun startPaywall(onlyIfRootVisible: Boolean = false) { revenueRepository.startPaywallUiFlow(context) paywallResultJob = combine(revenueRepository.isBillingFlowInProgress, revenueRepository.userBillingState) { inProgress, state -> if (inProgress) return@combine - if (state != UserBillingState.AD_REQUESTED) startSmartScenario() + if (state != UserBillingState.AD_REQUESTED) startSmartScenario(onlyIfRootVisible) paywallResultJob?.cancel() paywallResultJob = null }.launchIn(serviceScope) } - private fun startSmartScenario() { + private fun startSmartScenario(onlyIfRootVisible: Boolean = false) { serviceScope.launch { // Ignore Play while a switch owns this transition. Starting afterward could silently start detection on a // scenario different from the one the user saw when they pressed Play. if (!smartScenarioTransitionMutex.tryLock()) return@launch try { if (!state.isSmartLoaded || smartProcessingRepository.isRunning()) return@launch + if (onlyIfRootVisible && + (overlayManager.isOverlayStackHidden() || overlayManager.hasOverlayAboveRoot()) + ) return@launch smartProcessingRepository.startDetection( context = context, @@ -419,6 +446,13 @@ class LocalService( private const val SCENARIO_SWITCHER_PAUSE_TIMEOUT_MS = 5_000L private const val TAG = "LocalService" +internal fun canRunCurrentScenario( + isLoaded: Boolean, + isRunning: Boolean, + isHidden: Boolean, + hasOverlayAboveRoot: Boolean, +): Boolean = isLoaded && !isRunning && !isHidden && !hasOverlayAboveRoot + private data class LocalServiceState( val isStarted: Boolean, val isSmartLoaded: Boolean, diff --git a/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/RunCurrentScenarioTest.kt b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/RunCurrentScenarioTest.kt new file mode 100644 index 000000000..e1a61cca8 --- /dev/null +++ b/smartautoclicker/src/test/java/com/buzbuz/smartautoclicker/localservice/RunCurrentScenarioTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.localservice + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunCurrentScenarioTest { + + @Test + fun `paused scenario with visible root overlay can run`() { + assertTrue(canRunCurrentScenario(true, false, false, false)) + } + + @Test + fun `missing scenario cannot run`() { + assertFalse(canRunCurrentScenario(false, false, false, false)) + } + + @Test + fun `running scenario is left alone`() { + assertFalse(canRunCurrentScenario(true, true, false, false)) + } + + @Test + fun `hidden overlay cannot run`() { + assertFalse(canRunCurrentScenario(true, false, true, false)) + } + + @Test + fun `settings or switcher above root prevent running`() { + assertFalse(canRunCurrentScenario(true, false, false, true)) + } +} From 7db309b3eecc90d28176858ad8db3fedaf197662 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 22 Aug 2026 15:39:24 +0530 Subject: [PATCH 13/14] feat(locale): translate automation integrations --- .../src/main/res/values-it/strings.xml | 2 +- .../src/main/res/values-ru/strings.xml | 2 +- .../src/main/res/values-ar/strings.xml | 8 ++++++-- .../src/main/res/values-es/strings.xml | 6 +++++- .../src/main/res/values-fr/strings.xml | 10 +++++++--- .../src/main/res/values-it/strings.xml | 16 ++++++++++------ .../src/main/res/values-ja/strings.xml | 6 +++++- .../src/main/res/values-pt-rBR/strings.xml | 12 ++++++++---- .../src/main/res/values-ru/strings.xml | 4 ++++ .../src/main/res/values-uk/strings.xml | 4 ++++ .../src/main/res/values-zh-rCN/strings.xml | 6 +++++- .../src/main/res/values-zh-rTW/strings.xml | 6 +++++- .../src/main/res/values-ar/strings.xml | 13 +++++++++++++ .../src/main/res/values-es/strings.xml | 13 +++++++++++++ .../src/main/res/values-fr/strings.xml | 13 +++++++++++++ .../src/main/res/values-it/strings.xml | 13 +++++++++++++ .../src/main/res/values-ja/strings.xml | 13 +++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 13 +++++++++++++ .../src/main/res/values-ru/strings.xml | 13 +++++++++++++ .../src/main/res/values-uk/strings.xml | 13 +++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 13 +++++++++++++ .../src/main/res/values-zh-rTW/strings.xml | 13 +++++++++++++ .../src/main/res/values-ar/strings.xml | 4 +++- .../src/main/res/values-es/strings.xml | 2 ++ .../src/main/res/values-fr/strings.xml | 4 +++- .../src/main/res/values-it/strings.xml | 2 ++ .../src/main/res/values-ja/strings.xml | 2 ++ .../src/main/res/values-pt-rBR/strings.xml | 2 ++ .../src/main/res/values-ru/strings.xml | 2 ++ .../src/main/res/values-uk/strings.xml | 2 ++ .../src/main/res/values-zh-rCN/strings.xml | 4 +++- .../src/main/res/values-zh-rTW/strings.xml | 4 +++- 32 files changed, 215 insertions(+), 25 deletions(-) diff --git a/core/common/permissions/src/main/res/values-it/strings.xml b/core/common/permissions/src/main/res/values-it/strings.xml index 957ede212..6b86cc069 100644 --- a/core/common/permissions/src/main/res/values-it/strings.xml +++ b/core/common/permissions/src/main/res/values-it/strings.xml @@ -49,6 +49,6 @@ Richiesta di permesso Negare - Notifiche di avvio di riserva + Notifiche di avvio alternative Facoltativo: consente a Klick\'r di mostrare una notifica per completare l\'avvio di uno scenario quando l\'app di automazione non può aprirlo direttamente, ad esempio mentre il telefono è bloccato. diff --git a/core/common/permissions/src/main/res/values-ru/strings.xml b/core/common/permissions/src/main/res/values-ru/strings.xml index 8a44cd510..6130f3dfb 100644 --- a/core/common/permissions/src/main/res/values-ru/strings.xml +++ b/core/common/permissions/src/main/res/values-ru/strings.xml @@ -49,6 +49,6 @@ Запросить разрешение Отклонить - Уведомления для запасного запуска + Резервные уведомления о запуске Необязательно: позволяет Klick\'r показать уведомление, чтобы завершить запуск сценария, когда приложение автоматизации не может открыть его напрямую, например когда телефон заблокирован. diff --git a/feature/external-launch/src/main/res/values-ar/strings.xml b/feature/external-launch/src/main/res/values-ar/strings.xml index 473acebae..fcd30ae1b 100644 --- a/feature/external-launch/src/main/res/values-ar/strings.xml +++ b/feature/external-launch/src/main/res/values-ar/strings.xml @@ -16,11 +16,14 @@ * along with this program. If not, see . --> + حالة Klick\'r + يوفّر حالة سيناريو Klick\'r الحالي تشغيل Klick\'r أوقف النقر لم يتم تحديد أي سيناريو تشغيل سيناريو Klick\'r إيقاف Klick\'r + تشغيل سيناريو Klick\'r الحالي تشغيل سيناريو Klick\'r اختر السيناريو الذي سيجهزه Klick\'r. اضغط تشغيل في Klick\'r عندما تكون مستعدًا. السيناريو @@ -31,17 +34,18 @@ إلغاء تشغيل %1$s (%2$s) إيقاف Klick\'r + تشغيل سيناريو Klick\'r الحالي ذكي بسيط نوع السيناريو تشغيل الأتمتة إكمال تشغيل Klick\'r اضغط لتشغيل %1$s - تحتاج أتمتة Klick\'r إلى انتباه + تتطلب أتمتة Klick\'r اهتمامك لم يعد هذا الإجراء المحفوظ صالحًا. افتحه واحفظه مجددًا في تطبيق الأتمتة. لم يعد السيناريو المحدد موجودًا. عدّل هذا الإجراء في تطبيق الأتمتة. لم يتم منح أذونات Klick\'r المطلوبة. - تعذر على Klick\'r الفتح مباشرةً، كما أن الإشعارات معطلة للبديل. + تعذر فتح Klick\'r مباشرةً، كما أن الإشعارات معطلة كخيار احتياطي. لم يتم منح إذن تسجيل الشاشة. إجراء Klick\'r خارجي اختر إجراءً خارجيًا diff --git a/feature/external-launch/src/main/res/values-es/strings.xml b/feature/external-launch/src/main/res/values-es/strings.xml index b2d06d693..52913de3d 100644 --- a/feature/external-launch/src/main/res/values-es/strings.xml +++ b/feature/external-launch/src/main/res/values-es/strings.xml @@ -16,11 +16,14 @@ * along with this program. If not, see . --> + Estado de Klick\'r + Proporciona el estado actual del escenario de Klick\'r Iniciar Klick\'r Detener Klick\'r Ningún escenario definido Iniciar escenario de Klick\'r Detener Klick\'r + Ejecutar el escenario actual de Klick\'r Iniciar escenario de Klick\'r Elige el escenario que Klick\'r debe preparar. Pulsa Reproducir en Klick\'r cuando estés listo. Escenario @@ -31,6 +34,7 @@ Cancelar Iniciar %1$s (%2$s) Detener Klick\'r + Ejecutar el escenario actual de Klick\'r Inteligente Simple Tipo de escenario @@ -41,7 +45,7 @@ Esta acción guardada ya no es válida. Ábrela y guárdala de nuevo en tu aplicación de automatización. El escenario seleccionado ya no existe. Edita esta acción en tu aplicación de automatización. No se concedieron los permisos necesarios de Klick\'r. - Klick\'r no pudo abrirse directamente y las notificaciones están desactivadas para el recurso alternativo. + Klick\'r no pudo abrirse directamente y las notificaciones están desactivadas como alternativa. No se concedió el permiso para capturar la pantalla. Acción externa de Klick\'r Elegir acción externa diff --git a/feature/external-launch/src/main/res/values-fr/strings.xml b/feature/external-launch/src/main/res/values-fr/strings.xml index 940523d3c..843531d78 100644 --- a/feature/external-launch/src/main/res/values-fr/strings.xml +++ b/feature/external-launch/src/main/res/values-fr/strings.xml @@ -16,21 +16,25 @@ * along with this program. If not, see . --> + État de Klick\'r + Fournit l’état actuel du scénario Klick\'r Lancer Klick\'r Stop Klick\'r Aucun Scenario défini Lancer un scénario Klick\'r Arrêter Klick\'r + Exécuter le scénario Klick\'r actuel Lancer un scénario Klick\'r Choisissez le scénario que Klick\'r doit préparer. Appuyez sur Lecture dans Klick\'r lorsque vous êtes prêt. Scénario Créez un scénario dans Klick\'r avant de configurer cette action. Le scénario sélectionné précédemment n\'existe plus. Choisissez-en un autre. - Les scénarios intelligents demandent l\'autorisation de capture d\'écran au lancement, sauf si Klick\'r dispose déjà d\'une session active. + Les scénarios intelligents demandent l\'autorisation de capture d\'écran au lancement, sauf si Klick\'r dispose déjà d\'une session de capture active. Enregistrer Annuler Lancer %1$s (%2$s) Arrêter Klick\'r + Exécuter le scénario Klick\'r actuel Intelligent Simple Type de scénario @@ -47,8 +51,8 @@ Choisir une action externe Exécutez cette automatisation lorsqu\'un scénario intelligent déclenche l\'action externe sélectionnée. Action externe - Le nom doit correspondre à une action externe enregistrée dans un scénario intelligent Klick\'r. Les noms ne sont pas secrets : ne les utilisez pas pour protéger des automatisations sensibles. - Créez d\'abord une action externe dans un scénario intelligent Klick\'r, puis revenez ici. + Le nom doit correspondre à une action externe enregistrée dans un scénario intelligent de Klick\'r. Les noms ne sont pas secrets : ne les utilisez pas pour protéger des automatisations sensibles. + Créez d\'abord une action externe dans un scénario intelligent de Klick\'r, puis revenez ici. Ce nom enregistré n\'est pas utilisé actuellement dans Klick\'r. Vous pouvez l\'enregistrer tel quel ou en choisir un autre. Action externe : %1$s diff --git a/feature/external-launch/src/main/res/values-it/strings.xml b/feature/external-launch/src/main/res/values-it/strings.xml index e3d74e49d..b68dd109d 100644 --- a/feature/external-launch/src/main/res/values-it/strings.xml +++ b/feature/external-launch/src/main/res/values-it/strings.xml @@ -16,22 +16,26 @@ * along with this program. If not, see . --> + Stato di Klick\'r + Fornisce lo stato corrente dello scenario di Klick\'r Avvia Klick\'r Fermare Klick\'r Nessuno scenario definito Avvia scenario Klick\'r Arresta Klick\'r + Esegui lo scenario corrente di Klick\'r Avvia scenario Klick\'r Scegli lo scenario che Klick\'r deve preparare. Premi Riproduci in Klick\'r quando sei pronto. Scenario Crea uno scenario in Klick\'r prima di configurare questa azione. Lo scenario selezionato in precedenza non esiste più. Scegline un altro. - Gli scenari smart richiedono l\'autorizzazione per la cattura dello schermo all\'avvio, a meno che Klick\'r non abbia già una sessione attiva. + Gli scenari intelligenti richiedono l\'autorizzazione per la cattura dello schermo all\'avvio, a meno che Klick\'r non abbia già una sessione di acquisizione attiva. Salva Annulla Avvia %1$s (%2$s) Arresta Klick\'r - Smart + Esegui lo scenario corrente di Klick\'r + Intelligente Semplice Tipo di scenario Avvii dell\'automazione @@ -41,14 +45,14 @@ Questa azione salvata non è più valida. Aprila e salvala di nuovo nell\'app di automazione. Lo scenario selezionato non esiste più. Modifica questa azione nell\'app di automazione. Le autorizzazioni richieste di Klick\'r non sono state concesse. - Klick\'r non ha potuto aprirsi direttamente e le notifiche sono disattivate per il ripiego. + Klick\'r non ha potuto aprirsi direttamente e le notifiche sono disattivate come alternativa. L\'autorizzazione per la cattura dello schermo non è stata concessa. Azione esterna di Klick\'r Scegli azione esterna - Esegui questa automazione quando uno scenario smart attiva l\'azione esterna selezionata. + Esegui questa automazione quando uno scenario intelligente attiva l\'azione esterna selezionata. Azione esterna - Il nome deve corrispondere a un\'azione esterna salvata in uno scenario smart di Klick\'r. I nomi non sono segreti, quindi non usarli per proteggere automazioni sensibili. - Crea prima un\'azione esterna in uno scenario smart di Klick\'r, poi torna qui. + Il nome deve corrispondere a un\'azione esterna salvata in uno scenario intelligente di Klick\'r. I nomi non sono segreti, quindi non usarli per proteggere automazioni sensibili. + Crea prima un\'azione esterna in uno scenario intelligente di Klick\'r, poi torna qui. Questo nome salvato non è attualmente usato in Klick\'r. Puoi salvarlo invariato o sceglierne un altro. Azione esterna: %1$s diff --git a/feature/external-launch/src/main/res/values-ja/strings.xml b/feature/external-launch/src/main/res/values-ja/strings.xml index 27d360998..090132de7 100644 --- a/feature/external-launch/src/main/res/values-ja/strings.xml +++ b/feature/external-launch/src/main/res/values-ja/strings.xml @@ -1,10 +1,13 @@ + Klick\'r の状態 + 現在の Klick\'r シナリオの状態を提供します Klick\'r を起動 Klick\'r を停止 シナリオが定義されていません Klick\'r シナリオを起動 Klick\'r を停止 + 現在の Klick\'r シナリオを実行 Klick\'r シナリオを起動 Klick\'r で準備するシナリオを選択します。準備ができたら Klick\'r で再生を押してください。 シナリオ @@ -15,6 +18,7 @@ キャンセル %1$s を起動(%2$s) Klick\'r を停止 + 現在の Klick\'r シナリオを実行 スマート シンプル シナリオの種類 @@ -25,7 +29,7 @@ 保存したアクションは無効です。自動化アプリで開いて再度保存してください。 選択したシナリオは存在しません。自動化アプリでこのアクションを編集してください。 Klick\'r に必要な権限が許可されていません。 - Klick\'r を直接開けませんでした。代替手段の通知も無効です。 + Klick\'r を直接開けませんでした。代替通知も無効です。 画面キャプチャ権限が許可されていません。 Klick\'r 外部アクション 外部アクションを選択 diff --git a/feature/external-launch/src/main/res/values-pt-rBR/strings.xml b/feature/external-launch/src/main/res/values-pt-rBR/strings.xml index d942c45b5..14914450d 100644 --- a/feature/external-launch/src/main/res/values-pt-rBR/strings.xml +++ b/feature/external-launch/src/main/res/values-pt-rBR/strings.xml @@ -16,11 +16,14 @@ * along with this program. If not, see . --> + Status do Klick\'r + Fornece o status atual do cenário do Klick\'r Iniciar Klick\'r Parar Klick\'r Nenhum cenário definido Iniciar cenário do Klick\'r Parar o Klick\'r + Executar o cenário atual do Klick\'r Iniciar cenário do Klick\'r Escolha o cenário que o Klick\'r deve preparar. Pressione Reproduzir no Klick\'r quando estiver pronto. Cenário @@ -31,6 +34,7 @@ Cancelar Iniciar %1$s (%2$s) Parar o Klick\'r + Executar o cenário atual do Klick\'r Inteligente Simples Tipo de cenário @@ -41,14 +45,14 @@ Esta ação salva não é mais válida. Abra-a e salve-a novamente no seu app de automação. O cenário selecionado não existe mais. Edite esta ação no seu app de automação. As permissões necessárias do Klick\'r não foram concedidas. - O Klick\'r não pôde abrir diretamente e as notificações estão desativadas para o fallback. + O Klick\'r não pôde abrir diretamente e as notificações estão desativadas como alternativa. A permissão de captura de tela não foi concedida. Ação externa do Klick\'r Escolher ação externa - Execute esta automação quando um cenário inteligente disparar a Ação externa selecionada. + Execute esta automação quando um cenário inteligente disparar a ação externa selecionada. Ação externa - O nome deve corresponder a uma Ação externa salva em um cenário inteligente do Klick\'r. Os nomes não são secretos, portanto não os use para proteger automações sensíveis. - Crie primeiro uma Ação externa dentro de um cenário inteligente do Klick\'r e volte aqui. + O nome deve corresponder a uma ação externa salva em um cenário inteligente do Klick\'r. Os nomes não são secretos, portanto não os use para proteger automações sensíveis. + Crie primeiro uma ação externa dentro de um cenário inteligente do Klick\'r e volte aqui. Este nome salvo não é usado atualmente no Klick\'r. Você pode salvá-lo sem alterações ou escolher outro. Ação externa: %1$s diff --git a/feature/external-launch/src/main/res/values-ru/strings.xml b/feature/external-launch/src/main/res/values-ru/strings.xml index c65297da6..e6ce8eec5 100644 --- a/feature/external-launch/src/main/res/values-ru/strings.xml +++ b/feature/external-launch/src/main/res/values-ru/strings.xml @@ -16,11 +16,14 @@ * along with this program. If not, see . --> + Состояние Klick\'r + Предоставляет текущее состояние сценария Klick\'r Запустить Klick\'r Остановить Klick\'r Сценарий не был определён Запустить сценарий Klick\'r Остановить Klick\'r + Запустить текущий сценарий Klick\'r Запустить сценарий Klick\'r Выберите сценарий, который должен подготовить Klick\'r. Когда будете готовы, нажмите «Воспроизвести» в Klick\'r. Сценарий @@ -31,6 +34,7 @@ Отмена Запустить %1$s (%2$s) Остановить Klick\'r + Запустить текущий сценарий Klick\'r Умный Простой Тип сценария diff --git a/feature/external-launch/src/main/res/values-uk/strings.xml b/feature/external-launch/src/main/res/values-uk/strings.xml index 03a8a3266..93c9bae5b 100644 --- a/feature/external-launch/src/main/res/values-uk/strings.xml +++ b/feature/external-launch/src/main/res/values-uk/strings.xml @@ -16,11 +16,14 @@ * along with this program. If not, see . --> + Стан Klick\'r + Надає поточний стан сценарію Klick\'r Запустити Klick\'r Зупинити Klick\'r Сценарій не визначено Запустити сценарій Klick\'r Зупинити Klick\'r + Запустити поточний сценарій Klick\'r Запустити сценарій Klick\'r Виберіть сценарій, який має підготувати Klick\'r. Коли будете готові, натисніть «Відтворити» в Klick\'r. Сценарій @@ -31,6 +34,7 @@ Скасувати Запустити %1$s (%2$s) Зупинити Klick\'r + Запустити поточний сценарій Klick\'r Розумний Простий Тип сценарію diff --git a/feature/external-launch/src/main/res/values-zh-rCN/strings.xml b/feature/external-launch/src/main/res/values-zh-rCN/strings.xml index 52c211e39..24d7694d6 100644 --- a/feature/external-launch/src/main/res/values-zh-rCN/strings.xml +++ b/feature/external-launch/src/main/res/values-zh-rCN/strings.xml @@ -1,10 +1,13 @@ + Klick\'r 状态 + 提供当前 Klick\'r 场景状态 启动 Klick\'r 停止 Klick\'r 尚未定义场景 启动 Klick\'r 场景 停止 Klick\'r + 运行当前 Klick\'r 场景 启动 Klick\'r 场景 选择 Klick\'r 要准备的场景。准备好后,在 Klick\'r 中点击播放。 场景 @@ -15,13 +18,14 @@ 取消 启动 %1$s(%2$s) 停止 Klick\'r + 运行当前 Klick\'r 场景 智能 简单 场景类型 自动化启动 完成 Klick\'r 启动 点按以启动 %1$s - Klick\'r 自动化需要处理 + Klick\'r 自动化需要注意 保存的操作已无效。请在自动化应用中重新打开并保存。 所选场景已不存在。请在自动化应用中编辑此操作。 未授予 Klick\'r 所需的权限。 diff --git a/feature/external-launch/src/main/res/values-zh-rTW/strings.xml b/feature/external-launch/src/main/res/values-zh-rTW/strings.xml index b2c8f10c3..ccc983c87 100644 --- a/feature/external-launch/src/main/res/values-zh-rTW/strings.xml +++ b/feature/external-launch/src/main/res/values-zh-rTW/strings.xml @@ -1,10 +1,13 @@ + Klick\'r 狀態 + 提供目前 Klick\'r 情境的狀態 啟動 Klick\'r 停止 Klick\'r 尚未定義情境 啟動 Klick\'r 情境 停止 Klick\'r + 執行目前的 Klick\'r 情境 啟動 Klick\'r 情境 選擇 Klick\'r 要準備的情境。準備好後,在 Klick\'r 中按下播放。 情境 @@ -15,13 +18,14 @@ 取消 啟動 %1$s(%2$s) 停止 Klick\'r + 執行目前的 Klick\'r 情境 智慧 簡單 情境類型 自動化啟動 完成 Klick\'r 啟動 點按以啟動 %1$s - Klick\'r 自動化需要處理 + Klick\'r 自動化需要注意 儲存的動作已無效。請在自動化應用程式中重新開啟並儲存。 所選情境已不存在。請在自動化應用程式中編輯此動作。 未授予 Klick\'r 所需的權限。 diff --git a/feature/smart-config/src/main/res/values-ar/strings.xml b/feature/smart-config/src/main/res/values-ar/strings.xml index 39e73dd10..4e7b90958 100644 --- a/feature/smart-config/src/main/res/values-ar/strings.xml +++ b/feature/smart-config/src/main/res/values-ar/strings.xml @@ -502,4 +502,17 @@ إعادة المحاولة جارٍ التبديل إلى %1$s + إجراء خارجي + تشغيل تطبيق أتمتة خارجي + تنفيذ \"%1$s\" + اسم الإجراء الخارجي غير صالح + إجراء خارجي + اسم الإجراء الخارجي + استخدم هذا الاسم في مشغّل الإجراء الخارجي لـ Klick\'r داخل تطبيق الأتمتة. + لم يتم تحديد إجراء خارجي + انقر هنا لإعادة استخدام اسم إجراء خارجي + اسم مشغّل أتمتة قابل لإعادة الاستخدام + الإجراءات الخارجية + لم يتم العثور على إجراء خارجي + اكتب أولاً اسم إجراء خارجي جديد في سيناريو ذكي. diff --git a/feature/smart-config/src/main/res/values-es/strings.xml b/feature/smart-config/src/main/res/values-es/strings.xml index 07bf87957..25421a590 100644 --- a/feature/smart-config/src/main/res/values-es/strings.xml +++ b/feature/smart-config/src/main/res/values-es/strings.xml @@ -502,4 +502,17 @@ Reintentar Cambiando a %1$s + Acción externa + Activar una aplicación de automatización externa + Ejecutar \"%1$s\" + El nombre de la acción externa no es válido + Acción externa + Nombre de la acción externa + Usa este nombre en el disparador de acción externa de Klick\'r de tu aplicación de automatización. + Ninguna acción externa seleccionada + Pulsa aquí para reutilizar el nombre de una acción externa + Nombre reutilizable del disparador de automatización + Acciones externas + No se encontró ninguna acción externa + Escribe primero un nombre de acción externa nuevo en un escenario inteligente. diff --git a/feature/smart-config/src/main/res/values-fr/strings.xml b/feature/smart-config/src/main/res/values-fr/strings.xml index cef3590b4..c2dcdad87 100644 --- a/feature/smart-config/src/main/res/values-fr/strings.xml +++ b/feature/smart-config/src/main/res/values-fr/strings.xml @@ -482,4 +482,17 @@ Réessayer Passage à %1$s + Action externe + Déclencher une application d’automatisation externe + Déclencher \"%1$s\" + Le nom de l’action externe n’est pas valide + Action externe + Nom de l’action externe + Utilisez ce nom dans le déclencheur d’action externe Klick\'r de votre application d’automatisation. + Aucune action externe sélectionnée + Appuyez ici pour réutiliser le nom d’une action externe + Nom réutilisable du déclencheur d’automatisation + Actions externes + Aucune action externe trouvée + Saisissez d’abord un nouveau nom d’action externe dans un scénario intelligent. diff --git a/feature/smart-config/src/main/res/values-it/strings.xml b/feature/smart-config/src/main/res/values-it/strings.xml index 3525e00d0..4cb2e390c 100644 --- a/feature/smart-config/src/main/res/values-it/strings.xml +++ b/feature/smart-config/src/main/res/values-it/strings.xml @@ -481,4 +481,17 @@ Riprova Passaggio a %1$s + Azione esterna + Attiva un’app di automazione esterna + Attiva \"%1$s\" + Il nome dell’azione esterna non è valido + Azione esterna + Nome dell’azione esterna + Usa questo nome nel trigger dell’azione esterna di Klick\'r nell’app di automazione. + Nessuna azione esterna selezionata + Tocca qui per riutilizzare il nome di un’azione esterna + Nome riutilizzabile del trigger di automazione + Azioni esterne + Nessuna azione esterna trovata + Inserisci prima un nuovo nome di azione esterna in uno scenario intelligente. diff --git a/feature/smart-config/src/main/res/values-ja/strings.xml b/feature/smart-config/src/main/res/values-ja/strings.xml index 27e6b4433..14a87a288 100644 --- a/feature/smart-config/src/main/res/values-ja/strings.xml +++ b/feature/smart-config/src/main/res/values-ja/strings.xml @@ -341,4 +341,17 @@ 再試行 %1$s に切り替え中 + 外部アクション + 外部のオートメーションアプリを起動します + 「%1$s」を実行 + 外部アクション名が無効です + 外部アクション + 外部アクション名 + オートメーションアプリの Klick\'r 外部アクショントリガーでこの名前を使用します。 + 外部アクションが選択されていません + タップして外部アクション名を再利用 + 再利用できるオートメーショントリガー名 + 外部アクション + 外部アクションがありません + 先にスマートシナリオで新しい外部アクション名を入力してください。 diff --git a/feature/smart-config/src/main/res/values-pt-rBR/strings.xml b/feature/smart-config/src/main/res/values-pt-rBR/strings.xml index abb9148aa..50b3db275 100644 --- a/feature/smart-config/src/main/res/values-pt-rBR/strings.xml +++ b/feature/smart-config/src/main/res/values-pt-rBR/strings.xml @@ -498,4 +498,17 @@ Tentar novamente Mudando para %1$s + Ação externa + Acionar um aplicativo de automação externo + Acionar \"%1$s\" + O nome da ação externa é inválido + Ação externa + Nome da ação externa + Use este nome no acionador de ação externa do Klick\'r no seu aplicativo de automação. + Nenhuma ação externa selecionada + Toque aqui para reutilizar o nome de uma ação externa + Nome reutilizável do acionador de automação + Ações externas + Nenhuma ação externa encontrada + Primeiro, digite um novo nome de ação externa em um cenário inteligente. diff --git a/feature/smart-config/src/main/res/values-ru/strings.xml b/feature/smart-config/src/main/res/values-ru/strings.xml index 29241061e..ba188f2c9 100644 --- a/feature/smart-config/src/main/res/values-ru/strings.xml +++ b/feature/smart-config/src/main/res/values-ru/strings.xml @@ -502,4 +502,17 @@ Повторить Переключение на %1$s + Внешнее действие + Запустить внешнее приложение автоматизации + Вызвать \"%1$s\" + Недопустимое имя внешнего действия + Внешнее действие + Имя внешнего действия + Используйте это имя в триггере внешнего действия Klick\'r в приложении автоматизации. + Внешнее действие не выбрано + Нажмите здесь, чтобы повторно использовать имя внешнего действия + Повторно используемое имя триггера автоматизации + Внешние действия + Внешние действия не найдены + Сначала введите новое имя внешнего действия в умном сценарии. diff --git a/feature/smart-config/src/main/res/values-uk/strings.xml b/feature/smart-config/src/main/res/values-uk/strings.xml index 1929ddacc..ccd3828b3 100644 --- a/feature/smart-config/src/main/res/values-uk/strings.xml +++ b/feature/smart-config/src/main/res/values-uk/strings.xml @@ -505,4 +505,17 @@ Спробувати ще раз Перемикання на %1$s + Зовнішня дія + Запустити зовнішній застосунок автоматизації + Викликати \"%1$s\" + Неприпустима назва зовнішньої дії + Зовнішня дія + Назва зовнішньої дії + Використовуйте цю назву в тригері зовнішньої дії Klick\'r у застосунку автоматизації. + Зовнішню дію не вибрано + Торкніться тут, щоб повторно використати назву зовнішньої дії + Багаторазова назва тригера автоматизації + Зовнішні дії + Зовнішніх дій не знайдено + Спочатку введіть нову назву зовнішньої дії в розумному сценарії. diff --git a/feature/smart-config/src/main/res/values-zh-rCN/strings.xml b/feature/smart-config/src/main/res/values-zh-rCN/strings.xml index 46f2ea327..9648f033b 100644 --- a/feature/smart-config/src/main/res/values-zh-rCN/strings.xml +++ b/feature/smart-config/src/main/res/values-zh-rCN/strings.xml @@ -478,4 +478,17 @@ 重试 正在切换到 %1$s + 外部操作 + 触发外部自动化应用 + 触发“%1$s” + 外部操作名称无效 + 外部操作 + 外部操作名称 + 在自动化应用的 Klick\'r 外部操作触发器中使用此名称。 + 未选择外部操作 + 点按此处以复用外部操作名称 + 可复用的自动化触发器名称 + 外部操作 + 未找到外部操作 + 请先在智能场景中输入新的外部操作名称。 diff --git a/feature/smart-config/src/main/res/values-zh-rTW/strings.xml b/feature/smart-config/src/main/res/values-zh-rTW/strings.xml index 27ce59e69..219909b0a 100644 --- a/feature/smart-config/src/main/res/values-zh-rTW/strings.xml +++ b/feature/smart-config/src/main/res/values-zh-rTW/strings.xml @@ -478,4 +478,17 @@ 重試 正在切換至 %1$s + 外部動作 + 觸發外部自動化應用程式 + 觸發「%1$s」 + 外部動作名稱無效 + 外部動作 + 外部動作名稱 + 請在自動化應用程式的 Klick\'r 外部動作觸發器中使用此名稱。 + 未選取外部動作 + 點按此處以重複使用外部動作名稱 + 可重複使用的自動化觸發器名稱 + 外部動作 + 找不到外部動作 + 請先在智慧情境中輸入新的外部動作名稱。 diff --git a/smartautoclicker/src/main/res/values-ar/strings.xml b/smartautoclicker/src/main/res/values-ar/strings.xml index 11f91c09f..070053342 100644 --- a/smartautoclicker/src/main/res/values-ar/strings.xml +++ b/smartautoclicker/src/main/res/values-ar/strings.xml @@ -171,5 +171,7 @@ تصدير تم حظر التشغيل في الخلفية - تعذر على Klick\'r فتح نافذة تسجيل الشاشة تلقائيًا.\n\nلإكمال تشغيل السيناريو، عرض إشعارًا بدلًا من ذلك.\n\nتفرض بعض هواتف Android قيودًا أكبر على التطبيقات في الخلفية. راجع هذا الدليل لتحسين استقرار الأذونات. + تعذر على Klick\'r فتح نافذة تسجيل الشاشة تلقائيًا.\n\nلإكمال تشغيل السيناريو، أظهر إشعارًا بدلًا من ذلك.\n\nتفرض بعض هواتف Android قيودًا أكبر على التطبيقات في الخلفية. راجع هذا الدليل لتحسين استقرار الأذونات. + البرنامج التعليمي + تعرّف على كيفية استخدام Klick\'r diff --git a/smartautoclicker/src/main/res/values-es/strings.xml b/smartautoclicker/src/main/res/values-es/strings.xml index fe80ac360..8f6a87b68 100644 --- a/smartautoclicker/src/main/res/values-es/strings.xml +++ b/smartautoclicker/src/main/res/values-es/strings.xml @@ -165,4 +165,6 @@ Inicio en segundo plano bloqueado Klick\'r no pudo abrir automáticamente la ventana de captura de pantalla.\n\nPara terminar de iniciar el escenario, mostró una notificación.\n\nAlgunos teléfonos Android restringen más las aplicaciones en segundo plano. Consulta esta guía para mejorar la estabilidad de los permisos. + Tutorial + Aprende a usar Klick\'r diff --git a/smartautoclicker/src/main/res/values-fr/strings.xml b/smartautoclicker/src/main/res/values-fr/strings.xml index 494ee80d8..257bc70ef 100644 --- a/smartautoclicker/src/main/res/values-fr/strings.xml +++ b/smartautoclicker/src/main/res/values-fr/strings.xml @@ -165,5 +165,7 @@ Exporter Lancement en arrière-plan bloqué - Klick\'r n\'a pas pu ouvrir automatiquement la fenêtre de capture d\'écran.\n\nPour terminer le lancement du scénario, une notification a été affichée.\n\nCertains téléphones Android restreignent davantage les applications en arrière-plan. Consultez ce guide pour améliorer la stabilité des autorisations. + Klick\'r n\'a pas pu ouvrir automatiquement la fenêtre de capture d\'écran.\n\nPour terminer le lancement du scénario, Klick\'r a affiché une notification à la place.\n\nCertains téléphones Android imposent davantage de restrictions aux applications en arrière-plan. Consultez ce guide pour améliorer la stabilité des autorisations. + Tutoriel + Apprenez à utiliser Klick\'r diff --git a/smartautoclicker/src/main/res/values-it/strings.xml b/smartautoclicker/src/main/res/values-it/strings.xml index 4ac744013..4899a30a5 100644 --- a/smartautoclicker/src/main/res/values-it/strings.xml +++ b/smartautoclicker/src/main/res/values-it/strings.xml @@ -164,4 +164,6 @@ Avvio in background bloccato Klick\'r non ha potuto aprire automaticamente la finestra di cattura dello schermo.\n\nPer completare l\'avvio dello scenario, è stata mostrata una notifica.\n\nAlcuni telefoni Android impongono restrizioni maggiori alle app in background. Consulta questa guida per migliorare la stabilità delle autorizzazioni. + Tutorial + Scopri come usare Klick\'r diff --git a/smartautoclicker/src/main/res/values-ja/strings.xml b/smartautoclicker/src/main/res/values-ja/strings.xml index ddbe13ffd..fc65230e6 100644 --- a/smartautoclicker/src/main/res/values-ja/strings.xml +++ b/smartautoclicker/src/main/res/values-ja/strings.xml @@ -110,4 +110,6 @@ バックグラウンド起動がブロックされました Klick\'r は画面キャプチャの確認画面を自動で開けませんでした。\n\nシナリオの起動を完了するため、代わりに通知を表示しました。\n\n一部の Android 端末では、バックグラウンドアプリにより厳しい制限があります。このガイドを確認して権限の安定性を改善してください。 + チュートリアル + Klick\'r の使い方を確認します diff --git a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml index b70f5783e..cd32d8302 100644 --- a/smartautoclicker/src/main/res/values-pt-rBR/strings.xml +++ b/smartautoclicker/src/main/res/values-pt-rBR/strings.xml @@ -164,4 +164,6 @@ Inicialização em segundo plano bloqueada O Klick\'r não pôde abrir automaticamente a janela de captura de tela.\n\nPara concluir a inicialização do cenário, uma notificação foi exibida.\n\nAlguns telefones Android restringem mais os apps em segundo plano. Consulte este guia para melhorar a estabilidade das permissões. + Tutorial + Aprenda a usar o Klick\'r diff --git a/smartautoclicker/src/main/res/values-ru/strings.xml b/smartautoclicker/src/main/res/values-ru/strings.xml index 7475d4da4..8174e1a04 100644 --- a/smartautoclicker/src/main/res/values-ru/strings.xml +++ b/smartautoclicker/src/main/res/values-ru/strings.xml @@ -162,4 +162,6 @@ Фоновый запуск заблокирован Klick\'r не удалось автоматически открыть окно записи экрана.\n\nЧтобы завершить запуск сценария, было показано уведомление.\n\nНекоторые телефоны Android сильнее ограничивают фоновые приложения. Обратитесь к этому руководству, чтобы повысить стабильность разрешений. + Обучение + Узнайте, как пользоваться Klick\'r diff --git a/smartautoclicker/src/main/res/values-uk/strings.xml b/smartautoclicker/src/main/res/values-uk/strings.xml index a72a3b062..3426bddfa 100644 --- a/smartautoclicker/src/main/res/values-uk/strings.xml +++ b/smartautoclicker/src/main/res/values-uk/strings.xml @@ -165,4 +165,6 @@ Фоновий запуск заблоковано Klick\'r не вдалося автоматично відкрити вікно запису екрана.\n\nЩоб завершити запуск сценарію, було показано сповіщення.\n\nДеякі телефони Android сильніше обмежують фонові застосунки. Перегляньте цей посібник, щоб підвищити стабільність дозволів. + Навчання + Дізнайтеся, як користуватися Klick\'r diff --git a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml index 64184ebc0..763cf679d 100644 --- a/smartautoclicker/src/main/res/values-zh-rCN/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rCN/strings.xml @@ -109,5 +109,7 @@ 导出 后台启动被阻止 - Klick\'r 无法自动打开屏幕捕获提示。\n\n为了完成场景启动,我们改为显示了一条通知。\n\n部分 Android 手机对后台应用限制更严格。请参考此指南以提高权限稳定性。 + Klick\'r 无法自动打开屏幕捕获提示。\n\n为了完成场景启动,Klick\'r 改为显示了一条通知。\n\n部分 Android 手机对后台应用限制更严格。请参考此指南以提高权限稳定性。 + 教程 + 了解如何使用 Klick\'r diff --git a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml index 58101a63b..9eddd6023 100644 --- a/smartautoclicker/src/main/res/values-zh-rTW/strings.xml +++ b/smartautoclicker/src/main/res/values-zh-rTW/strings.xml @@ -109,5 +109,7 @@ 匯出 背景啟動遭到封鎖 - Klick\'r 無法自動開啟螢幕擷取提示。\n\n為了完成情境啟動,我們改為顯示通知。\n\n部分 Android 手機對背景應用程式限制更嚴格。請參考此指南以提高權限穩定性。 + Klick\'r 無法自動開啟螢幕擷取提示。\n\n為了完成情境啟動,Klick\'r 改為顯示通知。\n\n部分 Android 手機對背景應用程式限制更嚴格。請參考此指南以提高權限穩定性。 + 教學 + 瞭解如何使用 Klick\'r From 7ebca2245a1e1b35c522fe42aff3095863960467 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Sat, 22 Aug 2026 20:22:35 +0530 Subject: [PATCH 14/14] refactor(ui): use Material Symbol for external action --- .../ui/src/main/res/drawable/ic_external_action.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/common/ui/src/main/res/drawable/ic_external_action.xml b/core/common/ui/src/main/res/drawable/ic_external_action.xml index 472060058..d2a3a3857 100644 --- a/core/common/ui/src/main/res/drawable/ic_external_action.xml +++ b/core/common/ui/src/main/res/drawable/ic_external_action.xml @@ -1,10 +1,11 @@ + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z" />