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..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
@@ -22,9 +22,17 @@ import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario
interface LocalAccessibilityService {
- fun startDumbScenario(dumbScenario: DumbScenario)
- fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario)
+ fun isScenarioRunning(): 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 runCurrentScenario()
fun stopScenario()
fun release()
-}
\ No newline at end of file
+}
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/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..cc84980fc 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,18 @@ 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
+
+ /** @return true when a child overlay is open above a scenario's main menu, even if the stack is hidden. */
+ fun hasOverlayAboveRoot(): Boolean = 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 +386,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..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,4 +49,6 @@
Richiesta di permesso
Negare
+ 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-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..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,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/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/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/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/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/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..d2a3a3857
--- /dev/null
+++ b/core/common/ui/src/main/res/drawable/ic_external_action.xml
@@ -0,0 +1,11 @@
+
+
+
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/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/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/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 229562b08..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 = 23
\ No newline at end of file
+const val DATABASE_VERSION = 25
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/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/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/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/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/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/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/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/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/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/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 bbea1ae76..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.
*
@@ -60,6 +63,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 +138,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..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
@@ -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
}
@@ -147,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() }
@@ -259,4 +274,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/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/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/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/external-launch/src/main/AndroidManifest.xml b/feature/external-launch/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..d252ecad0
--- /dev/null
+++ b/feature/external-launch/src/main/AndroidManifest.xml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt
similarity index 53%
rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt
rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt
index bba18a337..84eab113d 100644
--- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileActionHandler.kt
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchActionHandler.kt
@@ -14,15 +14,26 @@
* 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.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)
+interface ExternalLaunchActionHandler {
+ fun isRunning(): Boolean
+ fun isScenarioRunning(): Boolean
+ fun isOverlayVisible(): Boolean
+ fun isOverlayHidden(): 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 runCurrentScenario()
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/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt
similarity index 56%
rename from feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt
rename to feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt
index 0f92a00f2..48b86433e 100644
--- a/feature/quick-settings-tile/src/main/java/com/buzbuz/smartautoclicker/feature/qstile/domain/QSTileRepository.kt
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/domain/ExternalLaunchRepository.kt
@@ -14,11 +14,12 @@
* 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.domain
import android.content.Context
import android.content.Intent
import android.service.quicksettings.Tile
+import android.util.Log
import com.buzbuz.smartautoclicker.core.base.di.Dispatcher
import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO
@@ -28,10 +29,14 @@ 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 com.buzbuz.smartautoclicker.feature.externallaunch.R
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate.ScenarioState
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate.ScenarioStatePluginContract
+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
@@ -56,8 +61,8 @@ import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
-class QSTileRepository @Inject constructor(
- @ApplicationContext context: Context,
+class ExternalLaunchRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
@param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val dumbRepository: DumbRepository,
private val dumbEngine: DumbEngine,
@@ -69,7 +74,8 @@ class QSTileRepository @Inject constructor(
private val coroutineScopeIo: CoroutineScope =
CoroutineScope(SupervisorJob() + ioDispatcher)
- private var qsTileActionHandler: QSTileActionHandler? = null
+ private var actionHandler: ExternalLaunchActionHandler? = null
+ private var lastPublishedScenarioStatus: PublishedScenarioStatus? = null
private val tileDisplayInfo: Flow = qsTileConfigDataSource.getQSTileScenarioInfo()
.flatMapLatest { scenarioInfo ->
@@ -112,24 +118,85 @@ class QSTileRepository @Inject constructor(
}
}
- fun setTileActionHandler(actionHandler: QSTileActionHandler) {
- qsTileActionHandler = actionHandler
+ fun setActionHandler(actionHandler: ExternalLaunchActionHandler) {
+ this.actionHandler = actionHandler
+ }
+
+ /** Ask every active Locale/Tasker host to query the current scenario condition again. */
+ @Synchronized
+ fun notifyScenarioStateChanged() {
+ val scenarioId = getSmartScenarioId() ?: getDumbScenarioId()
+ val status = PublishedScenarioStatus(
+ scenarioId = scenarioId,
+ state = ScenarioState.from(
+ isScenarioOpen = scenarioId != null,
+ isRunning = isScenarioRunning(),
+ isOverlayHidden = isOverlayHidden(),
+ isSettingsOpen = isScenarioConfigurationOpen(),
+ ),
+ )
+ if (status == lastPublishedScenarioStatus) return
+ Log.d(TAG, "Scenario status changed: $lastPublishedScenarioStatus -> $status; requesting requery")
+ lastPublishedScenarioStatus = status
+
+ context.sendBroadcast(
+ LocalePluginContract.createRequestQueryIntent(
+ ScenarioStatePluginContract.CONFIGURATION_ACTIVITY_CLASS_NAME,
+ ),
+ )
}
internal fun getLastScenarioDetails(): Pair =
qsTileDisplayInfo.value?.scenarioId to qsTileDisplayInfo.value?.isSmart
internal fun isAccessibilityServiceStarted(): Boolean =
- qsTileActionHandler?.isRunning() ?: false
+ actionHandler?.isRunning() ?: false
+
+ internal fun isScenarioRunning(): Boolean =
+ actionHandler?.isScenarioRunning() ?: false
+
+ internal fun isOverlayVisible(): Boolean =
+ actionHandler?.isOverlayVisible() ?: false
+
+ internal fun isOverlayHidden(): Boolean =
+ actionHandler?.isOverlayHidden() ?: false
- internal fun startDumbScenario(scenario: DumbScenario) =
- qsTileActionHandler?.startDumbScenario(scenario)
+ /** True while the user is editing the currently loaded scenario. */
+ internal fun isScenarioConfigurationOpen(): Boolean =
+ actionHandler?.isScenarioConfigurationOpen() ?: false
- internal fun startSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) =
- qsTileActionHandler?.startSmartScenario(resultCode, data, scenario)
+ internal fun isSmartScreenRecordActive(): Boolean =
+ actionHandler?.isSmartScreenRecordActive() ?: false
+
+ internal fun getSmartScenarioId(): Long? =
+ actionHandler?.getSmartScenarioId()
+
+ internal fun getDumbScenarioId(): Long? =
+ actionHandler?.getDumbScenarioId()
+
+ 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 runCurrentScenario() =
+ actionHandler?.runCurrentScenario()
internal fun stopScenarios() =
- qsTileActionHandler?.stop()
+ actionHandler?.stop()
private fun Context.getTileDisplayInfo(isSmart: Boolean, runningId: Long?, scenarioId: Long?, scenarioName: String?): QSTileDisplayInfo {
val state = when {
@@ -143,9 +210,9 @@ class QSTileRepository @Inject constructor(
tileState = state,
tileTitle = getString(
when (state) {
- Tile.STATE_INACTIVE -> R.string.tile_label_start_scenario
+ Tile.STATE_INACTIVE -> R.string.tile_label_launch_scenario
Tile.STATE_ACTIVE -> R.string.tile_label_stop_scenario
- else -> R.string.tile_label_start_scenario
+ else -> R.string.tile_label_launch_scenario
}
),
tileSubTitle =
@@ -157,3 +224,10 @@ class QSTileRepository @Inject constructor(
}
}
+private data class PublishedScenarioStatus(
+ val scenarioId: Long?,
+ val state: ScenarioState,
+)
+
+private const val TAG = "ExternalLaunchRepository"
+
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/LocalePluginActionExecutor.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.kt
new file mode 100644
index 000000000..6d6d895e1
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutor.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.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.RUN_CURRENT -> ResolvedLocalePluginAction.RunCurrent
+ 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 executeRunCurrent() = externalLaunchRepository.runCurrentScenario()
+
+ 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 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
new file mode 100644
index 000000000..049d6cafb
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfiguration.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.
+ */
+package com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal enum class LocalePluginOperation { LAUNCH, RUN_CURRENT, 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.RUN_CURRENT,
+ 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..6d7a19c30
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginContract.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.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_EDIT_CONDITION = "com.twofortyfouram.locale.intent.action.EDIT_CONDITION"
+ const val ACTION_FIRE_SETTING = "com.twofortyfouram.locale.intent.action.FIRE_SETTING"
+ 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 EXTRA_RELEVANT_VARIABLES = "net.dinglisch.android.tasker.RELEVANT_VARIABLES"
+ const val EXTRA_VARIABLES_BUNDLE = "net.dinglisch.android.tasker.extras.VARIABLES"
+ const val EXTRA_CONDITION_RESULT_RECEIVER = "net.dinglisch.android.tasker.EXTRA_RESULT_RECEIVER"
+
+ 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)
+
+ fun createConditionResult(configurationJson: String, blurb: String, relevantVariables: Array): Intent =
+ createResult(configurationJson, blurb)
+ .putExtra(EXTRA_RELEVANT_VARIABLES, relevantVariables)
+
+ fun createRequestQueryIntent(configurationActivityClassName: String): Intent =
+ Intent(ACTION_REQUEST_QUERY)
+ .putExtra(EXTRA_STRING_ACTIVITY_CLASS_NAME, configurationActivityClassName)
+}
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/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/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/LocalePluginConditionQueryReceiver.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginConditionQueryReceiver.kt
new file mode 100644
index 000000000..ee01c5115
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginConditionQueryReceiver.kt
@@ -0,0 +1,102 @@
+/*
+ * 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 android.os.Bundle
+import android.os.ResultReceiver
+import android.util.Log
+import com.buzbuz.smartautoclicker.core.common.actions.external.ExternalActionEventContract
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.externalaction.ExternalActionEventConfigurationCodec
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate.ScenarioStatePluginContract
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate.ScenarioStateProvider
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class LocalePluginConditionQueryReceiver : BroadcastReceiver() {
+
+ @Inject internal lateinit var codec: ExternalActionEventConfigurationCodec
+ @Inject internal lateinit var stateProvider: ScenarioStateProvider
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ExternalActionEventContract.ACTION_QUERY_CONDITION) {
+ resultCode = ExternalActionEventContract.RESULT_CONDITION_UNKNOWN
+ return
+ }
+
+ if (LocalePluginContract.readConfigurationJson(intent) == ScenarioStatePluginContract.CONFIGURATION_JSON) {
+ queryScenarioState(intent)
+ 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
+ }
+ }
+
+ private fun queryScenarioState(intent: Intent) {
+ val orderedBroadcast = isOrderedBroadcast
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ val snapshot = runCatching { stateProvider.getSnapshot() }
+ .onFailure { Log.w(TAG, "Unable to read scenario state", it) }
+ .getOrNull()
+ val conditionResult = when {
+ snapshot == null -> ExternalActionEventContract.RESULT_CONDITION_UNKNOWN
+ else -> ExternalActionEventContract.RESULT_CONDITION_SATISFIED
+ }
+ val resultExtras = snapshot?.let {
+ Bundle().apply {
+ putBundle(
+ LocalePluginContract.EXTRA_VARIABLES_BUNDLE,
+ ScenarioStatePluginContract.createVariables(it),
+ )
+ }
+ }
+
+ val resultReceiver = getResultReceiver(intent)
+ Log.d(
+ TAG,
+ "Scenario state query: ordered=$orderedBroadcast, resultReceiver=${resultReceiver != null}, " +
+ "open=${snapshot?.isScenarioOpen}, state=${snapshot?.state?.value}",
+ )
+ resultReceiver?.send(conditionResult, resultExtras)
+ pendingResult.setResultCode(conditionResult)
+ pendingResult.setResultExtras(resultExtras)
+ pendingResult.finish()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun getResultReceiver(intent: Intent): ResultReceiver? =
+ runCatching {
+ intent.getParcelableExtra(LocalePluginContract.EXTRA_CONDITION_RESULT_RECEIVER)
+ }.onFailure { Log.w(TAG, "Unable to read the host result receiver", it) }
+ .getOrNull()
+}
+
+private const val TAG = "ScenarioStatePlugin"
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..0f397c958
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/receiver/LocalePluginFireReceiver.kt
@@ -0,0 +1,235 @@
+/*
+ * 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)
+ 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
+ } 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/scenariostate/ScenarioStatePluginContract.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStatePluginContract.kt
new file mode 100644
index 000000000..2ec8e1774
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStatePluginContract.kt
@@ -0,0 +1,62 @@
+/*
+ * 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 android.os.Bundle
+
+internal object ScenarioStatePluginContract {
+ const val CONFIGURATION_JSON = "{\"version\":1,\"condition\":\"scenario_open\"}"
+ const val CONFIGURATION_ACTIVITY_CLASS_NAME =
+ "com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.ui.ScenarioStateConditionConfigurationActivity"
+
+ const val VARIABLE_SCENARIO_NAME = "%klickr_scenario_name"
+ const val VARIABLE_SCENARIO_STATE = "%klickr_scenario_state"
+
+ val relevantVariables: Array = arrayOf(
+ "$VARIABLE_SCENARIO_NAME\nScenario name\nName of the scenario currently open in Klick'r.",
+ "$VARIABLE_SCENARIO_STATE\nScenario state\nrunning, paused, hidden, settings, or none.",
+ )
+
+ fun createVariables(snapshot: ScenarioStateSnapshot): Bundle = Bundle().apply {
+ putString(VARIABLE_SCENARIO_NAME, snapshot.scenarioName)
+ putString(VARIABLE_SCENARIO_STATE, snapshot.state.value)
+ }
+}
+
+internal data class ScenarioStateSnapshot(
+ val scenarioName: String,
+ val state: ScenarioState,
+) {
+ val isScenarioOpen: Boolean
+ get() = state != ScenarioState.NONE
+}
+
+internal enum class ScenarioState(val value: String) {
+ RUNNING("running"),
+ PAUSED("paused"),
+ HIDDEN("hidden"),
+ SETTINGS("settings"),
+ NONE("none"),
+ ;
+
+ companion object {
+ fun from(
+ isScenarioOpen: Boolean,
+ isRunning: Boolean,
+ isOverlayHidden: Boolean,
+ isSettingsOpen: Boolean,
+ ): ScenarioState = when {
+ !isScenarioOpen -> NONE
+ isSettingsOpen -> SETTINGS
+ isOverlayHidden -> HIDDEN
+ isRunning -> RUNNING
+ else -> PAUSED
+ }
+ }
+}
diff --git a/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateProvider.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateProvider.kt
new file mode 100644
index 000000000..d65e41baf
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/scenariostate/ScenarioStateProvider.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.feature.externallaunch.localeplugin.scenariostate
+
+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.dumb.domain.DumbRepository
+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 ScenarioStateProvider @Inject constructor(
+ @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
+ private val smartRepository: IRepository,
+ private val dumbRepository: DumbRepository,
+ private val externalLaunchRepository: ExternalLaunchRepository,
+) {
+ suspend fun getSnapshot(): ScenarioStateSnapshot = withContext(ioDispatcher) {
+ val smartScenarioId = externalLaunchRepository.getSmartScenarioId()
+ val dumbScenarioId = if (smartScenarioId == null) externalLaunchRepository.getDumbScenarioId() else null
+ val scenarioId = smartScenarioId ?: dumbScenarioId
+ val scenarioName = when {
+ smartScenarioId != null -> smartRepository.getScenario(smartScenarioId)?.name.orEmpty()
+ dumbScenarioId != null -> dumbRepository.getDumbScenario(dumbScenarioId)?.name.orEmpty()
+ else -> ""
+ }
+
+ ScenarioStateSnapshot(
+ scenarioName = scenarioName,
+ state = ScenarioState.from(
+ isScenarioOpen = scenarioId != null,
+ isRunning = externalLaunchRepository.isScenarioRunning(),
+ isOverlayHidden = externalLaunchRepository.isOverlayHidden(),
+ isSettingsOpen = externalLaunchRepository.isScenarioConfigurationOpen(),
+ ),
+ )
+ }
+}
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/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/LocalePluginExecutionActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt
new file mode 100644
index 000000000..78bd78dc6
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionActivity.kt
@@ -0,0 +1,209 @@
+/*
+ * 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()
+ }
+ ResolvedLocalePluginAction.RunCurrent -> {
+ viewModel.executeRunCurrent()
+ 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..e3efd1ed1
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/LocalePluginExecutionViewModel.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 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/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/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ScenarioStateConditionConfigurationActivity.kt b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ScenarioStateConditionConfigurationActivity.kt
new file mode 100644
index 000000000..1c4d44846
--- /dev/null
+++ b/feature/external-launch/src/main/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/ui/ScenarioStateConditionConfigurationActivity.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.appcompat.app.AppCompatActivity
+import com.buzbuz.smartautoclicker.feature.externallaunch.R
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.domain.LocalePluginContract
+import com.buzbuz.smartautoclicker.feature.externallaunch.localeplugin.scenariostate.ScenarioStatePluginContract
+
+class ScenarioStateConditionConfigurationActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (intent?.action != LocalePluginContract.ACTION_EDIT_CONDITION) {
+ finish()
+ return
+ }
+
+ setResult(
+ Activity.RESULT_OK,
+ LocalePluginContract.createConditionResult(
+ configurationJson = ScenarioStatePluginContract.CONFIGURATION_JSON,
+ blurb = getString(R.string.locale_plugin_scenario_state_blurb),
+ relevantVariables = ScenarioStatePluginContract.relevantVariables,
+ ),
+ )
+ finish()
+ }
+}
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/external-launch/src/main/java/net/dinglisch/android/tasker/PluginResultReceiver.kt b/feature/external-launch/src/main/java/net/dinglisch/android/tasker/PluginResultReceiver.kt
new file mode 100644
index 000000000..c81dbbde3
--- /dev/null
+++ b/feature/external-launch/src/main/java/net/dinglisch/android/tasker/PluginResultReceiver.kt
@@ -0,0 +1,17 @@
+/*
+ * 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 net.dinglisch.android.tasker
+
+import android.os.Handler
+import android.os.ResultReceiver
+import androidx.annotation.Keep
+
+/** Compatibility class required by the Tasker condition result-receiver extension. */
+@Keep
+class PluginResultReceiver(handler: Handler?) : ResultReceiver(handler)
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/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/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..fcd30ae1b
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-ar/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ حالة Klick\'r
+ يوفّر حالة سيناريو Klick\'r الحالي
+ تشغيل 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 الحالي
+ ذكي
+ بسيط
+ نوع السيناريو
+ تشغيل الأتمتة
+ إكمال تشغيل 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..52913de3d
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-es/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ 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
+ 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
+ Ejecutar el escenario actual de 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 como alternativa.
+ 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..843531d78
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-fr/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ É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 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
+ 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 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
new file mode 100644
index 000000000..b68dd109d
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-it/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ 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 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
+ Esegui lo scenario corrente di Klick\'r
+ Intelligente
+ 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 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 intelligente attiva l\'azione esterna selezionata.
+ Azione esterna
+ 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
new file mode 100644
index 000000000..090132de7
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-ja/strings.xml
@@ -0,0 +1,42 @@
+
+
+ Klick\'r の状態
+ 現在の Klick\'r シナリオの状態を提供します
+ Klick\'r を起動
+ 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 シナリオを実行
+ スマート
+ シンプル
+ シナリオの種類
+ 自動化の起動
+ 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..14914450d
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ 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
+ 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
+ Executar o cenário atual do 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 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.
+ 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..e6ce8eec5
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-ru/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ Состояние Klick\'r
+ Предоставляет текущее состояние сценария Klick\'r
+ Запустить Klick\'r
+ Остановить 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
+ Умный
+ Простой
+ Тип сценария
+ Запуск автоматизации
+ Завершить запуск 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..93c9bae5b
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-uk/strings.xml
@@ -0,0 +1,58 @@
+
+
+
+ Стан Klick\'r
+ Надає поточний стан сценарію Klick\'r
+ Запустити Klick\'r
+ Зупинити 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
+ Розумний
+ Простий
+ Тип сценарію
+ Запуски автоматизації
+ Завершити запуск 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..24d7694d6
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,42 @@
+
+
+ Klick\'r 状态
+ 提供当前 Klick\'r 场景状态
+ 启动 Klick\'r
+ 停止 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 场景
+ 智能
+ 简单
+ 场景类型
+ 自动化启动
+ 完成 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..ccc983c87
--- /dev/null
+++ b/feature/external-launch/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,42 @@
+
+
+ Klick\'r 狀態
+ 提供目前 Klick\'r 情境的狀態
+ 啟動 Klick\'r
+ 停止 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 情境
+ 智慧
+ 簡單
+ 情境類型
+ 自動化啟動
+ 完成 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..c38b4984a
--- /dev/null
+++ b/feature/external-launch/src/main/res/values/strings.xml
@@ -0,0 +1,60 @@
+
+
+
+ Klick\'r Status
+ Provides the current Klick\'r scenario status
+ Launch Klick\'r
+ Stop Klick\'r
+ No Scenario defined
+
+ 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
+ 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
+ Run current Klick\'r scenario
+ 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/LocalePluginActionExecutorTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt
new file mode 100644
index 000000000..e14f0b7df
--- /dev/null
+++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginActionExecutorTest.kt
@@ -0,0 +1,176 @@
+/*
+ * 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 `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()
+ 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/LocalePluginConfigurationCodecTest.kt b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.kt
new file mode 100644
index 000000000..918b0e4f8
--- /dev/null
+++ b/feature/external-launch/src/test/java/com/buzbuz/smartautoclicker/feature/externallaunch/localeplugin/domain/LocalePluginConfigurationCodecTest.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.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 `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(
+ 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/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/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}"""))
+ }
+}
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/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)
+ }
+}
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-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/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/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/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/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..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
@@ -63,6 +64,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
@@ -95,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
@@ -113,6 +116,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 +129,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/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/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/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_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/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/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/feature/smart-config/src/main/res/values-ar/strings.xml b/feature/smart-config/src/main/res/values-ar/strings.xml
index 93b3e7764..4e7b90958 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,32 @@
أنت على وشك حذف هذا العنصر، هل أنت متأكد؟
-
\ No newline at end of file
+ إغلاق مبدّل السيناريو
+ تبديل السيناريو
+ الحالي: %1$s
+ غير معروف
+ لا توجد سيناريوهات ذكية أخرى متاحة
+ لم تعد خدمة السيناريو متاحة.
+ أوقف السيناريو مؤقتًا قبل التبديل.
+ تسجيل الشاشة غير متاح. أعد تشغيله قبل التبديل.
+ هذا السيناريو محمّل بالفعل.
+ لم يعد هذا السيناريو متاحًا.
+ تعذّر حفظ السيناريو. حاول مرة أخرى.
+ تعذّر تبديل السيناريو. حاول مرة أخرى.
+ إعادة المحاولة
+ جارٍ التبديل إلى %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 36edd07fc..25421a590 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,32 @@
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
+
+ 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 3dce2fb16..c2dcdad87 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,32 @@
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
+
+ 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 076b8d557..4cb2e390c 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,32 @@
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
+
+ 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 37a1adae2..14a87a288 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,32 @@
このアイテムを削除しようとしています。よろしいですか?
-
\ No newline at end of file
+ シナリオ切り替えを閉じる
+ シナリオを切り替え
+ 現在:%1$s
+ 不明
+ 他に利用可能なスマートシナリオはありません
+ シナリオサービスは利用できなくなりました。
+ 切り替える前にシナリオを一時停止してください。
+ 画面録画を利用できません。切り替える前に再起動してください。
+ このシナリオはすでに読み込まれています。
+ そのシナリオは利用できなくなりました。
+ シナリオを保存できませんでした。もう一度お試しください。
+ シナリオを切り替えられませんでした。もう一度お試しください。
+ 再試行
+ %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 6aa70e51a..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
@@ -483,4 +483,32 @@
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
+
+ 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 c77668ed9..ba188f2c9 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,32 @@
Вы собираетесь удалить этот элемент, вы уверены?
+ Закрыть переключатель сценариев
+ Переключить сценарий
+ Текущий: %1$s
+ Неизвестно
+ Нет других доступных умных сценариев
+ Служба сценариев больше недоступна.
+ Приостановите сценарий перед переключением.
+ Запись экрана недоступна. Перезапустите её перед переключением.
+ Этот сценарий уже загружен.
+ Этот сценарий больше недоступен.
+ Не удалось сохранить сценарий. Повторите попытку.
+ Не удалось переключить сценарий. Повторите попытку.
+ Повторить
+ Переключение на %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 81142eea1..ccd3828b3 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,32 @@
Ви збираєтесь видалити цей елемент, ви впевнені?
+ Закрити перемикач сценаріїв
+ Перемкнути сценарій
+ Поточний: %1$s
+ Невідомо
+ Немає інших доступних розумних сценаріїв
+ Служба сценаріїв більше недоступна.
+ Призупиніть сценарій перед перемиканням.
+ Запис екрана недоступний. Перезапустіть його перед перемиканням.
+ Цей сценарій уже завантажено.
+ Цей сценарій більше недоступний.
+ Не вдалося зберегти сценарій. Спробуйте ще раз.
+ Не вдалося перемкнути сценарій. Спробуйте ще раз.
+ Спробувати ще раз
+ Перемикання на %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 646a2a6c8..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
@@ -463,4 +463,32 @@
您即将删除此项目,确定吗?
-
\ No newline at end of file
+ 关闭场景切换器
+ 切换场景
+ 当前:%1$s
+ 未知
+ 没有其他可用的智能场景
+ 场景服务已不可用。
+ 切换前请暂停场景。
+ 屏幕录制不可用。请先重新启动屏幕录制,再切换场景。
+ 此场景已加载。
+ 该场景已不可用。
+ 无法保存场景。请重试。
+ 无法切换场景。请重试。
+ 重试
+ 正在切换到 %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 b7bf06108..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
@@ -463,4 +463,32 @@
您即將刪除此項目,確定嗎?
-
\ No newline at end of file
+ 關閉情境切換器
+ 切換情境
+ 目前:%1$s
+ 未知
+ 沒有其他可用的智慧情境
+ 情境服務已無法使用。
+ 切換前請暫停情境。
+ 螢幕錄製無法使用。請先重新啟動螢幕錄製,再切換情境。
+ 此情境已載入。
+ 該情境已無法使用。
+ 無法儲存情境。請重試。
+ 無法切換情境。請重試。
+ 重試
+ 正在切換至 %1$s
+
+ 外部動作
+ 觸發外部自動化應用程式
+ 觸發「%1$s」
+ 外部動作名稱無效
+ 外部動作
+ 外部動作名稱
+ 請在自動化應用程式的 Klick\'r 外部動作觸發器中使用此名稱。
+ 未選取外部動作
+ 點按此處以重複使用外部動作名稱
+ 可重複使用的自動化觸發器名稱
+ 外部動作
+ 找不到外部動作
+ 請先在智慧情境中輸入新的外部動作名稱。
+
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 365b16da0..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
@@ -565,4 +583,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
+
+
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..fb1fa138c
--- /dev/null
+++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/switcher/ScenarioSwitchViewModelTest.kt
@@ -0,0 +1,200 @@
+/*
+ * 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 isScreenRecordActive() = 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/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/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/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")
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 3fe45d914..b58301ae9 100644
--- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt
+++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt
@@ -36,14 +36,15 @@ 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
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
@@ -75,13 +76,14 @@ 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
@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
@@ -94,14 +96,38 @@ 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 isScenarioRunning(): Boolean =
+ localServiceConnection.getLocalService()?.isScenarioRunning() ?: false
+ override fun isOverlayVisible(): Boolean = overlayManager.isOverlayStackVisible()
+ override fun isOverlayHidden(): Boolean = overlayManager.isOverlayStackHidden()
+ override fun isScenarioConfigurationOpen(): Boolean =
+ overlayManager.hasOverlayAboveRoot()
+ 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 runCurrentScenario() {
+ localServiceConnection.getLocalService()?.runCurrentScenario()
}
override fun stop() {
localServiceConnection.getLocalService()?.stopScenario()
@@ -115,12 +141,15 @@ 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,
+ onScenarioStateChanged = externalLaunchRepository::notifyScenarioStateChanged,
onStop = ::onLocalServiceStopped,
)
)
@@ -132,6 +161,7 @@ class SmartAutoClickerService : AccessibilityService() {
release()
}
localServiceConnection.onAccessibilityServiceStopped()
+ externalLaunchRepository.notifyScenarioStateChanged()
qualityMetricsMonitor.onServiceUnbind()
actionExecutor.clear()
@@ -148,7 +178,7 @@ class SmartAutoClickerService : AccessibilityService() {
requestFilterKeyEvents(true)
displayConfigManager.startMonitoring(this)
- tileRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart)
+ externalLaunchRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart)
}
private fun onLocalServiceStopped() {
@@ -172,6 +202,10 @@ class SmartAutoClickerService : AccessibilityService() {
bitmapManager.clearCache()
}
+ private fun onLocalScenarioChanged(scenarioId: Long, isSmart: Boolean) {
+ externalLaunchRepository.setTileScenario(scenarioId = scenarioId, isSmart = isSmart)
+ }
+
override fun onKeyEvent(event: KeyEvent?): Boolean =
(localServiceConnection.getLocalService() as? LocalService)?.onKeyEvent(event) ?: super.onKeyEvent(event)
@@ -204,4 +238,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..7e8c4c471 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,28 @@ 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.sync.withLock
+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 onScenarioStateChanged: () -> Unit,
private val onStop: () -> Unit,
) : LocalAccessibilityService {
@@ -71,6 +81,26 @@ 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,
+ )
+ }
+ 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 {
@@ -84,21 +114,35 @@ 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
+ 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
+
+ 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
@@ -108,13 +152,19 @@ class LocalService(
dumbEngine.isRunning.value || smartProcessingRepository.isRunning(),
!isStackHidden
)
+ onScenarioStateChanged()
}
.launchIn(serviceScope)
+
+ overlayManager.backStackTopFlow
+ .onEach { onScenarioStateChanged() }
+ .launchIn(serviceScope)
}
- override fun startDumbScenario(dumbScenario: DumbScenario) {
+ override fun launchDumbScenario(dumbScenario: DumbScenario) {
if (state.isStarted) return
- state = LocalServiceState(isStarted = true, isSmartLoaded = false)
+ state = LocalServiceState(isStarted = true, isSmartLoaded = false, sessionId = ++nextServiceSessionId)
+ loadedDumbScenarioId = dumbScenario.id.databaseId
onStart(dumbScenario.id.databaseId, false, null)
startJob = serviceScope.launch {
@@ -143,9 +193,9 @@ 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)
+ state = LocalServiceState(isStarted = true, isSmartLoaded = true, sessionId = ++nextServiceSessionId)
onStart(
scenario.id.databaseId,
@@ -159,7 +209,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)
@@ -179,22 +234,57 @@ class LocalService(
}
override fun stopScenario() {
- if (!isStarted) return
- state = LocalServiceState(isStarted = false, isSmartLoaded = false)
+ serviceScope.launch { scenarioChangeMutex.withLock { stopAndWait() } }
+ }
+ override fun replaceDumbScenario(dumbScenario: DumbScenario) {
serviceScope.launch {
- startJob?.join()
- startJob = null
+ scenarioChangeMutex.withLock {
+ stopAndWait()
+ launchDumbScenario(dumbScenario)
+ }
+ }
+ }
- dumbEngine.release()
- overlayManager.closeAll(context)
- smartProcessingRepository.stopScreenRecord()
+ override fun replaceSmartScenario(resultCode: Int, data: Intent, scenario: Scenario) {
+ serviceScope.launch {
+ scenarioChangeMutex.withLock {
+ stopAndWait()
+ launchSmartScenario(resultCode, data, scenario)
+ }
+ }
+ }
- onStop()
- notificationController.destroyNotification()
+ 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
+ onScenarioStateChanged()
+
+ startJob?.join()
+ startJob = null
+
+ dumbEngine.release()
+ overlayManager.closeAll(context)
+ smartProcessingRepository.stopScreenRecord()
+
+ onStop()
+ notificationController.destroyNotification()
+ }
+
override fun release() {
serviceScope.cancel()
}
@@ -215,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 {
@@ -228,26 +342,52 @@ 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 {
- 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
+ if (onlyIfRootVisible &&
+ (overlayManager.isOverlayStackHidden() || overlayManager.hasOverlayAboveRoot())
+ ) 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)
+ onScenarioStateChanged()
+ } catch (error: Exception) {
+ Log.w(TAG, "Unable to update the quick-settings tile after switching scenario", error)
}
}
@@ -258,9 +398,63 @@ 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"
+
+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
-)
\ 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"
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/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/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/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"/>
+
+
+
+
عرض فلاتر السيناريو
عرض أو إخفاء الفلاتر في شاشة قائمة السيناريوهات.
+ عرض مبدّل السيناريو
+ يظهر مبدّل السيناريو في شريط الأدوات العائم للتبديل السريع.
+
واجهة الإجراءات القديمة
تغيير عرض الإجراءات إلى قائمة. مفيد للسيناريوهات المعقدة.
@@ -167,4 +170,8 @@
استيراد
تصدير
+ تم حظر التشغيل في الخلفية
+ تعذر على 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 c096fd501..8f6a87b68 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,8 @@
Importar
Exportar
-
\ No newline at end of file
+ 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 2e515c2cb..257bc70ef 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,8 @@
Importer
Exporter
-
\ No newline at end of file
+ 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, 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 8ebab815f..4899a30a5 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.
@@ -159,4 +162,8 @@
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.
+ 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 8ed63ee59..fc65230e6 100644
--- a/smartautoclicker/src/main/res/values-ja/strings.xml
+++ b/smartautoclicker/src/main/res/values-ja/strings.xml
@@ -72,6 +72,9 @@
シナリオのフィルターを表示
シナリオ一覧画面でフィルターの表示/非表示を切り替えます。
+ シナリオ切り替えを表示
+ フローティングツールバーにシナリオ切り替えを表示し、すばやく切り替えられるようにします。
+
従来の操作 UI
操作を一覧で表示します。複雑なシナリオに適しています。
@@ -105,4 +108,8 @@
インポート
エクスポート
+ バックグラウンド起動がブロックされました
+ 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 e1cfc4585..cd32d8302 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.
@@ -159,4 +162,8 @@
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.
+ 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 34f92ac70..8174e1a04 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,8 @@
Импортировать
Экспортировать
-
\ No newline at end of file
+ Фоновый запуск заблокирован
+ 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 db81624f5..3426bddfa 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,8 @@
Імпорт
Експорт
-
\ No newline at end of file
+ Фоновий запуск заблоковано
+ 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 60abe00c7..763cf679d 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 @@
显示场景筛选
在场景列表界面显示或隐藏筛选器。
+ 显示场景切换器
+ 在悬浮工具栏中显示场景切换器,方便快速切换。
+
经典操作界面
以列表方式显示操作,适用于复杂场景。
@@ -105,4 +108,8 @@
导入
导出
+ 后台启动被阻止
+ 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 baa18a1ca..9eddd6023 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 @@
顯示情境篩選
於情境清單畫面顯示或隱藏篩選器。
+ 顯示情境切換器
+ 於浮動工具列顯示情境切換器,方便快速切換。
+
經典操作介面
以清單方式顯示操作,適用於複雜情境。
@@ -105,4 +108,8 @@
匯入
匯出
+ 背景啟動遭到封鎖
+ Klick\'r 無法自動開啟螢幕擷取提示。\n\n為了完成情境啟動,Klick\'r 改為顯示通知。\n\n部分 Android 手機對背景應用程式限制更嚴格。請參考此指南以提高權限穩定性。
+ 教學
+ 瞭解如何使用 Klick\'r
diff --git a/smartautoclicker/src/main/res/values/strings.xml b/smartautoclicker/src/main/res/values/strings.xml
index d8ff0bc53..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.