From ae03feeee41807b2a197f248932f8d7821452ae0 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:28:05 +0200 Subject: [PATCH 01/56] Cleanup smart-debugging navigation --- core/common/navigation/build.gradle.kts | 1 + .../common/navigation/DebuggingNavigator.kt | 42 ++++++++++++ .../common/navigation/NavigationEntryPoint.kt | 1 + feature/smart-config/build.gradle.kts | 2 - .../screen/brief/ScreenConditionsBriefMenu.kt | 19 +++--- .../color/capture/ColorCaptureViewModel.kt | 2 +- .../smart/config/ui/event/EventDialog.kt | 8 +-- .../config/ui/scenario/more/MoreContent.kt | 7 +- feature/smart-debugging/build.gradle.kts | 1 + .../feature/smart/debugging/di/Hilt.kt | 16 +++++ .../navigation/DebuggingNavigatorImpl.kt | 67 +++++++++++++++++++ 11 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/DebuggingNavigator.kt create mode 100644 feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/navigation/DebuggingNavigatorImpl.kt diff --git a/core/common/navigation/build.gradle.kts b/core/common/navigation/build.gradle.kts index 1a4f5bc867..d1176ae9b6 100644 --- a/core/common/navigation/build.gradle.kts +++ b/core/common/navigation/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(project(":core:common:tutorial")) + implementation(project(":core:smart:domain")) } diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/DebuggingNavigator.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/DebuggingNavigator.kt new file mode 100644 index 0000000000..38f42ad766 --- /dev/null +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/DebuggingNavigator.kt @@ -0,0 +1,42 @@ +/* + * 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.navigation + +import android.content.Context +import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import dagger.hilt.EntryPoints + +interface DebuggingNavigator { + + fun showDebugReportDialog(context: Context) + + fun showTryEventOverlayMenu(context: Context, scenario: Scenario, triedElement: ScreenEvent) + + fun showTryConditionOverlayMenu( + context: Context, + scenario: Scenario, + imageCondition: ScreenCondition, + onNewThresholdSelected: (Int) -> Unit, + ) +} + +fun Context.getDebuggingNavigator(): DebuggingNavigator = + EntryPoints + .get(applicationContext, NavigationEntryPoint::class.java) + .debuggingNavigator() \ No newline at end of file diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt index 5f69a5b502..7eb660c88b 100644 --- a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt @@ -24,5 +24,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface NavigationEntryPoint { + fun debuggingNavigator(): DebuggingNavigator fun tutorialNavigator(): TutorialNavigator } diff --git a/feature/smart-config/build.gradle.kts b/feature/smart-config/build.gradle.kts index 0d72d93978..f8a02d5e79 100644 --- a/feature/smart-config/build.gradle.kts +++ b/feature/smart-config/build.gradle.kts @@ -53,6 +53,4 @@ dependencies { implementation(project(":core:smart:domain")) implementation(project(":core:smart:processing")) implementation(project(":feature:revenue")) - implementation(project(":feature:smart-debugging")) - implementation(project(":feature:tutorial")) } \ No newline at end of file diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/brief/ScreenConditionsBriefMenu.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/brief/ScreenConditionsBriefMenu.kt index f943bfa228..97a11e8c05 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/brief/ScreenConditionsBriefMenu.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/brief/ScreenConditionsBriefMenu.kt @@ -23,11 +23,13 @@ import android.view.ViewGroup import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.core.common.navigation.getDebuggingNavigator import com.buzbuz.smartautoclicker.core.common.overlays.base.viewModels import com.buzbuz.smartautoclicker.core.common.overlays.dialog.implementation.MoveToDialog import com.buzbuz.smartautoclicker.core.common.overlays.menu.implementation.brief.ItemBrief import com.buzbuz.smartautoclicker.core.common.overlays.menu.implementation.brief.ItemBriefMenu +import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredOverlayType import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition import com.buzbuz.smartautoclicker.core.ui.views.itembrief.ItemBriefDescription import com.buzbuz.smartautoclicker.feature.smart.config.R @@ -46,10 +48,8 @@ import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.imag import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.number.NumberConditionDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.selection.ScreenConditionTypeSelectionDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.text.TextConditionDialog -import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.conditiontry.TryImageConditionOverlayMenu import kotlinx.coroutines.launch -import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredOverlayType class ScreenConditionsBriefMenu( @@ -161,16 +161,13 @@ class ScreenConditionsBriefMenu( val condition = (focusedItem.data as? UiScreenCondition)?.condition ?: return viewModel.getEditedScenario()?.let { scenario -> - overlayManager.navigateTo( + context.getDebuggingNavigator().showTryConditionOverlayMenu( context = context, - newOverlay = TryImageConditionOverlayMenu( - scenario = scenario, - imageCondition = condition, - onNewThresholdSelected = { threshold -> - viewModel.updateConditionThreshold(threshold) - } - ), - hideCurrent = true, + scenario = scenario, + imageCondition = condition, + onNewThresholdSelected = { threshold -> + viewModel.updateConditionThreshold(threshold) + } ) } } diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModel.kt index 81c53d6d62..ba2cc9e85d 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModel.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModel.kt @@ -30,8 +30,8 @@ import com.buzbuz.smartautoclicker.core.display.config.DisplayConfigManager import com.buzbuz.smartautoclicker.core.display.recorder.DisplayRecorder import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredViewType import com.buzbuz.smartautoclicker.core.common.tutorial.domain.MonitoredViewsManager +import com.buzbuz.smartautoclicker.feature.smart.config.R import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.color.extensions.toRgbaHexString -import com.buzbuz.smartautoclicker.feature.smart.debugging.R import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/event/EventDialog.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/event/EventDialog.kt index a515220c00..3a14268244 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/event/EventDialog.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/event/EventDialog.kt @@ -26,6 +26,7 @@ import android.view.ViewGroup import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.core.common.navigation.getDebuggingNavigator import com.buzbuz.smartautoclicker.core.common.overlays.base.viewModels import com.buzbuz.smartautoclicker.core.common.overlays.dialog.OverlayDialog @@ -58,7 +59,6 @@ import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.dialogs.showCl import com.buzbuz.smartautoclicker.feature.smart.config.ui.common.dialogs.showDeleteEventWithAssociatedActionsDialog import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.brief.ScreenConditionsBriefMenu import com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.trigger.TriggerConditionListDialog -import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.eventtry.TryEventOverlayMenu import com.google.android.material.bottomsheet.BottomSheetDialog @@ -449,10 +449,10 @@ class EventDialog( private fun showTryElementMenu() { viewModel.getTryInfo()?.let { (scenario, imageEvent) -> - overlayManager.navigateTo( + context.getDebuggingNavigator().showTryEventOverlayMenu( context = context, - newOverlay = TryEventOverlayMenu(scenario, imageEvent), - hideCurrent = true, + scenario = scenario, + triedElement = imageEvent, ) } } diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/more/MoreContent.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/more/MoreContent.kt index 270de08faf..6620975b48 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/more/MoreContent.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/scenario/more/MoreContent.kt @@ -23,6 +23,7 @@ import android.view.ViewGroup import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.buzbuz.smartautoclicker.core.common.navigation.getDebuggingNavigator import com.buzbuz.smartautoclicker.core.common.overlays.dialog.implementation.navbar.NavBarDialogContent import com.buzbuz.smartautoclicker.core.common.overlays.dialog.implementation.navbar.viewModels @@ -37,7 +38,6 @@ import com.buzbuz.smartautoclicker.feature.smart.config.R import com.buzbuz.smartautoclicker.feature.smart.config.databinding.ContentMoreBinding import com.buzbuz.smartautoclicker.feature.smart.config.di.ScenarioConfigViewModelsEntryPoint import com.buzbuz.smartautoclicker.feature.smart.config.ui.counter.config.CountersConfigDialog -import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.DebugReportDialog import kotlinx.coroutines.launch @@ -113,10 +113,7 @@ class MoreContent(appContext: Context) : NavBarDialogContent(appContext) { } private fun showDebugReport() { - dialogController.overlayManager.navigateTo( - context = context, - newOverlay = DebugReportDialog(), - ) + context.getDebuggingNavigator().showDebugReportDialog(context) } private fun showCountersConfigDialog() { diff --git a/feature/smart-debugging/build.gradle.kts b/feature/smart-debugging/build.gradle.kts index 594b7cbbb7..ca71eeb35f 100644 --- a/feature/smart-debugging/build.gradle.kts +++ b/feature/smart-debugging/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(project(":core:common:base")) implementation(project(":core:common:bitmaps")) + implementation(project(":core:common:navigation")) implementation(project(":core:common:overlays")) implementation(project(":core:common:ui")) implementation(project(":core:smart:debugging")) diff --git a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/di/Hilt.kt b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/di/Hilt.kt index 2760ac5366..7012c00849 100644 --- a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/di/Hilt.kt +++ b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/di/Hilt.kt @@ -16,7 +16,10 @@ */ package com.buzbuz.smartautoclicker.feature.smart.debugging.di +import com.buzbuz.smartautoclicker.core.common.navigation.DebuggingNavigator +import com.buzbuz.smartautoclicker.core.common.navigation.TutorialNavigator import com.buzbuz.smartautoclicker.core.common.overlays.di.OverlayComponent +import com.buzbuz.smartautoclicker.feature.smart.debugging.navigation.DebuggingNavigatorImpl import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.conditiontry.TryImageConditionViewModel import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.eventtry.TryElementViewModel import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.DebugReportViewModel @@ -28,9 +31,13 @@ import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.deta import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.details.event.DebugEventsStateContentViewModel import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.timeline.filter.DebugReportTimelineFiltersViewModel import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.timeline.filter.events.FilteredEventsSelectorViewModel +import dagger.Binds +import dagger.Module import dagger.hilt.EntryPoint import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @EntryPoint @InstallIn(OverlayComponent::class) @@ -46,4 +53,13 @@ interface DebuggingViewModelsEntryPoint { fun filteredEventsSelectorViewModel(): FilteredEventsSelectorViewModel fun tryElementViewModel(): TryElementViewModel fun tryImageConditionViewModel(): TryImageConditionViewModel +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class DebuggingNavigationModule { + + @Binds + @Singleton + abstract fun bindDebuggingNavigator(impl: DebuggingNavigatorImpl): DebuggingNavigator } \ No newline at end of file diff --git a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/navigation/DebuggingNavigatorImpl.kt b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/navigation/DebuggingNavigatorImpl.kt new file mode 100644 index 0000000000..3d2eaf4d57 --- /dev/null +++ b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/navigation/DebuggingNavigatorImpl.kt @@ -0,0 +1,67 @@ +/* + * 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.debugging.navigation + +import android.content.Context +import com.buzbuz.smartautoclicker.core.common.navigation.DebuggingNavigator +import com.buzbuz.smartautoclicker.core.common.overlays.manager.OverlayManager +import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.conditiontry.TryImageConditionOverlayMenu +import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.live.eventtry.TryEventOverlayMenu +import com.buzbuz.smartautoclicker.feature.smart.debugging.ui.dialog.report.DebugReportDialog +import javax.inject.Inject + + +internal class DebuggingNavigatorImpl @Inject constructor( + private val overlayManager: OverlayManager, +) : DebuggingNavigator { + + override fun showDebugReportDialog(context: Context) { + overlayManager.navigateTo( + context = context, + newOverlay = DebugReportDialog(), + ) + } + + override fun showTryEventOverlayMenu(context: Context, scenario: Scenario, triedElement: ScreenEvent) { + overlayManager.navigateTo( + context = context, + newOverlay = TryEventOverlayMenu(scenario, triedElement), + hideCurrent = true, + ) + } + + override fun showTryConditionOverlayMenu( + context: Context, + scenario: Scenario, + imageCondition: ScreenCondition, + onNewThresholdSelected: (Int) -> Unit, + ) { + overlayManager.navigateTo( + context = context, + newOverlay = TryImageConditionOverlayMenu( + scenario = scenario, + imageCondition = imageCondition, + onNewThresholdSelected = onNewThresholdSelected, + ), + hideCurrent = true, + ) + } + +} \ No newline at end of file From 09c6d64afe42dc8736cdabe5d6535c800b031249 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:28:26 +0200 Subject: [PATCH 02/56] Split feature:revenue module into feature & core modules --- .../common/navigation/NavigationEntryPoint.kt | 1 + .../common/navigation/RevenueNavigator.kt | 34 +++++++++++ core/common/revenue/.gitignore | 1 + core/common/revenue/build.gradle.kts | 61 +++++++++++++++++++ .../revenue/domain/RevenueRepository.kt | 36 ++++++----- .../revenue/src/main/AndroidManifest.xml | 4 ++ .../core/common/revenue/di/Hilt.kt | 36 +++++++++++ .../revenue/domain}/IRevenueRepository.kt | 39 +++++++----- .../common}/revenue/domain/model/AdState.kt | 6 +- .../revenue/domain/model/ProModeInfo.kt | 4 +- .../revenue/domain/model/PurchaseState.kt | 4 +- .../revenue/domain/model}/RemoteAdState.kt | 6 +- .../revenue/domain/model}/UserBillingState.kt | 2 +- .../revenue/domain/model}/UserConsentState.kt | 2 +- .../revenue/src/playStore/AndroidManifest.xml | 31 ++++++++++ .../revenue/data/RevenueTestConfiguration.kt | 4 +- .../revenue/data/UserConsentDataSource.kt | 2 +- .../revenue/data/ads/DebugAdStateReceiver.kt | 5 +- .../data/ads/InterstitialAdsDataSource.kt | 12 ++-- .../revenue/data/ads/sdk/GoogleAdsSdk.kt | 7 ++- .../common}/revenue/data/ads/sdk/IAdsSdk.kt | 2 +- .../revenue/data/billing/BillingDataSource.kt | 16 ++--- .../data/billing/DebugBillingStateReceiver.kt | 2 +- .../data/billing/ProductDetailsManager.kt | 4 +- .../revenue/data/billing/PurchaseManager.kt | 2 +- .../common}/revenue/data/billing/Security.kt | 4 +- .../data/billing/sdk/BillingClientProxy.kt | 2 +- .../billing/sdk/BillingServiceConnection.kt | 5 +- .../data/billing/sdk/BillingUiFlowState.kt | 2 +- .../revenue/data/billing/sdk/Extensions.kt | 2 +- .../revenue/data/billing/sdk/InAppProduct.kt | 2 +- .../core/common}/revenue/di/Hilt.kt | 12 +--- .../revenue/domain/RevenueRepository.kt | 57 +++++++---------- .../ads/InterstitialAdsDataSourceTests.kt | 5 +- .../revenue/domain/RevenueRepositoryTests.kt | 23 +++---- feature/revenue/build.gradle.kts | 21 +------ .../navigation/RevenueNavigatorImpl.kt | 30 +++++++++ .../feature/revenue/di/Hilt.kt | 16 +++-- .../revenue/src/playStore/AndroidManifest.xml | 8 --- .../domain/InternalRevenueRepository.kt | 43 ------------- .../navigation/RevenueNavigatorImpl.kt | 57 +++++++++++++++++ .../feature/revenue/ui/BillingActivity.kt | 2 +- .../revenue/ui/BillingActivityViewModel.kt | 17 +++--- .../revenue/ui/paywall/PaywallViewModel.kt | 13 ++-- .../ui/purchase/PurchaseProModeViewModel.kt | 6 +- feature/smart-config/build.gradle.kts | 2 +- .../smart/config/ui/mainmenu/MainMenuModel.kt | 35 ++++++----- settings.gradle.kts | 1 + smartautoclicker/build.gradle.kts | 1 + .../SmartAutoClickerService.kt | 2 +- .../localservice/LocalService.kt | 25 ++++---- .../scenarios/ScenarioActivity.kt | 2 +- .../creation/ScenarioCreationViewModel.kt | 4 +- .../scenarios/viewmodel/ScenarioViewModel.kt | 4 +- .../settings/SettingsFragment.kt | 6 +- .../settings/SettingsViewModel.kt | 8 +-- 56 files changed, 470 insertions(+), 270 deletions(-) create mode 100644 core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/RevenueNavigator.kt create mode 100644 core/common/revenue/.gitignore create mode 100644 core/common/revenue/build.gradle.kts rename {feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/RevenueRepository.kt (54%) create mode 100644 core/common/revenue/src/main/AndroidManifest.xml create mode 100644 core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt rename {feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain}/IRevenueRepository.kt (67%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/model/AdState.kt (85%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/model/ProModeInfo.kt (88%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/model/PurchaseState.kt (88%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model}/RemoteAdState.kt (93%) rename {feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model}/UserBillingState.kt (92%) rename {feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue => core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model}/UserConsentState.kt (92%) create mode 100644 core/common/revenue/src/playStore/AndroidManifest.xml rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/RevenueTestConfiguration.kt (94%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/UserConsentDataSource.kt (98%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/ads/DebugAdStateReceiver.kt (88%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/ads/InterstitialAdsDataSource.kt (93%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/ads/sdk/GoogleAdsSdk.kt (95%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/ads/sdk/IAdsSdk.kt (95%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/BillingDataSource.kt (89%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/DebugBillingStateReceiver.kt (97%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/ProductDetailsManager.kt (95%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/PurchaseManager.kt (98%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/Security.kt (97%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/sdk/BillingClientProxy.kt (99%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/sdk/BillingServiceConnection.kt (96%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/sdk/BillingUiFlowState.kt (91%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/sdk/Extensions.kt (91%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/billing/sdk/InAppProduct.kt (95%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/di/Hilt.kt (67%) rename {feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/RevenueRepository.kt (84%) rename {feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/data/ads/InterstitialAdsDataSourceTests.kt (97%) rename {feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature => core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common}/revenue/domain/RevenueRepositoryTests.kt (90%) create mode 100644 feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt delete mode 100644 feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/InternalRevenueRepository.kt create mode 100644 feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt index 7eb660c88b..42385abff5 100644 --- a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt @@ -25,5 +25,6 @@ import dagger.hilt.components.SingletonComponent interface NavigationEntryPoint { fun debuggingNavigator(): DebuggingNavigator + fun revenueNavigator(): RevenueNavigator fun tutorialNavigator(): TutorialNavigator } diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/RevenueNavigator.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/RevenueNavigator.kt new file mode 100644 index 0000000000..11f44d42c0 --- /dev/null +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/RevenueNavigator.kt @@ -0,0 +1,34 @@ +/* + * 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.navigation + +import android.app.Activity +import android.content.Context +import dagger.hilt.EntryPoints + +interface RevenueNavigator { + fun startUserConsentRequestUiFlowIfNeeded(activity: Activity) + fun startPrivacySettingUiFlow(activity: Activity) + + fun startPaywallActivity(context: Context) + fun startPurchaseActivity(context: Context) +} + +fun Context.getRevenueNavigator(): RevenueNavigator = + EntryPoints + .get(applicationContext, NavigationEntryPoint::class.java) + .revenueNavigator() \ No newline at end of file diff --git a/core/common/revenue/.gitignore b/core/common/revenue/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/common/revenue/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/common/revenue/build.gradle.kts b/core/common/revenue/build.gradle.kts new file mode 100644 index 0000000000..8083b035b9 --- /dev/null +++ b/core/common/revenue/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * 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 . + */ +import com.buzbuz.gradle.convention.extensions.playStore +import com.buzbuz.gradle.convention.extensions.buildConfigField +import com.buzbuz.gradle.convention.extensions.manifestPlaceholders + +plugins { + alias(libs.plugins.buzbuz.androidLibrary) + alias(libs.plugins.buzbuz.androidUnitTest) + alias(libs.plugins.buzbuz.flavour) + alias(libs.plugins.buzbuz.buildParameters) + alias(libs.plugins.buzbuz.hilt) +} + +android { + namespace = "com.buzbuz.smartautoclicker.core.common.revenue" + + productFlavors { + playStore { + dimension = "version" + + buildFeatures { + buildConfig = true + } + + buildConfigField(buildParameters.consentTestDevicesIds) + buildConfigField(buildParameters.consentTestGeography) + buildConfigField(buildParameters.adsUnitId) + buildConfigField(buildParameters.adsTestDevicesIds) + buildConfigField(buildParameters.billingPublicKey) + + manifestPlaceholders(buildParameters.adsApplicationId) + } + } +} + +dependencies { + playStoreImplementation(libs.android.billingClient) + playStoreImplementation(libs.android.billingClient.ktx) + playStoreImplementation(libs.google.userMessaging) + playStoreImplementation(libs.google.gms.ads) + + implementation(project(":core:common:base")) + implementation(project(":core:common:quality")) + + testPlayStoreImplementation(libs.kotlinx.coroutines.test) +} \ No newline at end of file diff --git a/feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt b/core/common/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.kt similarity index 54% rename from feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt rename to core/common/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.kt index a7c395d0ce..11374aaa21 100644 --- a/feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt +++ b/core/common/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Kevin Buzeau + * 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 @@ -14,36 +14,42 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.domain +package com.buzbuz.smartautoclicker.core.common.revenue.domain import android.app.Activity import android.content.Context - -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState -import com.buzbuz.smartautoclicker.feature.revenue.UserConsentState - +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.AdState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.ProModeInfo +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserConsentState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf - import javax.inject.Inject import kotlin.time.Duration +internal class RevenueRepository @Inject constructor() : IRevenueRepository { -internal class RevenueRepository @Inject constructor(): IRevenueRepository { override val userConsentState: Flow = flowOf(UserConsentState.CANNOT_REQUEST_ADS) override val isPrivacySettingRequired: Flow = flowOf(false) override val userBillingState: StateFlow = MutableStateFlow(UserBillingState.PURCHASED) - override val isBillingFlowInProgress: Flow = flowOf(false) + override val proModeInfo: Flow = flowOf(null) + override val adsState: StateFlow = MutableStateFlow(AdState.NOT_INITIALIZED) + override val purchaseState: StateFlow = MutableStateFlow(PurchaseState.PURCHASED) + override val trialAvailable: Flow = flowOf(false) + override val paywallFinished: SharedFlow = MutableSharedFlow() override fun startUserConsentRequestUiFlowIfNeeded(activity: Activity) = Unit override fun startPrivacySettingUiFlow(activity: Activity) = Unit - override fun loadAdIfNeeded(context: Context) = Unit - override fun startPaywallUiFlow(context: Context) = Unit + override fun startPlayStoreBillingUiFlow(activity: Activity) = Unit + override fun notifyPaywallFinished() = Unit override fun refreshPurchases() = Unit - override fun startPurchaseUiFlow(context: Context) = Unit + override fun loadAdIfNeeded(context: Context) = Unit + override fun showAd(activity: Activity) = Unit + override fun requestTrial() = Unit override fun consumeTrial(): Duration? = null - -} \ No newline at end of file +} diff --git a/core/common/revenue/src/main/AndroidManifest.xml b/core/common/revenue/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/core/common/revenue/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt new file mode 100644 index 0000000000..dade58cc37 --- /dev/null +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.common.revenue.di + +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.RevenueRepository + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object BillingHiltModule { + + @Provides + @Singleton + internal fun providesRevenueRepository(revenueRepository: RevenueRepository): IRevenueRepository = + revenueRepository +} \ No newline at end of file diff --git a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/IRevenueRepository.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/IRevenueRepository.kt similarity index 67% rename from feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/IRevenueRepository.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/IRevenueRepository.kt index 33e9ff3967..62807696e5 100644 --- a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/IRevenueRepository.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/IRevenueRepository.kt @@ -1,34 +1,36 @@ /* - * Copyright (C) 2024 Kevin Buzeau - * + * 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.revenue +package com.buzbuz.smartautoclicker.core.common.revenue.domain import android.app.Activity import android.content.Context - import com.buzbuz.smartautoclicker.core.base.Dumpable import com.buzbuz.smartautoclicker.core.base.addDumpTabulationLvl import com.buzbuz.smartautoclicker.core.base.dumpWithTimeout - +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.AdState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.ProModeInfo +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserConsentState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.time.Duration - import java.io.PrintWriter - +import kotlin.time.Duration interface IRevenueRepository : Dumpable { @@ -36,17 +38,26 @@ interface IRevenueRepository : Dumpable { val isPrivacySettingRequired: Flow val userBillingState: StateFlow - val isBillingFlowInProgress: Flow + + val proModeInfo: Flow + val adsState: StateFlow + val purchaseState: StateFlow + val trialAvailable: Flow + + val paywallFinished: SharedFlow fun startUserConsentRequestUiFlowIfNeeded(activity: Activity) fun startPrivacySettingUiFlow(activity: Activity) - fun loadAdIfNeeded(context: Context) - fun startPaywallUiFlow(context: Context) + + fun startPlayStoreBillingUiFlow(activity: Activity) + fun notifyPaywallFinished() fun refreshPurchases() - fun startPurchaseUiFlow(context: Context) + fun loadAdIfNeeded(context: Context) + fun showAd(activity: Activity) + fun requestTrial() fun consumeTrial(): Duration? override fun dump(writer: PrintWriter, prefix: CharSequence) { diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/AdState.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/AdState.kt similarity index 85% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/AdState.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/AdState.kt index 8b91125ad2..29be82a28c 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/AdState.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/AdState.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Kevin Buzeau + * 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 @@ -14,9 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.domain.model +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model -internal enum class AdState { +enum class AdState { NOT_INITIALIZED, INITIALIZED, LOADING, diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/ProModeInfo.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/ProModeInfo.kt similarity index 88% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/ProModeInfo.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/ProModeInfo.kt index c392f616fb..f534fb9025 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/ProModeInfo.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/ProModeInfo.kt @@ -14,9 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.domain.model +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model -internal data class ProModeInfo( +data class ProModeInfo( val title: String, val description: String, val price: String, diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/PurchaseState.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/PurchaseState.kt similarity index 88% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/PurchaseState.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/PurchaseState.kt index 5df5269a2f..e63a8b75e9 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/model/PurchaseState.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/PurchaseState.kt @@ -14,9 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.domain.model +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model -internal enum class PurchaseState { +enum class PurchaseState { CANNOT_PURCHASE, NOT_PURCHASED, PENDING, diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/RemoteAdState.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/RemoteAdState.kt similarity index 93% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/RemoteAdState.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/RemoteAdState.kt index 13896bf754..a4c8f17e2d 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/RemoteAdState.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/RemoteAdState.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Kevin Buzeau + * 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 @@ -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.revenue.data.ads +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model internal sealed class RemoteAdState { @@ -36,4 +36,4 @@ internal sealed class RemoteAdState { override val message = null } } -} +} \ No newline at end of file diff --git a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserBillingState.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserBillingState.kt similarity index 92% rename from feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserBillingState.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserBillingState.kt index 376e572bb1..54db49b513 100644 --- a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserBillingState.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserBillingState.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.revenue +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model enum class UserBillingState { EXEMPTED, diff --git a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserConsentState.kt b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserConsentState.kt similarity index 92% rename from feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserConsentState.kt rename to core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserConsentState.kt index 95ed5d93a0..395ffbc021 100644 --- a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/UserConsentState.kt +++ b/core/common/revenue/src/main/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/model/UserConsentState.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.revenue +package com.buzbuz.smartautoclicker.core.common.revenue.domain.model enum class UserConsentState { UNKNOWN, diff --git a/core/common/revenue/src/playStore/AndroidManifest.xml b/core/common/revenue/src/playStore/AndroidManifest.xml new file mode 100644 index 0000000000..bda83f5515 --- /dev/null +++ b/core/common/revenue/src/playStore/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/RevenueTestConfiguration.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/RevenueTestConfiguration.kt similarity index 94% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/RevenueTestConfiguration.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/RevenueTestConfiguration.kt index 7e7b67f1dd..87235a9e27 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/RevenueTestConfiguration.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/RevenueTestConfiguration.kt @@ -14,11 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.data +package com.buzbuz.smartautoclicker.core.common.revenue.data import android.content.Context import android.util.Log -import com.buzbuz.smartautoclicker.feature.revenue.BuildConfig +import com.buzbuz.smartautoclicker.core.common.revenue.BuildConfig import com.google.android.ump.ConsentDebugSettings diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/UserConsentDataSource.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/UserConsentDataSource.kt similarity index 98% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/UserConsentDataSource.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/UserConsentDataSource.kt index 2a7dfaab98..445ed71dc3 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/UserConsentDataSource.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/UserConsentDataSource.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.revenue.data +package com.buzbuz.smartautoclicker.core.common.revenue.data import android.app.Activity import android.util.Log diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/DebugAdStateReceiver.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/DebugAdStateReceiver.kt similarity index 88% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/DebugAdStateReceiver.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/DebugAdStateReceiver.kt index 295ec58719..b1da41cece 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/DebugAdStateReceiver.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/DebugAdStateReceiver.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.revenue.data.ads +package com.buzbuz.smartautoclicker.core.common.revenue.data.ads import android.content.Context import android.content.Intent @@ -22,6 +22,7 @@ import android.content.IntentFilter import android.util.Log import com.buzbuz.smartautoclicker.core.base.SafeBroadcastReceiver +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState /** * Use to simulate the ad state while in debug. @@ -31,7 +32,7 @@ import com.buzbuz.smartautoclicker.core.base.SafeBroadcastReceiver * -a com.buzbuz.smartautoclicker.feature.revenue.data.ads.TEST_STATE \ * --es EXTRA_STATE RemoteAdState" * - * With RemoteAdState being the class name of the wanted [RemoteAdState] or null. + * With RemoteAdState being the class name of the wanted [com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState] or null. */ internal class DebugAdStateReceiver( private val onNewState: (RemoteAdState?) -> Unit, diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSource.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSource.kt similarity index 93% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSource.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSource.kt index 8f7a335ec7..c9ef81d325 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSource.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSource.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.revenue.data.ads +package com.buzbuz.smartautoclicker.core.common.revenue.data.ads import android.app.Activity import android.content.Context @@ -23,9 +23,10 @@ import androidx.annotation.VisibleForTesting import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.Main -import com.buzbuz.smartautoclicker.feature.revenue.BuildConfig -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.sdk.IAdsSdk -import com.buzbuz.smartautoclicker.feature.revenue.data.getAdsDebugTestDevicesIds +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk.IAdsSdk +import com.buzbuz.smartautoclicker.core.common.revenue.BuildConfig +import com.buzbuz.smartautoclicker.core.common.revenue.data.getAdsDebugTestDevicesIds +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState import dagger.hilt.android.qualifiers.ApplicationContext @@ -41,6 +42,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds @Singleton internal class InterstitialAdsDataSource @Inject constructor( @@ -173,7 +175,7 @@ internal class InterstitialAdsDataSource @Inject constructor( return@launch } - delay(RETRY_INCREMENTAL_DELAY_MS * loadRetries) + delay((RETRY_INCREMENTAL_DELAY_MS * loadRetries).milliseconds) _remoteAdState.value = RemoteAdState.Initialized loadAd(context) diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/GoogleAdsSdk.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/GoogleAdsSdk.kt similarity index 95% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/GoogleAdsSdk.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/GoogleAdsSdk.kt index 2a98b66b43..8c0f27c9ea 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/GoogleAdsSdk.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/GoogleAdsSdk.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.revenue.data.ads.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk import android.app.Activity import android.content.Context @@ -23,7 +23,7 @@ import androidx.annotation.MainThread import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.Main -import com.buzbuz.smartautoclicker.feature.revenue.BuildConfig +import com.buzbuz.smartautoclicker.core.common.revenue.BuildConfig import com.google.android.gms.ads.AdError import com.google.android.gms.ads.AdRequest @@ -42,6 +42,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds @Singleton class GoogleAdsSdk @Inject constructor( @@ -137,7 +138,7 @@ class GoogleAdsSdk @Inject constructor( } reconnectJob = coroutineScopeMain.launch { - delay(reconnectMilliseconds) + delay(reconnectMilliseconds.milliseconds) reconnectMilliseconds = min(reconnectMilliseconds * 2, RECONNECT_TIMER_MAX_TIME_MILLISECONDS) reconnectJob = null diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/IAdsSdk.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/IAdsSdk.kt similarity index 95% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/IAdsSdk.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/IAdsSdk.kt index 6fcbbe202c..793179c880 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/sdk/IAdsSdk.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/sdk/IAdsSdk.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.revenue.data.ads.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk import android.app.Activity import android.content.Context diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/BillingDataSource.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/BillingDataSource.kt similarity index 89% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/BillingDataSource.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/BillingDataSource.kt index 4b45439d22..8b6e0c1c34 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/BillingDataSource.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/BillingDataSource.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.revenue.data.billing +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing import android.app.Activity import android.content.Context @@ -22,13 +22,13 @@ import com.android.billingclient.api.Purchase import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO -import com.buzbuz.smartautoclicker.feature.revenue.BuildConfig -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.InAppProduct -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.BillingClientProxy -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.BillingServiceConnection -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.BillingUiFlowState -import dagger.hilt.android.qualifiers.ApplicationContext +import com.buzbuz.smartautoclicker.core.common.quality.BuildConfig +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.BillingClientProxy +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.BillingServiceConnection +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.BillingUiFlowState +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.InAppProduct +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -45,7 +45,7 @@ import javax.inject.Singleton internal class BillingDataSource @Inject constructor( @ApplicationContext context: Context, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, - private val billingServiceConnection: BillingServiceConnection, + billingServiceConnection: BillingServiceConnection, private val productDetailsManager: ProductDetailsManager, private val purchaseManager: NewPurchaseManager, ) { diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/DebugBillingStateReceiver.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/DebugBillingStateReceiver.kt similarity index 97% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/DebugBillingStateReceiver.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/DebugBillingStateReceiver.kt index e1d86664d5..87e67358fa 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/DebugBillingStateReceiver.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/DebugBillingStateReceiver.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.revenue.data.billing +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing import android.content.Context import android.content.Intent diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/ProductDetailsManager.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/ProductDetailsManager.kt similarity index 95% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/ProductDetailsManager.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/ProductDetailsManager.kt index 2f08a2ffed..ebb9019986 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/ProductDetailsManager.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/ProductDetailsManager.kt @@ -14,14 +14,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.data.billing +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing import android.os.SystemClock 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.revenue.data.billing.sdk.InAppProduct +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.InAppProduct import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/PurchaseManager.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/PurchaseManager.kt similarity index 98% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/PurchaseManager.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/PurchaseManager.kt index 3f81660334..534e65acf2 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/PurchaseManager.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/PurchaseManager.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.revenue.data.billing +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing import android.util.Log import com.android.billingclient.api.Purchase diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/Security.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/Security.kt similarity index 97% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/Security.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/Security.kt index 3e4b6a47fa..9a5f3be9cc 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/Security.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/Security.kt @@ -14,13 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.data.billing +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing import android.text.TextUtils import android.util.Base64 import android.util.Log -import com.buzbuz.smartautoclicker.feature.revenue.BuildConfig +import com.buzbuz.smartautoclicker.core.common.revenue.BuildConfig import java.io.IOException import java.security.InvalidKeyException diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingClientProxy.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingClientProxy.kt similarity index 99% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingClientProxy.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingClientProxy.kt index 2da3cff834..d32342918d 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingClientProxy.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingClientProxy.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk import android.app.Activity import android.content.Context diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingServiceConnection.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingServiceConnection.kt similarity index 96% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingServiceConnection.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingServiceConnection.kt index 41252443d2..1722fca04a 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingServiceConnection.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingServiceConnection.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.revenue.data.billing.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk import android.content.Context import android.util.Log @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds internal class BillingServiceConnection @Inject constructor( @param:ApplicationContext private val context: Context, @@ -128,7 +129,7 @@ internal class BillingServiceConnection @Inject constructor( } reconnectJob = coroutineScopeMain.launch { - delay(reconnectMilliseconds) + delay(reconnectMilliseconds.milliseconds) reconnectMilliseconds = min(reconnectMilliseconds * 2, RECONNECT_TIMER_MAX_TIME_MILLISECONDS) reconnectJob = null diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingUiFlowState.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingUiFlowState.kt similarity index 91% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingUiFlowState.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingUiFlowState.kt index 9d6bb3962c..0c5f381fd5 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/BillingUiFlowState.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/BillingUiFlowState.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.revenue.data.billing.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk enum class BillingUiFlowState { VISIBLE, diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/Extensions.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/Extensions.kt similarity index 91% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/Extensions.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/Extensions.kt index c4d6b56b81..86064c5673 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/Extensions.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/Extensions.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.revenue.data.billing.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk import com.android.billingclient.api.BillingResult diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/InAppProduct.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/InAppProduct.kt similarity index 95% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/InAppProduct.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/InAppProduct.kt index ac09c1e3cf..c00c4b9231 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/billing/sdk/InAppProduct.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/billing/sdk/InAppProduct.kt @@ -16,7 +16,7 @@ */ @file:Suppress("DEPRECATION") -package com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk +package com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk import com.android.billingclient.api.ProductDetails diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt similarity index 67% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt index cbc4a95a9a..6b2279c126 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/di/Hilt.kt @@ -14,12 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.di +package com.buzbuz.smartautoclicker.core.common.revenue.di -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.sdk.IAdsSdk -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.sdk.GoogleAdsSdk -import com.buzbuz.smartautoclicker.feature.revenue.domain.RevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.domain.InternalRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk.GoogleAdsSdk +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk.IAdsSdk import dagger.Module import dagger.Provides @@ -37,8 +35,4 @@ internal object PlayStoreBillingHiltModule { internal fun providesAdsSdk(googleAdsSdk: GoogleAdsSdk): IAdsSdk = googleAdsSdk - @Provides - @Singleton - internal fun providesInternalRevenueRepository(repository: RevenueRepository): InternalRevenueRepository = - repository } \ No newline at end of file diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.kt similarity index 84% rename from feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt rename to core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.kt index 9a6f6dd59c..f308f21445 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepository.kt +++ b/core/common/revenue/src/playStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepository.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.revenue.domain +package com.buzbuz.smartautoclicker.core.common.revenue.domain import android.app.Activity import android.content.Context @@ -27,20 +27,17 @@ import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers import com.buzbuz.smartautoclicker.core.base.dumpWithTimeout import com.buzbuz.smartautoclicker.core.common.quality.domain.Quality import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState -import com.buzbuz.smartautoclicker.feature.revenue.UserConsentState -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.InterstitialAdsDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.RemoteAdState -import com.buzbuz.smartautoclicker.feature.revenue.data.UserConsentDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.InAppPurchaseState -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.BillingDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.InAppProduct -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.AdState -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.ProModeInfo -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState -import com.buzbuz.smartautoclicker.feature.revenue.ui.BillingActivity -import com.buzbuz.smartautoclicker.feature.revenue.ui.paywall.PaywallFragment -import com.buzbuz.smartautoclicker.feature.revenue.ui.purchase.PurchaseProModeFragment +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserConsentState +import com.buzbuz.smartautoclicker.core.common.revenue.data.UserConsentDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.InterstitialAdsDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.BillingDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.InAppPurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.InAppProduct +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.AdState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.ProModeInfo +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState import dagger.hilt.android.qualifiers.ApplicationContext @@ -50,7 +47,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -75,7 +74,7 @@ internal class RevenueRepository @Inject constructor( private val adsDataSource: InterstitialAdsDataSource, private val billingDataSource: BillingDataSource, qualityRepository: QualityRepository, -): InternalRevenueRepository { +): IRevenueRepository { private val coroutineScopeIo: CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) @@ -117,6 +116,9 @@ internal class RevenueRepository @Inject constructor( override val trialAvailable: Flow = trialRequestCount.map { requestCount -> requestCount < MAX_TRIAL_COUNT } + private val _paywallFinished: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) + override val paywallFinished: SharedFlow = _paywallFinished + override val userBillingState: StateFlow = combine( adsState, purchaseState, @@ -125,9 +127,6 @@ internal class RevenueRepository @Inject constructor( ::toUserBillingState, ).stateIn(coroutineScopeIo, SharingStarted.Eagerly, UserBillingState.AD_REQUESTED) - override val isBillingFlowInProgress: MutableStateFlow = - MutableStateFlow(false) - init { // Once the user has given his consent, initialize the ads sdk @@ -154,20 +153,6 @@ internal class RevenueRepository @Inject constructor( adsDataSource.loadAd(context) } - override fun startPaywallUiFlow(context: Context) { - isBillingFlowInProgress.value = true - context.startActivity(BillingActivity.getStartIntent(context, PaywallFragment.FRAGMENT_TAG)) - } - - override fun startPurchaseUiFlow(context: Context) { - isBillingFlowInProgress.value = true - context.startActivity(BillingActivity.getStartIntent(context, PurchaseProModeFragment.FRAGMENT_TAG)) - } - - override fun setBillingActivityDestroyed() { - isBillingFlowInProgress.value = false - } - override fun showAd(activity: Activity) { when (adsState.value) { AdState.READY -> adsDataSource.showAd(activity) @@ -176,6 +161,10 @@ internal class RevenueRepository @Inject constructor( } } + override fun notifyPaywallFinished() { + _paywallFinished.tryEmit(Unit) + } + override fun startPlayStoreBillingUiFlow(activity: Activity) { if (purchaseState.value != PurchaseState.NOT_PURCHASED) return billingDataSource.launchBillingFlow(activity) @@ -287,7 +276,7 @@ private fun toUserBillingState(adState: AdState, purchaseState: PurchaseState, t else -> UserBillingState.AD_REQUESTED } -internal val TRIAL_SESSION_DURATION_DURATION = 30.minutes +val TRIAL_SESSION_DURATION_DURATION = 30.minutes @VisibleForTesting internal val AD_WATCHED_STATE_DURATION = 30.minutes private const val MAX_TRIAL_COUNT = 3 diff --git a/feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSourceTests.kt b/core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSourceTests.kt similarity index 97% rename from feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSourceTests.kt rename to core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSourceTests.kt index 5acf7b0a20..73776dc2e2 100644 --- a/feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/data/ads/InterstitialAdsDataSourceTests.kt +++ b/core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/data/ads/InterstitialAdsDataSourceTests.kt @@ -14,13 +14,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.data.ads +package com.buzbuz.smartautoclicker.core.common.revenue.data.ads import android.app.Activity import android.content.Context import android.os.Build -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.sdk.IAdsSdk +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.sdk.IAdsSdk +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first diff --git a/feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepositoryTests.kt b/core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepositoryTests.kt similarity index 90% rename from feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepositoryTests.kt rename to core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepositoryTests.kt index ae0f32ce48..eb437acaa1 100644 --- a/feature/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/feature/revenue/domain/RevenueRepositoryTests.kt +++ b/core/common/revenue/src/testPlayStore/java/com/buzbuz/smartautoclicker/core/common/revenue/domain/RevenueRepositoryTests.kt @@ -14,22 +14,23 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.revenue.domain +package com.buzbuz.smartautoclicker.core.common.revenue.domain import android.content.Context import android.os.Build -import com.buzbuz.smartautoclicker.core.common.quality.domain.Quality +import com.buzbuz.smartautoclicker.core.common.quality.domain.Quality import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.InterstitialAdsDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.UserConsentDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.ads.RemoteAdState -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.BillingDataSource -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.InAppPurchaseState -import com.buzbuz.smartautoclicker.feature.revenue.data.billing.sdk.InAppProduct -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.AdState -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState +import com.buzbuz.smartautoclicker.core.common.revenue.data.UserConsentDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.data.ads.InterstitialAdsDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.RemoteAdState +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.BillingDataSource +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.InAppPurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.data.billing.sdk.InAppProduct +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.AdState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState + import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow diff --git a/feature/revenue/build.gradle.kts b/feature/revenue/build.gradle.kts index fc9eb7ba4c..49589df6c2 100644 --- a/feature/revenue/build.gradle.kts +++ b/feature/revenue/build.gradle.kts @@ -16,8 +16,6 @@ */ import com.buzbuz.gradle.convention.extensions.playStore -import com.buzbuz.gradle.convention.extensions.buildConfigField -import com.buzbuz.gradle.convention.extensions.manifestPlaceholders plugins { alias(libs.plugins.buzbuz.androidLibrary) @@ -35,17 +33,8 @@ android { dimension = "version" buildFeatures { - buildConfig = true viewBinding = true } - - buildConfigField(buildParameters.consentTestDevicesIds) - buildConfigField(buildParameters.consentTestGeography) - buildConfigField(buildParameters.adsUnitId) - buildConfigField(buildParameters.adsTestDevicesIds) - buildConfigField(buildParameters.billingPublicKey) - - manifestPlaceholders(buildParameters.adsApplicationId) } } } @@ -54,7 +43,9 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(project(":core:common:base")) + implementation(project(":core:common:navigation")) implementation(project(":core:common:quality")) + implementation(project(":core:common:revenue")) implementation(project(":core:common:ui")) playStoreImplementation(libs.androidx.appCompat) @@ -62,13 +53,5 @@ dependencies { playStoreImplementation(libs.androidx.fragment.ktx) playStoreImplementation(libs.androidx.lifecycle.viewmodel.ktx) - playStoreImplementation(libs.android.billingClient) - playStoreImplementation(libs.android.billingClient.ktx) - - playStoreImplementation(libs.google.userMessaging) - playStoreImplementation(libs.google.gms.ads) - playStoreImplementation(libs.google.material) - - testPlayStoreImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt b/feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt new file mode 100644 index 0000000000..a69a87d3c2 --- /dev/null +++ b/feature/revenue/src/fDroid/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.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.revenue.navigation + +import android.app.Activity +import android.content.Context +import com.buzbuz.smartautoclicker.core.common.navigation.RevenueNavigator +import javax.inject.Inject + +internal class RevenueNavigatorImpl @Inject constructor(): RevenueNavigator { + + override fun startUserConsentRequestUiFlowIfNeeded(activity: Activity) = Unit + override fun startPrivacySettingUiFlow(activity: Activity) = Unit + override fun startPaywallActivity(context: Context) = Unit + override fun startPurchaseActivity(context: Context) = Unit +} \ No newline at end of file diff --git a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt b/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt index 8243e0dd22..ca2ef530d6 100644 --- a/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt +++ b/feature/revenue/src/main/java/com/buzbuz/smartautoclicker/feature/revenue/di/Hilt.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Kevin Buzeau + * 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 @@ -16,21 +16,19 @@ */ package com.buzbuz.smartautoclicker.feature.revenue.di -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.domain.RevenueRepository - +import com.buzbuz.smartautoclicker.core.common.navigation.RevenueNavigator +import com.buzbuz.smartautoclicker.feature.revenue.navigation.RevenueNavigatorImpl +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object BillingHiltModule { +internal abstract class RevenueNavigationModule { - @Provides + @Binds @Singleton - internal fun providesRevenueRepository(revenueRepository: RevenueRepository): IRevenueRepository = - revenueRepository + abstract fun bindRevenueNavigator(impl: RevenueNavigatorImpl): RevenueNavigator } \ No newline at end of file diff --git a/feature/revenue/src/playStore/AndroidManifest.xml b/feature/revenue/src/playStore/AndroidManifest.xml index 104d35a064..5b6cc137c2 100644 --- a/feature/revenue/src/playStore/AndroidManifest.xml +++ b/feature/revenue/src/playStore/AndroidManifest.xml @@ -17,16 +17,8 @@ --> - - - - - - . - */ -package com.buzbuz.smartautoclicker.feature.revenue.domain - -import android.app.Activity -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository - -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.AdState -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.ProModeInfo -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow - - -internal interface InternalRevenueRepository : IRevenueRepository { - - val proModeInfo: Flow - val adsState: StateFlow - val purchaseState: StateFlow - val trialAvailable: Flow - - fun showAd(activity: Activity) - fun requestTrial() - - fun startPlayStoreBillingUiFlow(activity: Activity) - - fun setBillingActivityDestroyed() -} \ No newline at end of file diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.kt new file mode 100644 index 0000000000..5fab905d7d --- /dev/null +++ b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/navigation/RevenueNavigatorImpl.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. + * + * 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.revenue.navigation + +import android.app.Activity +import android.content.Context +import com.buzbuz.smartautoclicker.core.common.navigation.RevenueNavigator +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.feature.revenue.ui.BillingActivity +import com.buzbuz.smartautoclicker.feature.revenue.ui.paywall.PaywallFragment +import com.buzbuz.smartautoclicker.feature.revenue.ui.purchase.PurchaseProModeFragment +import javax.inject.Inject + +internal class RevenueNavigatorImpl @Inject constructor( + private val revenueRepository: IRevenueRepository, +): RevenueNavigator { + + override fun startUserConsentRequestUiFlowIfNeeded(activity: Activity) { + revenueRepository.startUserConsentRequestUiFlowIfNeeded(activity) + } + + override fun startPrivacySettingUiFlow(activity: Activity) { + revenueRepository.startPrivacySettingUiFlow(activity) + } + + override fun startPaywallActivity(context: Context) { + context.startActivity( + BillingActivity.getStartIntent( + context = context, + billingFragment = PaywallFragment.FRAGMENT_TAG, + ) + ) + } + + override fun startPurchaseActivity(context: Context) { + context.startActivity( + BillingActivity.getStartIntent( + context = context, + billingFragment = PurchaseProModeFragment.FRAGMENT_TAG, + ) + ) + } +} \ No newline at end of file diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivity.kt b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivity.kt index e05bd01005..48261d22f3 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivity.kt +++ b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivity.kt @@ -81,7 +81,7 @@ internal class BillingActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - viewModel.setBillingActivityDestroyed() + viewModel.notifyPaywallFinished() } } diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivityViewModel.kt b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivityViewModel.kt index bf4dc56831..0fc27de4fd 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivityViewModel.kt +++ b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/BillingActivityViewModel.kt @@ -18,25 +18,22 @@ package com.buzbuz.smartautoclicker.feature.revenue.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState -import com.buzbuz.smartautoclicker.feature.revenue.domain.InternalRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.ExperimentalCoroutinesApi - import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn - import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds - -import kotlinx.coroutines.flow.transformLatest @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel internal class BillingActivityViewModel@Inject constructor( - private val revenueRepository: InternalRevenueRepository, + private val revenueRepository: IRevenueRepository, ) : ViewModel() { init { @@ -56,8 +53,8 @@ internal class BillingActivityViewModel@Inject constructor( revenueRepository.refreshPurchases() } - fun setBillingActivityDestroyed() { - revenueRepository.setBillingActivityDestroyed() + fun notifyPaywallFinished() { + revenueRepository.notifyPaywallFinished() } } diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/paywall/PaywallViewModel.kt b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/paywall/PaywallViewModel.kt index c5ec9c4ddc..09cf2900e5 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/paywall/PaywallViewModel.kt +++ b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/paywall/PaywallViewModel.kt @@ -20,13 +20,14 @@ import android.app.Activity import android.content.Context import androidx.lifecycle.ViewModel +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.TRIAL_SESSION_DURATION_DURATION +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.AdState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.ProModeInfo +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState import com.buzbuz.smartautoclicker.core.ui.bindings.buttons.LoadableButtonState import com.buzbuz.smartautoclicker.feature.revenue.R -import com.buzbuz.smartautoclicker.feature.revenue.domain.InternalRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.domain.TRIAL_SESSION_DURATION_DURATION -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.AdState -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.ProModeInfo -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState + import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -38,7 +39,7 @@ import javax.inject.Inject @HiltViewModel internal class AdsLoadingViewModel @Inject constructor( @ApplicationContext appContext: Context, - private val revenueRepository: InternalRevenueRepository, + private val revenueRepository: IRevenueRepository, ) : ViewModel() { val dialogState: Flow = combine( diff --git a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/purchase/PurchaseProModeViewModel.kt b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/purchase/PurchaseProModeViewModel.kt index bd7aaade3d..308d17befb 100644 --- a/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/purchase/PurchaseProModeViewModel.kt +++ b/feature/revenue/src/playStore/java/com/buzbuz/smartautoclicker/feature/revenue/ui/purchase/PurchaseProModeViewModel.kt @@ -19,8 +19,8 @@ package com.buzbuz.smartautoclicker.feature.revenue.ui.purchase import android.app.Activity import androidx.lifecycle.ViewModel -import com.buzbuz.smartautoclicker.feature.revenue.domain.InternalRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.domain.model.PurchaseState +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.PurchaseState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow @@ -30,7 +30,7 @@ import javax.inject.Inject @HiltViewModel internal class PurchaseProModeViewModel @Inject constructor( - private val revenueRepository: InternalRevenueRepository, + private val revenueRepository: IRevenueRepository, ) : ViewModel() { val dialogState: Flow = diff --git a/feature/smart-config/build.gradle.kts b/feature/smart-config/build.gradle.kts index f8a02d5e79..8b5c3d278e 100644 --- a/feature/smart-config/build.gradle.kts +++ b/feature/smart-config/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(project(":core:common:navigation")) implementation(project(":core:common:overlays")) implementation(project(":core:common:permissions")) + implementation(project(":core:common:revenue")) implementation(project(":core:common:settings")) implementation(project(":core:common:tutorial")) implementation(project(":core:common:ui")) @@ -52,5 +53,4 @@ dependencies { implementation(project(":core:smart:detection-models")) implementation(project(":core:smart:domain")) implementation(project(":core:smart:processing")) - implementation(project(":feature:revenue")) } \ No newline at end of file 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 2c53d760c8..0d3c32fc09 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 @@ -22,6 +22,9 @@ import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.buzbuz.smartautoclicker.core.common.navigation.getRevenueNavigator +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.Tip @@ -31,8 +34,6 @@ import com.buzbuz.smartautoclicker.core.smart.debugging.domain.DebuggingReposito import com.buzbuz.smartautoclicker.core.common.tutorial.domain.MonitoredViewsManager import com.buzbuz.smartautoclicker.core.common.tutorial.impl.monitoring.ViewPositioningType import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredViewType -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState import com.buzbuz.smartautoclicker.feature.smart.config.domain.EditionRepository import com.buzbuz.smartautoclicker.feature.smart.config.domain.usecase.alphabet.AreRequiredAlphabetModelsInstalledUseCase @@ -40,6 +41,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -48,7 +50,9 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -76,8 +80,8 @@ class MainMenuModel @Inject constructor( private var paywallResultJob: Job? = null /** Tells if the paywall is currently displayed. */ - val paywallIsVisible: Flow = - revenueRepository.isBillingFlowInProgress + private val _paywallIsVisible: MutableStateFlow = MutableStateFlow(false) + val paywallIsVisible: StateFlow = _paywallIsVisible val shouldShowStopWithVolumeDownTip: StateFlow = tutorialRepository .shouldShowTip(Tip.STOP_WITH_VOLUME_DOWN) @@ -153,17 +157,18 @@ class MainMenuModel @Inject constructor( !tutorialRepository.isTutorialStarted() private fun startPaywall(context: Context) { - revenueRepository.startPaywallUiFlow(context) - - paywallResultJob = combine(revenueRepository.isBillingFlowInProgress, revenueRepository.userBillingState) { inProgress, state -> - if (inProgress) return@combine - - Log.d(TAG, "onPaywall finished") - - if (!state.isAdRequested()) startDetection(context) - paywallResultJob?.cancel() - paywallResultJob = null - }.launchIn(viewModelScope) + context.getRevenueNavigator().startPaywallActivity(context) + _paywallIsVisible.value = true + + paywallResultJob = revenueRepository.paywallFinished + .take(1) + .onEach { + Log.d(TAG, "onPaywall finished") + _paywallIsVisible.value = false + if (!revenueRepository.userBillingState.value.isAdRequested()) startDetection(context) + paywallResultJob = null + } + .launchIn(viewModelScope) } private fun startDetection(context: Context) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 324b8d25e6..aa44c08bb8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,6 +50,7 @@ include(":core:common:navigation") include(":core:common:overlays") include(":core:common:permissions") include(":core:common:quality") +include(":core:common:revenue") include(":core:common:settings") include(":core:common:tutorial") include(":core:common:ui") diff --git a/smartautoclicker/build.gradle.kts b/smartautoclicker/build.gradle.kts index a182fe1196..6a4f1473b3 100644 --- a/smartautoclicker/build.gradle.kts +++ b/smartautoclicker/build.gradle.kts @@ -160,6 +160,7 @@ dependencies { implementation(project(":core:common:actions")) implementation(project(":core:common:base")) implementation(project(":core:common:bitmaps")) + implementation(project(":core:common:revenue")) implementation(project(":core:common:display")) implementation(project(":core:common:navigation")) implementation(project(":core:common:overlays")) diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index 3fe45d9143..a1fa03a920 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -34,6 +34,7 @@ import com.buzbuz.smartautoclicker.core.common.actions.AndroidActionExecutor import com.buzbuz.smartautoclicker.core.common.overlays.manager.OverlayManager import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityMetricsMonitor import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository import com.buzbuz.smartautoclicker.core.display.config.DisplayConfigManager import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario @@ -44,7 +45,6 @@ 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.revenue.IRevenueRepository import com.buzbuz.smartautoclicker.feature.review.ReviewRepository import com.buzbuz.smartautoclicker.localservice.LocalService 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 220aabc625..b35e176567 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/localservice/LocalService.kt @@ -25,6 +25,9 @@ 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.navigation.getRevenueNavigator +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario @@ -37,8 +40,6 @@ import com.buzbuz.smartautoclicker.feature.smart.config.ui.mainmenu.MainMenu import com.buzbuz.smartautoclicker.feature.dumb.config.ui.DumbMainMenu import com.buzbuz.smartautoclicker.feature.notifications.ServiceNotificationController import com.buzbuz.smartautoclicker.feature.notifications.ServiceNotificationListener -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,7 +50,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds class LocalService( private val context: Context, @@ -118,7 +121,7 @@ class LocalService( onStart(dumbScenario.id.databaseId, false, null) startJob = serviceScope.launch { - delay(500) + delay(500.milliseconds) dumbEngine.init(dumbScenario) @@ -229,15 +232,15 @@ class LocalService( !tutorialRepository.isTutorialStarted() private fun startPaywall() { - revenueRepository.startPaywallUiFlow(context) + context.getRevenueNavigator().startPaywallActivity(context) - paywallResultJob = combine(revenueRepository.isBillingFlowInProgress, revenueRepository.userBillingState) { inProgress, state -> - if (inProgress) return@combine - - if (state != UserBillingState.AD_REQUESTED) startSmartScenario() - paywallResultJob?.cancel() - paywallResultJob = null - }.launchIn(serviceScope) + paywallResultJob = revenueRepository.paywallFinished + .take(1) + .onEach { + if (revenueRepository.userBillingState.value != UserBillingState.AD_REQUESTED) startSmartScenario() + paywallResultJob = null + } + .launchIn(serviceScope) } private fun startSmartScenario() { 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 288bff0d76..3284324efa 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/ScenarioActivity.kt @@ -28,11 +28,11 @@ 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.common.revenue.domain.model.UserConsentState import com.buzbuz.smartautoclicker.core.display.recorder.MediaProjectionRequest 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.scenarios.viewmodel.ScenarioViewModel import dagger.hilt.android.AndroidEntryPoint diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/creation/ScenarioCreationViewModel.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/creation/ScenarioCreationViewModel.kt index c4507e4ce8..1231393889 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/creation/ScenarioCreationViewModel.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/scenarios/creation/ScenarioCreationViewModel.kt @@ -24,14 +24,14 @@ import androidx.lifecycle.viewModelScope import com.buzbuz.smartautoclicker.R import com.buzbuz.smartautoclicker.core.base.identifier.DATABASE_ID_INSERTION import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState 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.IDumbRepository import com.buzbuz.smartautoclicker.core.dumb.domain.model.DumbScenario import com.buzbuz.smartautoclicker.core.processing.domain.DETECTION_QUALITY_MIN -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext 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 6905325f9a..ace10e1a70 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 @@ -37,9 +37,9 @@ 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.common.permissions.model.PermissionPostNotification +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserConsentState import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserConsentState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted 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 982d87bc47..46e26ccf4a 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsFragment.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.buzbuz.smartautoclicker.R +import com.buzbuz.smartautoclicker.core.common.navigation.getRevenueNavigator import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setChecked import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setDescription import com.buzbuz.smartautoclicker.core.ui.bindings.fields.setOnClickListener @@ -88,7 +89,10 @@ class SettingsFragment : Fragment() { viewBinding.fieldRemoveAds.apply { setTitle(requireContext().getString(R.string.field_remove_ads)) - setOnClickListener { viewModel.showPurchaseActivity(requireActivity()) } + setOnClickListener { + requireContext().getRevenueNavigator() + .startPurchaseActivity(requireContext()) + } } viewBinding.fieldTroubleshooting.apply { 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 546c9488f3..2d4547dbc5 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/settings/SettingsViewModel.kt @@ -23,9 +23,9 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import com.buzbuz.smartautoclicker.core.base.workarounds.isImpactedByInputBlock import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.revenue.domain.model.UserBillingState import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository -import com.buzbuz.smartautoclicker.feature.revenue.IRevenueRepository -import com.buzbuz.smartautoclicker.feature.revenue.UserBillingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -94,10 +94,6 @@ class SettingsViewModel @Inject constructor( revenueRepository.startPrivacySettingUiFlow(activity) } - fun showPurchaseActivity(context: Context) { - revenueRepository.startPurchaseUiFlow(context) - } - fun showTroubleshootingDialog(activity: FragmentActivity) { qualityRepository.startTroubleshootingUiFlow(activity) } From db0616f6b250efadfdd7e736c7c4dc0c0863fda2 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:28:37 +0200 Subject: [PATCH 03/56] Split feature:review module into feature & core modules --- .../common/navigation/NavigationEntryPoint.kt | 1 + .../core/common/navigation/ReviewNavigator.kt | 29 +++++++++++++++ core/common/review/build.gradle.kts | 32 +++++++++++++++++ .../common}/review/ReviewRepositoryImpl.kt | 7 ++-- .../review/src/main/AndroidManifest.xml | 18 ++++++++++ .../core/common}/review/ReviewRepository.kt | 9 ++--- .../core/common/review/di/Hilt.kt | 36 +++++++++++++++++++ .../common}/review/ReviewRepositoryImpl.kt | 27 ++++++-------- .../common}/review/data/ReviewDataSource.kt | 10 +++--- .../engine/engagement/UserEngagement.kt | 6 ++-- .../engine/engagement/UserEngagementConfig.kt | 4 +-- .../engine/scheduling/ReviewScheduling.kt | 4 +-- .../scheduling/ReviewSchedulingConfig.kt | 4 +-- .../review/engine/session/UserSession.kt | 4 +-- .../engine/session/UserSessionsRecorder.kt | 6 ++-- feature/review/build.gradle.kts | 5 +-- .../review/navigation/ReviewNavigatorImpl.kt | 26 ++++++++++++++ .../feature/review/di/Hilt.kt | 15 ++++---- .../review/navigation/ReviewNavigatorImpl.kt | 35 ++++++++++++++++++ .../feature/review/ui/ReviewViewModel.kt | 5 ++- .../feature/review/ExampleUnitTest.kt | 17 --------- settings.gradle.kts | 1 + smartautoclicker/build.gradle.kts | 1 + .../SmartAutoClickerService.kt | 9 ++--- 24 files changed, 228 insertions(+), 83 deletions(-) create mode 100644 core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/ReviewNavigator.kt create mode 100644 core/common/review/build.gradle.kts rename {feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/fDroid/java/com/buzbuz/smartautoclicker/core/common}/review/ReviewRepositoryImpl.kt (84%) create mode 100644 core/common/review/src/main/AndroidManifest.xml rename {feature/review/src/main/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common}/review/ReviewRepository.kt (88%) create mode 100644 core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/di/Hilt.kt rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/ReviewRepositoryImpl.kt (83%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/data/ReviewDataSource.kt (91%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/engagement/UserEngagement.kt (92%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/engagement/UserEngagementConfig.kt (94%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/scheduling/ReviewScheduling.kt (93%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/scheduling/ReviewSchedulingConfig.kt (90%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/session/UserSession.kt (93%) rename {feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature => core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common}/review/engine/session/UserSessionsRecorder.kt (92%) create mode 100644 feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt create mode 100644 feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt delete mode 100644 feature/review/src/test/java/com/buzbuz/smartautoclicker/feature/review/ExampleUnitTest.kt diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt index 42385abff5..6ee59206ad 100644 --- a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/NavigationEntryPoint.kt @@ -26,5 +26,6 @@ interface NavigationEntryPoint { fun debuggingNavigator(): DebuggingNavigator fun revenueNavigator(): RevenueNavigator + fun reviewNavigator(): ReviewNavigator fun tutorialNavigator(): TutorialNavigator } diff --git a/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/ReviewNavigator.kt b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/ReviewNavigator.kt new file mode 100644 index 0000000000..8cc1f1e018 --- /dev/null +++ b/core/common/navigation/src/main/java/com/buzbuz/smartautoclicker/core/common/navigation/ReviewNavigator.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.common.navigation + +import android.content.Context +import dagger.hilt.EntryPoints + +interface ReviewNavigator { + fun startReviewActivity(context: Context) +} + +fun Context.getReviewNavigator(): ReviewNavigator = + EntryPoints + .get(applicationContext, NavigationEntryPoint::class.java) + .reviewNavigator() diff --git a/core/common/review/build.gradle.kts b/core/common/review/build.gradle.kts new file mode 100644 index 0000000000..35ab1ab6fe --- /dev/null +++ b/core/common/review/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +plugins { + alias(libs.plugins.buzbuz.androidLibrary) + alias(libs.plugins.buzbuz.flavour) + alias(libs.plugins.buzbuz.hilt) +} + +android { + namespace = "com.buzbuz.smartautoclicker.core.common.review" +} + +dependencies { + playStoreImplementation(libs.androidx.datastore) + + implementation(project(":core:common:base")) +} diff --git a/feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt b/core/common/review/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt similarity index 84% rename from feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt rename to core/common/review/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt index d474101097..9edd882dab 100644 --- a/feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt +++ b/core/common/review/src/fDroid/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt @@ -14,10 +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.review - -import android.content.Context -import android.content.Intent +package com.buzbuz.smartautoclicker.core.common.review import javax.inject.Inject import javax.inject.Singleton @@ -25,7 +22,7 @@ import javax.inject.Singleton @Singleton internal class ReviewRepositoryImpl @Inject constructor() : ReviewRepository { override fun isUserCandidateForReview(): Boolean = false - override fun getReviewActivityIntent(context: Context): Intent? = null + override fun updateLastReviewRequestTimestamp(): Unit = Unit override fun onUserSessionStarted(): Unit = Unit override fun onUserSessionStopped(): Unit = Unit } diff --git a/core/common/review/src/main/AndroidManifest.xml b/core/common/review/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..76a6bdf3ae --- /dev/null +++ b/core/common/review/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + diff --git a/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepository.kt b/core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepository.kt similarity index 88% rename from feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepository.kt rename to core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepository.kt index c93de360d7..35341d12cd 100644 --- a/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepository.kt +++ b/core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepository.kt @@ -14,10 +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.review - -import android.content.Context -import android.content.Intent +package com.buzbuz.smartautoclicker.core.common.review import com.buzbuz.smartautoclicker.core.base.Dumpable import com.buzbuz.smartautoclicker.core.base.addDumpTabulationLvl @@ -28,7 +25,7 @@ import java.io.PrintWriter interface ReviewRepository : Dumpable { fun isUserCandidateForReview(): Boolean - fun getReviewActivityIntent(context: Context): Intent? + fun updateLastReviewRequestTimestamp() fun onUserSessionStarted() fun onUserSessionStopped() @@ -43,4 +40,4 @@ interface ReviewRepository : Dumpable { .println() } } -} \ No newline at end of file +} diff --git a/core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/di/Hilt.kt b/core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/di/Hilt.kt new file mode 100644 index 0000000000..adc7652a4b --- /dev/null +++ b/core/common/review/src/main/java/com/buzbuz/smartautoclicker/core/common/review/di/Hilt.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.common.review.di + +import com.buzbuz.smartautoclicker.core.common.review.ReviewRepository +import com.buzbuz.smartautoclicker.core.common.review.ReviewRepositoryImpl + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReviewHiltModule { + + @Provides + @Singleton + internal fun providesReviewRepository(reviewRepository: ReviewRepositoryImpl): ReviewRepository = + reviewRepository +} diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt similarity index 83% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt index d5b1e212d2..da4ec86518 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ReviewRepositoryImpl.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/ReviewRepositoryImpl.kt @@ -14,21 +14,19 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.review +package com.buzbuz.smartautoclicker.core.common.review import android.content.Context -import android.content.Intent import com.buzbuz.smartautoclicker.core.base.Dumpable import com.buzbuz.smartautoclicker.core.base.addDumpTabulationLvl import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO import com.buzbuz.smartautoclicker.core.base.dumpWithTimeout -import com.buzbuz.smartautoclicker.feature.review.data.ReviewDataSource -import com.buzbuz.smartautoclicker.feature.review.engine.engagement.isUserEngaged -import com.buzbuz.smartautoclicker.feature.review.engine.scheduling.isLastReviewRequestOldEnough -import com.buzbuz.smartautoclicker.feature.review.engine.session.UserSessionsRecorder -import com.buzbuz.smartautoclicker.feature.review.ui.ReviewActivity +import com.buzbuz.smartautoclicker.core.common.review.data.ReviewDataSource +import com.buzbuz.smartautoclicker.core.common.review.engine.engagement.isUserEngaged +import com.buzbuz.smartautoclicker.core.common.review.engine.scheduling.isLastReviewRequestOldEnough +import com.buzbuz.smartautoclicker.core.common.review.engine.session.UserSessionsRecorder import dagger.hilt.android.qualifiers.ApplicationContext @@ -76,8 +74,11 @@ internal class ReviewRepositoryImpl @Inject constructor( override fun isUserCandidateForReview(): Boolean = isUserCandidateForReview.value - override fun getReviewActivityIntent(context: Context): Intent = - Intent(context, ReviewActivity::class.java) + override fun updateLastReviewRequestTimestamp() { + coroutineScope.launch { + reviewDataSource.setLastReviewRequestTimestamp(System.currentTimeMillis()) + } + } override fun onUserSessionStarted(): Unit = userSessionsRecorder.startSession() @@ -85,12 +86,6 @@ internal class ReviewRepositoryImpl @Inject constructor( override fun onUserSessionStopped(): Unit = userSessionsRecorder.stopSession() - internal fun updateLastReviewRequestTimestamp() { - coroutineScope.launch { - reviewDataSource.setLastReviewRequestTimestamp(System.currentTimeMillis()) - } - } - override fun dump(writer: PrintWriter, prefix: CharSequence) { super.dump(writer, prefix) val contentPrefix = prefix.addDumpTabulationLvl() @@ -101,5 +96,3 @@ internal class ReviewRepositoryImpl @Inject constructor( .println() } } - - diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/data/ReviewDataSource.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/data/ReviewDataSource.kt similarity index 91% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/data/ReviewDataSource.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/data/ReviewDataSource.kt index 33ae30bf4e..e508def4d0 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/data/ReviewDataSource.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/data/ReviewDataSource.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.review.data +package com.buzbuz.smartautoclicker.core.common.review.data import android.content.Context @@ -26,9 +26,9 @@ 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 com.buzbuz.smartautoclicker.core.base.extensions.trim -import com.buzbuz.smartautoclicker.feature.review.engine.session.UserSession -import com.buzbuz.smartautoclicker.feature.review.engine.session.toPreferences -import com.buzbuz.smartautoclicker.feature.review.engine.session.toUserSessions +import com.buzbuz.smartautoclicker.core.common.review.engine.session.UserSession +import com.buzbuz.smartautoclicker.core.common.review.engine.session.toPreferences +import com.buzbuz.smartautoclicker.core.common.review.engine.session.toUserSessions import dagger.hilt.android.qualifiers.ApplicationContext @@ -86,4 +86,4 @@ internal class ReviewDataSource @Inject constructor( preferences[KEY_LAST_USER_SESSION] = newSessions.toPreferences() } } -} \ No newline at end of file +} diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagement.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagement.kt similarity index 92% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagement.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagement.kt index 4a14bc3b52..3cb5842a49 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagement.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagement.kt @@ -14,10 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.review.engine.engagement +package com.buzbuz.smartautoclicker.core.common.review.engine.engagement import com.buzbuz.smartautoclicker.core.base.extensions.trim -import com.buzbuz.smartautoclicker.feature.review.engine.session.UserSession +import com.buzbuz.smartautoclicker.core.common.review.engine.session.UserSession internal fun List.isUserEngaged(): Boolean { val shortSessionEngagement = verifyEngagement( @@ -48,4 +48,4 @@ private fun List.verifyEngagement(durationRange: LongRange, session if (sessions.first().timestamp + MAX_ENGAGEMENT_AGE_MS < System.currentTimeMillis()) return false return true -} \ No newline at end of file +} diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagementConfig.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagementConfig.kt similarity index 94% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagementConfig.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagementConfig.kt index 9f03b77970..08c4554027 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/engagement/UserEngagementConfig.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/engagement/UserEngagementConfig.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.review.engine.engagement +package com.buzbuz.smartautoclicker.core.common.review.engine.engagement import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes @@ -38,4 +38,4 @@ internal val ENGAGEMENT_PERIOD_DURATION_MS = /** Maximum time since the last engagement period to be still considered engaged. */ internal val MAX_ENGAGEMENT_AGE_MS = - 7.days.inWholeMilliseconds \ No newline at end of file + 7.days.inWholeMilliseconds diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewScheduling.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewScheduling.kt similarity index 93% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewScheduling.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewScheduling.kt index 3ed8df565b..d27812f631 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewScheduling.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewScheduling.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.review.engine.scheduling +package com.buzbuz.smartautoclicker.core.common.review.engine.scheduling import android.content.Context @@ -27,4 +27,4 @@ internal fun isLastReviewRequestOldEnough(context: Context, lastRequestTs: Long? private val Context.appInstallTimestamp: Long - get() = packageManager.getPackageInfo(packageName, 0).firstInstallTime \ No newline at end of file + get() = packageManager.getPackageInfo(packageName, 0).firstInstallTime diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewSchedulingConfig.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewSchedulingConfig.kt similarity index 90% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewSchedulingConfig.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewSchedulingConfig.kt index de929d991a..343a18eba6 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/scheduling/ReviewSchedulingConfig.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/scheduling/ReviewSchedulingConfig.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.review.engine.scheduling +package com.buzbuz.smartautoclicker.core.common.review.engine.scheduling import kotlin.time.Duration.Companion.days @@ -24,4 +24,4 @@ internal val DELAY_BEFORE_FIRST_REVIEW_REQUEST_MS: Long = /** Delay minimum between two review requests (one month) */ internal val DELAY_BETWEEN_REVIEW_REQUEST_MS: Long = - 30.days.inWholeMilliseconds \ No newline at end of file + 30.days.inWholeMilliseconds diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSession.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSession.kt similarity index 93% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSession.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSession.kt index 9e94a1179a..75bb7c70df 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSession.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSession.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.review.engine.session +package com.buzbuz.smartautoclicker.core.common.review.engine.session internal data class UserSession( val timestamp: Long, @@ -46,4 +46,4 @@ private fun String.toUserSession(): UserSession { -private const val FIELD_DELIMITER = "|" \ No newline at end of file +private const val FIELD_DELIMITER = "|" diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSessionsRecorder.kt b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSessionsRecorder.kt similarity index 92% rename from feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSessionsRecorder.kt rename to core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSessionsRecorder.kt index 96448b6565..eb77f98b97 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/engine/session/UserSessionsRecorder.kt +++ b/core/common/review/src/playStore/java/com/buzbuz/smartautoclicker/core/common/review/engine/session/UserSessionsRecorder.kt @@ -14,13 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.buzbuz.smartautoclicker.feature.review.engine.session +package com.buzbuz.smartautoclicker.core.common.review.engine.session 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.review.data.ReviewDataSource +import com.buzbuz.smartautoclicker.core.common.review.data.ReviewDataSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -74,4 +74,4 @@ internal class UserSessionsRecorder @Inject constructor( } private val MINIMUM_SESSION_DURATION_MS = 1.minutes.inWholeMilliseconds -private const val TAG = "UserSessionsRecorder" \ No newline at end of file +private const val TAG = "UserSessionsRecorder" diff --git a/feature/review/build.gradle.kts b/feature/review/build.gradle.kts index 985ca4e5b2..3890c84d64 100644 --- a/feature/review/build.gradle.kts +++ b/feature/review/build.gradle.kts @@ -27,10 +27,11 @@ android { dependencies { implementation(project(":core:common:base")) + implementation(project(":core:common:navigation")) + implementation(project(":core:common:review")) playStoreImplementation(libs.androidx.appCompat) - playStoreImplementation(libs.androidx.datastore) playStoreImplementation(libs.androidx.fragment.ktx) playStoreImplementation(libs.google.play.review) playStoreImplementation(project(":core:common:ui")) -} \ No newline at end of file +} diff --git a/feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt b/feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt new file mode 100644 index 0000000000..6e2b47bf40 --- /dev/null +++ b/feature/review/src/fDroid/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.review.navigation + +import android.content.Context +import com.buzbuz.smartautoclicker.core.common.navigation.ReviewNavigator + +import javax.inject.Inject + +internal class ReviewNavigatorImpl @Inject constructor() : ReviewNavigator { + override fun startReviewActivity(context: Context) = Unit +} diff --git a/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/di/Hilt.kt b/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/di/Hilt.kt index 7ac793c695..26dd3d4bb3 100644 --- a/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/di/Hilt.kt +++ b/feature/review/src/main/java/com/buzbuz/smartautoclicker/feature/review/di/Hilt.kt @@ -16,21 +16,20 @@ */ package com.buzbuz.smartautoclicker.feature.review.di -import com.buzbuz.smartautoclicker.feature.review.ReviewRepository -import com.buzbuz.smartautoclicker.feature.review.ReviewRepositoryImpl +import com.buzbuz.smartautoclicker.core.common.navigation.ReviewNavigator +import com.buzbuz.smartautoclicker.feature.review.navigation.ReviewNavigatorImpl +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object ReviewHiltModule { +internal abstract class ReviewNavigationModule { - @Provides + @Binds @Singleton - internal fun providesReviewRepository(reviewRepository: ReviewRepositoryImpl): ReviewRepository = - reviewRepository -} \ No newline at end of file + abstract fun bindReviewNavigator(impl: ReviewNavigatorImpl): ReviewNavigator +} diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt b/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt new file mode 100644 index 0000000000..4553879a8d --- /dev/null +++ b/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/navigation/ReviewNavigatorImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.review.navigation + +import android.content.Context +import android.content.Intent +import com.buzbuz.smartautoclicker.core.common.navigation.ReviewNavigator +import com.buzbuz.smartautoclicker.feature.review.ui.ReviewActivity + +import javax.inject.Inject + +internal class ReviewNavigatorImpl @Inject constructor() : ReviewNavigator { + + override fun startReviewActivity(context: Context) { + context.startActivity( + Intent(context, ReviewActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + ) + } +} diff --git a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ui/ReviewViewModel.kt b/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ui/ReviewViewModel.kt index a7d57d2e02..7cb9d27329 100644 --- a/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ui/ReviewViewModel.kt +++ b/feature/review/src/playStore/java/com/buzbuz/smartautoclicker/feature/review/ui/ReviewViewModel.kt @@ -17,8 +17,7 @@ package com.buzbuz.smartautoclicker.feature.review.ui import androidx.lifecycle.ViewModel -import com.buzbuz.smartautoclicker.feature.review.ReviewRepository -import com.buzbuz.smartautoclicker.feature.review.ReviewRepositoryImpl +import com.buzbuz.smartautoclicker.core.common.review.ReviewRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -31,6 +30,6 @@ class ReviewViewModel @Inject constructor(private val reviewRepository: ReviewRe reviewRepository.isUserCandidateForReview() fun updateLastReviewRequestTimestamp() { - (reviewRepository as ReviewRepositoryImpl).updateLastReviewRequestTimestamp() + reviewRepository.updateLastReviewRequestTimestamp() } } diff --git a/feature/review/src/test/java/com/buzbuz/smartautoclicker/feature/review/ExampleUnitTest.kt b/feature/review/src/test/java/com/buzbuz/smartautoclicker/feature/review/ExampleUnitTest.kt deleted file mode 100644 index bb66ee0568..0000000000 --- a/feature/review/src/test/java/com/buzbuz/smartautoclicker/feature/review/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.buzbuz.smartautoclicker.feature.review - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index aa44c08bb8..ad3f2716b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,6 +51,7 @@ include(":core:common:overlays") include(":core:common:permissions") include(":core:common:quality") include(":core:common:revenue") +include(":core:common:review") include(":core:common:settings") include(":core:common:tutorial") include(":core:common:ui") diff --git a/smartautoclicker/build.gradle.kts b/smartautoclicker/build.gradle.kts index 6a4f1473b3..05a2a3ce3b 100644 --- a/smartautoclicker/build.gradle.kts +++ b/smartautoclicker/build.gradle.kts @@ -161,6 +161,7 @@ dependencies { implementation(project(":core:common:base")) implementation(project(":core:common:bitmaps")) implementation(project(":core:common:revenue")) + implementation(project(":core:common:review")) implementation(project(":core:common:display")) implementation(project(":core:common:navigation")) implementation(project(":core:common:overlays")) diff --git a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt index a1fa03a920..12e22e5f9f 100644 --- a/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt +++ b/smartautoclicker/src/main/java/com/buzbuz/smartautoclicker/SmartAutoClickerService.kt @@ -34,7 +34,9 @@ import com.buzbuz.smartautoclicker.core.common.actions.AndroidActionExecutor import com.buzbuz.smartautoclicker.core.common.overlays.manager.OverlayManager import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityMetricsMonitor import com.buzbuz.smartautoclicker.core.common.quality.domain.QualityRepository +import com.buzbuz.smartautoclicker.core.common.navigation.getReviewNavigator import com.buzbuz.smartautoclicker.core.common.revenue.domain.IRevenueRepository +import com.buzbuz.smartautoclicker.core.common.review.ReviewRepository import com.buzbuz.smartautoclicker.core.common.tutorial.domain.TutorialRepository import com.buzbuz.smartautoclicker.core.display.config.DisplayConfigManager import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario @@ -45,7 +47,6 @@ 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.review.ReviewRepository import com.buzbuz.smartautoclicker.localservice.LocalService import dagger.hilt.android.AndroidEntryPoint @@ -158,11 +159,7 @@ class SmartAutoClickerService : AccessibilityService() { if (reviewRepository.isUserCandidateForReview()) { Log.i(TAG, "User is candidate for review") - - reviewRepository.getReviewActivityIntent(this)?.let { intent -> - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - startActivity(intent) - } + getReviewNavigator().startReviewActivity(this) } requestFilterKeyEvents(false) From 274b37690be9b55e783a5232bbc80a97d1613e89 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:29:21 +0200 Subject: [PATCH 04/56] Declare new Gesture Action in domain Refacto is huge, starting from here the whole app might not compile completely until the gesture dev is completed. Click is also changed to match the same API as the gesture Unit test of each module should compile --- .../core/domain/data/ScenarioUpdateState.kt | 8 +- .../core/domain/model/action/Action.kt | 2 +- .../core/domain/model/action/Click.kt | 55 +++--- .../core/domain/model/action/Gesture.kt | 71 ++++++++ .../core/domain/model/action/Swipe.kt | 49 ------ .../model/action/gesture/GestureFinger.kt | 160 ++++++++++++++++++ .../model/action/gesture/GesturePosition.kt | 67 ++++++++ .../model/action/mapper/ActionEntityMapper.kt | 2 + 8 files changed, 323 insertions(+), 91 deletions(-) create mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Gesture.kt delete mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Swipe.kt create mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFinger.kt create mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GesturePosition.kt diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioUpdateState.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioUpdateState.kt index 7ed0b2bb9f..20aa7527b9 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioUpdateState.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/data/ScenarioUpdateState.kt @@ -17,8 +17,6 @@ package com.buzbuz.smartautoclicker.core.domain.data import com.buzbuz.smartautoclicker.core.base.identifier.Identifier -import com.buzbuz.smartautoclicker.core.domain.model.action.Action -import com.buzbuz.smartautoclicker.core.domain.model.action.Click internal class ScenarioUpdateState { @@ -49,11 +47,7 @@ internal class ScenarioUpdateState { conditionsDomainToDbIdMap[domainId] = dbId } - fun getClickOnConditionDatabaseId(action: Action): Long? = - if (action is Click) action.clickOnConditionId?.let { getConditionDbId(it) } - else null - - private fun getConditionDbId(identifier: Identifier?): Long = when { + fun getConditionDbId(identifier: Identifier?): Long = when { identifier != null && identifier.tempId == null && identifier.databaseId != 0L -> identifier.databaseId identifier != null -> conditionsDomainToDbIdMap[identifier.tempId] ?: throw IllegalStateException("Identifier is not found in condition map for $identifier") 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 721b73c80b..1848a8f04b 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 @@ -45,10 +45,10 @@ sealed class Action : Identifiable, Completable, Prioritizable { ): Action = when (this) { is Click -> copy(id = id, eventId = eventId, name = name, priority = priority) + is Gesture -> copy(id = id, eventId = eventId, name = name, priority = priority) is ChangeCounter -> 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) is ToggleEvent -> copy(id = id, eventId = eventId, name = name, priority = priority) is Notification -> copy(id = id, eventId = eventId, name = name, priority = priority) is SystemAction -> 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/Click.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Click.kt index 2957775527..a3f6de21fc 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Click.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Click.kt @@ -18,7 +18,8 @@ package com.buzbuz.smartautoclicker.core.domain.model.action import android.graphics.Point import com.buzbuz.smartautoclicker.core.base.identifier.Identifier -import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition /** * Click action. @@ -27,11 +28,8 @@ import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType * @param eventId the identifier of the event for this action. * @param name the name of the action. * @param pressDuration the duration between the click down and up in milliseconds. - * @param positionType the type of click position. - * @param position the position of the click. Not null only if [positionType] is [PositionType.USER_SELECTED]. - * @param clickOnConditionId the condition to click on. Not null only if [positionType] is [PositionType.ON_DETECTED_CONDITION]. - * @param clickOffset the offset in px from the detected condition center to apply when clicking. Not null only if - * [positionType] is [PositionType.ON_DETECTED_CONDITION]. + * @param position the position to click on, null if the user haven't selected it yet. + * @param offset the offset in px from the [position] to apply when clicking. */ data class Click( override val id: Identifier, @@ -39,42 +37,31 @@ data class Click( override val name: String? = null, override var priority: Int, val pressDuration: Long? = null, - val positionType: PositionType, - val position: Point? = null, - val clickOnConditionId: Identifier? = null, - val clickOffset: Point? = null, + val position: GesturePosition? = null, + val offset: GesturePosition? = null, ) : Action() { - /** - * Types of click positions a [Click]. - * Keep the same names as the db ones. - */ - enum class PositionType { - /** The user must manually select a position to be clicked. */ - USER_SELECTED, - /** - * Click on the detected condition. - * When the condition operator is AND, click on the condition specified by the user. - * When the condition operator is OR, click on the condition detected condition. - */ - ON_DETECTED_CONDITION; - - fun toEntity(): ClickPositionType = ClickPositionType.valueOf(name) - } - override fun isComplete(): Boolean = - super.isComplete() && pressDuration != null && isPositionValid() + super.isComplete() && pressDuration != null && position != null override fun hashCodeNoIds(): Int = - name.hashCode() + pressDuration.hashCode() + positionType.hashCode() + position.hashCode() + - clickOnConditionId.hashCode() + clickOffset.hashCode() + name.hashCode() + pressDuration.hashCode() + position.hashCode() + offset.hashCode() override fun deepCopy(): Click = copy(name = "" + name) - private fun isPositionValid(): Boolean = - (positionType == PositionType.USER_SELECTED && position != null) || positionType == PositionType.ON_DETECTED_CONDITION + /** The click position, or null if it is not a pair of literal coordinates. */ + fun getLiteralPosition(): Point? { + val coordinates = position as? GesturePosition.Coordinates ?: return null + val literalX = coordinates.x as? GestureCoordinatesValue.Literal ?: return null + val literalY = coordinates.y as? GestureCoordinatesValue.Literal ?: return null + return Point(literalX.value, literalY.value) + } + + /** The identifier of the condition to click on, or null if the click is not on a condition. */ + fun getClickOnConditionId(): Identifier? = + (position as? GesturePosition.Condition)?.conditionId fun isClickOnConditionValid(): Boolean = - (positionType == PositionType.ON_DETECTED_CONDITION && clickOnConditionId != null) || positionType == PositionType.USER_SELECTED -} \ No newline at end of file + position !is GesturePosition.Condition || position.conditionId != null +} diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Gesture.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Gesture.kt new file mode 100644 index 0000000000..4fe8377a23 --- /dev/null +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Gesture.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. + * + * 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 android.accessibilityservice.GestureDescription +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.base.interfaces.areComplete +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFinger + +/** + * Gesture action. + * + * A gesture is a multitouch interaction described as a set of [fingers], each one following its own timeline of + * motions on the screen. All fingers share the same time origin: the gesture starts at 0ms and each finger touches + * the screen at its own [GestureFinger.startTimeMs], allowing fingers to be sequenced (double tap) or overlapped + * (pinch). + * + * @param id the unique identifier for the action. + * @param eventId the identifier of the event for this action. + * @param name the name of the action. + * @param priority the priority of the action in its event. + * @param fingers the fingers composing the gesture, between 1 and [GESTURE_MAXIMUM_FINGER_COUNT]. + */ +data class Gesture( + override val id: Identifier, + override val eventId: Identifier, + override val name: String? = null, + override var priority: Int, + val fingers: List, +): Action() { + + companion object { + /** The maximum number of fingers a gesture can contain. */ + const val GESTURE_MAXIMUM_FINGER_COUNT = 5 + /** The maximum total duration of a gesture in milliseconds, as allowed by the Android accessibility API. */ + val GESTURE_MAXIMUM_DURATION_MS = GestureDescription.getMaxGestureDuration() + } + + /** + * @return true if the action has a name, contains a valid number of fingers and all of its fingers are complete. + */ + override fun isComplete(): Boolean { + if (!super.isComplete()) return false + if (fingers.isEmpty() || fingers.size > GESTURE_MAXIMUM_FINGER_COUNT) return false + + return fingers.areComplete() + } + + override fun hashCodeNoIds(): Int = + name.hashCode() + priority.hashCode() + fingers.hashCode() + + override fun deepCopy(): Gesture = copy(name = "" + name) + + /** @return the total duration of the gesture in milliseconds, from its start to the release of the last finger. */ + fun getDurationMs(): Long = + fingers.maxOfOrNull { finger -> finger.getDurationMs() } ?: 0L +} \ No newline at end of file diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Swipe.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Swipe.kt deleted file mode 100644 index 698071af41..0000000000 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/Swipe.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (C) 2024 Kevin Buzeau - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.buzbuz.smartautoclicker.core.domain.model.action - -import android.graphics.Point -import com.buzbuz.smartautoclicker.core.base.identifier.Identifier - -/** - * Swipe action. - * - * @param id the unique identifier for the action. - * @param eventId the identifier of the event for this action. - * @param name the name of the action. - * @param swipeDuration the duration between the swipe start and end in milliseconds. - * @param from the x position of the swipe start. - * @param to the x position of the swipe end. - */ -data class Swipe( - override val id: Identifier, - override val eventId: Identifier, - override val name: String? = null, - override var priority: Int, - val swipeDuration: Long? = null, - val from: Point? = null, - val to: Point? = null, -) : Action() { - - override fun isComplete(): Boolean = - super.isComplete() && swipeDuration != null && from != null&& to != null - - override fun hashCodeNoIds(): Int = - name.hashCode() + swipeDuration.hashCode() + from.hashCode() + to.hashCode() - - override fun deepCopy(): Swipe = copy(name = "" + name) -} \ No newline at end of file diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFinger.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFinger.kt new file mode 100644 index 0000000000..3e5ef0e1d0 --- /dev/null +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFinger.kt @@ -0,0 +1,160 @@ +/* + * 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.gesture + +import android.os.Build +import androidx.annotation.RequiresApi +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.base.interfaces.Completable +import com.buzbuz.smartautoclicker.core.base.interfaces.Identifiable +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Move +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Press +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Release +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Wait + +/** + * A single finger of a [Gesture]. + * + * A finger touches the screen once: its [motions] must start with a [Press] and end with a [Release], with any + * number of [Move] and [Wait] in between. Pressing or releasing again in the middle of the sequence is not supported, + * use another finger with a later [startTimeMs] instead. + * + * @param id the unique identifier for the finger. + * @param startTimeMs the delay in milliseconds between the start of the gesture and the [Press] of this finger. + * @param motions the ordered motions of this finger, from its press to its release. + */ +data class GestureFinger( + override val id: Identifier, + val startTimeMs: Long, + val motions: List, +): Identifiable, Completable { + + /** + * @return true if the motions start with a [Press], end with a [Release], are all complete and the finger ends + * before [Gesture.GESTURE_MAXIMUM_DURATION_MS]. + */ + override fun isComplete(): Boolean { + // All motions should start with press and end with release + if (motions.size < 2) return false + if (motions.first() !is Press) return false + if (motions.last() !is Release) return false + + if (getDurationMs() >= Gesture.GESTURE_MAXIMUM_DURATION_MS) return false + motions.forEach { motion -> if (!motion.isComplete()) return false } + + return true + } + + /** + * @return the time in milliseconds, relative to the start of the gesture, at which this finger is released. A + * finger with nothing but a press and a release is a tap and counts for 1ms. + */ + fun getDurationMs() : Long = + startTimeMs + ( + if (motions.size == 2 && motions.first() is Press && motions.last() is Release) 1L + else motions.sumOf { motion -> + when (motion) { + is Move -> motion.durationMs + is Wait -> motion.durationMs + is Press, + is Release -> 0L + } + } + ) +} + +/** + * A step in the timeline of a [GestureFinger]. + * + * Motions are executed one after the other, each one starting where the previous one ended. Only [Move] and [Wait] + * take time; [Press] and [Release] are instantaneous. + */ +sealed interface GestureFingerMotion : Identifiable, Completable { + + /** + * The finger touches the screen. Must be the first motion of a finger. + * + * @param id the unique identifier for the motion. + * @param position the position where the finger lands on the screen. + * @param offset the offset in px applied to [position]. + */ + data class Press( + override val id: Identifier, + override val position: GesturePosition, + override val offset: GesturePosition, + ): GestureFingerMotion, Positionable { + + override fun isComplete(): Boolean = true + } + + /** + * The finger slides from its current position to a new one. + * + * @param id the unique identifier for the motion. + * @param position the position where the finger stops moving. + * @param offset the offset in px applied to [position]. + * @param durationMs the time in milliseconds the finger takes to reach [position]. + */ + data class Move( + override val id: Identifier, + override val position: GesturePosition, + override val offset: GesturePosition, + val durationMs: Long, + ): GestureFingerMotion, Positionable { + + override fun isComplete(): Boolean = durationMs in 1 until Gesture.GESTURE_MAXIMUM_DURATION_MS + } + + /** + * The finger stays still on the screen at its current position. + * + * Holding a finger without moving it requires stroke continuation from the accessibility API, which is only + * available from Android 8; on older versions, this motion is skipped at execution. + * + * @param id the unique identifier for the motion. + * @param durationMs the time in milliseconds the finger stays still. + */ + @RequiresApi(Build.VERSION_CODES.O) + data class Wait( + override val id: Identifier, + val durationMs: Long, + ): GestureFingerMotion { + + override fun isComplete(): Boolean = durationMs in 1 until Gesture.GESTURE_MAXIMUM_DURATION_MS + } + + /** + * The finger leaves the screen at its current position. Must be the last motion of a finger. + * + * @param id the unique identifier for the motion. + */ + data class Release( + override val id: Identifier, + ): GestureFingerMotion { + + override fun isComplete(): Boolean = true + } + + /** A motion that brings the finger to a position on the screen. */ + interface Positionable { + /** The position of the finger at the end of the motion. */ + val position: GesturePosition + /** The offset in px added to [position] once it is resolved. */ + val offset: GesturePosition + } +} \ No newline at end of file diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GesturePosition.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GesturePosition.kt new file mode 100644 index 0000000000..0feca14d70 --- /dev/null +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GesturePosition.kt @@ -0,0 +1,67 @@ +/* + * 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.gesture + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier + +/** + * A position on the screen for a gesture or a click. + * + * The actual screen point is only known at execution time: it is either taken from a detection result or computed + * from coordinates that can themselves depend on the scenario counters. + */ +sealed interface GesturePosition { + + /** + * The position where a screen condition of the event has been detected. + * + * @param conditionId the identifier of the condition to use. Only relevant when the event conditions operator is + * AND; with OR, the first detected condition is used instead. Null if the user haven't selected it yet. + */ + data class Condition( + val conditionId: Identifier?, + ) : GesturePosition + + /** + * A position defined by its coordinates. + * + * @param x the horizontal coordinate in px. + * @param y the vertical coordinate in px. + */ + data class Coordinates( + val x: GestureCoordinatesValue, + val y: GestureCoordinatesValue, + ) : GesturePosition +} + +/** A single coordinate of a [GesturePosition.Coordinates]. */ +sealed interface GestureCoordinatesValue { + + /** + * A fixed coordinate. + * + * @param value the coordinate in px. + */ + data class Literal(val value: Int) : GestureCoordinatesValue + + /** + * A coordinate read from a scenario counter at execution time. + * + * @param counterName the name of the counter holding the coordinate in px. + */ + data class Counter(val counterName: String) : GestureCoordinatesValue +} \ No newline at end of file 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 feee13f421..4460bb60d8 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.Gesture 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 @@ -45,6 +46,7 @@ internal fun Action.toEntity(): ActionEntity { is Notification -> toNotificationEntity() is SystemAction -> toSystemActionEntity() is SetText -> toSetTextEntity() + is Gesture -> TODO() } } From d56220257b790ae918ea54529a14d76a9b3aa863 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:29:30 +0200 Subject: [PATCH 05/56] Add Gesture build/execution --- .../common/actions/AndroidActionExecutor.kt | 14 + .../actions/AndroidActionExecutorImpl.kt | 9 + .../common/actions/gesture/GestureExecutor.kt | 84 +++-- .../common/actions/gesture/GestureHelpers.kt | 66 ++++ .../actions/gesture/GestureExecutorTests.kt | 49 +++ .../data/processor/ActionExecutor.kt | 77 +++-- .../gesture/AndroidGestureBuilder.kt | 311 +++++++++++++++++ .../gesture/AndroidGestureBuilderCompat24.kt | 67 ++++ .../gesture/AndroidGestureBuilderCurrent.kt | 171 +++++++++ .../processing/tests/ActionExecutorTests.kt | 38 +- .../tests/ScenarioProcessorTests.kt | 8 +- .../AndroidGestureBuilderCompat24Tests.kt | 143 ++++++++ .../AndroidGestureBuilderCurrentTests.kt | 270 +++++++++++++++ .../gesture/AndroidGestureBuilderTests.kt | 324 ++++++++++++++++++ .../gesture/GestureBuilderTestData.kt | 204 +++++++++++ 15 files changed, 1745 insertions(+), 90 deletions(-) create mode 100644 core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilder.kt create mode 100644 core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCompat24.kt create mode 100644 core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCurrent.kt create mode 100644 core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCompat24Tests.kt create mode 100644 core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCurrentTests.kt create mode 100644 core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderTests.kt create mode 100644 core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/GestureBuilderTestData.kt 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 39ae1dc9ab..d61d9b3325 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 @@ -61,6 +61,20 @@ interface AndroidActionExecutor: Dumpable { */ suspend fun dispatchGesture(gestureDescription: GestureDescription) + /** + * Dispatch a chain of gestures, each one continuing the pointers left down by the previous one. + * + * A finger crossing several motions can't be described by a single GestureDescription: the system only honours a + * continued stroke when it is found in the first step of a separately dispatched gesture. Such a chain must be + * dispatched in one go, without waiting in between, otherwise the fingers would freeze on the screen between two + * of its gestures. + * + * Method will return only once the whole chain is dispatched. + * + * @param gestureDescriptions the gestures to dispatch, in order. + */ + suspend fun dispatchGestureChain(gestureDescriptions: List) + /** * Performs a global action. * 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 ba2b679300..7060305350 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 @@ -80,6 +80,15 @@ internal class AndroidActionExecutorImpl @Inject constructor( } } + override suspend fun dispatchGestureChain(gestureDescriptions: List) { + val service = accessibilityService ?: return + + if (!gestureExecutor.dispatchGestures(service, gestureDescriptions)) { + Log.w(TAG, "System did not execute the gesture properly, delaying processing to avoid spamming slow system") + delay(500) + } + } + override fun performGlobalAction(globalAction: Int) { val service = accessibilityService ?: return diff --git a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutor.kt b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutor.kt index be96ab983a..b61cf0a53c 100644 --- a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutor.kt +++ b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutor.kt @@ -24,13 +24,11 @@ import android.util.Log import com.buzbuz.smartautoclicker.core.base.Dumpable import com.buzbuz.smartautoclicker.core.base.addDumpTabulationLvl -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull import java.io.PrintWriter import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume import kotlin.time.Duration.Companion.milliseconds @@ -48,34 +46,55 @@ internal class GestureExecutor @Inject constructor() : Dumpable { errorGestures = 0L } - suspend fun dispatchGesture(service: AccessibilityService, gesture: GestureDescription): Boolean { - val result = withTimeoutOrNull(gesture.timeoutDurationMs().milliseconds) { - suspendCancellableCoroutine { continuation -> - try { - service.dispatchGesture( - /* gesture = */ gesture, - /* callback = */ object : GestureResultCallback() { - override fun onCompleted(g: GestureDescription?) = continuation.safeResume(true) - override fun onCancelled(g: GestureDescription?) = continuation.safeResume(false) - }, - /* handler = */ null, - ) - } catch (rEx: RuntimeException) { - Log.w(TAG, "System is not responsive, the user might be spamming gesture too quickly", rEx) - continuation.safeResume(false) - } + suspend fun dispatchGesture(service: AccessibilityService, gesture: GestureDescription): Boolean = + dispatchGestures(service, listOf(gesture)) + + /** + * Dispatch a chain of gestures, each one continuing the pointers left down by the previous one. + * + * They are all dispatched without waiting for their individual result: the system appends a gesture continuing + * the pointers currently down to what it has already scheduled, which keeps the strokes of a finger seamless. + * Waiting for each result instead would freeze the fingers on the screen between two of them. + */ + suspend fun dispatchGestures(service: AccessibilityService, gestures: List): Boolean { + if (gestures.isEmpty()) return true + + val results = List(gestures.size) { CompletableDeferred() } + var dispatchFailed = false + + gestures.forEachIndexed { index, gesture -> + if (dispatchFailed) { + // The chain is broken, the remaining gestures would continue pointers that are no longer down. + results[index].complete(false) + return@forEachIndexed } + + try { + service.dispatchGesture( + /* gesture = */ gesture, + /* callback = */ results[index].toGestureResultCallback(), + /* handler = */ null, + ) + } catch (rEx: RuntimeException) { + Log.w(TAG, "System is not responsive, the user might be spamming gesture too quickly", rEx) + dispatchFailed = true + results[index].complete(false) + } + } + + val allCompleted = withTimeoutOrNull(gestures.timeoutDurationMs().milliseconds) { + results.map { result -> result.await() }.all { completed -> completed } } - if (result == null) { + if (allCompleted == null) { Log.w(TAG, "Gesture error, timeout or system error occurred.") errorGestures++ return false } - if (!result) { + if (!allCompleted) { Log.w(TAG, "Gesture has been cancelled.") - cancelledGestures ++ + cancelledGestures++ return false } @@ -95,12 +114,15 @@ internal class GestureExecutor @Inject constructor() : Dumpable { } } -private fun Continuation.safeResume(value: T): Unit = - try { - resume(value) - } catch (isEx: IllegalStateException) { - Log.w(TAG, "Continuation have already been resumed. Did the same event got two results ?", isEx) - Unit +private fun CompletableDeferred.toGestureResultCallback(): GestureResultCallback = + object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + complete(true) + } + + override fun onCancelled(gestureDescription: GestureDescription?) { + complete(false) + } } private fun GestureDescription.durationMs(): Long { @@ -113,12 +135,12 @@ private fun GestureDescription.durationMs(): Long { return maxEndTime } -private fun GestureDescription.timeoutDurationMs(): Long = - (durationMs() * GESTURE_CALLBACK_TIMEOUT_DURATION_RATIO_MS) +private fun List.timeoutDurationMs(): Long = + (sumOf { gesture -> gesture.durationMs() } * GESTURE_CALLBACK_TIMEOUT_DURATION_RATIO_MS) .coerceIn(GESTURE_CALLBACK_TIMEOUT_MIN_MS, GESTURE_CALLBACK_TIMEOUT_MAX_MS) private const val GESTURE_CALLBACK_TIMEOUT_MIN_MS = 100L private const val GESTURE_CALLBACK_TIMEOUT_MAX_MS = 65_000L private const val GESTURE_CALLBACK_TIMEOUT_DURATION_RATIO_MS = 2 -private const val TAG = "GestureExecutor" \ No newline at end of file +private const val TAG = "GestureExecutor" diff --git a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureHelpers.kt b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureHelpers.kt index d58ac0612c..3af5763248 100644 --- a/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureHelpers.kt +++ b/core/common/actions/src/main/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureHelpers.kt @@ -19,6 +19,8 @@ package com.buzbuz.smartautoclicker.core.common.actions.gesture import android.accessibilityservice.GestureDescription import android.graphics.Path import android.graphics.Point +import android.os.Build +import androidx.annotation.RequiresApi import com.buzbuz.smartautoclicker.core.base.extensions.nextIntInOffset import com.buzbuz.smartautoclicker.core.base.extensions.nextLongInOffset import com.buzbuz.smartautoclicker.core.base.extensions.safeLineTo @@ -55,6 +57,40 @@ private fun Path.lineTo(position: Point, random: Random?) { ) } +/** + * Randomization must be applied once, when a position is resolved, and never again when the paths are built: a + * continued stroke is rejected by the system unless its first point is exactly the last point of the stroke it + * continues. Clamping is applied here for the same reason, [safeMoveTo] would otherwise silently move the point. + */ +fun Point.randomizedPosition(random: Random?): Point = + if (random == null) Point(max(0, x), max(0, y)) + else Point( + max(0, random.nextIntInOffset(x, RANDOMIZATION_POSITION_MAX_OFFSET_PX)), + max(0, random.nextIntInOffset(y, RANDOMIZATION_POSITION_MAX_OFFSET_PX)), + ) + +/** Same as [randomizedPosition], durations are randomized once when the gesture timeline is resolved. */ +fun Long.randomizedDurationMs(random: Random?): Long = + random?.nextLongInOffset(this, RANDOMIZATION_DURATION_MAX_OFFSET_MS) ?: this + +fun Path.moveTo(position: Point): Unit = + safeMoveTo(position.x, position.y) + +fun Path.lineTo(position: Point): Unit = + safeLineTo(position.x, position.y) + +fun Path.line(from: Point, to: Point) { + moveTo(from) + lineTo(to) +} + +/** + * A zero length path. The framework handles it as a touch that doesn't move: the pointer stays down at [position] + * for the whole stroke duration, without emitting a single move event. + */ +fun Path.hold(position: Point): Unit = + moveTo(position) + fun GestureDescription.Builder.buildSingleStroke( path: Path, durationMs: Long, @@ -83,6 +119,36 @@ fun GestureDescription.Builder.buildSingleStroke( return build() } +fun buildStroke( + path: Path, + startTimeMs: Long, + durationMs: Long, + willContinue: Boolean, +): GestureDescription.StrokeDescription { + val normalizedStart = startTimeMs.toNormalizedStrokeStartTime() + val normalizedDuration = durationMs.toNormalizedStrokeDurationMs() + + return if (willContinue && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + GestureDescription.StrokeDescription(path, normalizedStart, normalizedDuration, true) + } else { + GestureDescription.StrokeDescription(path, normalizedStart, normalizedDuration) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun GestureDescription.StrokeDescription.continueWith( + path: Path, + startTimeMs: Long, + durationMs: Long, + willContinue: Boolean, +): GestureDescription.StrokeDescription = + continueStroke( + path, + startTimeMs.toNormalizedStrokeStartTime(), + durationMs.toNormalizedStrokeDurationMs(), + willContinue, + ) + private fun Long.toNormalizedStrokeStartTime(): Long = max(0, this) diff --git a/core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt b/core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt index 9d35489aea..9273971f77 100644 --- a/core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt +++ b/core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt @@ -111,6 +111,55 @@ class GestureExecutorTests { assertTrue(nextResult.await()) } + @Test + fun dispatchGestures_dispatchesWholeChainBeforeAwaitingResults() = runTest { + val service = mock(AccessibilityService::class.java) + val executor = GestureExecutor() + val callbackCaptor = ArgumentCaptor.forClass(GestureResultCallback::class.java) + + val result = async { executor.dispatchGestures(service, listOf(gesture(), gesture(), gesture())) } + runCurrent() + + // The whole chain must be handed to the system in one go: waiting for a result in between would freeze the + // fingers on the screen until the next gesture is scheduled. + verify(service, times(3)).dispatchGesture(any(), callbackCaptor.capture(), any()) + assertFalse(result.isCompleted) + + callbackCaptor.allValues.forEach { callback -> callback.onCompleted(null) } + + assertTrue(result.await()) + } + + @Test + fun dispatchGestures_oneCancelledCallback_returnsFalse() = runTest { + val service = mock(AccessibilityService::class.java) + val executor = GestureExecutor() + val callbackCaptor = ArgumentCaptor.forClass(GestureResultCallback::class.java) + + val result = async { executor.dispatchGestures(service, listOf(gesture(), gesture())) } + runCurrent() + verify(service, times(2)).dispatchGesture(any(), callbackCaptor.capture(), any()) + + callbackCaptor.allValues.first().onCompleted(null) + callbackCaptor.allValues.last().onCancelled(null) + + assertFalse(result.await()) + } + + @Test + fun dispatchGestures_dispatchException_skipsRemainingGestures() = runTest { + val service = mock(AccessibilityService::class.java) + doThrow(IllegalStateException("Accessibility service is unavailable")) + .`when`(service) + .dispatchGesture(any(), any(), any()) + + val result = GestureExecutor().dispatchGestures(service, listOf(gesture(), gesture(), gesture())) + + // Once the chain is broken, the remaining gestures would continue pointers that are no longer down. + assertFalse(result) + verify(service, times(1)).dispatchGesture(any(), any(), any()) + } + private fun gesture(): GestureDescription = GestureDescription.Builder() .addStroke( GestureDescription.StrokeDescription( 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 16a07cefb1..b6eade300e 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 @@ -27,7 +27,6 @@ import com.buzbuz.smartautoclicker.core.base.workarounds.UnblockGestureScheduler import com.buzbuz.smartautoclicker.core.base.workarounds.buildUnblockGesture import com.buzbuz.smartautoclicker.core.common.actions.AndroidActionExecutor import com.buzbuz.smartautoclicker.core.common.actions.gesture.buildSingleStroke -import com.buzbuz.smartautoclicker.core.common.actions.gesture.line import com.buzbuz.smartautoclicker.core.common.actions.gesture.moveTo import com.buzbuz.smartautoclicker.core.common.actions.model.ActionNotificationRequest import com.buzbuz.smartautoclicker.core.common.actions.text.findCounterReferences @@ -38,21 +37,25 @@ import com.buzbuz.smartautoclicker.core.domain.model.OR import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.Click 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.Gesture 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 +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition import com.buzbuz.smartautoclicker.core.domain.model.action.intent.putDomainExtra import com.buzbuz.smartautoclicker.core.domain.model.event.Event import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilder import com.buzbuz.smartautoclicker.core.processing.data.processor.state.ProcessingState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds /** * Execute the actions of an event. @@ -93,7 +96,7 @@ internal class ActionExecutor( event.actions.forEach { action -> when (action) { is Click -> executeClick(event, action, results) - is Swipe -> executeSwipe(action) + is Gesture -> executeGesture(event, action, results) is Pause -> executePause(action) is Intent -> executeIntent(action) is ToggleEvent -> executeToggleEvent(action) @@ -106,15 +109,16 @@ internal class ActionExecutor( } private suspend fun executeClick(event: Event, click: Click, results: ConditionsResults?) { - val clickPath = when (click.positionType) { - Click.PositionType.USER_SELECTED -> { - click.position?.let { position -> - Path().apply { moveTo(position, random) } + val clickPath = when (val position = click.position) { + is GesturePosition.Coordinates -> + getUserSelectedClickPosition(position)?.let { point -> + Path().apply { moveTo(point, random) } } - } - Click.PositionType.ON_DETECTED_CONDITION -> - getOnConditionClickPath(event, click, results) + is GesturePosition.Condition -> + getOnConditionClickPath(event, position, click.offset, results) + + null -> null } ?: return val clickGesture = GestureDescription.Builder().buildSingleStroke( @@ -128,12 +132,32 @@ internal class ActionExecutor( } } - private fun getOnConditionClickPath(event: Event, click: Click, results: ConditionsResults?): Path? { + private fun getUserSelectedClickPosition(position: GesturePosition.Coordinates): Point? { + val x = resolveCoordinateValue(position.x) ?: return null + val y = resolveCoordinateValue(position.y) ?: return null + return Point(x, y) + } + + private fun resolveCoordinateValue(coordinatesValue: GestureCoordinatesValue): Int? = + when (coordinatesValue) { + is GestureCoordinatesValue.Literal -> + coordinatesValue.value + is GestureCoordinatesValue.Counter -> + processingState.getCounterValue(coordinatesValue.counterName)?.toInt() + } + + private fun getOnConditionClickPath( + event: Event, + position: GesturePosition.Condition, + clickOffset: GesturePosition?, + results: ConditionsResults?, + ): Path? { if (event !is ScreenEvent) return null + val conditionId = position.conditionId val result = when { event.conditionOperator == OR -> results?.getFirstScreenConditionDetectedResult() - click.clickOnConditionId != null -> results?.getScreenConditionResult(click.clickOnConditionId!!.databaseId) + conditionId != null -> results?.getScreenConditionResult(conditionId.databaseId) else -> null } @@ -142,32 +166,25 @@ internal class ActionExecutor( return null } + val offset = clickOffset as? GesturePosition.Coordinates return Path().apply { moveTo( position = Point( - (result.position?.x ?: 0) + (click.clickOffset?.x ?: 0), - (result.position?.y ?: 0) + (click.clickOffset?.y ?: 0), + (result.position?.x ?: 0) + (offset?.x?.let(::resolveCoordinateValue) ?: 0), + (result.position?.y ?: 0) + (offset?.y?.let(::resolveCoordinateValue) ?: 0), ), random = random, ) } } - /** - * Execute the provided swipe. - * @param swipe the swipe to be executed. - */ - private suspend fun executeSwipe(swipe: Swipe) { - val swipeGesture = GestureDescription.Builder().buildSingleStroke( - path = - if (swipe.from == null || swipe.to == null) return - else Path().apply { line(swipe.from, swipe.to, random) }, - durationMs = swipe.swipeDuration!!, - random = random, - ) + private suspend fun executeGesture(event: Event, gesture: Gesture, results: ConditionsResults?) { + val gestureChains = AndroidGestureBuilder + .createNewBuilder(processingState, random) + .build(gesture, event, results) ?: return withContext(Dispatchers.Main) { - androidExecutor.dispatchGesture(swipeGesture) + gestureChains.forEach { chain -> androidExecutor.dispatchGestureChain(chain) } } } @@ -176,7 +193,7 @@ internal class ActionExecutor( * @param pause the pause to be executed. */ private suspend fun executePause(pause: Pause) { - delay(pause.pauseDuration!!.getPauseDurationMs(random)) + delay(pause.pauseDuration!!.getPauseDurationMs(random).milliseconds) } /** @@ -199,12 +216,12 @@ internal class ActionExecutor( withContext(Dispatchers.Main) { androidExecutor.sendBroadcast(androidIntent) } - delay(INTENT_BROADCAST_DELAY) + delay(INTENT_BROADCAST_DELAY.milliseconds) } else { withContext(Dispatchers.Main) { androidExecutor.startActivity(androidIntent) } - delay(INTENT_START_ACTIVITY_DELAY) + delay(INTENT_START_ACTIVITY_DELAY.milliseconds) } } diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilder.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilder.kt new file mode 100644 index 0000000000..cbf02a98fb --- /dev/null +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilder.kt @@ -0,0 +1,311 @@ +/* + * 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.processing.data.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.graphics.Point +import android.os.Build +import android.util.Log + +import com.buzbuz.smartautoclicker.core.common.actions.gesture.randomizedDurationMs +import com.buzbuz.smartautoclicker.core.common.actions.gesture.randomizedPosition +import com.buzbuz.smartautoclicker.core.domain.model.AND +import com.buzbuz.smartautoclicker.core.domain.model.OR +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFinger +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition +import com.buzbuz.smartautoclicker.core.domain.model.event.Event +import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent +import com.buzbuz.smartautoclicker.core.processing.data.processor.ConditionsResults +import com.buzbuz.smartautoclicker.core.processing.data.processor.state.ProcessingState +import kotlin.math.roundToInt + +import kotlin.random.Random + + +internal abstract class AndroidGestureBuilder( + protected val processingState: ProcessingState, + protected val random: Random?, +) { + + companion object { + protected const val TAG = "AndroidGestureBuilder" + + fun createNewBuilder(processingState: ProcessingState, random: Random?): AndroidGestureBuilder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) AndroidGestureBuilderCurrent(processingState, random) + else AndroidGestureBuilderCompat24(processingState, random) + } + + private var gestureEvent: Event? = null + private var conditionsResults: ConditionsResults? = null + + /** + * Build the gesture as the chains of [GestureDescription] to dispatch, in order, or null if it can't be executed. + * + * The gestures of a same chain can be dispatched in one go, the system appends them to what it has already + * scheduled. Dispatching the first gesture of a chain cancels whatever is still pending: the previous chain must + * have been fully played before. + */ + fun build(gesture: Gesture, event: Event, results: ConditionsResults?): List>? { + gestureEvent = event + conditionsResults = results + + return try { + val fingers = gesture.fingers.mapIndexedNotNull { index, finger -> resolveFinger(index, finger) } + if (fingers.isEmpty()) { + Log.i(TAG, "No usable finger in gesture ${gesture.name}, skipping action") + return null + } + + buildSlices(fingers)?.toGestureChains()?.ifEmpty { null } + } catch (ex: IllegalArgumentException) { + Log.w(TAG, "Gesture ${gesture.name} has been refused by Android, skipping action", ex) + null + } catch (ex: IllegalStateException) { + Log.w(TAG, "Gesture ${gesture.name} has been refused by Android, skipping action", ex) + null + } finally { + gestureEvent = null + conditionsResults = null + } + } + + /** + * Whether this builder can execute a [GestureFingerMotion.Wait]. Holding a finger still on the screen requires + * stroke continuation, so waits are skipped on the Android versions without it. + */ + protected abstract val supportsWaitMotions: Boolean + + /** Split the resolved fingers into the slices to dispatch, in order. */ + protected abstract fun buildSlices(fingers: List): List? + + /** + * Place all the motions of a finger on the gesture timeline, resolving and randomizing every position and + * duration once and for all. + */ + private fun resolveFinger(index: Int, finger: GestureFinger): ResolvedFinger? { + if (!finger.isComplete()) { + Log.i(TAG, "Can't resolve finger $index, finger is incomplete") + return null + } + + var currentPosition = (finger.motions.first() as? GestureFingerMotion.Press) + ?.let(::resolveMotionPosition) + ?: return null + + var currentTimeMs = finger.startTimeMs.coerceAtLeast(0L) + val segments = mutableListOf() + + finger.motions.forEachIndexed { motionIndex, motion -> + when (motion) { + is GestureFingerMotion.Press, is GestureFingerMotion.Release -> { + // A finger touches the screen once: lifting and pressing it again starts a whole new touch + // sequence, which can't be described as the continuation of the previous one. + if (motionIndex != 0 && motionIndex != finger.motions.lastIndex) { + Log.i(TAG, "Can't resolve finger $index, it presses or releases more than once") + return null + } + } + + is GestureFingerMotion.Move -> { + val toPosition = resolveMotionPosition(motion) ?: return null + val durationMs = motion.durationMs.toResolvedDurationMs() + + segments.add( + ResolvedSegment(currentTimeMs, currentTimeMs + durationMs, currentPosition, toPosition) + ) + currentTimeMs += durationMs + currentPosition = toPosition + } + + is GestureFingerMotion.Wait -> { + if (!supportsWaitMotions) { + Log.i(TAG, "Skipping wait motion of finger $index, not supported on this Android version") + return@forEachIndexed + } + + val durationMs = motion.durationMs.toResolvedDurationMs() + + segments.add( + ResolvedSegment(currentTimeMs, currentTimeMs + durationMs, currentPosition, currentPosition) + ) + currentTimeMs += durationMs + } + } + } + + // Nothing but a press and a release: a simple tap, held long enough to be detected as such. + if (segments.isEmpty()) { + segments.add( + ResolvedSegment(currentTimeMs, currentTimeMs + TAP_DURATION_MS, currentPosition, currentPosition) + ) + } + + return ResolvedFinger(index, segments) + } + + private fun resolveMotionPosition(motion: GestureFingerMotion.Positionable): Point? { + val base = resolveGesturePosition(motion.position) + if (base == null) { + Log.d(TAG, "Cannot resolve position for $motion") + return null + } + val off = resolveGesturePosition(motion.offset) ?: Point(0, 0) + + return Point(base.x + off.x, base.y + off.y).randomizedPosition(random) + } + + private fun resolveGesturePosition(position: GesturePosition): Point? = + when (position) { + is GesturePosition.Coordinates -> { + val x = resolveGestureCoordinateValue(position.x) + val y = resolveGestureCoordinateValue(position.y) + + if (x != null && y != null) Point(x, y) + else { + Log.d(TAG, "Cannot resolve position for $position, coordinates are null") + null + } + } + + is GesturePosition.Condition -> { + val event = gestureEvent + if (event !is ScreenEvent) { + Log.d(TAG, "Cannot resolve position for $position, condition must be a screen event") + return null + } + + val condId = position.conditionId + val result = when { + event.conditionOperator == AND && condId != null -> { + val condResult = conditionsResults?.getScreenConditionResult(condId.databaseId) + if (condResult == null) { + Log.d(TAG, "Cannot resolve position for $position, condition has not been processed") + null + } else condResult + } + + event.conditionOperator == OR -> + conditionsResults?.getFirstScreenConditionDetectedResult() + + else -> { + Log.d(TAG, "Cannot find a condition to resolve, config is invalid") + null + } + } + + result?.position?.let { Point(it.x, it.y) } + } + } + + private fun resolveGestureCoordinateValue(coordinatesValue: GestureCoordinatesValue): Int? = + when (coordinatesValue) { + is GestureCoordinatesValue.Literal -> + coordinatesValue.value + is GestureCoordinatesValue.Counter -> + processingState.getCounterValue(coordinatesValue.counterName)?.toInt() + } + + + /** + * Group the slices into the chains of gestures to dispatch. + * + * Slices of a same chain can be dispatched in one go, the system appends them to what it has already scheduled. The + * first slice of a chain starts a new touch sequence, and dispatching it cancels whatever is still pending: the + * previous chain must have been fully played before. + */ + private fun List.toGestureChains(): List> { + val chains = mutableListOf>() + + forEach { slice -> + if (slice.continuesPreviousSlice && chains.isNotEmpty()) chains.last().add(slice.description) + else chains.add(mutableListOf(slice.description)) + } + + return chains + } + + private fun Long.toResolvedDurationMs(): Long = + randomizedDurationMs(random).coerceAtLeast(MINIMUM_MOTION_DURATION_MS) + + + /** + * A [Gesture][com.buzbuz.smartautoclicker.core.domain.model.action.Gesture] is executed as an ordered list of slices. + * + * The system only honours a continued stroke when it is found in the first step of a **separately dispatched** + * gesture, so a finger crossing several motions can't be described by one [GestureDescription]. Each slice is one + * dispatch, and holds at most one stroke per finger touching the screen during that time window. + * + * @param description the gesture to dispatch for this time window. + * @param continuesPreviousSlice true when at least one finger of this slice keeps a pointer of the previous slice + * down. Such a slice can be dispatched right away, the system appends it to what is already scheduled. A slice with + * false starts a new touch sequence: dispatching it cancels whatever is still pending, so the previous slice + * must have been fully played before. + */ + protected data class AndroidGestureSlice( + val description: GestureDescription, + val continuesPreviousSlice: Boolean, + ) + + /** + * A finger with all its positions and durations resolved into the gesture timeline. + * Randomization is already applied, those values are final. + */ + protected data class ResolvedFinger( + val index: Int, + val segments: List, + ) { + /** Time at which the finger touches the screen, relative to the gesture start. */ + val downMs: Long get() = segments.first().startMs + /** Time at which the finger leaves the screen, relative to the gesture start. */ + val upMs: Long get() = segments.last().endMs + + /** The segment being played at [timeMs], or null if the finger isn't touching the screen at that time. */ + fun segmentAt(timeMs: Long): ResolvedSegment? = + segments.firstOrNull { segment -> timeMs >= segment.startMs && timeMs < segment.endMs } + } + + /** A single [Move][com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Move] or + * [Wait][com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion.Wait] of a finger, + * placed on the gesture timeline. A wait has the same [from] and [to]. */ + protected data class ResolvedSegment( + val startMs: Long, + val endMs: Long, + val from: Point, + val to: Point, + ) { + + /** Position of the finger at [timeMs], the motion being played at a constant speed. */ + fun positionAt(timeMs: Long): Point { + if (from == to || timeMs >= endMs) return to + if (timeMs <= startMs) return from + + val progress = (timeMs - startMs).toDouble() / (endMs - startMs) + return Point( + from.x + ((to.x - from.x) * progress).roundToInt(), + from.y + ((to.y - from.y) * progress).roundToInt(), + ) + } + } +} + +/** A motion can't be shorter than that, a stroke duration must be strictly positive. */ +private const val MINIMUM_MOTION_DURATION_MS = 1L +/** Duration of a finger pressing and releasing without moving. Matches the platform tap timeout. */ +private const val TAP_DURATION_MS = 100L diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCompat24.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCompat24.kt new file mode 100644 index 0000000000..7153d0ee93 --- /dev/null +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCompat24.kt @@ -0,0 +1,67 @@ +/* + * 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.processing.data.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.graphics.Path + +import com.buzbuz.smartautoclicker.core.common.actions.gesture.buildStroke +import com.buzbuz.smartautoclicker.core.common.actions.gesture.lineTo +import com.buzbuz.smartautoclicker.core.common.actions.gesture.moveTo +import com.buzbuz.smartautoclicker.core.processing.data.processor.state.ProcessingState + +import kotlin.random.Random + + +/** + * Stroke continuation is only available from Android O, so a finger can only be described by a single stroke here. + * + * A stroke is played at a constant speed along its path, which means the time spent on a part of the path is its + * share of the total path length, and not the duration configured for the matching motion. The overall gesture + * duration is correct, the timing of each motion is not: this is why waits are not offered on those versions. + */ +internal class AndroidGestureBuilderCompat24( + processingState: ProcessingState, + random: Random?, +) : AndroidGestureBuilder(processingState, random) { + + override val supportsWaitMotions: Boolean = false + + override fun buildSlices(fingers: List): List { + val builder = GestureDescription.Builder() + + fingers.forEach { finger -> + val path = Path().apply { + moveTo(finger.segments.first().from) + finger.segments.forEach { segment -> + if (segment.from != segment.to) lineTo(segment.to) + } + } + + builder.addStroke( + buildStroke( + path = path, + startTimeMs = finger.downMs, + durationMs = finger.upMs - finger.downMs, + willContinue = false, + ) + ) + } + + return listOf(AndroidGestureSlice(builder.build(), continuesPreviousSlice = false)) + } +} diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCurrent.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCurrent.kt new file mode 100644 index 0000000000..3f08aa72ce --- /dev/null +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/gesture/AndroidGestureBuilderCurrent.kt @@ -0,0 +1,171 @@ +/* + * 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.processing.data.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.accessibilityservice.GestureDescription.StrokeDescription +import android.graphics.Path +import android.graphics.Point +import android.os.Build +import androidx.annotation.RequiresApi + +import com.buzbuz.smartautoclicker.core.common.actions.gesture.buildStroke +import com.buzbuz.smartautoclicker.core.common.actions.gesture.continueWith +import com.buzbuz.smartautoclicker.core.common.actions.gesture.hold +import com.buzbuz.smartautoclicker.core.common.actions.gesture.line +import com.buzbuz.smartautoclicker.core.processing.data.processor.state.ProcessingState + +import kotlin.random.Random + + +/** + * Splits a gesture into the smallest time windows during which no finger changes motion, and describes each of them + * as its own [GestureDescription]. A finger touching the screen over several windows is described by a stroke chain, + * each stroke continuing the one of the previous window. + * + * The slicing is global to the gesture, and not per finger: the system only accepts a continuation when the first + * step of the dispatched gesture holds exactly one stroke for each pointer currently down, no more, no less. + */ +@RequiresApi(Build.VERSION_CODES.O) +internal class AndroidGestureBuilderCurrent( + processingState: ProcessingState, + random: Random?, +) : AndroidGestureBuilder(processingState, random) { + + override val supportsWaitMotions: Boolean = true + + override fun buildSlices(fingers: List): List? { + val boundaries = fingers.toSliceBoundaries() + if (boundaries.size < 2) return null + + val slices = mutableListOf() + /** Last stroke built for a finger still touching the screen, indexed by finger index. */ + val heldStrokes = mutableMapOf() + /** Position at which the last stroke of a finger ended, indexed by finger index. */ + val heldPositions = mutableMapOf() + /** Time not consumed by the previous slices, to delay the next one with. */ + var pendingDelayMs = 0L + + for (boundaryIndex in 0 until boundaries.lastIndex) { + val sliceStartMs = boundaries[boundaryIndex] + val sliceEndMs = boundaries[boundaryIndex + 1] + val sliceDurationMs = sliceEndMs - sliceStartMs + + val sliceFingers = fingers.mapNotNull { finger -> + val segment = finger.segmentAt(sliceStartMs) ?: return@mapNotNull null + SliceFinger( + index = finger.index, + continuedStroke = heldStrokes[finger.index], + from = heldPositions[finger.index] ?: segment.from, + to = segment.positionAt(sliceEndMs), + willContinue = finger.upMs > sliceEndMs, + ) + } + + // Nothing is touching the screen during this window, keep its duration to delay the next slice. + if (sliceFingers.isEmpty()) { + pendingDelayMs += sliceDurationMs + heldStrokes.clear() + heldPositions.clear() + continue + } + + // The system schedules a dispatched gesture up to its last motion event, and nothing more: a moving + // finger emits them all along the slice, and a finger leaving the screen emits one at its very end. + // Without any of those, the slice duration is dropped, and the gesture is even refused when it has no + // event at all to emit. Keep that duration to delay the next slice instead: its pointers then stay + // untouched on the screen for exactly that time, which is how a wait is executed. + val advancesTime = sliceFingers.any { sliceFinger -> sliceFinger.isMoving || !sliceFinger.willContinue } + if (!advancesTime && sliceFingers.none { sliceFinger -> sliceFinger.isPressing }) { + pendingDelayMs += sliceDurationMs + continue + } + + val continuesPreviousSlice = sliceFingers.any { sliceFinger -> !sliceFinger.isPressing } + val builder = GestureDescription.Builder() + val newStrokes = mutableMapOf() + val newPositions = mutableMapOf() + + sliceFingers.forEach { sliceFinger -> + val path = Path().apply { + if (sliceFinger.isMoving) line(sliceFinger.from, sliceFinger.to) + else hold(sliceFinger.from) + } + + // A finger pressing down while others are already held can't be part of the first step of the slice, + // the system would then refuse the whole continuation. Delay its press by one millisecond. + val pressDelayMs = + if (sliceFinger.isPressing && continuesPreviousSlice) NEW_FINGER_PRESS_DELAY_MS + else 0L + val startTimeMs = pendingDelayMs + pressDelayMs + val durationMs = (sliceDurationMs - pressDelayMs).coerceAtLeast(1L) + + val stroke = sliceFinger.continuedStroke + ?.continueWith(path, startTimeMs, durationMs, sliceFinger.willContinue) + ?: buildStroke(path, startTimeMs, durationMs, sliceFinger.willContinue) + + builder.addStroke(stroke) + if (sliceFinger.willContinue) { + newStrokes[sliceFinger.index] = stroke + newPositions[sliceFinger.index] = sliceFinger.to + } + } + + slices.add(AndroidGestureSlice(builder.build(), continuesPreviousSlice)) + + heldStrokes.clear() + heldStrokes.putAll(newStrokes) + heldPositions.clear() + heldPositions.putAll(newPositions) + // A slice dispatched only to press a finger down emits its event right away and consumes nothing more. + pendingDelayMs = if (advancesTime) 0L else sliceDurationMs + } + + return slices.ifEmpty { null } + } + + /** + * All the times at which a finger starts or ends a motion, defining the windows to slice the gesture into. Zero + * is always one of them, so that a gesture where no finger starts immediately keeps its initial delay. + */ + private fun List.toSliceBoundaries(): List { + val boundaries = sortedSetOf(0L) + forEach { finger -> + finger.segments.forEach { segment -> + boundaries.add(segment.startMs) + boundaries.add(segment.endMs) + } + } + + return boundaries.toList() + } +} + +/** What a finger does during a single slice. */ +private data class SliceFinger( + val index: Int, + val continuedStroke: StrokeDescription?, + val from: Point, + val to: Point, + val willContinue: Boolean, +) { + val isMoving: Boolean get() = from != to + val isPressing: Boolean get() = continuedStroke == null +} + +/** Delay applied to a finger pressing down in a slice that continues another one. */ +private const val NEW_FINGER_PRESS_DELAY_MS = 1L 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 83d20baf27..66f5071b0e 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,8 +30,9 @@ 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.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition 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 import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent import com.buzbuz.smartautoclicker.core.processing.data.processor.ActionExecutor @@ -70,23 +71,20 @@ class ActionExecutorTests { private const val TEST_NAME = "Action name" private const val TEST_DURATION = 25L private const val TEST_X1 = 12 - private const val TEST_X2 = 24 private const val TEST_Y1 = 88 - private const val TEST_Y2 = 76 fun getNewDefaultEvent(operator: Int = OR, conditions: List = emptyList(), actions: List = emptyList()) = ScreenEvent(TEST_EVENT_ID, Identifier(databaseId = 12L), "Name", operator, actions, conditions, true, 0, cooldownMs = 0, keepDetecting = false) fun getNewDefaultClickUserPos(id: Long, duration: Long = TEST_DURATION) = - Click(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 0, duration, Click.PositionType.USER_SELECTED, Point( - TEST_X1, TEST_Y1 - ), null) + Click(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 0, duration, + GesturePosition.Coordinates( + GestureCoordinatesValue.Literal(TEST_X1), + GestureCoordinatesValue.Literal(TEST_Y1), + )) fun getNewDefaultClickCondition(id: Long, conditionId: Long? = null) = - Click(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 1, TEST_DURATION, Click.PositionType.ON_DETECTED_CONDITION, null, conditionId?.let { Identifier(databaseId = conditionId) }) - fun getNewDefaultSwipe(id: Long) = - Swipe(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 2, TEST_DURATION, Point(TEST_X1, TEST_Y1), Point( - TEST_X2, TEST_Y2 - )) + Click(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 1, TEST_DURATION, + GesturePosition.Condition(conditionId?.let { Identifier(databaseId = it) })) fun getNewDefaultPause(id: Long) = Pause(Identifier(databaseId = id), TEST_EVENT_ID, TEST_NAME, 3, TEST_DURATION) @@ -212,20 +210,6 @@ class ActionExecutorTests { assertActionGesture(gestureCaptor.lastValue) } - @Test - fun execute_oneSwipe() = runTest { - val swipeAction = getNewDefaultSwipe(1) - - actionExecutor.executeActions( - event = getNewDefaultEvent(actions = listOf(swipeAction)), - results = ConditionsResults(), - ) - - val gestureCaptor = argumentCaptor() - verify(mockAndroidExecutor).dispatchGesture(gestureCaptor.capture()) - assertActionGesture(gestureCaptor.lastValue) - } - @Test fun execute_onePause() = runTest { val pause = getNewDefaultPause(1) @@ -244,12 +228,12 @@ class ActionExecutorTests { fun execute_mixed() = runTest { val click = getNewDefaultClickUserPos(1) val pause = getNewDefaultPause(2) - val swipe = getNewDefaultSwipe(3) + val otherClick = getNewDefaultClickUserPos(3) val gestureCaptor = argumentCaptor() // Execute the actions. actionExecutor.executeActions( - event = getNewDefaultEvent(actions = listOf(click, pause, swipe)), + event = getNewDefaultEvent(actions = listOf(click, pause, otherClick)), results = ConditionsResults(), ) diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt index 30b451cf69..479a91b6c2 100644 --- a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt @@ -34,6 +34,8 @@ import com.buzbuz.smartautoclicker.core.common.actions.AndroidActionExecutor import com.buzbuz.smartautoclicker.core.domain.model.OR import com.buzbuz.smartautoclicker.core.domain.model.WHOLE_SCREEN import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent import com.buzbuz.smartautoclicker.core.domain.model.event.TriggerEvent @@ -101,8 +103,10 @@ class ScenarioProcessorTests { eventId = Identifier(databaseId = 1), priority = 0, pressDuration = duration, - positionType = Click.PositionType.USER_SELECTED, - position = Point(10, 10), + position = GesturePosition.Coordinates( + x = GestureCoordinatesValue.Literal(10), + y = GestureCoordinatesValue.Literal(10), + ), ) } diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCompat24Tests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCompat24Tests.kt new file mode 100644 index 0000000000..6953aaa0a2 --- /dev/null +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCompat24Tests.kt @@ -0,0 +1,143 @@ +/* + * 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.processing.tests.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.graphics.Point +import android.os.Build + +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilderCompat24 +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.TAP_DURATION_MS +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.move +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newGesture +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newProcessingState +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newScreenEvent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newTapFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.press +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.release +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.wait + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Tests the single stroke per finger description of [AndroidGestureBuilderCompat24]. The builder only uses APIs + * available before Android O, so it is tested on a recent SDK where the stroke paths can be inspected. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class AndroidGestureBuilderCompat24Tests { + + private lateinit var builder: AndroidGestureBuilderCompat24 + + @Before + fun setUp() { + builder = AndroidGestureBuilderCompat24(newProcessingState(), random = null) + } + + private fun build(gesture: Gesture): List>? = + builder.build(gesture, newScreenEvent(), null) + + @Test + fun tap_singleHoldStroke() { + val chains = build(newGesture(newTapFinger(10, 20))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStrokesCount(1, chain[0]) + assertHoldStroke(chain[0].getStroke(0), 0L, TAP_DURATION_MS, Point(10, 20)) + } + + @Test + fun tap_delayedStart_delaysTheStroke() { + val chains = build(newGesture(newTapFinger(10, 20, startTimeMs = 50L))) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 50L, TAP_DURATION_MS, Point(10, 20)) + } + + @Test + fun swipe_singleLineStroke() { + val chains = build(newGesture(newFinger(0L, press(0, 0), move(100, 200, 500L), release()))) + + assertChainsCount(1, chains) + assertStroke(chains!!.first().single().getStroke(0), 0L, 500L, from = Point(0, 0), to = Point(100, 200)) + } + + @Test + fun multipleMoves_singleStrokeFollowingAllPositions() { + val chains = build( + newGesture(newFinger(0L, press(0, 0), move(100, 0, 200L), move(100, 100, 300L), release())) + ) + + assertChainsCount(1, chains) + val stroke = chains!!.first().single().getStroke(0) + assertStroke(stroke, 0L, 500L, from = Point(0, 0), to = Point(100, 100)) + assertEquals(listOf(Point(0, 0), Point(100, 0), Point(100, 100)), stroke.points) + } + + @Test + fun wait_isSkipped() { + val chains = build(newGesture(newFinger(0L, press(10, 10), wait(300L), move(50, 50, 200L), release()))) + + assertChainsCount(1, chains) + val stroke = chains!!.first().single().getStroke(0) + assertStroke(stroke, 0L, 200L, from = Point(10, 10), to = Point(50, 50)) + assertEquals(listOf(Point(10, 10), Point(50, 50)), stroke.points) + } + + @Test + fun onlyWait_fallsBackToATap() { + val chains = build(newGesture(newFinger(0L, press(10, 10), wait(300L), release()))) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(10, 10)) + } + + @Test + fun multipleFingers_singleGestureWithOneStrokePerFinger() { + val chains = build( + newGesture( + newFinger(0L, press(0, 0), move(100, 0, 400L), release()), + newTapFinger(300, 300, startTimeMs = 200L), + ) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStrokesCount(2, chain[0]) + assertStroke(chain[0].getStroke(0), 0L, 400L, from = Point(0, 0), to = Point(100, 0)) + assertHoldStroke(chain[0].getStroke(1), 200L, TAP_DURATION_MS, Point(300, 300)) + } + + @Test + fun tooManyFingers_returnsNull() { + val fingers = Array(GestureDescription.getMaxStrokeCount() + 1) { index -> newTapFinger(index, index) } + + assertNull(build(newGesture(*fingers))) + } +} diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCurrentTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCurrentTests.kt new file mode 100644 index 0000000000..6cf93aa398 --- /dev/null +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderCurrentTests.kt @@ -0,0 +1,270 @@ +/* + * 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.processing.tests.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.graphics.Point +import android.os.Build + +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilderCurrent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.NEW_FINGER_PRESS_DELAY_MS +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.TAP_DURATION_MS +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.move +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newGesture +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newProcessingState +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newScreenEvent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newTapFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.press +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.release +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.wait + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Tests the slicing of a gesture into continued strokes by [AndroidGestureBuilderCurrent]. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class AndroidGestureBuilderCurrentTests { + + private lateinit var builder: AndroidGestureBuilderCurrent + + @Before + fun setUp() { + builder = AndroidGestureBuilderCurrent(newProcessingState(), random = null) + } + + private fun build(gesture: Gesture): List>? = + builder.build(gesture, newScreenEvent(), null) + + @Test + fun tap_singleHoldStroke() { + val chains = build(newGesture(newTapFinger(10, 20))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStrokesCount(1, chain[0]) + assertHoldStroke(chain[0].getStroke(0), 0L, TAP_DURATION_MS, Point(10, 20)) + } + + @Test + fun tap_delayedStart_delaysTheStroke() { + val chains = build(newGesture(newTapFinger(10, 20, startTimeMs = 50L))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertHoldStroke(chain[0].getStroke(0), 50L, TAP_DURATION_MS, Point(10, 20)) + } + + @Test + fun longPress_singleHoldStrokeOfTheWaitDuration() { + val chains = build(newGesture(newFinger(0L, press(10, 20), wait(300L), release()))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertHoldStroke(chain[0].getStroke(0), 0L, 300L, Point(10, 20)) + } + + @Test + fun swipe_singleLineStroke() { + val chains = build(newGesture(newFinger(0L, press(0, 0), move(100, 200, 500L), release()))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStroke(chain[0].getStroke(0), 0L, 500L, from = Point(0, 0), to = Point(100, 200)) + } + + @Test + fun moveThenMove_secondStrokeContinuesTheFirst() { + val chains = build( + newGesture(newFinger(0L, press(0, 0), move(100, 0, 200L), move(100, 100, 300L), release())) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + val firstStroke = chain[0].getStroke(0) + assertStroke(firstStroke, 0L, 200L, from = Point(0, 0), to = Point(100, 0), willContinue = true) + assertStroke(chain[1].getStroke(0), 0L, 300L, from = Point(100, 0), to = Point(100, 100), continues = firstStroke) + } + + @Test + fun moveThenWait_waitIsAHeldContinuation() { + val chains = build(newGesture(newFinger(0L, press(0, 0), move(100, 100, 200L), wait(300L), release()))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + val moveStroke = chain[0].getStroke(0) + assertStroke(moveStroke, 0L, 200L, from = Point(0, 0), to = Point(100, 100), willContinue = true) + assertHoldStroke(chain[1].getStroke(0), 0L, 300L, Point(100, 100), continues = moveStroke) + } + + @Test + fun waitThenMove_pressIsDispatchedThenMoveIsDelayedByTheWait() { + val chains = build(newGesture(newFinger(0L, press(10, 10), wait(300L), move(50, 50, 200L), release()))) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + val pressStroke = chain[0].getStroke(0) + assertHoldStroke(pressStroke, 0L, 300L, Point(10, 10), willContinue = true) + assertStroke(chain[1].getStroke(0), 300L, 200L, from = Point(10, 10), to = Point(50, 50), continues = pressStroke) + } + + @Test + fun moveThenWaitThenMove_waitIsMergedIntoTheNextStrokeDelay() { + val chains = build( + newGesture(newFinger(0L, press(0, 0), move(100, 0, 100L), wait(150L), move(100, 100, 100L), release())) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + val firstMove = chain[0].getStroke(0) + assertStroke(firstMove, 0L, 100L, from = Point(0, 0), to = Point(100, 0), willContinue = true) + assertStroke(chain[1].getStroke(0), 150L, 100L, from = Point(100, 0), to = Point(100, 100), continues = firstMove) + } + + @Test + fun consecutiveWaits_delaysAreAccumulated() { + val chains = build( + newGesture(newFinger(0L, press(10, 10), wait(100L), wait(200L), move(50, 50, 300L), release())) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + val pressStroke = chain[0].getStroke(0) + assertHoldStroke(pressStroke, 0L, 100L, Point(10, 10), willContinue = true) + assertStroke(chain[1].getStroke(0), 300L, 300L, from = Point(10, 10), to = Point(50, 50), continues = pressStroke) + } + + @Test + fun sequentialFingers_eachTapIsItsOwnChain() { + val chains = build(newGesture(newTapFinger(10, 10), newTapFinger(20, 20, startTimeMs = 200L))) + + assertChainsCount(2, chains) + chains!!.forEach { chain -> assertGesturesCount(1, chain) } + + assertHoldStroke(chains[0][0].getStroke(0), 0L, TAP_DURATION_MS, Point(10, 10)) + // The second tap only starts once the first one is fully played: it is delayed by the gap between them. + assertHoldStroke(chains[1][0].getStroke(0), 100L, TAP_DURATION_MS, Point(20, 20)) + } + + @Test + fun simultaneousFingers_singleGestureWithOneStrokePerFinger() { + val chains = build( + newGesture( + newFinger(0L, press(0, 0), move(100, 0, 400L), release()), + newFinger(0L, press(500, 0), move(400, 0, 400L), release()), + ) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStrokesCount(2, chain[0]) + assertStroke(chain[0].getStroke(0), 0L, 400L, from = Point(0, 0), to = Point(100, 0)) + assertStroke(chain[0].getStroke(1), 0L, 400L, from = Point(500, 0), to = Point(400, 0)) + } + + @Test + fun waitingFingerAlongMovingFinger_singleGesture() { + val chains = build( + newGesture( + newFinger(0L, press(0, 0), wait(200L), release()), + newFinger(0L, press(50, 50), move(100, 100, 200L), release()), + ) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(1, chain) + assertStrokesCount(2, chain[0]) + assertHoldStroke(chain[0].getStroke(0), 0L, 200L, Point(0, 0)) + assertStroke(chain[0].getStroke(1), 0L, 200L, from = Point(50, 50), to = Point(100, 100)) + } + + @Test + fun fingerPressingWhileAnotherIsHeld_pressIsDelayedAndMovingFingerIsSplit() { + val chains = build( + newGesture( + newFinger(0L, press(0, 0), move(100, 100, 200L), move(200, 200, 200L), release()), + newTapFinger(300, 300, startTimeMs = 200L), + ) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(3, chain) + + // Slice 0-200ms: only the first finger, moving. + assertStrokesCount(1, chain[0]) + val firstMove = chain[0].getStroke(0) + assertStroke(firstMove, 0L, 200L, from = Point(0, 0), to = Point(100, 100), willContinue = true) + + // Slice 200-300ms: the first finger keeps moving, the second one taps. Its press can't be in the first step + // of the slice, it is delayed by a millisecond. + assertStrokesCount(2, chain[1]) + val secondMove = chain[1].getStroke(0) + assertStroke(secondMove, 0L, 100L, from = Point(100, 100), to = Point(150, 150), willContinue = true, continues = firstMove) + assertHoldStroke(chain[1].getStroke(1), NEW_FINGER_PRESS_DELAY_MS, TAP_DURATION_MS - NEW_FINGER_PRESS_DELAY_MS, Point(300, 300)) + + // Slice 300-400ms: only the first finger, ending its move. + assertStrokesCount(1, chain[2]) + assertStroke(chain[2].getStroke(0), 0L, 100L, from = Point(150, 150), to = Point(200, 200), continues = secondMove) + } + + @Test + fun fingerReleasedWhileAnotherIsHeld_heldFingerIsSplitAtTheRelease() { + val chains = build( + newGesture( + newFinger(0L, press(0, 0), wait(300L), release()), + newFinger(0L, press(50, 50), move(100, 100, 100L), release()), + ) + ) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + + // Slice 0-100ms: the second finger swipes while the first one is held. + assertStrokesCount(2, chain[0]) + val holdStroke = chain[0].getStroke(0) + assertHoldStroke(holdStroke, 0L, 100L, Point(0, 0), willContinue = true) + assertStroke(chain[0].getStroke(1), 0L, 100L, from = Point(50, 50), to = Point(100, 100)) + + // Slice 100-300ms: the first finger stays held until its release. + assertStrokesCount(1, chain[1]) + assertHoldStroke(chain[1].getStroke(0), 0L, 200L, Point(0, 0), continues = holdStroke) + } +} diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderTests.kt new file mode 100644 index 0000000000..dc61bb21a6 --- /dev/null +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/AndroidGestureBuilderTests.kt @@ -0,0 +1,324 @@ +/* + * 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.processing.tests.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.graphics.Point +import android.os.Build + +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.model.AND +import com.buzbuz.smartautoclicker.core.domain.model.OR +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilder +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilderCompat24 +import com.buzbuz.smartautoclicker.core.processing.data.processor.gesture.AndroidGestureBuilderCurrent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.COUNTER_NAME +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.COUNTER_VALUE +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.TAP_DURATION_MS +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.coordinates +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.move +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newCondition +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newConditionsResults +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newGesture +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newProcessingState +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newScreenEvent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newTapFinger +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.newTriggerEvent +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.press +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.release +import com.buzbuz.smartautoclicker.core.processing.tests.processor.gesture.GestureBuilderTestData.wait + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +import kotlin.random.Random + +/** + * Tests the behaviour shared by all [AndroidGestureBuilder] implementations: finger validation, position + * resolution, randomization and error handling. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class AndroidGestureBuilderTests { + + private companion object { + const val RANDOMIZATION_POSITION_MAX_OFFSET_PX = 5 + const val RANDOMIZATION_DURATION_MAX_OFFSET_MS = 5L + } + + private fun newBuilder(random: Random? = null): AndroidGestureBuilder = + AndroidGestureBuilderCurrent(newProcessingState(), random) + + private fun conditionPosition(conditionId: Long? = null): GesturePosition.Condition = + GesturePosition.Condition(conditionId?.let { Identifier(databaseId = it) }) + + @Test + fun createNewBuilder_currentSdk_returnsCurrent() { + assertTrue(AndroidGestureBuilder.createNewBuilder(newProcessingState(), null) is AndroidGestureBuilderCurrent) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun createNewBuilder_belowOreo_returnsCompat24() { + assertTrue(AndroidGestureBuilder.createNewBuilder(newProcessingState(), null) is AndroidGestureBuilderCompat24) + } + + @Test + fun build_noFingers_returnsNull() { + assertNull(newBuilder().build(newGesture(), newScreenEvent(), null)) + } + + @Test + fun build_incompleteFinger_returnsNull() { + val gesture = newGesture(newFinger(0L, press(10, 20))) + + assertNull(newBuilder().build(gesture, newScreenEvent(), null)) + } + + @Test + fun build_fingerPressingTwice_returnsNull() { + val gesture = newGesture(newFinger(0L, press(10, 20), press(30, 40), release())) + + assertNull(newBuilder().build(gesture, newScreenEvent(), null)) + } + + @Test + fun build_fingerReleasingTwice_returnsNull() { + val gesture = newGesture(newFinger(0L, press(10, 20), release(), release())) + + assertNull(newBuilder().build(gesture, newScreenEvent(), null)) + } + + @Test + fun build_oneFingerUnresolvable_skipsOnlyThatFinger() { + val gesture = newGesture( + newFinger(0L, press(10, 20), press(30, 40), release()), + newTapFinger(50, 60), + ) + + val chains = newBuilder().build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + val description = chains!!.first().single() + assertStrokesCount(1, description) + assertHoldStroke(description.getStroke(0), 0L, TAP_DURATION_MS, Point(50, 60)) + } + + @Test + fun build_negativeStartTime_coercedToZero() { + val gesture = newGesture(newTapFinger(10, 20, startTimeMs = -50L)) + + val chains = newBuilder().build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(10, 20)) + } + + @Test + fun build_tooManyStrokes_returnsNull() { + val fingers = Array(GestureDescription.getMaxStrokeCount() + 1) { index -> newTapFinger(index * 10, index * 10) } + + assertNull(newBuilder().build(newGesture(*fingers), newScreenEvent(), null)) + } + + @Test + fun resolvePosition_offsetIsAdded() { + val gesture = newGesture(newFinger(0L, press(coordinates(10, 20), coordinates(5, -5)), release())) + + val chains = newBuilder().build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(15, 15)) + } + + @Test + fun resolvePosition_negativeCoordinates_clampedToZero() { + val gesture = newGesture(newTapFinger(-10, -20)) + + val chains = newBuilder().build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(0, 0)) + } + + @Test + fun resolvePosition_counterCoordinate_usesCounterValue() { + val position = GesturePosition.Coordinates( + x = GestureCoordinatesValue.Counter(COUNTER_NAME), + y = GestureCoordinatesValue.Literal(20), + ) + val gesture = newGesture(newFinger(0L, press(position), release())) + + val chains = newBuilder().build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(COUNTER_VALUE, 20)) + } + + @Test + fun resolvePosition_unknownCounter_returnsNull() { + val position = GesturePosition.Coordinates( + x = GestureCoordinatesValue.Counter("unknown"), + y = GestureCoordinatesValue.Literal(20), + ) + val gesture = newGesture(newFinger(0L, press(position), release())) + + assertNull(newBuilder().build(gesture, newScreenEvent(), null)) + } + + @Test + fun resolvePosition_unresolvableMove_returnsNull() { + val position = GesturePosition.Coordinates( + x = GestureCoordinatesValue.Counter("unknown"), + y = GestureCoordinatesValue.Literal(20), + ) + val gesture = newGesture(newFinger(0L, press(10, 20), move(position, durationMs = 100L), release())) + + assertNull(newBuilder().build(gesture, newScreenEvent(), null)) + } + + @Test + fun resolvePosition_conditionOrOperator_usesFirstDetectedCondition() { + val condition = newCondition(42L) + val event = newScreenEvent(OR, listOf(condition)) + val results = newConditionsResults(condition to Point(15, 25)) + val gesture = newGesture(newFinger(0L, press(conditionPosition()), release())) + + val chains = newBuilder().build(gesture, event, results) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(15, 25)) + } + + @Test + fun resolvePosition_conditionOrOperator_noResults_returnsNull() { + val condition = newCondition(42L) + val event = newScreenEvent(OR, listOf(condition)) + val gesture = newGesture(newFinger(0L, press(conditionPosition()), release())) + + assertNull(newBuilder().build(gesture, event, null)) + assertNull(newBuilder().build(gesture, event, newConditionsResults())) + } + + @Test + fun resolvePosition_conditionAndOperator_usesSelectedCondition() { + val selectedCondition = newCondition(42L) + val otherCondition = newCondition(43L) + val event = newScreenEvent(AND, listOf(otherCondition, selectedCondition)) + val results = newConditionsResults(otherCondition to Point(1, 1), selectedCondition to Point(15, 25)) + val gesture = newGesture(newFinger(0L, press(conditionPosition(42L)), release())) + + val chains = newBuilder().build(gesture, event, results) + + assertChainsCount(1, chains) + assertHoldStroke(chains!!.first().single().getStroke(0), 0L, TAP_DURATION_MS, Point(15, 25)) + } + + @Test + fun resolvePosition_conditionAndOperator_conditionNotProcessed_returnsNull() { + val condition = newCondition(42L) + val event = newScreenEvent(AND, listOf(condition)) + val gesture = newGesture(newFinger(0L, press(conditionPosition(42L)), release())) + + assertNull(newBuilder().build(gesture, event, newConditionsResults())) + } + + @Test + fun resolvePosition_conditionAndOperator_noConditionSelected_returnsNull() { + val condition = newCondition(42L) + val event = newScreenEvent(AND, listOf(condition)) + val results = newConditionsResults(condition to Point(15, 25)) + val gesture = newGesture(newFinger(0L, press(conditionPosition()), release())) + + assertNull(newBuilder().build(gesture, event, results)) + } + + @Test + fun resolvePosition_conditionNotDetected_returnsNull() { + val condition = newCondition(42L) + val event = newScreenEvent(OR, listOf(condition)) + val results = newConditionsResults(condition to null) + val gesture = newGesture(newFinger(0L, press(conditionPosition()), release())) + + assertNull(newBuilder().build(gesture, event, results)) + } + + @Test + fun resolvePosition_conditionOnTriggerEvent_returnsNull() { + val gesture = newGesture(newFinger(0L, press(conditionPosition()), release())) + + assertNull(newBuilder().build(gesture, newTriggerEvent(), null)) + } + + @Test + fun randomize_swipe_positionsAndDurationStayWithinOffsets() { + val gesture = newGesture(newFinger(0L, press(100, 200), move(300, 400, 500L), release())) + + val chains = newBuilder(Random(42)).build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + val stroke = chains!!.first().single().getStroke(0) + assertTrue(stroke.duration in 500L - RANDOMIZATION_DURATION_MAX_OFFSET_MS..500L + RANDOMIZATION_DURATION_MAX_OFFSET_MS) + assertPointNear(Point(100, 200), stroke.points.first()) + assertPointNear(Point(300, 400), stroke.points.last()) + } + + @Test + fun randomize_negativeCoordinates_clampedToZero() { + val gesture = newGesture(newTapFinger(0, 0)) + + val chains = newBuilder(Random(42)).build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + val position = chains!!.first().single().getStroke(0).points.single() + assertTrue("Position should be clamped to the screen", position.x >= 0 && position.y >= 0) + } + + @Test + fun randomize_continuedStroke_startsWhereThePreviousOneEnded() { + val gesture = newGesture(newFinger(0L, press(100, 200), move(300, 400, 500L), wait(300L), release())) + + val chains = newBuilder(Random(42)).build(gesture, newScreenEvent(), null) + + assertChainsCount(1, chains) + val chain = chains!!.first() + assertGesturesCount(2, chain) + val moveStroke = chain[0].getStroke(0) + val holdStroke = chain[1].getStroke(0) + assertEquals("Continuation should start at the randomized end of the move", moveStroke.points.last(), holdStroke.points.single()) + assertEquals(moveStroke.strokeId, holdStroke.continuedStrokeId) + } + + private fun assertPointNear(expected: Point, actual: Point) { + assertTrue( + "Point $actual should be within $RANDOMIZATION_POSITION_MAX_OFFSET_PX px of $expected", + expected.x - RANDOMIZATION_POSITION_MAX_OFFSET_PX <= actual.x + && actual.x <= expected.x + RANDOMIZATION_POSITION_MAX_OFFSET_PX + && expected.y - RANDOMIZATION_POSITION_MAX_OFFSET_PX <= actual.y + && actual.y <= expected.y + RANDOMIZATION_POSITION_MAX_OFFSET_PX, + ) + } +} diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/GestureBuilderTestData.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/GestureBuilderTestData.kt new file mode 100644 index 0000000000..15d4a0e7cb --- /dev/null +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/processor/gesture/GestureBuilderTestData.kt @@ -0,0 +1,204 @@ +/* + * 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.processing.tests.processor.gesture + +import android.accessibilityservice.GestureDescription +import android.accessibilityservice.GestureDescription.StrokeDescription +import android.graphics.Point +import android.graphics.Rect + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.domain.model.ConditionOperator +import com.buzbuz.smartautoclicker.core.domain.model.EXACT +import com.buzbuz.smartautoclicker.core.domain.model.OR +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFinger +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition +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.ScreenEvent +import com.buzbuz.smartautoclicker.core.domain.model.event.TriggerEvent +import com.buzbuz.smartautoclicker.core.processing.data.processor.ConditionsResults +import com.buzbuz.smartautoclicker.core.processing.data.processor.state.ProcessingState +import com.buzbuz.smartautoclicker.core.processing.domain.model.ProcessedConditionResult + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import kotlin.math.roundToInt + +/** Builds the domain objects consumed by the gesture builders. */ +internal object GestureBuilderTestData { + + /** Duration of a finger pressing and releasing without moving, as applied by the builder. */ + const val TAP_DURATION_MS = 100L + /** Delay applied by the builder to a finger pressing down while another one is already held. */ + const val NEW_FINGER_PRESS_DELAY_MS = 1L + + const val COUNTER_NAME = "counter" + const val COUNTER_VALUE = 300 + + private val SCENARIO_ID = Identifier(databaseId = 1L) + private val EVENT_ID = Identifier(databaseId = 2L) + private val ITEM_ID = Identifier(databaseId = 3L) + + fun newProcessingState(): ProcessingState = ProcessingState( + screenEvents = emptyList(), + triggerEvents = emptyList(), + counters = listOf(Counter(COUNTER_NAME, COUNTER_VALUE.toDouble(), SCENARIO_ID)), + progressListener = null, + ) + + fun newScreenEvent( + @ConditionOperator operator: Int = OR, + conditions: List = emptyList(), + ): ScreenEvent = ScreenEvent( + id = EVENT_ID, + scenarioId = SCENARIO_ID, + name = "Event", + conditionOperator = operator, + conditions = conditions, + priority = 0, + keepDetecting = false, + cooldownMs = 0L, + ) + + fun newTriggerEvent(): TriggerEvent = + TriggerEvent(id = EVENT_ID, scenarioId = SCENARIO_ID, name = "Event", conditionOperator = OR) + + fun newCondition(id: Long): ScreenCondition.Image = ScreenCondition.Image( + id = Identifier(databaseId = id), + eventId = EVENT_ID, + name = "Condition", + threshold = 10, + shouldBeDetected = true, + priority = 0, + path = "path", + area = Rect(), + detectionType = EXACT, + ) + + fun newConditionsResults(vararg results: Pair): ConditionsResults = + ConditionsResults().apply { + results.forEach { (condition, position) -> + addResult( + conditionId = condition.id.databaseId, + result = ProcessedConditionResult.Screen( + isFulfilled = true, + haveBeenDetected = true, + condition = condition, + confidenceRate = 100.0, + position = position, + size = position?.let { Point(10, 10) }, + ), + ) + } + } + + fun newGesture(vararg fingers: GestureFinger): Gesture = + Gesture(id = ITEM_ID, eventId = EVENT_ID, name = "Gesture", priority = 0, fingers = fingers.toList()) + + fun newFinger(startTimeMs: Long = 0L, vararg motions: GestureFingerMotion): GestureFinger = + GestureFinger(id = ITEM_ID, startTimeMs = startTimeMs, motions = motions.toList()) + + fun newTapFinger(x: Int, y: Int, startTimeMs: Long = 0L): GestureFinger = + newFinger(startTimeMs, press(x, y), release()) + + fun coordinates(x: Int, y: Int): GesturePosition.Coordinates = + GesturePosition.Coordinates(GestureCoordinatesValue.Literal(x), GestureCoordinatesValue.Literal(y)) + + fun press(x: Int, y: Int): GestureFingerMotion.Press = + press(coordinates(x, y)) + + fun press(position: GesturePosition, offset: GesturePosition = coordinates(0, 0)): GestureFingerMotion.Press = + GestureFingerMotion.Press(ITEM_ID, position, offset) + + fun move(x: Int, y: Int, durationMs: Long): GestureFingerMotion.Move = + move(coordinates(x, y), durationMs = durationMs) + + fun move( + position: GesturePosition, + offset: GesturePosition = coordinates(0, 0), + durationMs: Long, + ): GestureFingerMotion.Move = + GestureFingerMotion.Move(ITEM_ID, position, offset, durationMs) + + fun wait(durationMs: Long): GestureFingerMotion.Wait = + GestureFingerMotion.Wait(ITEM_ID, durationMs) + + fun release(): GestureFingerMotion.Release = + GestureFingerMotion.Release(ITEM_ID) +} + +/** The points the stroke path goes through, from its start to its end. A held finger has a single point. */ +internal val StrokeDescription.points: List + get() = path.approximate(0.5f).toList().chunked(3).map { (_, x, y) -> Point(x.roundToInt(), y.roundToInt()) } + +/** [StrokeDescription.getId] is hidden from the SDK, but available at runtime. */ +internal val StrokeDescription.strokeId: Int + get() = StrokeDescription::class.java.getMethod("getId").invoke(this) as Int + +/** [StrokeDescription.getContinuedStrokeId] is hidden from the SDK, but available at runtime. */ +internal val StrokeDescription.continuedStrokeId: Int + get() = StrokeDescription::class.java.getMethod("getContinuedStrokeId").invoke(this) as Int + +internal fun assertChainsCount(expected: Int, chains: List>?) { + assertTrue("Gesture should have been built", chains != null) + assertEquals("Invalid chains count", expected, chains!!.size) +} + +internal fun assertGesturesCount(expected: Int, chain: List) { + assertEquals("Invalid gestures count in chain", expected, chain.size) +} + +internal fun assertStrokesCount(expected: Int, gesture: GestureDescription) { + assertEquals("Invalid strokes count in gesture", expected, gesture.strokeCount) +} + +internal fun assertStroke( + stroke: StrokeDescription, + startTimeMs: Long, + durationMs: Long, + from: Point, + to: Point = from, + willContinue: Boolean = false, + continues: StrokeDescription? = null, +) { + assertEquals("Invalid stroke start time", startTimeMs, stroke.startTime) + assertEquals("Invalid stroke duration", durationMs, stroke.duration) + assertEquals("Invalid stroke start position", from, stroke.points.first()) + assertEquals("Invalid stroke end position", to, stroke.points.last()) + assertEquals("Invalid stroke continuation flag", willContinue, stroke.willContinue()) + + if (continues == null) assertFalse("Stroke should not continue another one", stroke.continuedStrokeId >= 0) + else assertEquals("Stroke continues the wrong stroke", continues.strokeId, stroke.continuedStrokeId) +} + +/** A held finger is described by a single point path: it stays at its start position without moving. */ +internal fun assertHoldStroke( + stroke: StrokeDescription, + startTimeMs: Long, + durationMs: Long, + position: Point, + willContinue: Boolean = false, + continues: StrokeDescription? = null, +) { + assertStroke(stroke, startTimeMs, durationMs, position, position, willContinue, continues) + assertEquals("A held finger should not move", listOf(position), stroke.points) +} From 4c8da490621d9208cd7977622dcbd803679e222b Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:30:10 +0200 Subject: [PATCH 06/56] Add gesture data model --- .../core/base/migrations/SQLiteQuery.kt | 9 + .../24.json | 967 ++++++++++++++++++ .../core/database/ClickDatabase.kt | 4 + .../core/database/DatabaseInfo.kt | 5 +- .../core/database/dao/ActionDao.kt | 25 + .../smartautoclicker/core/database/di/Hilt.kt | 2 + .../core/database/entity/ActionEntity.kt | 60 +- .../core/database/entity/DatabaseEnums.kt | 44 +- .../database/entity/GestureFingerEntity.kt | 76 ++ .../entity/GestureFingerMotionEntity.kt | 115 +++ .../database/migrations/Migration23to24.kt | 344 +++++++ .../core/database/migrations/Migration5to6.kt | 2 +- .../database/migrations/Migration9to10.kt | 8 +- .../database/serialization/Deserializer.kt | 55 +- .../serialization/KotlinDeserializer.kt | 4 +- .../compat/CompatDeserializer.kt | 121 +-- .../compat/CompatV11Deserializer.kt | 47 +- .../compat/CompatV13Deserializer.kt | 9 +- .../compat/CompatV20Deserializer.kt | 6 +- .../compat/CompatV24Deserializer.kt | 260 +++++ .../migrations/Migration23to24Tests.kt | 587 +++++++++++ .../database/migrations/Migration3to4Tests.kt | 2 +- .../database/migrations/Migration5to6Tests.kt | 2 +- .../migrations/Migration9to10Tests.kt | 2 +- .../serialization/DeserializerTests.kt | 1 + .../compat/CompatV11DeserializerTests.kt | 115 ++- .../compat/CompatV24DeserializerTests.kt | 841 +++++++++++++++ .../core/database/utils/Migrations.kt | 49 + .../core/domain/data/ScenarioDataSource.kt | 61 +- .../action/gesture/GestureFingerMapper.kt | 183 ++++ .../model/action/mapper/ActionDomainMapper.kt | 87 +- .../model/action/mapper/ActionEntityMapper.kt | 89 +- .../core/domain/model/event/Event.kt | 4 +- .../domain/model/action/ActionMapperTests.kt | 538 +++++++++- .../domain/model/action/ActionTestsData.kt | 221 +++- .../gesture/GestureFingerMapperTests.kt | 372 +++++++ .../core/domain/utils/Asserts.kt | 20 +- 37 files changed, 5000 insertions(+), 337 deletions(-) create mode 100644 core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json create mode 100644 core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerEntity.kt create mode 100644 core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerMotionEntity.kt create mode 100644 core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt create mode 100644 core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24Deserializer.kt create mode 100644 core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt create mode 100644 core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24DeserializerTests.kt create mode 100644 core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapper.kt create mode 100644 core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapperTests.kt diff --git a/core/common/base/src/main/java/com/buzbuz/smartautoclicker/core/base/migrations/SQLiteQuery.kt b/core/common/base/src/main/java/com/buzbuz/smartautoclicker/core/base/migrations/SQLiteQuery.kt index 786894d434..45f9dc4c54 100644 --- a/core/common/base/src/main/java/com/buzbuz/smartautoclicker/core/base/migrations/SQLiteQuery.kt +++ b/core/common/base/src/main/java/com/buzbuz/smartautoclicker/core/base/migrations/SQLiteQuery.kt @@ -56,6 +56,8 @@ class SQLiteQueryResult internal constructor( fun getBoolean(columnName: String): Boolean = cursor.getInt(getColumnIndex(columnName)) != 0 + fun isNull(columnName: String): Boolean = cursor.isNull(getColumnIndex(columnName)) + inline fun getValue(column: SQLiteColumn): ColumnType? = when (column) { is SQLiteColumn.Boolean -> getBoolean(column.name) @@ -67,6 +69,13 @@ class SQLiteQueryResult internal constructor( is SQLiteColumn.Double -> getDouble(column.name) } as ColumnType? + /** + * [getValue] returns 0 for a null numeric column; use this instead when the column is nullable and the + * difference between null and 0 matters. + */ + inline fun getNullableValue(column: SQLiteColumn): ColumnType? = + if (isNull(column.name)) null else getValue(column) + private fun getColumnIndex(columnName: String): Int = columnsNamesToInfo[columnName]?.second ?: throw IllegalArgumentException("Can't get Int value, column $columnName doesn't exist") 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 0000000000..d76ede85d8 --- /dev/null +++ b/core/smart/database/schemas/com.buzbuz.smartautoclicker.core.database.ClickDatabase/24.json @@ -0,0 +1,967 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "95bb40a14dacb3960e7241cc929cf77b", + "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, `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 )", + "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": "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`)" + } + ], + "foreignKeys": [ + { + "table": "event_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "eventId" + ], + "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": false, + "columnNames": [ + "scenario_id" + ], + "orders": [], + "createSql": "CREATE 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" + ] + } + ] + }, + { + "tableName": "gesture_finger_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `action_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `start_time_ms` INTEGER 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTimeMs", + "columnName": "start_time_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_gesture_finger_table_action_id", + "unique": false, + "columnNames": [ + "action_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_gesture_finger_table_action_id` ON `${TABLE_NAME}` (`action_id`)" + } + ], + "foreignKeys": [ + { + "table": "action_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "action_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "gesture_finger_motion_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `finger_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `motion_type` TEXT NOT NULL, `duration_ms` INTEGER, `x_coordinate_type` TEXT, `y_coordinate_type` TEXT, `condition_id` INTEGER, `x_literal` INTEGER, `x_counter` TEXT, `y_literal` INTEGER, `y_counter` TEXT, `x_offset_coordinate_type` TEXT, `y_offset_coordinate_type` TEXT, `x_offset_literal` INTEGER, `x_offset_counter` TEXT, `y_offset_literal` INTEGER, `y_offset_counter` TEXT, FOREIGN KEY(`finger_id`) REFERENCES `gesture_finger_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerId", + "columnName": "finger_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "motionType", + "columnName": "motion_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "xCoordinateType", + "columnName": "x_coordinate_type", + "affinity": "TEXT" + }, + { + "fieldPath": "yCoordinateType", + "columnName": "y_coordinate_type", + "affinity": "TEXT" + }, + { + "fieldPath": "conditionId", + "columnName": "condition_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "xLiteral", + "columnName": "x_literal", + "affinity": "INTEGER" + }, + { + "fieldPath": "xCounter", + "columnName": "x_counter", + "affinity": "TEXT" + }, + { + "fieldPath": "yLiteral", + "columnName": "y_literal", + "affinity": "INTEGER" + }, + { + "fieldPath": "yCounter", + "columnName": "y_counter", + "affinity": "TEXT" + }, + { + "fieldPath": "xOffsetCoordinateType", + "columnName": "x_offset_coordinate_type", + "affinity": "TEXT" + }, + { + "fieldPath": "yOffsetCoordinateType", + "columnName": "y_offset_coordinate_type", + "affinity": "TEXT" + }, + { + "fieldPath": "xOffsetLiteral", + "columnName": "x_offset_literal", + "affinity": "INTEGER" + }, + { + "fieldPath": "xOffsetCounter", + "columnName": "x_offset_counter", + "affinity": "TEXT" + }, + { + "fieldPath": "yOffsetLiteral", + "columnName": "y_offset_literal", + "affinity": "INTEGER" + }, + { + "fieldPath": "yOffsetCounter", + "columnName": "y_offset_counter", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_gesture_finger_motion_table_finger_id", + "unique": false, + "columnNames": [ + "finger_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_gesture_finger_motion_table_finger_id` ON `${TABLE_NAME}` (`finger_id`)" + } + ], + "foreignKeys": [ + { + "table": "gesture_finger_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "finger_id" + ], + "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, '95bb40a14dacb3960e7241cc929cf77b')" + ] + } +} \ 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 bb63536238..5d78888023 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 @@ -24,6 +24,8 @@ import com.buzbuz.smartautoclicker.core.database.entity.ConditionEntity import com.buzbuz.smartautoclicker.core.database.entity.CountersEntity import com.buzbuz.smartautoclicker.core.database.entity.EventEntity import com.buzbuz.smartautoclicker.core.database.entity.EventToggleEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraEntity import com.buzbuz.smartautoclicker.core.database.entity.ScenarioEntity import com.buzbuz.smartautoclicker.core.database.entity.ScenarioStatsEntity @@ -42,6 +44,8 @@ import javax.inject.Singleton EventToggleEntity::class, ScenarioStatsEntity::class, CountersEntity::class, + GestureFingerEntity::class, + GestureFingerMotionEntity::class, ], version = DATABASE_VERSION, exportSchema = true, 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 229562b083..7ce0b9479d 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 @@ -22,6 +22,7 @@ import androidx.annotation.StringDef @StringDef( SCENARIO_TABLE, EVENT_TABLE, ACTION_TABLE, CONDITION_TABLE, END_CONDITION_TABLE, INTENT_EXTRA_TABLE, EVENT_TOGGLE_TABLE, TUTORIAL_SUCCESS_TABLE, SCENARIO_USAGE_TABLE, COUNTERS_TABLE, + GESTURE_FINGER_TABLE, GESTURE_FINGER_MOTION_TABLE, ) @Retention(AnnotationRetention.SOURCE) internal annotation class DatabaseTable @@ -35,9 +36,11 @@ internal const val EVENT_TOGGLE_TABLE = "event_toggle_table" internal const val TUTORIAL_SUCCESS_TABLE = "tutorial_success_table" internal const val SCENARIO_USAGE_TABLE = "scenario_usage_table" internal const val COUNTERS_TABLE = "counters_table" +internal const val GESTURE_FINGER_TABLE = "gesture_finger_table" +internal const val GESTURE_FINGER_MOTION_TABLE = "gesture_finger_motion_table" /** DELETED but kept because referenced in migrations. */ internal const val END_CONDITION_TABLE = "end_condition_table" /** Current version of the database. */ -const val DATABASE_VERSION = 23 \ No newline at end of file +const val DATABASE_VERSION = 24 \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ActionDao.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ActionDao.kt index 783a03e77a..0708e97152 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ActionDao.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/dao/ActionDao.kt @@ -26,10 +26,13 @@ import androidx.room.Update import com.buzbuz.smartautoclicker.core.database.ACTION_TABLE import com.buzbuz.smartautoclicker.core.database.EVENT_TOGGLE_TABLE +import com.buzbuz.smartautoclicker.core.database.GESTURE_FINGER_TABLE import com.buzbuz.smartautoclicker.core.database.INTENT_EXTRA_TABLE import com.buzbuz.smartautoclicker.core.database.entity.ActionEntity import com.buzbuz.smartautoclicker.core.database.entity.CompleteActionEntity import com.buzbuz.smartautoclicker.core.database.entity.EventToggleEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraEntity import kotlinx.coroutines.flow.Flow @@ -140,4 +143,26 @@ abstract class ActionDao { */ @Delete abstract suspend fun deleteEventToggles(eventToggles: List) + + /** + * Delete all gesture fingers for a given action. Cascades to their motions. + * @param actionId the identifier of the gesture action. + */ + @Query("DELETE FROM $GESTURE_FINGER_TABLE WHERE action_id=:actionId") + abstract suspend fun deleteFingersByActionId(actionId: Long) + + /** + * Add a gesture finger to the database. + * @param finger the finger to be added. + * @return the database id of the inserted finger. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun addFinger(finger: GestureFingerEntity): Long + + /** + * Add a list of gesture finger motions to the database. + * @param motions the motions to be added. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun addMotions(motions: List): List } \ 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 0ac0a6baf7..0ea9bc3232 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 @@ -25,6 +25,7 @@ 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.Migration1to2 +import com.buzbuz.smartautoclicker.core.database.migrations.Migration23to24 import com.buzbuz.smartautoclicker.core.database.migrations.Migration2to3 import com.buzbuz.smartautoclicker.core.database.migrations.Migration3to4 import com.buzbuz.smartautoclicker.core.database.migrations.Migration4to5 @@ -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 0fc9af1217..d9ff318d90 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 @@ -16,6 +16,7 @@ */ package com.buzbuz.smartautoclicker.core.database.entity +import android.annotation.SuppressLint import androidx.room.* import com.buzbuz.smartautoclicker.core.base.interfaces.EntityWithId @@ -39,26 +40,6 @@ import kotlinx.serialization.Serializable * @param name the name of this action * @param type type of this action. Must be the this representation of the [ActionType] enum. * - * @param clickPositionType [ActionType.CLICK] only: indicates how the click position is interpreted. If USER_SELECTED, - * [x] and [y] will be used. If ON_DETECTED_CONDITION, the [clickOnConditionId] will be used. - * @param x [ActionType.CLICK] only: the x position of the click. Null for others [ActionType]. - * @param y [ActionType.CLICK] only: the y position of the click. Null for others [ActionType]. - * @param clickOnConditionId [ActionType.CLICK] only: if defined, the condition to click on. If null, the x and y - * coordinates will be used. - * @param pressDuration [ActionType.CLICK] only: the duration of the click press in milliseconds. - * Null for others [ActionType]. - * @param clickOffsetX [ActionType.CLICK] & [ClickPositionType.ON_DETECTED_CONDITION] only: the offset to apply in the X - * axis when clicking on a condition. Null for others [ActionType]. - * @param clickOffsetY [ActionType.CLICK] & [ClickPositionType.ON_DETECTED_CONDITION] only: the offset to apply in the Y - * axis when clicking on a condition. - * - * @param fromX [ActionType.SWIPE] only: the swipe start x coordinates. Null for others [ActionType]. - * @param fromY [ActionType.SWIPE] only: the swipe start y coordinates. Null for others [ActionType]. - * @param toX [ActionType.SWIPE] only: the swipe end x coordinates. Null for others [ActionType]. - * @param toY [ActionType.SWIPE] only: the swipe end y coordinates. Null for others [ActionType]. - * @param swipeDuration [ActionType.SWIPE] only: the delay between the swipe start and end in milliseconds. - * Null for others [ActionType]. - * * @param pauseDuration [ActionType.PAUSE] only: the duration of the pause in milliseconds. * * @param isAdvanced [ActionType.INTENT] only: true if the user have picked advanced intent config, false if simple. @@ -89,9 +70,10 @@ 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. */ +@SuppressLint("UnsafeOptInUsageError") @Entity( tableName = ACTION_TABLE, - indices = [Index("eventId"), Index("clickOnConditionId")], + indices = [Index("eventId")], foreignKeys = [ ForeignKey( entity = EventEntity::class, @@ -99,12 +81,6 @@ import kotlinx.serialization.Serializable childColumns = ["eventId"], onDelete = ForeignKey.CASCADE, ), - ForeignKey( - entity = ConditionEntity::class, - parentColumns = ["id"], - childColumns = ["clickOnConditionId"], - onDelete = ForeignKey.SET_NULL, - ), ] ) @Serializable @@ -115,21 +91,8 @@ data class ActionEntity( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "type") val type: ActionType, - // ActionType.CLICK - @ColumnInfo(name = "clickPositionType") val clickPositionType: ClickPositionType? = null, - @ColumnInfo(name = "x") val x: Int? = null, - @ColumnInfo(name = "y") val y: Int? = null, - @ColumnInfo(name = "clickOnConditionId") var clickOnConditionId: Long? = null, - @ColumnInfo(name = "pressDuration") val pressDuration: Long? = null, - @ColumnInfo(name = "clickOffsetX") val clickOffsetX: Int? = null, - @ColumnInfo(name = "clickOffsetY") val clickOffsetY: Int? = null, - - // ActionType.SWIPE - @ColumnInfo(name = "fromX") val fromX: Int? = null, - @ColumnInfo(name = "fromY") val fromY: Int? = null, - @ColumnInfo(name = "toX") val toX: Int? = null, - @ColumnInfo(name = "toY") val toY: Int? = null, - @ColumnInfo(name = "swipeDuration") val swipeDuration: Long? = null, + // ActionType.CLICK & ActionType.GESTURE + // No specific columns, specificities contained in GestureFingerEntity relation // ActionType.PAUSE @ColumnInfo(name = "pauseDuration") val pauseDuration: Long? = null, @@ -165,14 +128,19 @@ data class ActionEntity( ) : EntityWithId /** - * Entity embedding an intent action and its extras. - * - * Automatically do the junction between action_table and intent_extra_table, and provide this - * representation of the one to many relations between scenario to actions and conditions entities. + * Entity embedding an action and its relations. + * Depending on the [ActionEntity.type], the relevant list will be filled. */ +@SuppressLint("UnsafeOptInUsageError") @Serializable data class CompleteActionEntity( @Embedded val action: ActionEntity, + @Relation( + parentColumn = "id", + entityColumn = "action_id", + entity = GestureFingerEntity::class + ) + val gestureFingers: List, @Relation( parentColumn = "id", entityColumn = "action_id" 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 68b8cf1d52..a3d7dbd2f9 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 @@ -37,8 +37,8 @@ enum class EventType { enum class ActionType { /** A single tap on the screen. */ CLICK, - /** A swipe on the screen. */ - SWIPE, + /** A complex gesture to execute on the screen. */ + GESTURE, /** A pause, waiting before the next action. */ PAUSE, /** An Android Intent, allowing to interact with other applications. */ @@ -56,21 +56,18 @@ enum class ActionType { } -/** - * Type of click position for a [ActionType.CLICK]. - * Indicates how to click on the screen for the action. - */ -enum class ClickPositionType { - /** The user must manually select a position to be clicked. */ - USER_SELECTED, - /** - * Click on the detected condition. - * When the condition operator is AND, click on the condition specified by the user. - * When the condition operator is OR, click on the condition detected condition. - */ - ON_DETECTED_CONDITION, +enum class GestureFingerMotionType { + MOVE, + PRESS, + RELEASE, + WAIT, } +enum class CoordinatesType { + CONDITION, + COUNTER, + LITERAL, +} /** * Type of [ConditionEntity]. @@ -185,4 +182,19 @@ enum class NumberFormatType { enum class NotificationMessageType { TEXT, COUNTER_VALUE; -} \ No newline at end of file +} + +/** + * DELETED but kept because referenced in migrations/deserialization. + * The position of a [ActionType.CLICK] is now defined by its press [GestureFingerMotionEntity]. + */ +enum class ClickPositionType { + /** The user must manually select a position to be clicked. */ + USER_SELECTED, + /** + * Click on the detected condition. + * When the condition operator is AND, click on the condition specified by the user. + * When the condition operator is OR, click on the condition detected condition. + */ + ON_DETECTED_CONDITION, +} diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerEntity.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerEntity.kt new file mode 100644 index 0000000000..289ff76aba --- /dev/null +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerEntity.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. + * + * 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.database.entity + +import android.annotation.SuppressLint +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation + +import com.buzbuz.smartautoclicker.core.base.interfaces.EntityWithId +import com.buzbuz.smartautoclicker.core.database.GESTURE_FINGER_TABLE + +import kotlinx.serialization.Serializable + +/** + * Entity defining the behaviour of a finger during a gesture action. + * + * Each gesture action can have up to 5 gesture fingers. + * Each gesture finger is associated with a list of GestureFingerMotionEntity. + * + * @param id unique identifier of the entity. + * @param actionId the gesture action using this finger. + * @param index the index of the finger within the finger list of the gesture action. Max is 4. + * @param startTimeMs the time at which the finger should start its motions, relative to the gesture begin. Should + * always be 0 when the [index] is 0, as the first finger always starts the gesture. + */ +@SuppressLint("UnsafeOptInUsageError") +@Entity( + tableName = GESTURE_FINGER_TABLE, + indices = [Index("action_id")], + foreignKeys = [ + ForeignKey( + entity = ActionEntity::class, + parentColumns = ["id"], + childColumns = ["action_id"], + onDelete = ForeignKey.CASCADE + ), + ] +) +@Serializable +data class GestureFingerEntity( + @PrimaryKey(autoGenerate = true) override val id: Long, + @ColumnInfo(name = "action_id") val actionId: Long, + @ColumnInfo(name = "index") val index: Int, + @ColumnInfo(name = "start_time_ms") val startTimeMs: Long, +) : EntityWithId + +/** A [GestureFingerEntity] with its [GestureFingerMotionEntity] */ +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class GestureFingerWithMotions( + @Embedded val finger: GestureFingerEntity, + @Relation( + parentColumn = "id", + entityColumn = "finger_id" + ) + val motions: List, +) diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerMotionEntity.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerMotionEntity.kt new file mode 100644 index 0000000000..466819b759 --- /dev/null +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/entity/GestureFingerMotionEntity.kt @@ -0,0 +1,115 @@ +/* + * 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.database.entity + +import android.annotation.SuppressLint +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +import com.buzbuz.smartautoclicker.core.base.interfaces.EntityWithId +import com.buzbuz.smartautoclicker.core.database.GESTURE_FINGER_MOTION_TABLE + +import kotlinx.serialization.Serializable + +/** + * Entity defining a motion for a finger within a gesture action. + * + * A GestureFingerMotionEntity in contained within one GestureFinger and is part of a list of motions, with the + * execution order represented by [index]. + * + * @param id unique identifier of this motion. + * @param fingerId identifier of the finger that should perform this motion + * @param index index of the motion within the list of motions for a finger. + * @param motionType the type of motion this entity represents. + * + * @param durationMs the duration of the motion in milliseconds. For [GestureFingerMotionType.WAIT], it is the time + * spent still with the finger pressed. For [GestureFingerMotionType.MOVE], it is the time to go from start to end. + * For all others [GestureFingerMotionType], it will be null. + * + * @param xCoordinateType the type of coordinates used for the x position of this motion. Null for + * [GestureFingerMotionType.WAIT] & [GestureFingerMotionType.RELEASE], as they do not have coordinates. + * @param yCoordinateType the type of coordinates used for the y position of this motion. Null for + * [GestureFingerMotionType.WAIT] & [GestureFingerMotionType.RELEASE], as they do not have coordinates. + * + * @param conditionId the identifier of the condition to execute the motion on. Only defined + * if [xCoordinateType] & [yCoordinateType] are [CoordinatesType.CONDITION] and if the event condition operator is AND. + * + * @param xLiteral the x position of the motion in pixels. Only defined when [xCoordinateType] is + * [CoordinatesType.LITERAL]. + * @param xCounter the name of the counter containing the value of x in pixels. Only defined when [xCoordinateType] is + * [CoordinatesType.COUNTER]. + * + * @param yLiteral the y position of the motion in pixels. Only defined when [yCoordinateType] is + * [CoordinatesType.LITERAL]. + * @param yCounter the name of the counter containing the value of y in pixels. Only defined when [yCoordinateType] is + * [CoordinatesType.COUNTER]. + * + * @param xOffsetCoordinateType the type of coordinates used for the x position of this motion offset. Null for + * [GestureFingerMotionType.WAIT] & [GestureFingerMotionType.RELEASE], as they do not have coordinates. + * @param yOffsetCoordinateType the type of coordinates used for the y position of this motion offset. Null for + * [GestureFingerMotionType.WAIT] & [GestureFingerMotionType.RELEASE], as they do not have coordinates. + * + * @param xOffsetLiteral the x position of the motion offset in pixels. Only defined when [xOffsetCoordinateType] is + * [CoordinatesType.LITERAL]. + * @param xOffsetCounter the name of the counter containing the value of x offset in pixels. Only defined when + * [xOffsetCoordinateType] is [CoordinatesType.COUNTER]. + * + * @param yOffsetLiteral the y position of the motion offset in pixels. Only defined when [yOffsetCoordinateType] is + * [CoordinatesType.LITERAL]. + * @param yOffsetCounter the name of the counter containing the value of y offset in pixels. Only defined when + * [yOffsetCoordinateType] is [CoordinatesType.COUNTER]. + */ +@SuppressLint("UnsafeOptInUsageError") +@Entity( + tableName = GESTURE_FINGER_MOTION_TABLE, + indices = [Index("finger_id")], + foreignKeys = [ + ForeignKey( + entity = GestureFingerEntity::class, + parentColumns = ["id"], + childColumns = ["finger_id"], + onDelete = ForeignKey.CASCADE, + ), + ] +) +@Serializable +data class GestureFingerMotionEntity( + @PrimaryKey(autoGenerate = true) override val id: Long, + @ColumnInfo(name = "finger_id") val fingerId: Long, + @ColumnInfo(name = "index") val index: Int, + @ColumnInfo(name = "motion_type") val motionType: GestureFingerMotionType, + + @ColumnInfo(name = "duration_ms") val durationMs: Long? = null, + + @ColumnInfo(name = "x_coordinate_type") val xCoordinateType: CoordinatesType? = null, + @ColumnInfo(name = "y_coordinate_type") val yCoordinateType: CoordinatesType? = null, + @ColumnInfo(name = "condition_id") var conditionId: Long? = null, + @ColumnInfo(name = "x_literal") val xLiteral: Int? = null, + @ColumnInfo(name = "x_counter") val xCounter: String? = null, + @ColumnInfo(name = "y_literal") val yLiteral: Int? = null, + @ColumnInfo(name = "y_counter") val yCounter: String? = null, + + @ColumnInfo(name = "x_offset_coordinate_type") val xOffsetCoordinateType: CoordinatesType? = null, + @ColumnInfo(name = "y_offset_coordinate_type") val yOffsetCoordinateType: CoordinatesType? = null, + @ColumnInfo(name = "x_offset_literal") val xOffsetLiteral: Int? = null, + @ColumnInfo(name = "x_offset_counter") val xOffsetCounter: String? = null, + @ColumnInfo(name = "y_offset_literal") val yOffsetLiteral: Int? = null, + @ColumnInfo(name = "y_offset_counter") val yOffsetCounter: String? = null, +) : EntityWithId \ No newline at end of file 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 0000000000..7f8e3d2736 --- /dev/null +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24.kt @@ -0,0 +1,344 @@ +/* + * 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.database.migrations + +import android.content.ContentValues +import androidx.room.ForeignKey +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +import com.buzbuz.smartautoclicker.core.base.migrations.SQLiteColumn +import com.buzbuz.smartautoclicker.core.base.migrations.SQLiteTable +import com.buzbuz.smartautoclicker.core.base.migrations.getSQLiteTableReference +import com.buzbuz.smartautoclicker.core.database.ACTION_TABLE +import com.buzbuz.smartautoclicker.core.database.GESTURE_FINGER_MOTION_TABLE +import com.buzbuz.smartautoclicker.core.database.GESTURE_FINGER_TABLE +import com.buzbuz.smartautoclicker.core.database.entity.ActionType +import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType + + +/** + * Introduces the gesture tables and moves the swipes and clicks into them. + * + * A swipe becomes a gesture with one finger doing a press, a move and a release. A click keeps its + * [ActionType.CLICK] type, but its position and press duration are now stored as one finger doing a press, a wait + * and a release. + */ +object Migration23to24 : Migration(23, 24) { + + private const val LEGACY_ACTION_TYPE_SWIPE = "SWIPE" + /** Press duration used for the clicks having an invalid one, in milliseconds. */ + private const val DEFAULT_PRESS_DURATION_MS = 1L + + private val actionIdColumn = SQLiteColumn.PrimaryKey() + private val actionPriorityColumn = SQLiteColumn.Int("priority") + private val actionNameColumn = SQLiteColumn.Text("name") + private val actionFromXColumn = SQLiteColumn.Int("fromX", isNotNull = false) + private val actionFromYColumn = SQLiteColumn.Int("fromY", isNotNull = false) + private val actionToXColumn = SQLiteColumn.Int("toX", isNotNull = false) + private val actionToYColumn = SQLiteColumn.Int("toY", isNotNull = false) + private val actionSwipeDurationColumn = SQLiteColumn.Long("swipeDuration", isNotNull = false) + private val actionClickPositionTypeColumn = SQLiteColumn.Text("clickPositionType", isNotNull = false) + private val actionXColumn = SQLiteColumn.Int("x", isNotNull = false) + private val actionYColumn = SQLiteColumn.Int("y", isNotNull = false) + private val actionClickOnConditionIdColumn = SQLiteColumn.Long("clickOnConditionId", isNotNull = false) + private val actionPressDurationColumn = SQLiteColumn.Long("pressDuration", isNotNull = false) + private val actionClickOffsetXColumn = SQLiteColumn.Int("clickOffsetX", isNotNull = false) + private val actionClickOffsetYColumn = SQLiteColumn.Int("clickOffsetY", isNotNull = false) + + override fun migrate(db: SupportSQLiteDatabase) { + // Create the new tables with their indexes + db.getSQLiteTableReference(GESTURE_FINGER_TABLE, GestureFingerTableColumns.id).apply { + createTable(columns = GestureFingerTableColumns.allColumns()) + createIndex(GestureFingerTableColumns.actionId) + } + db.getSQLiteTableReference(GESTURE_FINGER_MOTION_TABLE, GestureFingerMotionTableColumns.id).apply { + createTable(columns = GestureFingerMotionTableColumns.allColumns()) + createIndex(GestureFingerMotionTableColumns.fingerId) + } + + // Migrate to gestures + db.migrateClicks() + db.migrateSwipes() + + // Remove all swipe and click columns + db.getSQLiteTableReference(ACTION_TABLE).alterTableDropColumn( + droppedColumns = setOf( + actionFromXColumn.name, actionFromYColumn.name, actionToXColumn.name, actionToYColumn.name, + actionSwipeDurationColumn.name, actionClickPositionTypeColumn.name, actionXColumn.name, + actionYColumn.name, actionClickOnConditionIdColumn.name, actionPressDurationColumn.name, + actionClickOffsetXColumn.name, actionClickOffsetYColumn.name, + ) + ) + } + + private fun SupportSQLiteDatabase.migrateClicks() { + getSQLiteTableReference(ACTION_TABLE).forEachClick { legacyClickData -> + val gestureFingerId = insertGestureFinger(legacyClickData.id) + + getSQLiteTableReference(GESTURE_FINGER_MOTION_TABLE) + .recreateClickWithNewApi(gestureFingerId, legacyClickData) + } + } + + private fun SupportSQLiteDatabase.migrateSwipes() { + val actionTable = getSQLiteTableReference(ACTION_TABLE) + actionTable.forEachSwipe { legacySwipeData -> + val gestureFingerId = insertGestureFinger(legacySwipeData.id) + + getSQLiteTableReference(GESTURE_FINGER_MOTION_TABLE) + .recreateSwipeWithNewApi(gestureFingerId, legacySwipeData) + } + + // Update all swipes to become gestures + actionTable.update( + extraClause = "WHERE `type` = \"${LEGACY_ACTION_TYPE_SWIPE}\"", + contentValues = ContentValues().apply { + put("type", ActionType.GESTURE.name) + } + ) + } + + private fun SupportSQLiteDatabase.insertGestureFinger(actionId: Long): Long = + getSQLiteTableReference(GESTURE_FINGER_TABLE).insertIntoValues( + ContentValues().apply { + put(GestureFingerTableColumns.actionId.name, actionId) + put("`${GestureFingerTableColumns.index.name}`", 0) + put(GestureFingerTableColumns.startTimeMs.name, 0) + } + ) + + private fun SQLiteTable.recreateClickWithNewApi(fingerId: Long, legacyClickData: LegacyClickData) { + // Press, on the position the click was configured with + insertIntoValues( + ContentValues().apply { + put(GestureFingerMotionTableColumns.fingerId.name, fingerId) + put("`${GestureFingerMotionTableColumns.index.name}`", 0) + put(GestureFingerMotionTableColumns.motionType.name, GestureFingerMotionType.PRESS.name) + put(GestureFingerMotionTableColumns.conditionId.name, legacyClickData.clickOnConditionId) + + when (legacyClickData.positionType) { + ClickPositionType.USER_SELECTED -> { + put(GestureFingerMotionTableColumns.xCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.yCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.xLiteral.name, legacyClickData.x) + put(GestureFingerMotionTableColumns.yLiteral.name, legacyClickData.y) + } + + ClickPositionType.ON_DETECTED_CONDITION -> { + put(GestureFingerMotionTableColumns.xCoordinatesType.name, CoordinatesType.CONDITION.name) + put(GestureFingerMotionTableColumns.yCoordinatesType.name, CoordinatesType.CONDITION.name) + + legacyClickData.clickOffsetX?.let { offsetX -> + put(GestureFingerMotionTableColumns.xOffsetCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.xOffsetLiteral.name, offsetX) + } + legacyClickData.clickOffsetY?.let { offsetY -> + put(GestureFingerMotionTableColumns.yOffsetCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.yOffsetLiteral.name, offsetY) + } + } + } + } + ) + + // Wait, for the duration the click was pressing the screen + insertIntoValues( + ContentValues().apply { + put(GestureFingerMotionTableColumns.fingerId.name, fingerId) + put("`${GestureFingerMotionTableColumns.index.name}`", 1) + put(GestureFingerMotionTableColumns.motionType.name, GestureFingerMotionType.WAIT.name) + put(GestureFingerMotionTableColumns.durationMs.name, legacyClickData.pressDurationMs) + } + ) + + // Release + insertRelease(fingerId, index = 2) + } + + private fun SQLiteTable.recreateSwipeWithNewApi(fingerId: Long, legacySwipeData: LegacySwipeData) { + // Press + insertIntoValues( + ContentValues().apply { + put(GestureFingerMotionTableColumns.fingerId.name, fingerId) + put("`${GestureFingerMotionTableColumns.index.name}`", 0) + put(GestureFingerMotionTableColumns.motionType.name, GestureFingerMotionType.PRESS.name) + put(GestureFingerMotionTableColumns.xCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.yCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.xLiteral.name, legacySwipeData.fromX) + put(GestureFingerMotionTableColumns.yLiteral.name, legacySwipeData.fromY) + } + ) + + // Move + insertIntoValues( + ContentValues().apply { + put(GestureFingerMotionTableColumns.fingerId.name, fingerId) + put("`${GestureFingerMotionTableColumns.index.name}`", 1) + put(GestureFingerMotionTableColumns.motionType.name, GestureFingerMotionType.MOVE.name) + put(GestureFingerMotionTableColumns.xCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.yCoordinatesType.name, CoordinatesType.LITERAL.name) + put(GestureFingerMotionTableColumns.xLiteral.name, legacySwipeData.toX) + put(GestureFingerMotionTableColumns.yLiteral.name, legacySwipeData.toY) + put(GestureFingerMotionTableColumns.durationMs.name, legacySwipeData.swipeDurationMs) + } + ) + + // Release + insertRelease(fingerId, index = 2) + } + + private fun SQLiteTable.insertRelease(fingerId: Long, index: Int) { + insertIntoValues( + ContentValues().apply { + put(GestureFingerMotionTableColumns.fingerId.name, fingerId) + put("`${GestureFingerMotionTableColumns.index.name}`", index) + put(GestureFingerMotionTableColumns.motionType.name, GestureFingerMotionType.RELEASE.name) + } + ) + } + + private fun SQLiteTable.forEachClick(closure: (LegacyClickData) -> Unit) { + select( + extraClause = "WHERE `type` = \"${ActionType.CLICK.name}\"", + distinct = false, + actionIdColumn, actionClickPositionTypeColumn, actionXColumn, actionYColumn, + actionClickOnConditionIdColumn, actionPressDurationColumn, actionClickOffsetXColumn, + actionClickOffsetYColumn, + ).use { queryResult -> + queryResult.forEachRow { row -> + val positionTypeName = row.getValue(actionClickPositionTypeColumn) + val positionType = ClickPositionType.entries.find { it.name == positionTypeName } + ?: return@forEachRow + + closure( + LegacyClickData( + id = row.getValue(actionIdColumn) ?: return@forEachRow, + positionType = positionType, + x = row.getNullableValue(actionXColumn), + y = row.getNullableValue(actionYColumn), + clickOnConditionId = row.getNullableValue(actionClickOnConditionIdColumn), + pressDurationMs = row.getNullableValue(actionPressDurationColumn)?.takeIf { it > 0 } + ?: DEFAULT_PRESS_DURATION_MS, + clickOffsetX = row.getNullableValue(actionClickOffsetXColumn), + clickOffsetY = row.getNullableValue(actionClickOffsetYColumn), + ) + ) + } + } + } + + private fun SQLiteTable.forEachSwipe(closure: (LegacySwipeData) -> Unit) { + select( + extraClause = "WHERE `type` = \"${LEGACY_ACTION_TYPE_SWIPE}\"", + distinct = false, + actionIdColumn, actionPriorityColumn, actionNameColumn, actionFromXColumn, + actionFromYColumn, actionToXColumn, actionToYColumn, actionSwipeDurationColumn, + ).use { queryResult -> + queryResult.forEachRow { row -> + closure( + LegacySwipeData( + id = row.getValue(actionIdColumn) ?: return@forEachRow, + name = row.getValue(actionNameColumn) ?: return@forEachRow, + priority = row.getValue(actionPriorityColumn) ?: return@forEachRow, + fromX = row.getValue(actionFromXColumn) ?: return@forEachRow, + fromY = row.getValue(actionFromYColumn) ?: return@forEachRow, + toX = row.getValue(actionToXColumn) ?: return@forEachRow, + toY = row.getValue(actionToYColumn) ?: return@forEachRow, + swipeDurationMs = row.getValue(actionSwipeDurationColumn) ?: return@forEachRow, + ) + ) + } + } + } + + private data class LegacySwipeData( + val id: Long, + val name: String, + val priority: Int, + val fromX: Int, + val fromY: Int, + val toX: Int, + val toY: Int, + val swipeDurationMs: Long, + ) + + private data class LegacyClickData( + val id: Long, + val positionType: ClickPositionType, + val x: Int?, + val y: Int?, + val clickOnConditionId: Long?, + val pressDurationMs: Long, + val clickOffsetX: Int?, + val clickOffsetY: Int?, + ) +} + +private object GestureFingerTableColumns { + + val id = SQLiteColumn.PrimaryKey("id") + val actionId = SQLiteColumn.ForeignKey( + name = "action_id", + referencedTable = ACTION_TABLE, + referencedColumn = "id", + deleteAction = ForeignKey.CASCADE, + ) + + val index = SQLiteColumn.Int("index") + val startTimeMs = SQLiteColumn.Long("start_time_ms") + + + fun allColumns(): Set> = + setOf(actionId, index, startTimeMs) +} + +private object GestureFingerMotionTableColumns { + + val id = SQLiteColumn.PrimaryKey("id") + val fingerId = SQLiteColumn.ForeignKey( + name = "finger_id", + referencedTable = GESTURE_FINGER_TABLE, + referencedColumn = "id", + deleteAction = ForeignKey.CASCADE, + ) + + val index = SQLiteColumn.Int("index") + val motionType = SQLiteColumn.Text("motion_type") + val durationMs = SQLiteColumn.Long("duration_ms", isNotNull = false) + val xCoordinatesType = SQLiteColumn.Text("x_coordinate_type", isNotNull = false) + val yCoordinatesType = SQLiteColumn.Text("y_coordinate_type", isNotNull = false) + val conditionId = SQLiteColumn.Long("condition_id", isNotNull = false) + val xLiteral = SQLiteColumn.Int("x_literal", isNotNull = false) + val xCounter = SQLiteColumn.Text("x_counter", isNotNull = false) + val yLiteral = SQLiteColumn.Int("y_literal", isNotNull = false) + val yCounter = SQLiteColumn.Text("y_counter", isNotNull = false) + val xOffsetCoordinatesType = SQLiteColumn.Text("x_offset_coordinate_type", isNotNull = false) + val yOffsetCoordinatesType = SQLiteColumn.Text("y_offset_coordinate_type", isNotNull = false) + val xOffsetLiteral = SQLiteColumn.Int("x_offset_literal", isNotNull = false) + val xOffsetCounter = SQLiteColumn.Text("x_offset_counter", isNotNull = false) + val yOffsetLiteral = SQLiteColumn.Int("y_offset_literal", isNotNull = false) + val yOffsetCounter = SQLiteColumn.Text("y_offset_counter", isNotNull = false) + + + fun allColumns(): Set> = + setOf(fingerId, index, motionType, durationMs, xCoordinatesType, yCoordinatesType, conditionId, xLiteral, + xCounter, yLiteral, yCounter, xOffsetCoordinatesType, yOffsetCoordinatesType, xOffsetLiteral, + xOffsetCounter, yOffsetLiteral, yOffsetCounter) +} diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6.kt index 150e37a00d..83dcf64e16 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6.kt @@ -54,7 +54,7 @@ object Migration5to6 : Migration(5, 6) { forEachRow(null, columnA = actionIdColumn, columnB = actionTypeColumn) { actionId, actionType -> if (actionId == null || actionType == null) return@forEachRow - if (ActionType.valueOf(actionType) == ActionType.CLICK) + if (actionType == ActionType.CLICK.name) updateClickOnCondition(actionId, 0) } } diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10.kt index ecc59411b3..f989244a48 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10.kt @@ -53,9 +53,9 @@ object Migration9to10 : Migration(9, 10) { db.getSQLiteTableReference(ACTION_TABLE).apply { forEachClicksAndSwipes { id, type, pressDuration, swipeDuration -> if (id == null || type == null || pressDuration == null || swipeDuration == null) return@forEachClicksAndSwipes - when (ActionType.valueOf(type)) { - ActionType.CLICK -> updateClickPressDuration(id, pressDuration.toLimitedDuration()) - ActionType.SWIPE -> updateSwipeDuration(id, swipeDuration.toLimitedDuration()) + when (type) { + ActionType.CLICK.name -> updateClickPressDuration(id, pressDuration.toLimitedDuration()) + "SWIPE" -> updateSwipeDuration(id, swipeDuration.toLimitedDuration()) else -> throw IllegalArgumentException("It should be a click or a swipe") } } @@ -64,7 +64,7 @@ object Migration9to10 : Migration(9, 10) { private fun SQLiteTable.forEachClicksAndSwipes(closure: (Long?, String?, Long?, Long?) -> Unit) { forEachRow( - extraClause = "WHERE `type` = \"${ActionType.CLICK}\" OR `type` = \"${ActionType.SWIPE}\"", + extraClause = "WHERE `type` = \"${ActionType.CLICK}\" OR `type` = \"SWIPE\"", columnA = actionIdColumn, columnB = actionTypeColumn, columnC = actionPressDurationColumn, diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/Deserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/Deserializer.kt index 8bbf8525c8..fffda0a7ba 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/Deserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/Deserializer.kt @@ -16,14 +16,18 @@ */ package com.buzbuz.smartautoclicker.core.database.serialization +import android.os.Build import android.util.Log import com.buzbuz.smartautoclicker.core.database.DATABASE_VERSION +import com.buzbuz.smartautoclicker.core.database.entity.ActionType import com.buzbuz.smartautoclicker.core.database.entity.CompleteScenario +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType import com.buzbuz.smartautoclicker.core.database.serialization.compat.CompatDeserializer import com.buzbuz.smartautoclicker.core.database.serialization.compat.CompatV11Deserializer import com.buzbuz.smartautoclicker.core.database.serialization.compat.CompatV13Deserializer import com.buzbuz.smartautoclicker.core.database.serialization.compat.CompatV20Deserializer +import com.buzbuz.smartautoclicker.core.database.serialization.compat.CompatV24Deserializer import kotlinx.serialization.json.JsonObject @@ -40,6 +44,7 @@ object DeserializerFactory { databaseVersion < VERSION_DETECTION_QUALITY_UPDATE -> CompatV11Deserializer() databaseVersion < VERSION_ADVANCED_AUTOMATION_UPDATE -> CompatV13Deserializer() databaseVersion < VERSION_COUNTER_TYPE_UPDATE -> CompatV20Deserializer() + databaseVersion < VERSION_GESTURE_UPDATE -> CompatV24Deserializer() databaseVersion < VERSION_UP_TO_DATE -> CompatDeserializer() databaseVersion == VERSION_UP_TO_DATE -> KotlinDeserializer() @@ -51,6 +56,8 @@ object DeserializerFactory { /** Maximum json object version supported. */ private const val VERSION_UP_TO_DATE = DATABASE_VERSION + /** Multitouch gesture version. */ + private const val VERSION_GESTURE_UPDATE = 24 /** Number & Text detection update. */ private const val VERSION_COUNTER_TYPE_UPDATE = 20 /** Trigger events update version. */ @@ -64,6 +71,48 @@ object DeserializerFactory { private const val TAG = "ScenarioDeserializerFactory" } -interface Deserializer { - fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario -} \ No newline at end of file +abstract class Deserializer { + + /** Deserialize a complete scenario, and remove from it everything the device can't execute. */ + fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario { + val completeScenario = deserialize(jsonCompleteScenario) + + // Holding a finger still requires stroke continuation, only available from Android O. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Log.w(TAG, "Removing all gesture finger wait motions, not supported on this Android version.") + return completeScenario.withoutGestureWaitMotions() + } + + return completeScenario + } + + /** Deserialize a complete scenario from a json of the versions handled by this deserializer. */ + protected abstract fun deserialize(jsonCompleteScenario: JsonObject): CompleteScenario + + private companion object { + /** Tag for logs. */ + const val TAG = "Deserializer" + } +} + +private fun CompleteScenario.withoutGestureWaitMotions(): CompleteScenario = + copy( + events = events.map { completeEvent -> + completeEvent.copy( + actions = completeEvent.actions.map { completeAction -> + // A click wait motion is only there to hold its press duration, it is never continuing a stroke + if (completeAction.action.type == ActionType.CLICK) return@map completeAction + + completeAction.copy( + gestureFingers = completeAction.gestureFingers.map { fingerWithMotions -> + fingerWithMotions.copy( + motions = fingerWithMotions.motions.filter { motion -> + motion.motionType != GestureFingerMotionType.WAIT + }, + ) + }, + ) + }, + ) + }, + ) \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/KotlinDeserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/KotlinDeserializer.kt index 128bb25707..c92b519734 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/KotlinDeserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/KotlinDeserializer.kt @@ -22,8 +22,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement -internal class KotlinDeserializer : Deserializer { +internal class KotlinDeserializer : Deserializer() { - override fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario = + override fun deserialize(jsonCompleteScenario: JsonObject): CompleteScenario = Json.decodeFromJsonElement(jsonCompleteScenario) } \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatDeserializer.kt index 227563f881..41e0f84a3a 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 @@ -46,6 +46,11 @@ import com.buzbuz.smartautoclicker.core.database.entity.EventEntity import com.buzbuz.smartautoclicker.core.database.entity.EventToggleEntity import com.buzbuz.smartautoclicker.core.database.entity.EventToggleType import com.buzbuz.smartautoclicker.core.database.entity.EventType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerWithMotions import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraEntity import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraType import com.buzbuz.smartautoclicker.core.database.entity.NumberFormatType @@ -57,7 +62,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject -internal open class CompatDeserializer : Deserializer { +internal open class CompatDeserializer : Deserializer() { protected companion object { /** Scenario detection quality lower bound on compat deserialization. */ @@ -94,8 +99,6 @@ internal open class CompatDeserializer : Deserializer { const val DURATION_GESTURE_UPPER_BOUND = 59_999L /** Default click duration in ms on compat deserialization. */ const val DEFAULT_CLICK_DURATION = 1L - /** Default swipe duration in ms on compat deserialization. */ - const val DEFAULT_SWIPE_DURATION = 250L /** Default pause duration in ms on compat deserialization. */ const val DEFAULT_PAUSE_DURATION = 50L @@ -103,7 +106,7 @@ internal open class CompatDeserializer : Deserializer { private const val TAG = "DeserializerCompat" } - override fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario { + override fun deserialize(jsonCompleteScenario: JsonObject): CompleteScenario { val scenarioEntity = jsonCompleteScenario.getJsonObject("scenario")?.let(::deserializeScenario) ?: throw IllegalArgumentException("Json CompleteScenario doesn't contains a valid Scenario") @@ -193,10 +196,15 @@ internal open class CompatDeserializer : Deserializer { ?.getListOf { jsonEventToggle -> deserializeEventToggle(jsonEventToggle, scenarioEvents)} ?: emptyList() + val gestureFingerList = jsonCompleteAction.getJsonArray("gestureFingers") + ?.getListOf { jsonGestureFinger -> deserializeGestureFinger(jsonGestureFinger)} + ?: emptyList() + return CompleteActionEntity( action = actionEntity, intentExtras = extraEntityList, eventsToggle = eventToggleList, + gestureFingers = gestureFingerList, ) } @@ -477,7 +485,7 @@ internal open class CompatDeserializer : Deserializer { ): ActionEntity? = when (deserializeActionType(jsonAction)) { ActionType.CLICK -> deserializeActionClick(jsonAction, eventConditions, conditionsOperator) - ActionType.SWIPE -> deserializeActionSwipe(jsonAction) + ActionType.GESTURE -> deserializeActionGesture(jsonAction) ActionType.PAUSE -> deserializeActionPause(jsonAction) ActionType.INTENT -> deserializeActionIntent(jsonAction) ActionType.TOGGLE_EVENT -> deserializeActionToggleEvent(jsonAction) @@ -488,6 +496,7 @@ internal open class CompatDeserializer : Deserializer { null -> null } + /** The position and press duration of a click are contained in its gesture fingers. */ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) open fun deserializeActionClick( jsonClick: JsonObject, @@ -497,78 +506,26 @@ internal open class CompatDeserializer : Deserializer { val id = jsonClick.getLong("id", true) ?: return null val eventId = jsonClick.getLong("eventId", true) ?: return null - val x: Int? - val y: Int? - val clickOnConditionId: Long? - val clickPositionType = jsonClick.getEnum("clickPositionType", true) - ?: return null - val clickOffsetX: Int? - val clickOffsetY: Int? - - val isAndConditionsOperator = conditionsOperator == 1 - - when (clickPositionType) { - ClickPositionType.ON_DETECTED_CONDITION -> { - x = null - y = null - clickOnConditionId = jsonClick.getLong("clickOnConditionId", isAndConditionsOperator) - if (isAndConditionsOperator && (clickOnConditionId == null || !eventConditions.containsId(clickOnConditionId))) { - Log.w(TAG, "Can't deserialize action, clickOnConditionId is not valid.") - return null - } - clickOffsetX = jsonClick.getInt("clickOffsetX") - clickOffsetY = jsonClick.getInt("clickOffsetY") - } - - ClickPositionType.USER_SELECTED -> { - x = jsonClick.getInt("x", true) ?: return null - y = jsonClick.getInt("y", true) ?: return null - clickOnConditionId = null - clickOffsetX = null - clickOffsetY = null - } - } - return ActionEntity( id = id, eventId = eventId, name = jsonClick.getString("name") ?: "", priority = jsonClick.getInt("priority")?.coerceAtLeast(0) ?: 0, type = ActionType.CLICK, - clickPositionType = clickPositionType, - clickOnConditionId = clickOnConditionId, - x = x, - y = y, - pressDuration = jsonClick.getLong("pressDuration") - ?.coerceIn(DURATION_LOWER_BOUND..DURATION_GESTURE_UPPER_BOUND) - ?: DEFAULT_CLICK_DURATION, - clickOffsetX = clickOffsetX, - clickOffsetY = clickOffsetY, ) } @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) - open fun deserializeActionSwipe(jsonSwipe: JsonObject): ActionEntity? { + open fun deserializeActionGesture(jsonSwipe: JsonObject): ActionEntity? { val id = jsonSwipe.getLong("id", true) ?: return null val eventId = jsonSwipe.getLong("eventId", true) ?: return null - val fromX = jsonSwipe.getInt("fromX", true) ?: return null - val fromY = jsonSwipe.getInt("fromY", true) ?: return null - val toX = jsonSwipe.getInt("toX", true) ?: return null - val toY = jsonSwipe.getInt("toY", true) ?: return null return ActionEntity( id = id, eventId = eventId, name = jsonSwipe.getString("name") ?: "", priority = jsonSwipe.getInt("priority")?.coerceAtLeast(0) ?: 0, - type = ActionType.SWIPE, - fromX = fromX, - fromY = fromY, - toX = toX, - toY = toY, - swipeDuration = jsonSwipe.getLong("swipeDuration") - ?.coerceIn(DURATION_LOWER_BOUND..DURATION_GESTURE_UPPER_BOUND) - ?: DEFAULT_SWIPE_DURATION, + type = ActionType.GESTURE, ) } @@ -737,6 +694,52 @@ internal open class CompatDeserializer : Deserializer { return EventToggleEntity(id, actionId, type, toggleEventId) } + + // ======================= GESTURES + + /** @return the deserialized gesture finger with its motions. */ + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + open fun deserializeGestureFinger(jsonGestureFingerWithMotions: JsonObject): GestureFingerWithMotions? { + val jsonFinger = jsonGestureFingerWithMotions.getJsonObject("finger", true) ?: return null + + val finger = GestureFingerEntity( + id = jsonFinger.getLong("id", true) ?: return null, + actionId = jsonFinger.getLong("actionId", true) ?: return null, + index = jsonFinger.getInt("index", true) ?: return null, + startTimeMs = jsonFinger.getLong("startTimeMs") ?: 0L, + ) + + val motions = jsonGestureFingerWithMotions.getJsonArray("motions") + ?.getListOf(::deserializeGestureFingerMotion) + ?: emptyList() + + return GestureFingerWithMotions(finger = finger, motions = motions) + } + + /** @return the deserialized gesture finger motion. */ + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + open fun deserializeGestureFingerMotion(jsonMotion: JsonObject): GestureFingerMotionEntity? = + GestureFingerMotionEntity( + id = jsonMotion.getLong("id", true) ?: return null, + fingerId = jsonMotion.getLong("fingerId", true) ?: return null, + index = jsonMotion.getInt("index", true) ?: return null, + motionType = jsonMotion.getEnum("motionType", true) ?: return null, + durationMs = jsonMotion.getLong("durationMs"), + xCoordinateType = jsonMotion.getEnum("xCoordinateType"), + yCoordinateType = jsonMotion.getEnum("yCoordinateType"), + conditionId = jsonMotion.getLong("conditionId"), + xLiteral = jsonMotion.getInt("xLiteral"), + xCounter = jsonMotion.getString("xCounter"), + yLiteral = jsonMotion.getInt("yLiteral"), + yCounter = jsonMotion.getString("yCounter"), + xOffsetCoordinateType = jsonMotion.getEnum("xOffsetCoordinateType"), + yOffsetCoordinateType = jsonMotion.getEnum("yOffsetCoordinateType"), + xOffsetLiteral = jsonMotion.getInt("xOffsetLiteral"), + xOffsetCounter = jsonMotion.getString("xOffsetCounter"), + yOffsetLiteral = jsonMotion.getInt("yOffsetLiteral"), + yOffsetCounter = jsonMotion.getString("yOffsetCounter"), + ) + // ======================= COMPAT METHODS @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11Deserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11Deserializer.kt index a1fe8516f4..2c63a00d19 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11Deserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11Deserializer.kt @@ -18,10 +18,7 @@ package com.buzbuz.smartautoclicker.core.database.serialization.compat import com.buzbuz.smartautoclicker.core.base.extensions.getBoolean import com.buzbuz.smartautoclicker.core.base.extensions.getInt -import com.buzbuz.smartautoclicker.core.base.extensions.getLong -import com.buzbuz.smartautoclicker.core.base.extensions.getString import com.buzbuz.smartautoclicker.core.database.entity.ActionEntity -import com.buzbuz.smartautoclicker.core.database.entity.ActionType import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType import com.buzbuz.smartautoclicker.core.database.entity.ConditionEntity @@ -43,42 +40,22 @@ internal open class CompatV11Deserializer : CompatV13Deserializer() { eventConditions: List, conditionsOperator: Int, ): ActionEntity? { - - val id = jsonClick.getLong("id", true) ?: return null - val eventId = jsonClick.getLong("eventId", true) ?: return null val clickOnCondition = jsonClick.getBoolean("clickOnCondition") ?: return null - val x: Int? - val y: Int? - val clickOnConditionId: Long? - val clickPositionType: ClickPositionType - - if (clickOnCondition) { - x = null - y = null - clickOnConditionId = eventConditions.find { it.shouldBeDetected == true }?.id - clickPositionType = ClickPositionType.ON_DETECTED_CONDITION + return if (clickOnCondition) { + deserializeLegacyClick( + jsonClick = jsonClick, + positionType = ClickPositionType.ON_DETECTED_CONDITION, + clickOnConditionId = eventConditions.find { it.shouldBeDetected == true }?.id, + ) } else { - x = jsonClick.getInt("x", true) ?: return null - y = jsonClick.getInt("y", true) ?: return null - clickOnConditionId = null - clickPositionType = ClickPositionType.USER_SELECTED + deserializeLegacyClick( + jsonClick = jsonClick, + positionType = ClickPositionType.USER_SELECTED, + x = jsonClick.getInt("x", true) ?: return null, + y = jsonClick.getInt("y", true) ?: return null, + ) } - - return ActionEntity( - id = id, - eventId = eventId, - name = jsonClick.getString("name") ?: "", - priority = jsonClick.getInt("priority")?.coerceAtLeast(0) ?: 0, - type = ActionType.CLICK, - clickPositionType = clickPositionType, - clickOnConditionId = clickOnConditionId, - x = x, - y = y, - pressDuration = jsonClick.getLong("pressDuration") - ?.coerceIn(DURATION_LOWER_BOUND..DURATION_GESTURE_UPPER_BOUND) - ?: DEFAULT_CLICK_DURATION, - ) } } \ No newline at end of file diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV13Deserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV13Deserializer.kt index 98ea2ff1d4..5083a227c1 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV13Deserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV13Deserializer.kt @@ -71,14 +71,14 @@ internal open class CompatV13Deserializer : CompatV20Deserializer() { private var currentTemporaryId: Long = -1 - override fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario { + override fun deserialize(jsonCompleteScenario: JsonObject): CompleteScenario { legacyEndConditions.clear() legacyEndConditionOperator = 0 toggleEventAggregation.clear() newEventToggles.clear() currentTemporaryId = -1 - return super.deserializeCompleteScenario(jsonCompleteScenario) + return super.deserialize(jsonCompleteScenario) .migrateToEventToggle() .migrateLegacyEndConditions(jsonCompleteScenario) } @@ -158,6 +158,7 @@ internal open class CompatV13Deserializer : CompatV20Deserializer() { action = aggregatorCompleteAction.action, intentExtras = emptyList(), eventsToggle = toggleEvents, + gestureFingers = emptyList(), ) } else completeActionEntity } @@ -232,7 +233,7 @@ internal open class CompatV13Deserializer : CompatV20Deserializer() { add(CompleteEventEntity( event = stopTriggerEvent, conditions = stopTriggerEventConditions, - actions = listOf(CompleteActionEntity(stopScenarioAction, emptyList(), emptyList())), + actions = listOf(CompleteActionEntity(stopScenarioAction, emptyList(), emptyList(), emptyList())), )) addAll( @@ -242,7 +243,7 @@ internal open class CompatV13Deserializer : CompatV20Deserializer() { event = completeEvent.event, conditions = completeEvent.conditions, actions = completeEvent.actions.toMutableList().apply { - add(CompleteActionEntity(execCountAction, emptyList(), emptyList())) + add(CompleteActionEntity(execCountAction, emptyList(), emptyList(), emptyList())) } ) } ?: completeEvent diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV20Deserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV20Deserializer.kt index 4c931e88e9..790cea1f37 100644 --- a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV20Deserializer.kt +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV20Deserializer.kt @@ -31,10 +31,10 @@ import kotlinx.serialization.json.JsonObject /** Deserializer for all Json object version below 20. */ -internal open class CompatV20Deserializer : CompatDeserializer() { +internal open class CompatV20Deserializer : CompatV24Deserializer() { - override fun deserializeCompleteScenario(jsonCompleteScenario: JsonObject): CompleteScenario = - super.deserializeCompleteScenario(jsonCompleteScenario) + override fun deserialize(jsonCompleteScenario: JsonObject): CompleteScenario = + super.deserialize(jsonCompleteScenario) .migrateToCounterTable() override fun deserializeCounterConditionValue(jsonCounterCondition: JsonObject): Double = diff --git a/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24Deserializer.kt b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24Deserializer.kt new file mode 100644 index 0000000000..922e3f59ed --- /dev/null +++ b/core/smart/database/src/main/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24Deserializer.kt @@ -0,0 +1,260 @@ +/* + * 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.database.serialization.compat + +import android.util.Log + +import com.buzbuz.smartautoclicker.core.base.extensions.getEnum +import com.buzbuz.smartautoclicker.core.base.extensions.getInt +import com.buzbuz.smartautoclicker.core.base.extensions.getLong +import com.buzbuz.smartautoclicker.core.base.extensions.getString +import com.buzbuz.smartautoclicker.core.base.interfaces.containsId +import com.buzbuz.smartautoclicker.core.database.entity.ActionEntity +import com.buzbuz.smartautoclicker.core.database.entity.ActionType +import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.database.entity.CompleteActionEntity +import com.buzbuz.smartautoclicker.core.database.entity.ConditionEntity +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.EventEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerWithMotions + +import kotlinx.serialization.json.JsonObject + + +/** + * Deserializer for all JSON object version below 24. + * + * Before 24, the clicks and the swipes were declaring their own columns in the action table. They are now both + * defined by a list of gesture fingers: a swipe becomes a gesture with one finger doing a press, a move and a + * release, while a click keeps its [ActionType.CLICK] type but stores its position and press duration in one finger + * doing a press, a wait and a release. + */ +internal open class CompatV24Deserializer : CompatDeserializer() { + + private companion object { + const val LEGACY_ACTION_TYPE_SWIPE = "SWIPE" + /** Default swipe duration in ms on compat deserialization. */ + const val DEFAULT_SWIPE_DURATION = 250L + + /** Tag for logs */ + const val TAG = "CompatV24Deserializer" + } + + /** The fingers created for the action being deserialized. Reset for each of them. */ + private val migratedGestureFingers: MutableList = mutableListOf() + private var currentTemporaryId: Long = -1 + + override fun deserializeCompleteAction( + jsonCompleteAction: JsonObject, + scenarioEvents: List, + eventConditions: List, + conditionsOperator: Int + ): CompleteActionEntity? { + migratedGestureFingers.clear() + + val completeAction = + super.deserializeCompleteAction(jsonCompleteAction, scenarioEvents, eventConditions, conditionsOperator) + ?: return null + + return if (migratedGestureFingers.isEmpty()) completeAction + else completeAction.copy(gestureFingers = migratedGestureFingers.toList()) + } + + override fun deserializeAction( + jsonAction: JsonObject, + eventConditions: List, + conditionsOperator: Int, + ): ActionEntity? = + if (jsonAction.getString("type") == LEGACY_ACTION_TYPE_SWIPE) deserializeLegacySwipe(jsonAction) + else super.deserializeAction(jsonAction, eventConditions, conditionsOperator) + + override fun deserializeActionClick( + jsonClick: JsonObject, + eventConditions: List, + conditionsOperator: Int, + ): ActionEntity? { + val clickPositionType = jsonClick.getEnum("clickPositionType", true) + ?: return null + + return when (clickPositionType) { + ClickPositionType.ON_DETECTED_CONDITION -> { + val isAndConditionsOperator = conditionsOperator == 1 + val clickOnConditionId = jsonClick.getLong("clickOnConditionId", isAndConditionsOperator) + if (isAndConditionsOperator && (clickOnConditionId == null || !eventConditions.containsId(clickOnConditionId))) { + Log.w(TAG, "Can't deserialize action, clickOnConditionId is not valid.") + return null + } + + deserializeLegacyClick( + jsonClick = jsonClick, + positionType = clickPositionType, + clickOnConditionId = clickOnConditionId, + clickOffsetX = jsonClick.getInt("clickOffsetX"), + clickOffsetY = jsonClick.getInt("clickOffsetY"), + ) + } + + ClickPositionType.USER_SELECTED -> + deserializeLegacyClick( + jsonClick = jsonClick, + positionType = clickPositionType, + x = jsonClick.getInt("x", true) ?: return null, + y = jsonClick.getInt("y", true) ?: return null, + ) + } + } + + protected fun deserializeLegacyClick( + jsonClick: JsonObject, + positionType: ClickPositionType, + x: Int? = null, + y: Int? = null, + clickOnConditionId: Long? = null, + clickOffsetX: Int? = null, + clickOffsetY: Int? = null, + ): ActionEntity? { + val id = jsonClick.getLong("id", true) ?: return null + val eventId = jsonClick.getLong("eventId", true) ?: return null + val pressDurationMs = jsonClick.getLong("pressDuration") + ?.coerceIn(DURATION_LOWER_BOUND..DURATION_GESTURE_UPPER_BOUND) + ?: DEFAULT_CLICK_DURATION + + val isOnCondition = positionType == ClickPositionType.ON_DETECTED_CONDITION + addMigratedGestureFinger( + actionId = id, + motions = { fingerId -> + listOf( + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else CoordinatesType.LITERAL, + yCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else CoordinatesType.LITERAL, + conditionId = clickOnConditionId, + xLiteral = x, + yLiteral = y, + xOffsetCoordinateType = clickOffsetX?.let { CoordinatesType.LITERAL }, + yOffsetCoordinateType = clickOffsetY?.let { CoordinatesType.LITERAL }, + xOffsetLiteral = clickOffsetX, + yOffsetLiteral = clickOffsetY, + ), + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 1, + motionType = GestureFingerMotionType.WAIT, + durationMs = pressDurationMs, + ), + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 2, + motionType = GestureFingerMotionType.RELEASE, + ), + ) + }, + ) + + return ActionEntity( + id = id, + eventId = eventId, + name = jsonClick.getString("name") ?: "", + priority = jsonClick.getInt("priority")?.coerceAtLeast(0) ?: 0, + type = ActionType.CLICK, + ) + } + + private fun deserializeLegacySwipe(jsonSwipe: JsonObject): ActionEntity? { + val id = jsonSwipe.getLong("id", true) ?: return null + val eventId = jsonSwipe.getLong("eventId", true) ?: return null + val fromX = jsonSwipe.getInt("fromX", true) ?: return null + val fromY = jsonSwipe.getInt("fromY", true) ?: return null + val toX = jsonSwipe.getInt("toX", true) ?: return null + val toY = jsonSwipe.getInt("toY", true) ?: return null + val swipeDurationMs = jsonSwipe.getLong("swipeDuration") + ?.coerceIn(DURATION_LOWER_BOUND..DURATION_GESTURE_UPPER_BOUND) + ?: DEFAULT_SWIPE_DURATION + + addMigratedGestureFinger( + actionId = id, + motions = { fingerId -> + listOf( + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.LITERAL, + yCoordinateType = CoordinatesType.LITERAL, + xLiteral = fromX, + yLiteral = fromY, + ), + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 1, + motionType = GestureFingerMotionType.MOVE, + xCoordinateType = CoordinatesType.LITERAL, + yCoordinateType = CoordinatesType.LITERAL, + xLiteral = toX, + yLiteral = toY, + durationMs = swipeDurationMs, + ), + GestureFingerMotionEntity( + id = getNextId(), + fingerId = fingerId, + index = 2, + motionType = GestureFingerMotionType.RELEASE, + ), + ) + }, + ) + + return ActionEntity( + id = id, + eventId = eventId, + name = jsonSwipe.getString("name") ?: "", + priority = jsonSwipe.getInt("priority")?.coerceAtLeast(0) ?: 0, + type = ActionType.GESTURE, + ) + } + + /** Clicks and swipes were supporting only one finger, so there is never more than one to create. */ + private fun addMigratedGestureFinger( + actionId: Long, + motions: (fingerId: Long) -> List, + ) { + val fingerId = getNextId() + migratedGestureFingers.add( + GestureFingerWithMotions( + finger = GestureFingerEntity( + id = fingerId, + actionId = actionId, + index = 0, + startTimeMs = 0, + ), + motions = motions(fingerId), + ) + ) + } + + private fun getNextId(): Long = --currentTemporaryId +} 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 0000000000..21ac28a6d9 --- /dev/null +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration23to24Tests.kt @@ -0,0 +1,587 @@ +/* + * 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.database.migrations + +import android.content.Context +import android.os.Build + +import androidx.room.testing.MigrationTestHelper +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.entity.ActionType +import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.utils.assertColumnEquals +import com.buzbuz.smartautoclicker.core.database.utils.assertColumnNull +import com.buzbuz.smartautoclicker.core.database.utils.assertCountEquals +import com.buzbuz.smartautoclicker.core.database.utils.getInsertV23Click +import com.buzbuz.smartautoclicker.core.database.utils.getInsertV23EmptyAction +import com.buzbuz.smartautoclicker.core.database.utils.getInsertV23Swipe +import com.buzbuz.smartautoclicker.core.database.utils.getV24Actions +import com.buzbuz.smartautoclicker.core.database.utils.getV24GestureFingerMotionsForFinger +import com.buzbuz.smartautoclicker.core.database.utils.getV24GestureFingersForAction +import com.buzbuz.smartautoclicker.core.database.utils.getV24GestureFingers +import com.buzbuz.smartautoclicker.core.database.utils.getV24GestureFingerMotions + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +import org.robolectric.annotation.Config + +/** Tests the [Migration23to24]. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class Migration23to24Tests { + + private companion object { + private const val OLD_DB_VERSION = 23 + private const val NEW_DB_VERSION = 24 + + private const val ACTION_ID_COLUMN = "id" + private const val ACTION_TYPE_COLUMN = "type" + private const val FINGER_ACTION_ID_COLUMN = "action_id" + private const val FINGER_INDEX_COLUMN = "index" + private const val FINGER_START_TIME_MS_COLUMN = "start_time_ms" + private const val MOTION_FINGER_ID_COLUMN = "finger_id" + private const val MOTION_INDEX_COLUMN = "index" + private const val MOTION_TYPE_COLUMN = "motion_type" + private const val MOTION_DURATION_MS_COLUMN = "duration_ms" + private const val MOTION_X_COORD_TYPE_COLUMN = "x_coordinate_type" + private const val MOTION_Y_COORD_TYPE_COLUMN = "y_coordinate_type" + private const val MOTION_X_LITERAL_COLUMN = "x_literal" + private const val MOTION_Y_LITERAL_COLUMN = "y_literal" + private const val MOTION_CONDITION_ID_COLUMN = "condition_id" + private const val MOTION_X_OFFSET_COORD_TYPE_COLUMN = "x_offset_coordinate_type" + private const val MOTION_Y_OFFSET_COORD_TYPE_COLUMN = "y_offset_coordinate_type" + private const val MOTION_X_OFFSET_LITERAL_COLUMN = "x_offset_literal" + private const val MOTION_Y_OFFSET_LITERAL_COLUMN = "y_offset_literal" + } + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + ClickDatabase::class.java, + ) + + private lateinit var dbPath: String + + @Before + fun setUp() { + dbPath = ApplicationProvider + .getApplicationContext() + .getDatabasePath("migration-test").path + } + + @Test + fun migrate_noSwipes_gestureTablesAreEmpty() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23EmptyAction(1L, 1L, "click", ActionType.CLICK.name, 0)) + execSQL(getInsertV23EmptyAction(2L, 1L, "pause", ActionType.PAUSE.name, 1)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24GestureFingers()).use { cursor -> + cursor.assertCountEquals(0) + } + dbV24.query(getV24GestureFingerMotions()).use { cursor -> + cursor.assertCountEquals(0) + } + + dbV24.close() + } + + @Test + fun migrate_noSwipes_nonSwipeActionTypesUnchanged() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23EmptyAction(1L, 1L, "click", ActionType.CLICK.name, 0)) + execSQL(getInsertV23EmptyAction(2L, 1L, "pause", ActionType.PAUSE.name, 1)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24Actions()).use { cursor -> + cursor.assertCountEquals(2) + cursor.moveToFirst() + cursor.assertColumnEquals(ActionType.CLICK.name, ACTION_TYPE_COLUMN) + cursor.moveToNext() + cursor.assertColumnEquals(ActionType.PAUSE.name, ACTION_TYPE_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_oneSwipe_actionTypeBecomesGesture() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(1L, 1L, "swipe", 10, 20, 30, 40, 500L, 0)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24Actions()).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(ActionType.GESTURE.name, ACTION_TYPE_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_oneSwipe_oneFingerCreated() { + val actionId = 1L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(actionId, 1L, "swipe", 10, 20, 30, 40, 500L, 0)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(actionId, FINGER_ACTION_ID_COLUMN) + cursor.assertColumnEquals(0, FINGER_INDEX_COLUMN) + cursor.assertColumnEquals(0L, FINGER_START_TIME_MS_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_oneSwipe_threeMotionsCreated() { + val actionId = 1L + val fromX = 10 + val fromY = 20 + val toX = 30 + val toY = 40 + val swipeDuration = 500L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(actionId, 1L, "swipe", fromX, fromY, toX, toY, swipeDuration, 0)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.moveToFirst() + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.assertCountEquals(3) + + // PRESS: finger down at (fromX, fromY) + cursor.moveToFirst() + cursor.assertColumnEquals(0, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.PRESS.name, MOTION_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_Y_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(fromX, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(fromY, MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_DURATION_MS_COLUMN) + + // MOVE: drag to (toX, toY) over swipeDuration + cursor.moveToNext() + cursor.assertColumnEquals(1, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.MOVE.name, MOTION_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_Y_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(toX, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(toY, MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnEquals(swipeDuration, MOTION_DURATION_MS_COLUMN) + + // RELEASE: finger up, no coordinates + cursor.moveToNext() + cursor.assertColumnEquals(2, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.RELEASE.name, MOTION_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_Y_COORD_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_X_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_DURATION_MS_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_multipleSwipes_eachGetsOwnFingerAndMotions() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(1L, 1L, "swipe1", 10, 20, 30, 40, 100L, 0)) + execSQL(getInsertV23Swipe(2L, 1L, "swipe2", 50, 60, 70, 80, 200L, 1)) + execSQL(getInsertV23Swipe(3L, 1L, "swipe3", 90, 100, 110, 120, 300L, 2)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24Actions()).use { cursor -> + cursor.assertCountEquals(3) + cursor.moveToFirst(); cursor.assertColumnEquals(ActionType.GESTURE.name, ACTION_TYPE_COLUMN) + cursor.moveToNext(); cursor.assertColumnEquals(ActionType.GESTURE.name, ACTION_TYPE_COLUMN) + cursor.moveToNext(); cursor.assertColumnEquals(ActionType.GESTURE.name, ACTION_TYPE_COLUMN) + } + + dbV24.query(getV24GestureFingers()).use { cursor -> + cursor.assertCountEquals(3) + } + + dbV24.query(getV24GestureFingerMotions()).use { cursor -> + cursor.assertCountEquals(9) + } + + dbV24.close() + } + + @Test + fun migrate_multipleSwipes_coordinatesAreCorrectPerSwipe() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(1L, 1L, "swipe1", 10, 20, 30, 40, 100L, 0)) + execSQL(getInsertV23Swipe(2L, 1L, "swipe2", 50, 60, 70, 80, 200L, 1)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + // Verify first swipe + val finger1Id = dbV24.query(getV24GestureFingersForAction(1L)).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(1L, FINGER_ACTION_ID_COLUMN) + cursor.getLong(cursor.getColumnIndex("id")) + } + dbV24.query(getV24GestureFingerMotionsForFinger(finger1Id)).use { cursor -> + cursor.assertCountEquals(3) + cursor.moveToFirst() + cursor.assertColumnEquals(10, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(20, MOTION_Y_LITERAL_COLUMN) + cursor.moveToNext() + cursor.assertColumnEquals(30, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(40, MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnEquals(100L, MOTION_DURATION_MS_COLUMN) + } + + // Verify second swipe + val finger2Id = dbV24.query(getV24GestureFingersForAction(2L)).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(2L, FINGER_ACTION_ID_COLUMN) + cursor.getLong(cursor.getColumnIndex("id")) + } + dbV24.query(getV24GestureFingerMotionsForFinger(finger2Id)).use { cursor -> + cursor.assertCountEquals(3) + cursor.moveToFirst() + cursor.assertColumnEquals(50, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(60, MOTION_Y_LITERAL_COLUMN) + cursor.moveToNext() + cursor.assertColumnEquals(70, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(80, MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnEquals(200L, MOTION_DURATION_MS_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_userSelectedClick_actionTypeStaysClick() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = 1L, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.USER_SELECTED.name, + pressDuration = 250L, x = 10, y = 20, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24Actions()).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(ActionType.CLICK.name, ACTION_TYPE_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_userSelectedClick_threeMotionsCreated() { + val actionId = 1L + val x = 10 + val y = 20 + val pressDuration = 250L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = actionId, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.USER_SELECTED.name, + pressDuration = pressDuration, x = x, y = y, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(0, FINGER_INDEX_COLUMN) + cursor.assertColumnEquals(0L, FINGER_START_TIME_MS_COLUMN) + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.assertCountEquals(3) + + // PRESS: finger down on the click position + cursor.moveToFirst() + cursor.assertColumnEquals(0, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.PRESS.name, MOTION_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_Y_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(x, MOTION_X_LITERAL_COLUMN) + cursor.assertColumnEquals(y, MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_CONDITION_ID_COLUMN) + cursor.assertColumnNull(MOTION_DURATION_MS_COLUMN) + + // WAIT: hold the press for the click duration + cursor.moveToNext() + cursor.assertColumnEquals(1, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.WAIT.name, MOTION_TYPE_COLUMN) + cursor.assertColumnEquals(pressDuration, MOTION_DURATION_MS_COLUMN) + cursor.assertColumnNull(MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_Y_COORD_TYPE_COLUMN) + + // RELEASE: finger up, no coordinates + cursor.moveToNext() + cursor.assertColumnEquals(2, MOTION_INDEX_COLUMN) + cursor.assertColumnEquals(GestureFingerMotionType.RELEASE.name, MOTION_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_DURATION_MS_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_onConditionClick_pressMotionIsOnCondition() { + val actionId = 1L + val conditionId = 8L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = actionId, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.ON_DETECTED_CONDITION.name, + pressDuration = 250L, clickOnConditionId = conditionId, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.moveToFirst() + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.moveToFirst() + cursor.assertColumnEquals(CoordinatesType.CONDITION.name, MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.CONDITION.name, MOTION_Y_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(conditionId, MOTION_CONDITION_ID_COLUMN) + cursor.assertColumnNull(MOTION_X_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_Y_LITERAL_COLUMN) + cursor.assertColumnNull(MOTION_X_OFFSET_COORD_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_Y_OFFSET_COORD_TYPE_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_onConditionClickWithOffset_pressMotionHasOffset() { + val actionId = 1L + val offsetX = -5 + val offsetY = 15 + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = actionId, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.ON_DETECTED_CONDITION.name, + pressDuration = 250L, clickOnConditionId = 8L, + clickOffsetX = offsetX, clickOffsetY = offsetY, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.moveToFirst() + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.moveToFirst() + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_X_OFFSET_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(CoordinatesType.LITERAL.name, MOTION_Y_OFFSET_COORD_TYPE_COLUMN) + cursor.assertColumnEquals(offsetX, MOTION_X_OFFSET_LITERAL_COLUMN) + cursor.assertColumnEquals(offsetY, MOTION_Y_OFFSET_LITERAL_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_onConditionClickWithoutConditionId_pressMotionHasNoCondition() { + val actionId = 1L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = actionId, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.ON_DETECTED_CONDITION.name, + pressDuration = 250L, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.moveToFirst() + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.moveToFirst() + cursor.assertColumnEquals(CoordinatesType.CONDITION.name, MOTION_X_COORD_TYPE_COLUMN) + cursor.assertColumnNull(MOTION_CONDITION_ID_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_clickWithoutPressDuration_waitMotionUsesDefault() { + val actionId = 1L + + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL( + getInsertV23Click( + id = actionId, eventId = 1L, name = "click", priority = 0, + clickPositionType = ClickPositionType.USER_SELECTED.name, + pressDuration = null, x = 10, y = 20, + ) + ) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + val fingerId = dbV24.query(getV24GestureFingersForAction(actionId)).use { cursor -> + cursor.moveToFirst() + cursor.getLong(cursor.getColumnIndex("id")) + } + + dbV24.query(getV24GestureFingerMotionsForFinger(fingerId)).use { cursor -> + cursor.moveToFirst() + cursor.moveToNext() + cursor.assertColumnEquals(GestureFingerMotionType.WAIT.name, MOTION_TYPE_COLUMN) + cursor.assertColumnEquals(1L, MOTION_DURATION_MS_COLUMN) + } + + dbV24.close() + } + + @Test + fun migrate_clicksAndSwipes_eachGetsOwnFingerAndMotions() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(1L, 1L, "swipe", 10, 20, 30, 40, 100L, 0)) + execSQL( + getInsertV23Click( + id = 2L, eventId = 1L, name = "click", priority = 1, + clickPositionType = ClickPositionType.USER_SELECTED.name, + pressDuration = 250L, x = 50, y = 60, + ) + ) + execSQL(getInsertV23EmptyAction(3L, 1L, "pause", ActionType.PAUSE.name, 2)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24GestureFingers()).use { cursor -> + cursor.assertCountEquals(2) + } + dbV24.query(getV24GestureFingerMotions()).use { cursor -> + cursor.assertCountEquals(6) + } + dbV24.query(getV24GestureFingersForAction(3L)).use { cursor -> + cursor.assertCountEquals(0) + } + + dbV24.close() + } + + @Test + fun migrate_mixedActions_onlySwipesGetGestureData() { + helper.createDatabase(dbPath, OLD_DB_VERSION).apply { + execSQL(getInsertV23Swipe(1L, 1L, "swipe", 10, 20, 30, 40, 500L, 0)) + execSQL(getInsertV23EmptyAction(2L, 1L, "click", ActionType.CLICK.name, 1)) + execSQL(getInsertV23EmptyAction(3L, 1L, "pause", ActionType.PAUSE.name, 2)) + close() + } + + val dbV24 = helper.runMigrationsAndValidate(dbPath, NEW_DB_VERSION, true, Migration23to24) + + dbV24.query(getV24Actions()).use { cursor -> + cursor.assertCountEquals(3) + } + dbV24.query(getV24GestureFingers()).use { cursor -> + cursor.assertCountEquals(1) + cursor.moveToFirst() + cursor.assertColumnEquals(1L, FINGER_ACTION_ID_COLUMN) + } + dbV24.query(getV24GestureFingerMotions()).use { cursor -> + cursor.assertCountEquals(3) + } + + dbV24.close() + } +} diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration3to4Tests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration3to4Tests.kt index d670e9ad18..620da71580 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration3to4Tests.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration3to4Tests.kt @@ -418,7 +418,7 @@ class Migration3to4Tests { eventId = swipeId, priority = 0, name = swipeName, - type = ActionType.SWIPE.toString(), + type = "SWIPE", x1 = swipeX1, y1 = swipeY1, x2 = swipeX2, diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6Tests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6Tests.kt index f41bff8a3b..f4e5aa927f 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6Tests.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration5to6Tests.kt @@ -123,7 +123,7 @@ class Migration5to6Tests { fun migrateActions_clickOnConditionColumn_swipe() { // Insert in V5 and close helper.createDatabase(dbPath, 5).apply { - execSQL(getInsertV5Swipe(24L, 1L, "", ActionType.SWIPE.toString(), 0, 0, 0, 0, 0, 0)) + execSQL(getInsertV5Swipe(24L, 1L, "", "SWIPE", 0, 0, 0, 0, 0, 0)) close() } diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10Tests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10Tests.kt index 98994c6702..6eb704ae2d 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10Tests.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/migrations/Migration9to10Tests.kt @@ -69,7 +69,7 @@ class Migration9to10Tests { id, 1L, "toto", - ActionType.SWIPE.toString(), + "SWIPE", 79, 97, 12, 45, durationMs, diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/DeserializerTests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/DeserializerTests.kt index 1bb6219b59..e739976414 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/DeserializerTests.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/DeserializerTests.kt @@ -51,6 +51,7 @@ class DeserializerTests { isAdvanced = false, isBroadcast = false, intentAction = "org.action", flags = 0, componentName = "org.action/org.action.TOTO", ), + gestureFingers = emptyList(), intentExtras = listOf( IntentExtraEntity(1, 1, IntentExtraType.BOOLEAN, "ExtraKey", "true") ), diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11DeserializerTests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11DeserializerTests.kt index a74750b4c6..deb1d2a668 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11DeserializerTests.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV11DeserializerTests.kt @@ -20,9 +20,12 @@ import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import com.buzbuz.smartautoclicker.core.database.entity.ActionType -import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.database.entity.CompleteActionEntity import com.buzbuz.smartautoclicker.core.database.entity.ConditionEntity import com.buzbuz.smartautoclicker.core.database.entity.ConditionType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType import com.buzbuz.smartautoclicker.core.database.entity.ScenarioEntity import com.buzbuz.smartautoclicker.core.database.serialization.DeserializerFactory import com.buzbuz.smartautoclicker.core.database.utils.encodeToJsonObject @@ -48,19 +51,28 @@ class CompatV11DeserializerTests { private const val VERSION_MAXIMUM = 10 } - private fun createJsonClick(clickOnCondition: Boolean, x: Int? = null, y: Int? = null): JsonObject = JsonObject( - mapOf( - "id" to JsonPrimitive(1L), - "eventId" to JsonPrimitive(1L), - "name" to JsonPrimitive("toto"), - "priority" to JsonPrimitive(1L), - "type" to JsonPrimitive(ActionType.CLICK.name), - "clickOnCondition" to JsonPrimitive(clickOnCondition), - "x" to JsonPrimitive(x), - "y" to JsonPrimitive(y), - "pressDuration" to JsonPrimitive(1), + private fun createJsonCompleteClick(clickOnCondition: Boolean, x: Int? = null, y: Int? = null): JsonObject = + JsonObject( + mapOf( + "action" to JsonObject( + mapOf( + "id" to JsonPrimitive(1L), + "eventId" to JsonPrimitive(1L), + "name" to JsonPrimitive("toto"), + "priority" to JsonPrimitive(1L), + "type" to JsonPrimitive(ActionType.CLICK.name), + "clickOnCondition" to JsonPrimitive(clickOnCondition), + "x" to JsonPrimitive(x), + "y" to JsonPrimitive(y), + "pressDuration" to JsonPrimitive(1), + ) + ), + ) ) - ) + + /** The position of a click is now defined by the press motion of its single gesture finger. */ + private fun CompleteActionEntity.getPressMotion(): GestureFingerMotionEntity = + gestureFingers.first().motions.first { it.motionType == GestureFingerMotionType.PRESS } private fun getDefaultImageCondition(shouldBeDetected: Boolean): ConditionEntity = ConditionEntity( id = 1L, eventId = 1L, name = "toto", type = ConditionType.ON_IMAGE_DETECTED, path = "/toto", @@ -97,67 +109,96 @@ class CompatV11DeserializerTests { // Given val x = 10 val y = 20 - val jsonClick = createJsonClick(clickOnCondition = false, x, y) + val jsonClick = createJsonCompleteClick(clickOnCondition = false, x, y) // When val decodedClick = (DeserializerFactory.create(VERSION_MAXIMUM) as CompatV11Deserializer) - .deserializeAction(jsonClick, emptyList(), 1) + .deserializeCompleteAction(jsonClick, emptyList(), emptyList(), 1) // Then assertNotNull(decodedClick) - assertEquals(ClickPositionType.USER_SELECTED, decodedClick!!.clickPositionType) - assertEquals(x, decodedClick.x) - assertEquals(y, decodedClick.y) - assertNull(decodedClick.clickOnConditionId) + assertEquals(ActionType.CLICK, decodedClick!!.action.type) + decodedClick.getPressMotion().let { pressMotion -> + assertEquals(CoordinatesType.LITERAL, pressMotion.xCoordinateType) + assertEquals(CoordinatesType.LITERAL, pressMotion.yCoordinateType) + assertEquals(x, pressMotion.xLiteral) + assertEquals(y, pressMotion.yLiteral) + assertNull(pressMotion.conditionId) + } } @Test fun deserialization_action_click_on_condition_valid_condition() { // Given - val jsonClick = createJsonClick(clickOnCondition = true) + val jsonClick = createJsonCompleteClick(clickOnCondition = true) val condition = getDefaultImageCondition(true) // When val decodedClick = (DeserializerFactory.create(VERSION_MAXIMUM) as CompatV11Deserializer) - .deserializeAction(jsonClick, listOf(condition), 1) + .deserializeCompleteAction(jsonClick, emptyList(), listOf(condition), 1) // Then assertNotNull(decodedClick) - assertEquals(ClickPositionType.ON_DETECTED_CONDITION, decodedClick!!.clickPositionType) - assertEquals(condition.id, decodedClick.clickOnConditionId) - assertNull(decodedClick.x) - assertNull(decodedClick.y) + decodedClick!!.getPressMotion().let { pressMotion -> + assertEquals(CoordinatesType.CONDITION, pressMotion.xCoordinateType) + assertEquals(CoordinatesType.CONDITION, pressMotion.yCoordinateType) + assertEquals(condition.id, pressMotion.conditionId) + assertNull(pressMotion.xLiteral) + assertNull(pressMotion.yLiteral) + } } @Test fun deserialization_action_click_on_condition_no_conditions() { // Given - val jsonClick = createJsonClick(clickOnCondition = true) + val jsonClick = createJsonCompleteClick(clickOnCondition = true) // When val decodedClick = (DeserializerFactory.create(VERSION_MAXIMUM) as CompatV11Deserializer) - .deserializeAction(jsonClick, emptyList(), 1) + .deserializeCompleteAction(jsonClick, emptyList(), emptyList(), 1) // Then assertNotNull(decodedClick) - assertEquals(ClickPositionType.ON_DETECTED_CONDITION, decodedClick!!.clickPositionType) - assertNull(decodedClick.clickOnConditionId) - assertNull(decodedClick.x) - assertNull(decodedClick.y) + decodedClick!!.getPressMotion().let { pressMotion -> + assertEquals(CoordinatesType.CONDITION, pressMotion.xCoordinateType) + assertNull(pressMotion.conditionId) + assertNull(pressMotion.xLiteral) + assertNull(pressMotion.yLiteral) + } } @Test fun deserialization_action_click_on_condition_invalid_conditions() { // Given - val jsonClick = createJsonClick(clickOnCondition = true) + val jsonClick = createJsonCompleteClick(clickOnCondition = true) val condition = getDefaultImageCondition(shouldBeDetected = false) // When val decodedClick = (DeserializerFactory.create(VERSION_MAXIMUM) as CompatV11Deserializer) - .deserializeAction(jsonClick, listOf(condition), 1) + .deserializeCompleteAction(jsonClick, emptyList(), listOf(condition), 1) + + // Then + assertNotNull(decodedClick) + decodedClick!!.getPressMotion().let { pressMotion -> + assertEquals(CoordinatesType.CONDITION, pressMotion.xCoordinateType) + assertNull(pressMotion.conditionId) + assertNull(pressMotion.xLiteral) + assertNull(pressMotion.yLiteral) + } + } + + @Test + fun deserialization_action_click_press_duration_in_wait_motion() { + // Given + val jsonClick = createJsonCompleteClick(clickOnCondition = false, x = 10, y = 20) + + // When + val decodedClick = (DeserializerFactory.create(VERSION_MAXIMUM) as CompatV11Deserializer) + .deserializeCompleteAction(jsonClick, emptyList(), emptyList(), 1) // Then assertNotNull(decodedClick) - assertEquals(ClickPositionType.ON_DETECTED_CONDITION, decodedClick!!.clickPositionType) - assertNull(decodedClick.clickOnConditionId) - assertNull(decodedClick.x) - assertNull(decodedClick.y) + val motions = decodedClick!!.gestureFingers.first().motions + assertEquals(3, motions.size) + assertEquals(GestureFingerMotionType.WAIT, motions[1].motionType) + assertEquals(1L, motions[1].durationMs) + assertEquals(GestureFingerMotionType.RELEASE, motions[2].motionType) } } \ No newline at end of file diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24DeserializerTests.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24DeserializerTests.kt new file mode 100644 index 0000000000..4d0ba9355b --- /dev/null +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/serialization/compat/CompatV24DeserializerTests.kt @@ -0,0 +1,841 @@ +/* + * 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.database.serialization.compat + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.database.entity.ActionType +import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.EventType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.serialization.DeserializerFactory +import com.buzbuz.smartautoclicker.core.database.utils.toJsonArray + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Test the [CompatV24Deserializer] class. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class CompatV24DeserializerTests { + + private companion object { + private const val VERSION_MINIMUM = 20 + private const val VERSION_MAXIMUM = 23 + private const val SCENARIO_ID = 42L + private const val EVENT_ID = 1L + private const val CONDITION_ID = 1L + } + + private val deserializer = CompatV24Deserializer() + + // ======================= JSON builder helpers ======================= + + private fun createJsonCompleteScenario( + scenarioJson: JsonObject = createJsonScenario(), + completeEventsJson: List = emptyList(), + ): JsonObject = JsonObject( + mapOf( + "scenario" to scenarioJson, + "events" to completeEventsJson.toJsonArray(), + ) + ) + + private fun createJsonScenario(): JsonObject = JsonObject( + mapOf( + "id" to JsonPrimitive(SCENARIO_ID), + "name" to JsonPrimitive("test"), + "detectionQuality" to JsonPrimitive(1000), + ) + ) + + private fun createJsonCompleteEvent( + eventJson: JsonObject = createJsonEvent(), + conditionsJson: List, + actionsJson: List, + ): JsonObject = JsonObject( + mapOf( + "event" to eventJson, + "conditions" to conditionsJson.toJsonArray(), + "actions" to actionsJson.toJsonArray(), + ) + ) + + private fun createJsonEvent(id: Long = EVENT_ID): JsonObject = JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "scenarioId" to JsonPrimitive(SCENARIO_ID), + "name" to JsonPrimitive("event"), + "type" to JsonPrimitive(EventType.IMAGE_EVENT.name), + "conditionOperator" to JsonPrimitive(1), + "priority" to JsonPrimitive(0), + "enabledOnStart" to JsonPrimitive(true), + ) + ) + + private fun createJsonCompleteAction(actionJson: JsonObject): JsonObject = JsonObject( + mapOf( + "action" to actionJson, + "intentExtras" to emptyList().toJsonArray(), + ) + ) + + private fun createJsonSwipeAction( + id: Long = 1L, + eventId: Long = EVENT_ID, + name: String = "swipe", + fromX: Int? = 10, + fromY: Int? = 20, + toX: Int? = 30, + toY: Int? = 40, + swipeDuration: Long? = 500L, + ): JsonObject = JsonObject( + buildMap { + put("id", JsonPrimitive(id)) + put("eventId", JsonPrimitive(eventId)) + put("name", JsonPrimitive(name)) + put("priority", JsonPrimitive(0)) + put("type", JsonPrimitive("SWIPE")) + if (fromX != null) put("fromX", JsonPrimitive(fromX)) + if (fromY != null) put("fromY", JsonPrimitive(fromY)) + if (toX != null) put("toX", JsonPrimitive(toX)) + if (toY != null) put("toY", JsonPrimitive(toY)) + if (swipeDuration != null) put("swipeDuration", JsonPrimitive(swipeDuration)) + } + ) + + private fun createJsonClickAction( + id: Long = 1L, + eventId: Long = EVENT_ID, + name: String = "click", + positionType: String = ClickPositionType.USER_SELECTED.name, + x: Int? = 10, + y: Int? = 20, + clickOnConditionId: Long? = null, + clickOffsetX: Int? = null, + clickOffsetY: Int? = null, + pressDuration: Long? = 250L, + ): JsonObject = JsonObject( + buildMap { + put("id", JsonPrimitive(id)) + put("eventId", JsonPrimitive(eventId)) + put("name", JsonPrimitive(name)) + put("priority", JsonPrimitive(0)) + put("type", JsonPrimitive(ActionType.CLICK.name)) + put("clickPositionType", JsonPrimitive(positionType)) + if (x != null) put("x", JsonPrimitive(x)) + if (y != null) put("y", JsonPrimitive(y)) + if (clickOnConditionId != null) put("clickOnConditionId", JsonPrimitive(clickOnConditionId)) + if (clickOffsetX != null) put("clickOffsetX", JsonPrimitive(clickOffsetX)) + if (clickOffsetY != null) put("clickOffsetY", JsonPrimitive(clickOffsetY)) + if (pressDuration != null) put("pressDuration", JsonPrimitive(pressDuration)) + } + ) + + private fun createJsonPauseAction(id: Long = 99L, eventId: Long = EVENT_ID): JsonObject = JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "eventId" to JsonPrimitive(eventId), + "name" to JsonPrimitive("pause"), + "priority" to JsonPrimitive(0), + "type" to JsonPrimitive(ActionType.PAUSE.name), + "pauseDuration" to JsonPrimitive(50L), + ) + ) + + private fun createJsonConditionImage(id: Long = 1L, eventId: Long = EVENT_ID): JsonObject = JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "eventId" to JsonPrimitive(eventId), + "name" to JsonPrimitive("img_cond"), + "type" to JsonPrimitive("ON_IMAGE_DETECTED"), + "priority" to JsonPrimitive(0), + "path" to JsonPrimitive("/test/image"), + "areaLeft" to JsonPrimitive(0), + "areaTop" to JsonPrimitive(0), + "areaRight" to JsonPrimitive(100), + "areaBottom" to JsonPrimitive(100), + "shouldBeDetected" to JsonPrimitive(true), + "detectionType" to JsonPrimitive(1), + "threshold" to JsonPrimitive(4), + ) + ) + + // ======================= Factory ======================= + + @Test + fun deserialization_factory_availability() { + for (i in VERSION_MINIMUM..VERSION_MAXIMUM) { + DeserializerFactory.create(i).let { d -> + assertNotNull(d) + assertTrue(d is CompatV24Deserializer) + } + } + } + + // ======================= Required field null-safety ======================= + + @Test + fun deserializeLegacySwipe_missingId_actionNotDeserialized() { + val swipeJson = JsonObject(buildMap { + // no "id" + put("eventId", JsonPrimitive(EVENT_ID)) + put("type", JsonPrimitive("SWIPE")) + put("fromX", JsonPrimitive(10)) + put("fromY", JsonPrimitive(20)) + put("toX", JsonPrimitive(30)) + put("toY", JsonPrimitive(40)) + put("swipeDuration", JsonPrimitive(500L)) + }) + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(swipeJson)), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacySwipe_missingEventId_actionNotDeserialized() { + val swipeJson = JsonObject(buildMap { + put("id", JsonPrimitive(1L)) + // no "eventId" + put("type", JsonPrimitive("SWIPE")) + put("fromX", JsonPrimitive(10)) + put("fromY", JsonPrimitive(20)) + put("toX", JsonPrimitive(30)) + put("toY", JsonPrimitive(40)) + put("swipeDuration", JsonPrimitive(500L)) + }) + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(swipeJson)), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacySwipe_missingFromX_actionNotDeserialized() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(fromX = null))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacySwipe_missingFromY_actionNotDeserialized() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(fromY = null))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacySwipe_missingToX_actionNotDeserialized() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(toX = null))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacySwipe_missingToY_actionNotDeserialized() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(toY = null))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + // ======================= Action entity type ======================= + + @Test + fun deserializeLegacySwipe_actionTypeIsGesture() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertEquals(ActionType.GESTURE, result.events.first().actions.first().action.type) + } + + @Test + fun nonSwipe_actionType_passedThrough() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonPauseAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertEquals(ActionType.PAUSE, result.events.first().actions.first().action.type) + } + + // ======================= Duration handling ======================= + + @Test + fun deserializeLegacySwipe_missingDuration_usesDefault250ms() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(swipeDuration = null))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val moveMotion = result.events.first().actions.first().gestureFingers.first() + .motions.first { it.motionType == GestureFingerMotionType.MOVE } + assertEquals(250L, moveMotion.durationMs) + } + + @Test + fun deserializeLegacySwipe_durationBelowLowerBound_clampedToOne() { + // DURATION_LOWER_BOUND = 1L; 0 is below it + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(swipeDuration = 0L))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val moveMotion = result.events.first().actions.first().gestureFingers.first() + .motions.first { it.motionType == GestureFingerMotionType.MOVE } + assertEquals(1L, moveMotion.durationMs) + } + + @Test + fun deserializeLegacySwipe_durationAboveUpperBound_clampedToMax() { + // DURATION_GESTURE_UPPER_BOUND = 59_999L; 100_000 is above it + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(swipeDuration = 100_000L))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val moveMotion = result.events.first().actions.first().gestureFingers.first() + .motions.first { it.motionType == GestureFingerMotionType.MOVE } + assertEquals(59_999L, moveMotion.durationMs) + } + + // ======================= Gesture finger structure ======================= + + @Test + fun deserializeLegacySwipe_oneFinger() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertEquals(1, result.events.first().actions.first().gestureFingers.size) + } + + @Test + fun deserializeLegacySwipe_finger_hasCorrectStructure() { + val actionId = 7L + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(id = actionId))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val finger = result.events.first().actions.first().gestureFingers.first().finger + assertEquals(actionId, finger.actionId) + assertEquals(0, finger.index) + assertEquals(0L, finger.startTimeMs) + assertTrue("finger.id should be a negative temporary id", finger.id < 0) + } + + // ======================= Gesture motion structure ======================= + + @Test + fun deserializeLegacySwipe_threeMotions() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertEquals(3, result.events.first().actions.first().gestureFingers.first().motions.size) + } + + @Test + fun deserializeLegacySwipe_pressMotion_hasFromCoordinates() { + val fromX = 10 + val fromY = 20 + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction(fromX = fromX, fromY = fromY))), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val pressMotion = result.events.first().actions.first().gestureFingers.first().motions[0] + assertEquals(GestureFingerMotionType.PRESS, pressMotion.motionType) + assertEquals(0, pressMotion.index) + assertEquals(CoordinatesType.LITERAL, pressMotion.xCoordinateType) + assertEquals(CoordinatesType.LITERAL, pressMotion.yCoordinateType) + assertEquals(fromX, pressMotion.xLiteral) + assertEquals(fromY, pressMotion.yLiteral) + assertNull(pressMotion.durationMs) + } + + @Test + fun deserializeLegacySwipe_moveMotion_hasToCoordinatesAndDuration() { + val toX = 30 + val toY = 40 + val duration = 500L + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf( + createJsonCompleteAction(createJsonSwipeAction(toX = toX, toY = toY, swipeDuration = duration)) + ), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val moveMotion = result.events.first().actions.first().gestureFingers.first().motions[1] + assertEquals(GestureFingerMotionType.MOVE, moveMotion.motionType) + assertEquals(1, moveMotion.index) + assertEquals(CoordinatesType.LITERAL, moveMotion.xCoordinateType) + assertEquals(CoordinatesType.LITERAL, moveMotion.yCoordinateType) + assertEquals(toX, moveMotion.xLiteral) + assertEquals(toY, moveMotion.yLiteral) + assertEquals(duration, moveMotion.durationMs) + } + + @Test + fun deserializeLegacySwipe_releaseMotion_hasNoCoordinates() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val releaseMotion = result.events.first().actions.first().gestureFingers.first().motions[2] + assertEquals(GestureFingerMotionType.RELEASE, releaseMotion.motionType) + assertEquals(2, releaseMotion.index) + assertNull(releaseMotion.xCoordinateType) + assertNull(releaseMotion.yCoordinateType) + assertNull(releaseMotion.xLiteral) + assertNull(releaseMotion.yLiteral) + assertNull(releaseMotion.durationMs) + } + + // ======================= Temporary IDs ======================= + + @Test + fun deserializeLegacySwipe_temporaryIds_areNegative() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonSwipeAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val fingerWithMotions = result.events.first().actions.first().gestureFingers.first() + assertTrue("finger.id should be negative", fingerWithMotions.finger.id < 0) + fingerWithMotions.motions.forEach { motion -> + assertTrue("motion.id should be negative", motion.id < 0) + } + } + + // ======================= Non-swipe passes through ======================= + + @Test + fun nonSwipe_gestureFingers_isEmpty() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf(createJsonCompleteAction(createJsonPauseAction())), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.first().actions.first().gestureFingers.isEmpty()) + } + + // ======================= Multiple swipes ======================= + + @Test + fun deserializeLegacySwipe_multipleSwipes_allActionsAreGestures() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf( + createJsonCompleteAction(createJsonSwipeAction(id = 1L)), + createJsonCompleteAction(createJsonSwipeAction(id = 2L)), + ), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val actions = result.events.first().actions + assertEquals(2, actions.size) + assertEquals(ActionType.GESTURE, actions[0].action.type) + assertEquals(ActionType.GESTURE, actions[1].action.type) + } + + @Test + fun deserializeLegacySwipe_multipleSwipes_eachHasGestureFingers() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf( + createJsonCompleteAction(createJsonSwipeAction(id = 1L)), + createJsonCompleteAction(createJsonSwipeAction(id = 2L)), + ), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val actions = result.events.first().actions + assertTrue(actions[0].gestureFingers.isNotEmpty()) + assertTrue(actions[1].gestureFingers.isNotEmpty()) + } + + // ======================= Legacy clicks ======================= + + @Test + fun deserializeLegacyClick_actionTypeIsStillClick() { + val result = deserializer.deserializeCompleteScenario(createScenarioWithClick(createJsonClickAction())) + + assertEquals(ActionType.CLICK, result.events.first().actions.first().action.type) + } + + @Test + fun deserializeLegacyClick_missingPositionType_actionNotDeserialized() { + val clickJson = JsonObject( + mapOf( + "id" to JsonPrimitive(1L), + "eventId" to JsonPrimitive(EVENT_ID), + "name" to JsonPrimitive("click"), + "priority" to JsonPrimitive(0), + "type" to JsonPrimitive(ActionType.CLICK.name), + "x" to JsonPrimitive(10), + "y" to JsonPrimitive(20), + ) + ) + + val result = deserializer.deserializeCompleteScenario(createScenarioWithClick(clickJson)) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacyClick_userSelected_threeMotions() { + val result = deserializer.deserializeCompleteScenario(createScenarioWithClick(createJsonClickAction())) + + val motions = result.events.first().actions.first().gestureFingers.first().motions + assertEquals(3, motions.size) + assertEquals(GestureFingerMotionType.PRESS, motions[0].motionType) + assertEquals(GestureFingerMotionType.WAIT, motions[1].motionType) + assertEquals(GestureFingerMotionType.RELEASE, motions[2].motionType) + } + + @Test + fun deserializeLegacyClick_userSelected_pressMotionHasCoordinates() { + val x = 42 + val y = 84 + val result = deserializer + .deserializeCompleteScenario(createScenarioWithClick(createJsonClickAction(x = x, y = y))) + + val pressMotion = result.events.first().actions.first().gestureFingers.first().motions[0] + assertEquals(CoordinatesType.LITERAL, pressMotion.xCoordinateType) + assertEquals(CoordinatesType.LITERAL, pressMotion.yCoordinateType) + assertEquals(x, pressMotion.xLiteral) + assertEquals(y, pressMotion.yLiteral) + assertNull(pressMotion.conditionId) + } + + @Test + fun deserializeLegacyClick_userSelected_missingCoordinates_actionNotDeserialized() { + val result = deserializer + .deserializeCompleteScenario(createScenarioWithClick(createJsonClickAction(x = null))) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacyClick_pressDurationIsWaitMotionDuration() { + val pressDuration = 1234L + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick(createJsonClickAction(pressDuration = pressDuration)) + ) + + val waitMotion = result.events.first().actions.first().gestureFingers.first().motions[1] + assertEquals(pressDuration, waitMotion.durationMs) + } + + @Test + fun deserializeLegacyClick_missingPressDuration_usesDefault() { + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick(createJsonClickAction(pressDuration = null)) + ) + + val waitMotion = result.events.first().actions.first().gestureFingers.first().motions[1] + assertEquals(1L, waitMotion.durationMs) + } + + @Test + fun deserializeLegacyClick_onCondition_pressMotionHasConditionPosition() { + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick( + createJsonClickAction( + positionType = ClickPositionType.ON_DETECTED_CONDITION.name, + x = null, + y = null, + clickOnConditionId = CONDITION_ID, + ) + ) + ) + + val pressMotion = result.events.first().actions.first().gestureFingers.first().motions[0] + assertEquals(CoordinatesType.CONDITION, pressMotion.xCoordinateType) + assertEquals(CoordinatesType.CONDITION, pressMotion.yCoordinateType) + assertEquals(CONDITION_ID, pressMotion.conditionId) + assertNull(pressMotion.xLiteral) + assertNull(pressMotion.yLiteral) + } + + @Test + fun deserializeLegacyClick_onCondition_offsetIsInPressMotion() { + val offsetX = -5 + val offsetY = 15 + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick( + createJsonClickAction( + positionType = ClickPositionType.ON_DETECTED_CONDITION.name, + x = null, + y = null, + clickOnConditionId = CONDITION_ID, + clickOffsetX = offsetX, + clickOffsetY = offsetY, + ) + ) + ) + + val pressMotion = result.events.first().actions.first().gestureFingers.first().motions[0] + assertEquals(CoordinatesType.LITERAL, pressMotion.xOffsetCoordinateType) + assertEquals(CoordinatesType.LITERAL, pressMotion.yOffsetCoordinateType) + assertEquals(offsetX, pressMotion.xOffsetLiteral) + assertEquals(offsetY, pressMotion.yOffsetLiteral) + } + + @Test + fun deserializeLegacyClick_onCondition_withoutOffset_pressMotionHasNoOffset() { + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick( + createJsonClickAction( + positionType = ClickPositionType.ON_DETECTED_CONDITION.name, + x = null, + y = null, + clickOnConditionId = CONDITION_ID, + ) + ) + ) + + val pressMotion = result.events.first().actions.first().gestureFingers.first().motions[0] + assertNull(pressMotion.xOffsetCoordinateType) + assertNull(pressMotion.yOffsetCoordinateType) + assertNull(pressMotion.xOffsetLiteral) + assertNull(pressMotion.yOffsetLiteral) + } + + @Test + fun deserializeLegacyClick_onCondition_invalidConditionId_actionNotDeserialized() { + val result = deserializer.deserializeCompleteScenario( + createScenarioWithClick( + createJsonClickAction( + positionType = ClickPositionType.ON_DETECTED_CONDITION.name, + x = null, + y = null, + clickOnConditionId = 4242L, + ) + ) + ) + + assertTrue(result.events.flatMap { it.actions }.isEmpty()) + } + + @Test + fun deserializeLegacyClick_afterASwipe_hasItsOwnFingers() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf( + createJsonCompleteAction(createJsonSwipeAction(id = 1L)), + createJsonCompleteAction(createJsonClickAction(id = 2L)), + ), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + val actions = result.events.first().actions + assertEquals(2L, actions[1].gestureFingers.first().finger.actionId) + assertEquals(GestureFingerMotionType.WAIT, actions[1].gestureFingers.first().motions[1].motionType) + } + + @Test + fun nonMigratedAction_afterAClick_hasNoFingers() { + val scenarioJson = createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage()), + actionsJson = listOf( + createJsonCompleteAction(createJsonClickAction(id = 1L)), + createJsonCompleteAction(createJsonPauseAction(id = 2L)), + ), + ) + ) + ) + + val result = deserializer.deserializeCompleteScenario(scenarioJson) + + assertTrue(result.events.first().actions[1].gestureFingers.isEmpty()) + } + + private fun createScenarioWithClick(clickJson: JsonObject): JsonObject = + createJsonCompleteScenario( + completeEventsJson = listOf( + createJsonCompleteEvent( + conditionsJson = listOf(createJsonConditionImage(id = CONDITION_ID)), + actionsJson = listOf(createJsonCompleteAction(clickJson)), + ) + ) + ) +} diff --git a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/utils/Migrations.kt b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/utils/Migrations.kt index e4076c8569..95d5b4e018 100644 --- a/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/utils/Migrations.kt +++ b/core/smart/database/src/test/java/com/buzbuz/smartautoclicker/core/database/utils/Migrations.kt @@ -238,3 +238,52 @@ fun getInsertV10Condition(id: Long, eventId: Long, name: String, path: String, l fun getV11Scenarios() = "SELECT * FROM scenario_table" fun getV11Actions() = "SELECT * FROM action_table" + + +// ----- Utils for Database V23 ----- + +fun getV23Actions() = "SELECT * FROM action_table" + +fun getInsertV23Swipe(id: Long, eventId: Long, name: String, fromX: Int, fromY: Int, toX: Int, toY: Int, swipeDuration: Long, priority: Int) = + """ + INSERT INTO action_table (id, eventId, priority, name, type, fromX, fromY, toX, toY, swipeDuration) + VALUES ($id, $eventId, $priority, "$name", "SWIPE", $fromX, $fromY, $toX, $toY, $swipeDuration) + """.trimIndent() + +fun getInsertV23EmptyAction(id: Long, eventId: Long, name: String, type: String, priority: Int) = + """ + INSERT INTO action_table (id, eventId, priority, name, type) + VALUES ($id, $eventId, $priority, "$name", "$type") + """.trimIndent() + +fun getInsertV23Click( + id: Long, + eventId: Long, + name: String, + priority: Int, + clickPositionType: String, + pressDuration: Long?, + x: Int? = null, + y: Int? = null, + clickOnConditionId: Long? = null, + clickOffsetX: Int? = null, + clickOffsetY: Int? = null, +) = + """ + INSERT INTO action_table (id, eventId, priority, name, type, clickPositionType, x, y, clickOnConditionId, + pressDuration, clickOffsetX, clickOffsetY) + VALUES ($id, $eventId, $priority, "$name", "CLICK", "$clickPositionType", ${x.orNull()}, ${y.orNull()}, + ${clickOnConditionId.orNull()}, ${pressDuration.orNull()}, ${clickOffsetX.orNull()}, + ${clickOffsetY.orNull()}) + """.trimIndent() + +private fun Any?.orNull(): String = this?.toString() ?: "NULL" + + +// ----- Utils for Database V24 ----- + +fun getV24Actions() = "SELECT * FROM action_table" +fun getV24GestureFingers() = "SELECT * FROM gesture_finger_table ORDER BY id" +fun getV24GestureFingerMotions() = "SELECT * FROM gesture_finger_motion_table ORDER BY id" +fun getV24GestureFingersForAction(actionId: Long) = "SELECT * FROM gesture_finger_table WHERE action_id = $actionId ORDER BY `index`" +fun getV24GestureFingerMotionsForFinger(fingerId: Long) = "SELECT * FROM gesture_finger_motion_table WHERE finger_id = $fingerId ORDER BY `index`" 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 dc43ac63e9..e4a2e04e78 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 @@ -36,12 +36,21 @@ import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraEntity import com.buzbuz.smartautoclicker.core.database.entity.ScenarioStatsEntity import com.buzbuz.smartautoclicker.core.database.entity.ScenarioWithEvents 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.Gesture import com.buzbuz.smartautoclicker.core.domain.model.action.Intent import com.buzbuz.smartautoclicker.core.domain.model.action.ToggleEvent +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFinger +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.toFingerEntity +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.toMotionEntity import com.buzbuz.smartautoclicker.core.domain.model.action.toggleevent.EventToggle import com.buzbuz.smartautoclicker.core.domain.model.action.intent.IntentExtra import com.buzbuz.smartautoclicker.core.domain.model.action.intent.toEntity import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toEntity +import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toGestureFingerEntity +import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toGestureMotionEntities import com.buzbuz.smartautoclicker.core.domain.model.action.toggleevent.toEntity import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition @@ -374,10 +383,7 @@ internal class ScenarioDataSource @Inject constructor( currentEntities = currentActionsEntities, newItems = newActions, mappingClosure = { actionInEvent -> - actionInEvent.toEntity().apply { - eventId = eventDbId - clickOnConditionId = scenarioUpdateState.getClickOnConditionDatabaseId(actionInEvent) - } + actionInEvent.toEntity().apply { eventId = eventDbId } } ) Log.d(TAG, "Actions updater: $updater") @@ -404,6 +410,20 @@ internal class ScenarioDataSource @Inject constructor( private suspend fun updateActionsChildren(actions: List) { actions.forEach { action -> when (action) { + is Click -> { + updateClickGesture( + actionDbId = scenarioUpdateState.getActionDbId(action.id), + click = action, + ) + } + + is Gesture -> { + updateGestureFingers( + actionDbId = scenarioUpdateState.getActionDbId(action.id), + newFingers = action.fingers, + ) + } + is Intent -> { action.extras?.let { extras -> updateIntentExtras( @@ -425,6 +445,35 @@ internal class ScenarioDataSource @Inject constructor( } } + private suspend fun updateClickGesture(actionDbId: Long, click: Click) { + val dao = database.actionDao() + dao.deleteFingersByActionId(actionDbId) + + val fingerDbId = dao.addFinger(click.toGestureFingerEntity(actionId = actionDbId)) + dao.addMotions( + click.toGestureMotionEntities( + fingerId = fingerDbId, + clickOnConditionDbId = click.getClickOnConditionId()?.let(scenarioUpdateState::getConditionDbId), + ) + ) + } + + private suspend fun updateGestureFingers(actionDbId: Long, newFingers: List) { + val dao = database.actionDao() + dao.deleteFingersByActionId(actionDbId) + + newFingers.forEachIndexed { fingerIndex, finger -> + val fingerDbId = dao.addFinger(finger.toFingerEntity(actionId = actionDbId, index = fingerIndex)) + dao.addMotions( + finger.motions.mapIndexed { motionIndex, motion -> + motion.toMotionEntity(fingerId = fingerDbId, index = motionIndex).apply { + conditionId = motion.getConditionIdentifier()?.let(scenarioUpdateState::getConditionDbId) + } + } + ) + } + } + private suspend fun updateIntentExtras(actionDbId: Long, newExtras: List>) { val updater = DatabaseListUpdater, IntentExtraEntity>() @@ -507,5 +556,9 @@ internal class ScenarioDataSource @Inject constructor( } } +/** @return the identifier of the condition this motion is positioned on, if any. */ +private fun GestureFingerMotion.getConditionIdentifier(): Identifier? = + ((this as? GestureFingerMotion.Positionable)?.position as? GesturePosition.Condition)?.conditionId + /** Tag for logs. */ private const val TAG = "ScenarioDataSource" \ No newline at end of file diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapper.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapper.kt new file mode 100644 index 0000000000..99fdcbe1a0 --- /dev/null +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapper.kt @@ -0,0 +1,183 @@ +/* + * 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.gesture + +import android.util.Log +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerWithMotions +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.* + + +internal fun GestureFinger.toFingerEntity(actionId: Long, index: Int): GestureFingerEntity = + GestureFingerEntity( + id = id.databaseId, + actionId = actionId, + index = index, + startTimeMs = startTimeMs, + ) + +internal fun GestureFingerMotion.toMotionEntity(fingerId: Long, index: Int): GestureFingerMotionEntity = + when (this) { + is GestureFingerMotion.Press -> GestureFingerMotionEntity( + id = id.databaseId, + fingerId = fingerId, + index = index, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = position.xCoordinatesType(), + yCoordinateType = position.yCoordinatesType(), + conditionId = (position as? GesturePosition.Condition)?.conditionId?.databaseId, + xLiteral = position.xLiteral(), + xCounter = position.xCounter(), + yLiteral = position.yLiteral(), + yCounter = position.yCounter(), + xOffsetCoordinateType = (offset as? GesturePosition.Coordinates)?.x?.toCoordinatesType(), + yOffsetCoordinateType = (offset as? GesturePosition.Coordinates)?.y?.toCoordinatesType(), + xOffsetLiteral = (offset as? GesturePosition.Coordinates)?.x?.literal(), + xOffsetCounter = (offset as? GesturePosition.Coordinates)?.x?.counter(), + yOffsetLiteral = (offset as? GesturePosition.Coordinates)?.y?.literal(), + yOffsetCounter = (offset as? GesturePosition.Coordinates)?.y?.counter(), + ) + + is GestureFingerMotion.Move -> GestureFingerMotionEntity( + id = id.databaseId, + fingerId = fingerId, + index = index, + motionType = GestureFingerMotionType.MOVE, + durationMs = durationMs, + xCoordinateType = position.xCoordinatesType(), + yCoordinateType = position.yCoordinatesType(), + conditionId = (position as? GesturePosition.Condition)?.conditionId?.databaseId, + xLiteral = position.xLiteral(), + xCounter = position.xCounter(), + yLiteral = position.yLiteral(), + yCounter = position.yCounter(), + xOffsetCoordinateType = (offset as? GesturePosition.Coordinates)?.x?.toCoordinatesType(), + yOffsetCoordinateType = (offset as? GesturePosition.Coordinates)?.y?.toCoordinatesType(), + xOffsetLiteral = (offset as? GesturePosition.Coordinates)?.x?.literal(), + xOffsetCounter = (offset as? GesturePosition.Coordinates)?.x?.counter(), + yOffsetLiteral = (offset as? GesturePosition.Coordinates)?.y?.literal(), + yOffsetCounter = (offset as? GesturePosition.Coordinates)?.y?.counter(), + ) + + is GestureFingerMotion.Wait -> GestureFingerMotionEntity( + id = id.databaseId, + fingerId = fingerId, + index = index, + motionType = GestureFingerMotionType.WAIT, + durationMs = durationMs, + ) + + is GestureFingerMotion.Release -> GestureFingerMotionEntity( + id = id.databaseId, + fingerId = fingerId, + index = index, + motionType = GestureFingerMotionType.RELEASE, + ) + } + +private fun GesturePosition.xCoordinatesType(): CoordinatesType = when (this) { + is GesturePosition.Condition -> CoordinatesType.CONDITION + is GesturePosition.Coordinates -> x.toCoordinatesType() +} + +private fun GesturePosition.yCoordinatesType(): CoordinatesType = when (this) { + is GesturePosition.Condition -> CoordinatesType.CONDITION + is GesturePosition.Coordinates -> y.toCoordinatesType() +} + +private fun GesturePosition.xLiteral(): Int? = + (this as? GesturePosition.Coordinates)?.x?.literal() + +private fun GesturePosition.xCounter(): String? = + (this as? GesturePosition.Coordinates)?.x?.counter() + +private fun GesturePosition.yLiteral(): Int? = + (this as? GesturePosition.Coordinates)?.y?.literal() + +private fun GesturePosition.yCounter(): String? = + (this as? GesturePosition.Coordinates)?.y?.counter() + +private fun GestureCoordinatesValue.toCoordinatesType(): CoordinatesType = when (this) { + is Literal -> CoordinatesType.LITERAL + is Counter -> CoordinatesType.COUNTER +} + +private fun GestureCoordinatesValue.literal(): Int? = (this as? Literal)?.value +private fun GestureCoordinatesValue.counter(): String? = (this as? Counter)?.counterName + +internal fun GestureFingerWithMotions.toDomainGestureFinger(cleanIds: Boolean = false): GestureFinger = + GestureFinger( + id = Identifier(finger.id, asTemporary = cleanIds), + startTimeMs = finger.startTimeMs, + motions = motions + .sortedBy { it.index } + .map { it.toDomainGestureMotion() } + ) + +private fun GestureFingerMotionEntity.toDomainGestureMotion(cleanIds: Boolean = false): GestureFingerMotion = + when (motionType) { + GestureFingerMotionType.PRESS -> GestureFingerMotion.Press( + id = Identifier(id, asTemporary = cleanIds), + position = toGesturePosition(), + offset = toGestureOffset(), + ) + GestureFingerMotionType.MOVE -> GestureFingerMotion.Move( + id = Identifier(id, asTemporary = cleanIds), + position = toGesturePosition(), + offset = toGestureOffset(), + durationMs = durationMs ?: 0L, + ) + GestureFingerMotionType.WAIT -> GestureFingerMotion.Wait( + id = Identifier(id, asTemporary = cleanIds), + durationMs = durationMs ?: 0L, + ) + GestureFingerMotionType.RELEASE -> GestureFingerMotion.Release( + id = Identifier(id, asTemporary = cleanIds), + ) + } + +private fun GestureFingerMotionEntity.toGesturePosition(): GesturePosition = + if (xCoordinateType == yCoordinateType && xCoordinateType == CoordinatesType.CONDITION) { + GesturePosition.Condition(conditionId = conditionId?.let { Identifier(databaseId = it) }) + } else { + GesturePosition.Coordinates( + x = toGestureCoordinatesValue(xCoordinateType, xLiteral, xCounter), + y = toGestureCoordinatesValue(yCoordinateType, yLiteral, yCounter), + ) + } + +private fun GestureFingerMotionEntity.toGestureOffset(): GesturePosition = + GesturePosition.Coordinates( + x = toGestureCoordinatesValue(xOffsetCoordinateType, xOffsetLiteral, xOffsetCounter), + y = toGestureCoordinatesValue(yOffsetCoordinateType, yOffsetLiteral, yOffsetCounter), + ) + +private fun toGestureCoordinatesValue(type: CoordinatesType?, literal: Int?, counter: String?): GestureCoordinatesValue = + when (type) { + CoordinatesType.COUNTER -> Counter(counterName = counter ?: "") + CoordinatesType.LITERAL -> Literal(value = literal ?: 0) + else -> { + Log.e(TAG, "Unsupported coordinates type $type; using 0 as value for coordinates") + Literal(value = 0) + } + } + +private const val TAG = "GestureFingerMapper" \ No newline at end of file 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 c645c04ea2..fc618969ea 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 @@ -1,32 +1,36 @@ package com.buzbuz.smartautoclicker.core.domain.model.action.mapper import android.content.ComponentName -import android.graphics.Point import com.buzbuz.smartautoclicker.core.base.identifier.Identifier import com.buzbuz.smartautoclicker.core.database.entity.ActionType import com.buzbuz.smartautoclicker.core.database.entity.ChangeCounterOperationType -import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType import com.buzbuz.smartautoclicker.core.database.entity.CompleteActionEntity +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType import com.buzbuz.smartautoclicker.core.database.entity.EventToggleType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType import com.buzbuz.smartautoclicker.core.database.entity.SystemActionType import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationValue 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.Gesture 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 import com.buzbuz.smartautoclicker.core.domain.model.action.SetText -import com.buzbuz.smartautoclicker.core.domain.model.action.Swipe import com.buzbuz.smartautoclicker.core.domain.model.action.SystemAction import com.buzbuz.smartautoclicker.core.domain.model.action.ToggleEvent +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.toDomainGestureFinger import com.buzbuz.smartautoclicker.core.domain.model.action.intent.toDomainIntentExtra import com.buzbuz.smartautoclicker.core.domain.model.action.toggleevent.toDomain /** Convert an Action entity into a Domain Action. */ internal fun CompleteActionEntity.toDomain(cleanIds: Boolean = false): Action = when (action.type) { ActionType.CLICK -> toDomainClick(cleanIds) - ActionType.SWIPE -> toDomainSwipe(cleanIds) + ActionType.GESTURE -> toDomainGesture(cleanIds) ActionType.PAUSE -> toDomainPause(cleanIds) ActionType.INTENT -> toDomainIntent(cleanIds) ActionType.TOGGLE_EVENT -> toDomainToggleEvent(cleanIds) @@ -36,28 +40,54 @@ internal fun CompleteActionEntity.toDomain(cleanIds: Boolean = false): Action = ActionType.TEXT -> toDomainSetText(cleanIds) } -private fun CompleteActionEntity.toDomainClick(cleanIds: Boolean = false) = Click( - id = Identifier(id = action.id, asTemporary = cleanIds), - eventId = Identifier(id = action.eventId, asTemporary = cleanIds), - name = action.name, - priority = action.priority, - pressDuration = action.pressDuration!!, - positionType = action.clickPositionType!!.toDomain(), - position = getPositionIfValid(action.x, action.y), - clickOnConditionId = action.clickOnConditionId?.let { Identifier(id = it, asTemporary = cleanIds) }, - clickOffset = - if (action.clickOffsetX != null && action.clickOffsetY != null) Point(action.clickOffsetX!!, action.clickOffsetY!!) - else null -) +/** + * A click is stored as a gesture with a single finger pressing on the click position, waiting for the press duration, + * and releasing. + */ +private fun CompleteActionEntity.toDomainClick(cleanIds: Boolean = false): Click { + val motions = gestureFingers.firstOrNull()?.motions?.sortedBy { it.index } + val press = motions?.find { it.motionType == GestureFingerMotionType.PRESS } + + return Click( + id = Identifier(id = action.id, asTemporary = cleanIds), + eventId = Identifier(id = action.eventId, asTemporary = cleanIds), + name = action.name, + priority = action.priority, + pressDuration = motions?.find { it.motionType == GestureFingerMotionType.WAIT }?.durationMs + ?: DEFAULT_PRESS_DURATION_MS, + position = press?.toDomainClickPosition(cleanIds), + offset = press?.toDomainClickOffset(), + ) +} + +private fun GestureFingerMotionEntity.toDomainClickPosition(cleanIds: Boolean): GesturePosition? { + if (xCoordinateType == CoordinatesType.CONDITION && yCoordinateType == CoordinatesType.CONDITION) { + return GesturePosition.Condition( + conditionId = conditionId?.let { Identifier(id = it, asTemporary = cleanIds) }, + ) + } + + val x = toClickCoordinatesValue(xCoordinateType, xLiteral, xCounter) ?: return null + val y = toClickCoordinatesValue(yCoordinateType, yLiteral, yCounter) ?: return null -private fun CompleteActionEntity.toDomainSwipe(cleanIds: Boolean = false) = Swipe( + return GesturePosition.Coordinates(x = x, y = y) +} + +private fun GestureFingerMotionEntity.toDomainClickOffset(): GesturePosition.Coordinates? { + val x = toClickCoordinatesValue(xOffsetCoordinateType, xOffsetLiteral, xOffsetCounter) ?: return null + val y = toClickCoordinatesValue(yOffsetCoordinateType, yOffsetLiteral, yOffsetCounter) ?: return null + + return GesturePosition.Coordinates(x = x, y = y) +} + +private fun CompleteActionEntity.toDomainGesture(cleanIds: Boolean = false) = Gesture( id = Identifier(id = action.id, asTemporary = cleanIds), eventId = Identifier(id = action.eventId, asTemporary = cleanIds), name = action.name, priority = action.priority, - swipeDuration = action.swipeDuration!!, - from = getPositionIfValid(action.fromX, action.fromY), - to = getPositionIfValid(action.toX, action.toY), + fingers = gestureFingers + .sortedBy { it.finger.index } + .map { it.toDomainGestureFinger() }, ) private fun CompleteActionEntity.toDomainPause(cleanIds: Boolean = false) = Pause( @@ -131,9 +161,6 @@ private fun CompleteActionEntity.toDomainSetText(cleanIds: Boolean = false) = Se validateInput = action.textValidateInput ?: false, ) -private fun ClickPositionType.toDomain(): Click.PositionType = - Click.PositionType.valueOf(name) - private fun EventToggleType.toDomain(): ToggleEvent.ToggleType = ToggleEvent.ToggleType.valueOf(name) @@ -147,5 +174,13 @@ private fun String?.toComponentName(): ComponentName? = this?.let { ComponentName.unflattenFromString(it) } -private fun getPositionIfValid(x: Int?, y: Int?): Point? = - if (x != null && y != null) Point(x, y) else null \ No newline at end of file +/** A null [type] with a null [literal] means the click has no position yet. */ +private fun toClickCoordinatesValue(type: CoordinatesType?, literal: Int?, counter: String?): GestureCoordinatesValue? = + when (type) { + CoordinatesType.COUNTER -> GestureCoordinatesValue.Counter(counterName = counter ?: "") + CoordinatesType.LITERAL, null -> literal?.let(GestureCoordinatesValue::Literal) + CoordinatesType.CONDITION -> null + } + +/** Press duration used for the clicks without a wait motion, in milliseconds. */ +private const val DEFAULT_PRESS_DURATION_MS = 1L \ No newline at end of file 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 4460bb60d8..25e64896ad 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 @@ -16,9 +16,14 @@ */ package com.buzbuz.smartautoclicker.core.domain.model.action.mapper +import com.buzbuz.smartautoclicker.core.base.identifier.DATABASE_ID_INSERTION import com.buzbuz.smartautoclicker.core.database.entity.ActionEntity import com.buzbuz.smartautoclicker.core.database.entity.ActionType +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType import com.buzbuz.smartautoclicker.core.database.entity.CounterOperationValueType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationValue import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.action.ChangeCounter @@ -28,9 +33,10 @@ 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 import com.buzbuz.smartautoclicker.core.domain.model.action.SetText -import com.buzbuz.smartautoclicker.core.domain.model.action.Swipe import com.buzbuz.smartautoclicker.core.domain.model.action.SystemAction import com.buzbuz.smartautoclicker.core.domain.model.action.ToggleEvent +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition internal fun Action.toEntity(): ActionEntity { @@ -38,7 +44,7 @@ internal fun Action.toEntity(): ActionEntity { return when (this) { is Click -> toClickEntity() - is Swipe -> toSwipeEntity() + is Gesture -> toGestureEntity() is Pause -> toPauseEntity() is Intent -> toIntentEntity() is ToggleEvent -> toToggleEventEntity() @@ -57,27 +63,72 @@ private fun Click.toClickEntity(): ActionEntity = priority = priority, name = name!!, type = ActionType.CLICK, - pressDuration = pressDuration, - clickPositionType = positionType.toEntity(), - x = position?.x, - y = position?.y, - clickOnConditionId = clickOnConditionId?.databaseId, - clickOffsetX = clickOffset?.x, - clickOffsetY = clickOffset?.y, ) -private fun Swipe.toSwipeEntity(): ActionEntity = +/** + * A click is stored the same way a gesture is: one finger pressing on the click position, waiting for the press + * duration, and releasing. + */ +internal fun Click.toGestureFingerEntity(actionId: Long): GestureFingerEntity = + GestureFingerEntity( + id = DATABASE_ID_INSERTION, + actionId = actionId, + index = 0, + startTimeMs = 0, + ) + +/** + * @param fingerId the database identifier of the finger created with [toGestureFingerEntity]. + * @param clickOnConditionDbId the database identifier of the condition to click on, if any. + */ +internal fun Click.toGestureMotionEntities(fingerId: Long, clickOnConditionDbId: Long?): List { + val isOnCondition = position is GesturePosition.Condition + val coordinates = position as? GesturePosition.Coordinates + val offsetCoordinates = offset as? GesturePosition.Coordinates + + return listOf( + GestureFingerMotionEntity( + id = DATABASE_ID_INSERTION, + fingerId = fingerId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else coordinates?.x?.toCoordinatesType(), + yCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else coordinates?.y?.toCoordinatesType(), + conditionId = clickOnConditionDbId, + xLiteral = (coordinates?.x as? GestureCoordinatesValue.Literal)?.value, + yLiteral = (coordinates?.y as? GestureCoordinatesValue.Literal)?.value, + xCounter = (coordinates?.x as? GestureCoordinatesValue.Counter)?.counterName, + yCounter = (coordinates?.y as? GestureCoordinatesValue.Counter)?.counterName, + xOffsetCoordinateType = offsetCoordinates?.x?.toCoordinatesType(), + yOffsetCoordinateType = offsetCoordinates?.y?.toCoordinatesType(), + xOffsetLiteral = (offsetCoordinates?.x as? GestureCoordinatesValue.Literal)?.value, + yOffsetLiteral = (offsetCoordinates?.y as? GestureCoordinatesValue.Literal)?.value, + xOffsetCounter = (offsetCoordinates?.x as? GestureCoordinatesValue.Counter)?.counterName, + yOffsetCounter = (offsetCoordinates?.y as? GestureCoordinatesValue.Counter)?.counterName, + ), + GestureFingerMotionEntity( + id = DATABASE_ID_INSERTION, + fingerId = fingerId, + index = 1, + motionType = GestureFingerMotionType.WAIT, + durationMs = pressDuration, + ), + GestureFingerMotionEntity( + id = DATABASE_ID_INSERTION, + fingerId = fingerId, + index = 2, + motionType = GestureFingerMotionType.RELEASE, + ), + ) +} + +private fun Gesture.toGestureEntity(): ActionEntity = ActionEntity( id = id.databaseId, eventId = eventId.databaseId, priority = priority, name = name!!, - type = ActionType.SWIPE, - swipeDuration = swipeDuration, - fromX = from?.x, - fromY = from?.y, - toX = to?.x, - toY = to?.y, + type = ActionType.GESTURE, ) private fun Pause.toPauseEntity(): ActionEntity = @@ -163,3 +214,9 @@ private fun SetText.toSetTextEntity(): ActionEntity = textValue = text, textValidateInput = validateInput, ) + +private fun GestureCoordinatesValue.toCoordinatesType(): CoordinatesType = + when (this) { + is GestureCoordinatesValue.Literal -> CoordinatesType.LITERAL + is GestureCoordinatesValue.Counter -> CoordinatesType.COUNTER + } diff --git a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/event/Event.kt b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/event/Event.kt index 15b7c39618..588f6d751f 100644 --- a/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/event/Event.kt +++ b/core/smart/domain/src/main/java/com/buzbuz/smartautoclicker/core/domain/model/event/Event.kt @@ -27,6 +27,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.action.Action import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition import com.buzbuz.smartautoclicker.core.domain.model.ConditionOperator import com.buzbuz.smartautoclicker.core.domain.model.action.Click +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition import com.buzbuz.smartautoclicker.core.domain.model.condition.TriggerCondition @@ -123,8 +124,7 @@ data class TriggerEvent( if (!isComplete()) return false return when (this) { - is Click -> positionType == Click.PositionType.USER_SELECTED - && clickOnConditionId == null + is Click -> position is GesturePosition.Coordinates else -> true } 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 5f0bf8f0bf..d5761a4dd3 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 @@ -19,8 +19,21 @@ package com.buzbuz.smartautoclicker.core.domain.model.action import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.base.identifier.DATABASE_ID_INSERTION +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.Counter +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.Literal +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toDomain import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toEntity +import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toGestureFingerEntity +import com.buzbuz.smartautoclicker.core.domain.model.action.mapper.toGestureMotionEntities +import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationValue +import com.buzbuz.smartautoclicker.core.domain.utils.asIdentifier import org.junit.Assert.assertEquals import org.junit.Test @@ -32,6 +45,35 @@ import org.robolectric.annotation.Config @Config(sdk = [Build.VERSION_CODES.Q]) class ActionMapperTests { + private companion object { + private const val ACTION_DB_ID = 42L + private const val CLICK_CONDITION_DB_ID = 51L + private const val CLICK_X_COUNTER_NAME = "counterX" + private const val CLICK_Y_COUNTER_NAME = "counterY" + private const val CLICK_OFFSET_X = 10 + private const val CLICK_OFFSET_Y = 20 + /** Press duration used by the domain mapper when the stored click has no wait motion. */ + private const val CLICK_DEFAULT_PRESS_DURATION_MS = 1L + + private const val GESTURE_SECOND_FINGER_DB_ID = 81L + + private const val CHANGE_COUNTER_OTHER_COUNTER_NAME = "otherCounter" + } + + /* ======================== Incomplete action ======================== */ + + @Test(expected = IllegalStateException::class) + fun incompleteAction_noName_toEntity_throws() { + ActionTestsData.getNewPause(eventId = ActionTestsData.ACTION_EVENT_ID, name = null).toEntity() + } + + @Test(expected = IllegalStateException::class) + fun incompleteClick_noPosition_toEntity_throws() { + ActionTestsData.getNewClick(eventId = ActionTestsData.ACTION_EVENT_ID, position = null).toEntity() + } + + /* ======================== Click ======================== */ + @Test fun click_toEntity() { assertEquals( @@ -49,21 +91,269 @@ class ActionMapperTests { } @Test - fun swipe_toEntity() { + fun click_toDomain_cleanIds() { + val expected = ActionTestsData.getNewClick( + id = ACTION_DB_ID, + eventId = ActionTestsData.ACTION_EVENT_ID, + x = null, + y = null, + ).copy( + id = Identifier(id = ACTION_DB_ID, asTemporary = true), + eventId = Identifier(id = ActionTestsData.ACTION_EVENT_ID, asTemporary = true), + position = GesturePosition.Condition(Identifier(id = CLICK_CONDITION_DB_ID, asTemporary = true)), + ) + + assertEquals( + expected, + ActionTestsData.getNewClickEntity( + id = ACTION_DB_ID, + eventId = ActionTestsData.ACTION_EVENT_ID, + x = null, + y = null, + clickOnConditionId = CLICK_CONDITION_DB_ID, + ).toDomain(cleanIds = true), + ) + } + + @Test + fun click_toDomain_counterCoordinates() { + val motions = ActionTestsData.getNewClickMotionEntities().map { motion -> + if (motion.motionType != GestureFingerMotionType.PRESS) motion + else motion.copy( + xCoordinateType = CoordinatesType.COUNTER, + yCoordinateType = CoordinatesType.COUNTER, + xLiteral = null, + yLiteral = null, + xCounter = CLICK_X_COUNTER_NAME, + yCounter = CLICK_Y_COUNTER_NAME, + ) + } + + assertEquals( + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + position = GesturePosition.Coordinates(Counter(CLICK_X_COUNTER_NAME), Counter(CLICK_Y_COUNTER_NAME)), + ), + ActionTestsData.getNewClickEntity(eventId = ActionTestsData.ACTION_EVENT_ID, motions = motions).toDomain(), + ) + } + + @Test + fun click_toDomain_nullCoordinateTypes_areReadAsLiterals() { + val motions = ActionTestsData.getNewClickMotionEntities().map { motion -> + if (motion.motionType != GestureFingerMotionType.PRESS) motion + else motion.copy(xCoordinateType = null, yCoordinateType = null) + } + + assertEquals( + ActionTestsData.getNewClick(eventId = ActionTestsData.ACTION_EVENT_ID), + ActionTestsData.getNewClickEntity(eventId = ActionTestsData.ACTION_EVENT_ID, motions = motions).toDomain(), + ) + } + + @Test + fun click_toDomain_withOffset() { + assertEquals( + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + offset = GesturePosition.Coordinates(Literal(CLICK_OFFSET_X), Literal(CLICK_OFFSET_Y)), + ), + ActionTestsData.getNewClickEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + motions = ActionTestsData.getNewClickMotionEntities(offsetX = CLICK_OFFSET_X, offsetY = CLICK_OFFSET_Y), + ).toDomain(), + ) + } + + @Test + fun click_toDomain_noWaitMotion_usesDefaultPressDuration() { + val motions = ActionTestsData.getNewClickMotionEntities() + .filter { it.motionType != GestureFingerMotionType.WAIT } + + assertEquals( + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + pressDuration = CLICK_DEFAULT_PRESS_DURATION_MS, + ), + ActionTestsData.getNewClickEntity(eventId = ActionTestsData.ACTION_EVENT_ID, motions = motions).toDomain(), + ) + } + + @Test + fun click_toDomain_noFingers() { assertEquals( - ActionTestsData.getNewSwipeEntity(eventId = ActionTestsData.ACTION_EVENT_ID).action, - ActionTestsData.getNewSwipe(eventId = ActionTestsData.ACTION_EVENT_ID).toEntity(), + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + pressDuration = CLICK_DEFAULT_PRESS_DURATION_MS, + position = null, + ), + ActionTestsData.getNewClickEntity(eventId = ActionTestsData.ACTION_EVENT_ID) + .copy(gestureFingers = emptyList()) + .toDomain(), ) } @Test - fun swipe_toDomain() { + fun click_toGestureFingerEntity() { assertEquals( - ActionTestsData.getNewSwipe(eventId = ActionTestsData.ACTION_EVENT_ID), - ActionTestsData.getNewSwipeEntity(eventId = ActionTestsData.ACTION_EVENT_ID).toDomain(), + GestureFingerEntity( + id = DATABASE_ID_INSERTION, + actionId = ACTION_DB_ID, + index = 0, + startTimeMs = 0, + ), + ActionTestsData.getNewClick(eventId = ActionTestsData.ACTION_EVENT_ID) + .toGestureFingerEntity(actionId = ACTION_DB_ID), ) } + @Test + fun click_toGestureMotionEntities() { + assertEquals( + ActionTestsData.getNewClickMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + pressMotionId = DATABASE_ID_INSERTION, + waitMotionId = DATABASE_ID_INSERTION, + releaseMotionId = DATABASE_ID_INSERTION, + ), + ActionTestsData.getNewClick(eventId = ActionTestsData.ACTION_EVENT_ID) + .toGestureMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + clickOnConditionDbId = null, + ), + ) + } + + @Test + fun click_toGestureMotionEntities_counterCoordinates() { + val expected = ActionTestsData.getNewClickMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + pressMotionId = DATABASE_ID_INSERTION, + waitMotionId = DATABASE_ID_INSERTION, + releaseMotionId = DATABASE_ID_INSERTION, + ).map { motion -> + if (motion.motionType != GestureFingerMotionType.PRESS) motion + else motion.copy( + xCoordinateType = CoordinatesType.COUNTER, + yCoordinateType = CoordinatesType.COUNTER, + xLiteral = null, + yLiteral = null, + xCounter = CLICK_X_COUNTER_NAME, + yCounter = CLICK_Y_COUNTER_NAME, + ) + } + + assertEquals( + expected, + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + position = GesturePosition.Coordinates(Counter(CLICK_X_COUNTER_NAME), Counter(CLICK_Y_COUNTER_NAME)), + ).toGestureMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + clickOnConditionDbId = null, + ), + ) + } + + @Test + fun click_toGestureMotionEntities_withOffset() { + assertEquals( + ActionTestsData.getNewClickMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + pressMotionId = DATABASE_ID_INSERTION, + waitMotionId = DATABASE_ID_INSERTION, + releaseMotionId = DATABASE_ID_INSERTION, + offsetX = CLICK_OFFSET_X, + offsetY = CLICK_OFFSET_Y, + ), + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + offset = GesturePosition.Coordinates(Literal(CLICK_OFFSET_X), Literal(CLICK_OFFSET_Y)), + ).toGestureMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + clickOnConditionDbId = null, + ), + ) + } + + @Test + fun clickOnCondition_toGestureMotionEntities() { + assertEquals( + ActionTestsData.getNewClickMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + pressMotionId = DATABASE_ID_INSERTION, + waitMotionId = DATABASE_ID_INSERTION, + releaseMotionId = DATABASE_ID_INSERTION, + x = null, + y = null, + clickOnConditionId = CLICK_CONDITION_DB_ID, + ), + ActionTestsData + .getNewClick(eventId = ActionTestsData.ACTION_EVENT_ID, x = null, y = null) + .toGestureMotionEntities( + fingerId = ActionTestsData.CLICK_FINGER_ID, + clickOnConditionDbId = CLICK_CONDITION_DB_ID, + ), + ) + } + + @Test + fun clickOnCondition_toDomain() { + assertEquals( + ActionTestsData.getNewClick( + eventId = ActionTestsData.ACTION_EVENT_ID, + x = null, + y = null, + clickOnConditionId = CLICK_CONDITION_DB_ID, + ), + ActionTestsData.getNewClickEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + x = null, + y = null, + clickOnConditionId = CLICK_CONDITION_DB_ID, + ).toDomain(), + ) + } + + /* ======================== Gesture ======================== */ + + @Test + fun gesture_toEntity() { + assertEquals( + ActionTestsData.getNewGestureEntity(eventId = ActionTestsData.ACTION_EVENT_ID).action, + ActionTestsData.getNewGesture(eventId = ActionTestsData.ACTION_EVENT_ID).toEntity(), + ) + } + + @Test + fun gesture_toDomain() { + assertEquals( + ActionTestsData.getNewGesture(eventId = ActionTestsData.ACTION_EVENT_ID), + ActionTestsData.getNewGestureEntity(eventId = ActionTestsData.ACTION_EVENT_ID).toDomain(), + ) + } + + @Test + fun gesture_toDomain_fingersSortedByIndex() { + val entity = ActionTestsData.getNewGestureEntity(eventId = ActionTestsData.ACTION_EVENT_ID) + val firstFinger = entity.gestureFingers.first() + val secondFinger = firstFinger.copy( + finger = firstFinger.finger.copy(id = GESTURE_SECOND_FINGER_DB_ID, index = 1), + motions = firstFinger.motions.map { it.copy(fingerId = GESTURE_SECOND_FINGER_DB_ID) }, + ) + + val gesture = ActionTestsData.getNewGesture(eventId = ActionTestsData.ACTION_EVENT_ID) + val expectedFirstFinger = gesture.fingers.first() + val expectedSecondFinger = expectedFirstFinger.copy(id = GESTURE_SECOND_FINGER_DB_ID.asIdentifier()) + + assertEquals( + gesture.copy(fingers = listOf(expectedFirstFinger, expectedSecondFinger)), + entity.copy(gestureFingers = listOf(secondFinger, firstFinger)).toDomain(), + ) + } + + /* ======================== Pause ======================== */ + @Test fun pause_toEntity() { assertEquals( @@ -80,6 +370,25 @@ class ActionMapperTests { ) } + @Test + fun pause_toDomain_cleanIds() { + val expected = ActionTestsData.getNewPause( + id = ACTION_DB_ID, + eventId = ActionTestsData.ACTION_EVENT_ID, + ).copy( + id = Identifier(id = ACTION_DB_ID, asTemporary = true), + eventId = Identifier(id = ActionTestsData.ACTION_EVENT_ID, asTemporary = true), + ) + + assertEquals( + expected, + ActionTestsData.getNewPauseEntity(id = ACTION_DB_ID, eventId = ActionTestsData.ACTION_EVENT_ID) + .toDomain(cleanIds = true), + ) + } + + /* ======================== Intent ======================== */ + @Test fun intent_toEntity() { assertEquals( @@ -88,6 +397,14 @@ class ActionMapperTests { ) } + @Test + fun intent_toEntity_nullComponentName() { + assertEquals( + ActionTestsData.getNewIntentEntity(eventId = ActionTestsData.ACTION_EVENT_ID, componentName = null).action, + ActionTestsData.getNewIntent(eventId = ActionTestsData.ACTION_EVENT_ID, componentName = null).toEntity() + ) + } + @Test fun intent_toDomain() { assertEquals( @@ -96,6 +413,64 @@ class ActionMapperTests { ) } + @Test + fun intent_toDomain_nullOptionalFields() { + assertEquals( + ActionTestsData.getNewIntent( + eventId = ActionTestsData.ACTION_EVENT_ID, + isBroadcast = false, + componentName = null, + ), + ActionTestsData.getNewIntentEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + isBroadcast = null, + componentName = null, + ).toDomain(), + ) + } + + @Test + fun intent_toDomain_withExtras() { + assertEquals( + ActionTestsData.getNewIntent( + eventId = ActionTestsData.ACTION_EVENT_ID, + intentExtras = mutableListOf(ActionTestsData.getNewIntentExtra(value = 42)), + ), + ActionTestsData.getNewIntentEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + intentExtras = listOf(ActionTestsData.getNewIntentExtraEntity(value = "42")), + ).toDomain(), + ) + } + + @Test + fun intent_toDomain_withExtras_cleanIds() { + val extra = ActionTestsData.getNewIntentExtra(value = 42) + val expected = ActionTestsData.getNewIntent(eventId = ActionTestsData.ACTION_EVENT_ID) + .let { intent -> + intent.copy( + id = Identifier(id = intent.id.databaseId, asTemporary = true), + eventId = Identifier(id = ActionTestsData.ACTION_EVENT_ID, asTemporary = true), + extras = mutableListOf( + extra.copy( + id = Identifier(id = extra.id.databaseId, asTemporary = true), + actionId = Identifier(id = extra.actionId.databaseId, asTemporary = true), + ) + ), + ) + } + + assertEquals( + expected, + ActionTestsData.getNewIntentEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + intentExtras = listOf(ActionTestsData.getNewIntentExtraEntity(value = "42")), + ).toDomain(cleanIds = true), + ) + } + + /* ======================== Toggle Event ======================== */ + @Test fun toggleEvent_toEntity() { assertEquals( @@ -104,6 +479,23 @@ class ActionMapperTests { ) } + @Test + fun toggleEvent_toEntity_withEventToggles() { + assertEquals( + ActionTestsData.getNewToggleEventEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + ).action, + ActionTestsData.getNewToggleEvent( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + eventToggle = mutableListOf(ActionTestsData.getNewEventToggleExtra()), + ).toEntity() + ) + } + @Test fun toggleEvent_toDomain() { assertEquals( @@ -112,6 +504,74 @@ class ActionMapperTests { ) } + @Test + fun toggleEvent_toDomain_nullToggleAll() { + assertEquals( + ActionTestsData.getNewToggleEvent( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + ), + ActionTestsData.getNewToggleEventEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = null, + toggleType = null, + ).toDomain(), + ) + } + + @Test + fun toggleEvent_toDomain_withEventToggles() { + assertEquals( + ActionTestsData.getNewToggleEvent( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + eventToggle = mutableListOf(ActionTestsData.getNewEventToggleExtra()), + ), + ActionTestsData.getNewToggleEventEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + eventToggles = listOf(ActionTestsData.getNewEventToggleEntity()), + ).toDomain(), + ) + } + + @Test + fun toggleEvent_toDomain_withEventToggles_cleanIds() { + val eventToggle = ActionTestsData.getNewEventToggleExtra() + val expected = ActionTestsData.getNewToggleEvent( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + ).let { toggleEvent -> + toggleEvent.copy( + id = Identifier(id = toggleEvent.id.databaseId, asTemporary = true), + eventId = Identifier(id = ActionTestsData.ACTION_EVENT_ID, asTemporary = true), + eventToggles = mutableListOf( + eventToggle.copy( + id = Identifier(id = eventToggle.id.databaseId, asTemporary = true), + actionId = Identifier(id = eventToggle.actionId.databaseId, asTemporary = true), + targetEventId = Identifier(id = eventToggle.targetEventId!!.databaseId, asTemporary = true), + ) + ), + ) + } + + assertEquals( + expected, + ActionTestsData.getNewToggleEventEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + toggleAll = false, + toggleType = null, + eventToggles = listOf(ActionTestsData.getNewEventToggleEntity()), + ).toDomain(cleanIds = true), + ) + } + + /* ======================== Change Counter ======================== */ + @Test fun changeCounter_toEntity() { assertEquals( @@ -120,6 +580,22 @@ class ActionMapperTests { ) } + @Test + fun changeCounter_toEntity_counterOperationValue() { + val operationValue = CounterOperationValue.Counter(CHANGE_COUNTER_OTHER_COUNTER_NAME) + + assertEquals( + ActionTestsData.getNewChangeCounterEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + operationValue = operationValue, + ).action, + ActionTestsData.getNewChangeCounter( + eventId = ActionTestsData.ACTION_EVENT_ID, + operationValue = operationValue, + ).toEntity(), + ) + } + @Test fun changeCounter_toDomain() { assertEquals( @@ -128,6 +604,34 @@ class ActionMapperTests { ) } + @Test + fun changeCounter_toDomain_counterOperationValue() { + val operationValue = CounterOperationValue.Counter(CHANGE_COUNTER_OTHER_COUNTER_NAME) + + assertEquals( + ActionTestsData.getNewChangeCounter( + eventId = ActionTestsData.ACTION_EVENT_ID, + operationValue = operationValue, + ), + ActionTestsData.getNewChangeCounterEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + operationValue = operationValue, + ).toDomain(), + ) + } + + @Test + fun changeCounter_toDomain_nullOperationValueType_isReadAsNumber() { + val entity = ActionTestsData.getNewChangeCounterEntity(eventId = ActionTestsData.ACTION_EVENT_ID) + + assertEquals( + ActionTestsData.getNewChangeCounter(eventId = ActionTestsData.ACTION_EVENT_ID), + entity.copy(action = entity.action.copy(counterOperationValueType = null)).toDomain(), + ) + } + + /* ======================== Notification ======================== */ + @Test fun notification_toEntity() { assertEquals( @@ -144,6 +648,8 @@ class ActionMapperTests { ) } + /* ======================== System Action ======================== */ + @Test fun systemAction_toEntity() { assertEquals( @@ -160,6 +666,8 @@ class ActionMapperTests { ) } + /* ======================== Set Text ======================== */ + @Test fun setText_toEntity() { assertEquals( @@ -175,4 +683,20 @@ class ActionMapperTests { ActionTestsData.getNewSetTextEntity(eventId = ActionTestsData.ACTION_EVENT_ID).toDomain(), ) } -} \ No newline at end of file + + @Test + fun setText_toDomain_nullValues() { + assertEquals( + ActionTestsData.getNewSetText( + eventId = ActionTestsData.ACTION_EVENT_ID, + text = "", + validateInput = false, + ), + ActionTestsData.getNewSetTextEntity( + eventId = ActionTestsData.ACTION_EVENT_ID, + text = null, + validateInput = null, + ).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 728a45d652..db4161c223 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 @@ -23,14 +23,23 @@ import com.buzbuz.smartautoclicker.core.base.identifier.Identifier import com.buzbuz.smartautoclicker.core.database.entity.ActionEntity import com.buzbuz.smartautoclicker.core.database.entity.ActionType import com.buzbuz.smartautoclicker.core.database.entity.ChangeCounterOperationType -import com.buzbuz.smartautoclicker.core.database.entity.ClickPositionType import com.buzbuz.smartautoclicker.core.database.entity.CompleteActionEntity +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType import com.buzbuz.smartautoclicker.core.database.entity.CounterOperationValueType import com.buzbuz.smartautoclicker.core.database.entity.EventToggleEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerWithMotions import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraEntity import com.buzbuz.smartautoclicker.core.database.entity.IntentExtraType import com.buzbuz.smartautoclicker.core.database.entity.SystemActionType import com.buzbuz.smartautoclicker.core.domain.model.counter.CounterOperationValue +import com.buzbuz.smartautoclicker.core.domain.model.action.Gesture +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFinger +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.Literal +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureFingerMotion +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GesturePosition import com.buzbuz.smartautoclicker.core.domain.model.action.intent.IntentExtra import com.buzbuz.smartautoclicker.core.domain.model.action.toggleevent.EventToggle import com.buzbuz.smartautoclicker.core.domain.model.event.EventTestsData @@ -47,6 +56,10 @@ internal object ActionTestsData { private const val CLICK_PRESS_DURATION = 250L private const val CLICK_X_POSITION = 24 private const val CLICK_Y_POSITION = 87 + const val CLICK_FINGER_ID = 70L + private const val CLICK_PRESS_MOTION_ID = 701L + private const val CLICK_WAIT_MOTION_ID = 702L + private const val CLICK_RELEASE_MOTION_ID = 703L fun getNewClickEntity( id: Long = CLICK_ID, @@ -56,18 +69,72 @@ internal object ActionTestsData { x: Int? = CLICK_X_POSITION, y: Int? = CLICK_Y_POSITION, clickOnConditionId: Long? = null, - positionType: ClickPositionType = - if (x != null && y != null) ClickPositionType.USER_SELECTED - else ClickPositionType.ON_DETECTED_CONDITION, + motions: List = getNewClickMotionEntities( + pressDuration = pressDuration, + x = x, + y = y, + clickOnConditionId = clickOnConditionId, + ), eventId: Long, ) = CompleteActionEntity( - action = ActionEntity(id, eventId, priority, name, ActionType.CLICK, x = x, y = y, - clickOnConditionId = clickOnConditionId, clickPositionType = positionType, - pressDuration = pressDuration), + action = ActionEntity(id, eventId, priority, name, ActionType.CLICK), + gestureFingers = listOf( + GestureFingerWithMotions( + finger = GestureFingerEntity(CLICK_FINGER_ID, id, 0, 0), + motions = motions, + ), + ), intentExtras = emptyList(), eventsToggle = emptyList(), ) + /** A click is stored as a press on the click position, a wait for the press duration, and a release. */ + fun getNewClickMotionEntities( + fingerId: Long = CLICK_FINGER_ID, + pressMotionId: Long = CLICK_PRESS_MOTION_ID, + waitMotionId: Long = CLICK_WAIT_MOTION_ID, + releaseMotionId: Long = CLICK_RELEASE_MOTION_ID, + pressDuration: Long = CLICK_PRESS_DURATION, + x: Int? = CLICK_X_POSITION, + y: Int? = CLICK_Y_POSITION, + clickOnConditionId: Long? = null, + offsetX: Int? = null, + offsetY: Int? = null, + ): List { + val isOnCondition = x == null && y == null + + return listOf( + GestureFingerMotionEntity( + id = pressMotionId, + fingerId = fingerId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else CoordinatesType.LITERAL, + yCoordinateType = if (isOnCondition) CoordinatesType.CONDITION else CoordinatesType.LITERAL, + conditionId = clickOnConditionId, + xLiteral = x, + yLiteral = y, + xOffsetCoordinateType = offsetX?.let { CoordinatesType.LITERAL }, + yOffsetCoordinateType = offsetY?.let { CoordinatesType.LITERAL }, + xOffsetLiteral = offsetX, + yOffsetLiteral = offsetY, + ), + GestureFingerMotionEntity( + id = waitMotionId, + fingerId = fingerId, + index = 1, + motionType = GestureFingerMotionType.WAIT, + durationMs = pressDuration, + ), + GestureFingerMotionEntity( + id = releaseMotionId, + fingerId = fingerId, + index = 2, + motionType = GestureFingerMotionType.RELEASE, + ), + ) + } + fun getNewClick( id: Long = CLICK_ID, name: String? = CLICK_NAME, @@ -76,56 +143,89 @@ internal object ActionTestsData { x: Int? = CLICK_X_POSITION, y: Int? = CLICK_Y_POSITION, clickOnConditionId: Long? = null, - positionType: Click.PositionType = - if (x != null && y != null) Click.PositionType.USER_SELECTED - else Click.PositionType.ON_DETECTED_CONDITION, + position: GesturePosition? = + if (x != null && y != null) GesturePosition.Coordinates(Literal(x), Literal(y)) + else GesturePosition.Condition(clickOnConditionId?.let { Identifier(databaseId = it) }), + offset: GesturePosition? = null, eventId: Long, - ) = Click(id.asIdentifier(), eventId.asIdentifier(), name, priority, pressDuration, positionType, - if (x != null && y != null) Point(x, y) else null, - clickOnConditionId?.let { Identifier(databaseId = clickOnConditionId) }, - ) + ) = Click(id.asIdentifier(), eventId.asIdentifier(), name, priority, pressDuration, position, offset) - /* ------- Swipe Action Data ------- */ + /* ------- Gesture Action Data ------- */ - private const val SWIPE_ID = 8L - private const val SWIPE_NAME = "Swipe name" - private const val SWIPE_DURATION = 1000L - private const val SWIPE_FROM_X_POSITION = 42 - private const val SWIPE_FROM_Y_POSITION = 78 - private const val SWIPE_TO_X_POSITION = 789 - private const val SWIPE_TO_Y_POSITION = 1445 + private const val GESTURE_ID = 8L + private const val GESTURE_NAME = "Gesture name" + private const val GESTURE_FINGER_ID = 80L + private const val GESTURE_PRESS_MOTION_ID = 801L + private const val GESTURE_RELEASE_MOTION_ID = 802L + private const val GESTURE_FINGER_START_TIME_MS = 0L + private const val GESTURE_PRESS_X = 42 + private const val GESTURE_PRESS_Y = 78 - fun getNewSwipeEntity( - id: Long = SWIPE_ID, - name: String = SWIPE_NAME, + fun getNewGestureEntity( + id: Long = GESTURE_ID, + name: String = GESTURE_NAME, priority: Int = 0, - swipeDuration: Long = SWIPE_DURATION, - fromX: Int = SWIPE_FROM_X_POSITION, - fromY: Int = SWIPE_FROM_Y_POSITION, - toX: Int = SWIPE_TO_X_POSITION, - toY: Int = SWIPE_TO_Y_POSITION, eventId: Long, ) = CompleteActionEntity( - action = ActionEntity(id, eventId, priority, name, ActionType.SWIPE, fromX = fromX, fromY = fromY, toX = toX, - toY = toY, swipeDuration = swipeDuration), + action = ActionEntity(id, eventId, priority, name, ActionType.GESTURE), + gestureFingers = listOf( + GestureFingerWithMotions( + finger = GestureFingerEntity(GESTURE_FINGER_ID, id, 0, GESTURE_FINGER_START_TIME_MS), + motions = listOf( + GestureFingerMotionEntity( + id = GESTURE_PRESS_MOTION_ID, + fingerId = GESTURE_FINGER_ID, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.LITERAL, + yCoordinateType = CoordinatesType.LITERAL, + xLiteral = GESTURE_PRESS_X, + yLiteral = GESTURE_PRESS_Y, + xOffsetCoordinateType = CoordinatesType.LITERAL, + yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, + yOffsetLiteral = 0, + ), + GestureFingerMotionEntity( + id = GESTURE_RELEASE_MOTION_ID, + fingerId = GESTURE_FINGER_ID, + index = 1, + motionType = GestureFingerMotionType.RELEASE, + ), + ), + ), + ), intentExtras = emptyList(), eventsToggle = emptyList(), ) - fun getNewSwipe( - id: Long = SWIPE_ID, - name: String? = SWIPE_NAME, + fun getNewGesture( + id: Long = GESTURE_ID, + name: String? = GESTURE_NAME, priority: Int = 0, - swipeDuration: Long? = SWIPE_DURATION, - fromX: Int? = SWIPE_FROM_X_POSITION, - fromY: Int? = SWIPE_FROM_Y_POSITION, - toX: Int? = SWIPE_TO_X_POSITION, - toY: Int? = SWIPE_TO_Y_POSITION, eventId: Long, - ) : Swipe = Swipe(id.asIdentifier(), eventId.asIdentifier(), name, priority, swipeDuration, - if (fromX != null && fromY != null) Point(fromX, fromY) else null, - if (toX != null && toY != null) Point(toX, toY) else null, + ) = Gesture( + id = id.asIdentifier(), + eventId = eventId.asIdentifier(), + name = name, + priority = priority, + fingers = listOf( + GestureFinger( + id = GESTURE_FINGER_ID.asIdentifier(), + startTimeMs = GESTURE_FINGER_START_TIME_MS, + motions = listOf( + GestureFingerMotion.Press( + id = GESTURE_PRESS_MOTION_ID.asIdentifier(), + position = GesturePosition.Coordinates(Literal(GESTURE_PRESS_X), Literal(GESTURE_PRESS_Y)), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + ), + GestureFingerMotion.Release( + id = GESTURE_RELEASE_MOTION_ID.asIdentifier(), + ), + ), + ), + ), ) @@ -143,6 +243,7 @@ internal object ActionTestsData { eventId: Long, ) = CompleteActionEntity( action = ActionEntity(id, eventId, priority, name, ActionType.PAUSE, pauseDuration = pauseDuration), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = emptyList(), ) @@ -172,15 +273,16 @@ internal object ActionTestsData { name: String = INTENT_NAME, priority: Int = 0, isAdvanced: Boolean = INTENT_IS_ADVANCED, - isBroadcast: Boolean = INTENT_IS_BROADCAST, + isBroadcast: Boolean? = INTENT_IS_BROADCAST, action: String = INTENT_ACTION, - componentName: String = INTENT_COMPONENT_NAME_STRING, + componentName: String? = INTENT_COMPONENT_NAME_STRING, flags: Int = INTENT_FLAGS, eventId: Long, intentExtras: List = emptyList() ) = CompleteActionEntity( action = ActionEntity(id, eventId, priority, name, ActionType.INTENT, isAdvanced = isAdvanced, isBroadcast = isBroadcast, intentAction = action, componentName = componentName, flags = flags), + gestureFingers = emptyList(), intentExtras = intentExtras, eventsToggle = emptyList(), ) @@ -192,7 +294,7 @@ internal object ActionTestsData { isAdvanced: Boolean = INTENT_IS_ADVANCED, isBroadcast: Boolean = INTENT_IS_BROADCAST, action: String = INTENT_ACTION, - componentName: ComponentName = INTENT_COMPONENT_NAME!!, + componentName: ComponentName? = INTENT_COMPONENT_NAME, flags: Int = INTENT_FLAGS, eventId: Long, intentExtras: MutableList> = mutableListOf() @@ -233,12 +335,13 @@ internal object ActionTestsData { id: Long = TOGGLE_EVENT_ID, name: String = TOGGLE_EVENT_NAME, priority: Int = 0, - toggleAll: Boolean = TOGGLE_EVENT_TOGGLE_ALL, - toggleType: ToggleEvent.ToggleType = TOGGLE_EVENT_TOGGLE_ALL_TYPE, + toggleAll: Boolean? = TOGGLE_EVENT_TOGGLE_ALL, + toggleType: ToggleEvent.ToggleType? = TOGGLE_EVENT_TOGGLE_ALL_TYPE, eventToggles: List = emptyList(), eventId: Long, ) = CompleteActionEntity( - action = ActionEntity(id, eventId, priority, name, ActionType.TOGGLE_EVENT, toggleAll = toggleAll, toggleAllType = toggleType.toEntity()), + action = ActionEntity(id, eventId, priority, name, ActionType.TOGGLE_EVENT, toggleAll = toggleAll, toggleAllType = toggleType?.toEntity()), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = eventToggles, ) @@ -248,7 +351,7 @@ internal object ActionTestsData { name: String? = TOGGLE_EVENT_NAME, priority: Int = 0, toggleAll: Boolean = TOGGLE_EVENT_TOGGLE_ALL, - toggleType: ToggleEvent.ToggleType = TOGGLE_EVENT_TOGGLE_ALL_TYPE, + toggleType: ToggleEvent.ToggleType? = TOGGLE_EVENT_TOGGLE_ALL_TYPE, eventToggle: MutableList = mutableListOf(), eventId: Long, ) = ToggleEvent(id.asIdentifier(), eventId.asIdentifier(), name, priority, toggleAll, toggleType, eventToggle) @@ -282,16 +385,21 @@ internal object ActionTestsData { priority: Int = 0, counterName: String = CHANGE_COUNTER_COUNTER_NAME, operation: ChangeCounter.OperationType = CHANGE_COUNTER_OPERATION, - operationValue: Double = CHANGE_COUNTER_VALUE, + operationValue: CounterOperationValue = CounterOperationValue.Number(CHANGE_COUNTER_VALUE), eventId: Long, ) = CompleteActionEntity( action = ActionEntity( id, eventId, priority, name, ActionType.CHANGE_COUNTER, counterName = counterName, counterOperation = ChangeCounterOperationType.valueOf(operation.name), - counterOperationValueType = CounterOperationValueType.NUMBER, - counterOperationValue = operationValue, + counterOperationValueType = when (operationValue) { + is CounterOperationValue.Number -> CounterOperationValueType.NUMBER + is CounterOperationValue.Counter -> CounterOperationValueType.COUNTER + }, + counterOperationValue = (operationValue as? CounterOperationValue.Number)?.value, + counterOperationCounterName = (operationValue as? CounterOperationValue.Counter)?.value, ), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = emptyList(), ) @@ -327,6 +435,7 @@ internal object ActionTestsData { notificationMessageText = messageText, notificationImportance = channelImportance, ), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = emptyList(), ) @@ -358,6 +467,7 @@ internal object ActionTestsData { id, eventId, priority, name, ActionType.SYSTEM, systemActionType = SystemActionType.valueOf(type.name), ), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = emptyList(), ) @@ -382,8 +492,8 @@ internal object ActionTestsData { id: Long = SET_TEXT_ID, name: String = SET_TEXT_NAME, priority: Int = 0, - text: String = SET_TEXT_VALUE, - validateInput: Boolean = SET_TEXT_VALIDATE_INPUT, + text: String? = SET_TEXT_VALUE, + validateInput: Boolean? = SET_TEXT_VALIDATE_INPUT, eventId: Long, ) = CompleteActionEntity( action = ActionEntity( @@ -391,6 +501,7 @@ internal object ActionTestsData { textValue = text, textValidateInput = validateInput, ), + gestureFingers = emptyList(), intentExtras = emptyList(), eventsToggle = emptyList(), ) diff --git a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapperTests.kt b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapperTests.kt new file mode 100644 index 0000000000..196e3bd98d --- /dev/null +++ b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/model/action/gesture/GestureFingerMapperTests.kt @@ -0,0 +1,372 @@ +/* + * 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.gesture + +import android.os.Build + +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.database.entity.CoordinatesType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionEntity +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerMotionType +import com.buzbuz.smartautoclicker.core.database.entity.GestureFingerWithMotions +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.Counter +import com.buzbuz.smartautoclicker.core.domain.model.action.gesture.GestureCoordinatesValue.Literal + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class GestureFingerMapperTests { + + /* ======================== Test constants ======================== */ + + private val fingerId = Identifier(databaseId = 100L) + private val pressMotionId = Identifier(databaseId = 101L) + private val moveMotionId = Identifier(databaseId = 102L) + private val waitMotionId = Identifier(databaseId = 103L) + private val releaseMotionId = Identifier(databaseId = 104L) + private val conditionId = Identifier(databaseId = 200L) + + private val actionId = 8L + private val fingerIndex = 0 + + private val startTimeMs = 50L + private val pressX = 42 + private val pressY = 78 + private val moveToX = 789 + private val moveToY = 456 + private val moveDurationMs = 500L + private val waitDurationMs = 250L + private val offsetX = 10 + private val offsetY = 20 + private val counterXName = "counterX" + private val counterYName = "counterY" + + /* ======================== toFingerEntity ======================== */ + + @Test + fun fingerToEntity() { + val finger = GestureFinger( + id = fingerId, + startTimeMs = startTimeMs, + motions = listOf( + GestureFingerMotion.Press(pressMotionId, GesturePosition.Coordinates(Literal(pressX), Literal(pressY)), GesturePosition.Coordinates(Literal(0), Literal(0))), + GestureFingerMotion.Release(releaseMotionId), + ), + ) + + assertEquals( + GestureFingerEntity(id = fingerId.databaseId, actionId = actionId, index = fingerIndex, startTimeMs = startTimeMs), + finger.toFingerEntity(actionId = actionId, index = fingerIndex), + ) + } + + /* ======================== toMotionEntity — Press ======================== */ + + @Test + fun pressMotion_literalCoords_toEntity() { + val press = GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Coordinates(Literal(pressX), Literal(pressY)), + offset = GesturePosition.Coordinates(Literal(offsetX), Literal(offsetY)), + ) + + assertEquals( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.LITERAL, + yCoordinateType = CoordinatesType.LITERAL, + xLiteral = pressX, + yLiteral = pressY, + xOffsetCoordinateType = CoordinatesType.LITERAL, + yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = offsetX, + yOffsetLiteral = offsetY, + ), + press.toMotionEntity(fingerId = fingerId.databaseId, index = 0), + ) + } + + @Test + fun pressMotion_counterCoords_toEntity() { + val press = GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Coordinates(Counter(counterXName), Counter(counterYName)), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + ) + + assertEquals( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.COUNTER, + yCoordinateType = CoordinatesType.COUNTER, + xCounter = counterXName, + yCounter = counterYName, + xOffsetCoordinateType = CoordinatesType.LITERAL, + yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, + yOffsetLiteral = 0, + ), + press.toMotionEntity(fingerId = fingerId.databaseId, index = 0), + ) + } + + @Test + fun pressMotion_conditionPosition_toEntity() { + val press = GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Condition(conditionId = conditionId), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + ) + + assertEquals( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.CONDITION, + yCoordinateType = CoordinatesType.CONDITION, + conditionId = conditionId.databaseId, + xOffsetCoordinateType = CoordinatesType.LITERAL, + yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, + yOffsetLiteral = 0, + ), + press.toMotionEntity(fingerId = fingerId.databaseId, index = 0), + ) + } + + /* ======================== toMotionEntity — Move ======================== */ + + @Test + fun moveMotion_literalCoords_toEntity() { + val move = GestureFingerMotion.Move( + id = moveMotionId, + position = GesturePosition.Coordinates(Literal(moveToX), Literal(moveToY)), + offset = GesturePosition.Coordinates(Literal(offsetX), Literal(offsetY)), + durationMs = moveDurationMs, + ) + + assertEquals( + GestureFingerMotionEntity( + id = moveMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 1, + motionType = GestureFingerMotionType.MOVE, + durationMs = moveDurationMs, + xCoordinateType = CoordinatesType.LITERAL, + yCoordinateType = CoordinatesType.LITERAL, + xLiteral = moveToX, + yLiteral = moveToY, + xOffsetCoordinateType = CoordinatesType.LITERAL, + yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = offsetX, + yOffsetLiteral = offsetY, + ), + move.toMotionEntity(fingerId = fingerId.databaseId, index = 1), + ) + } + + /* ======================== toMotionEntity — Wait ======================== */ + + @Test + fun waitMotion_toEntity() { + val wait = GestureFingerMotion.Wait( + id = waitMotionId, + durationMs = waitDurationMs, + ) + + assertEquals( + GestureFingerMotionEntity( + id = waitMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 2, + motionType = GestureFingerMotionType.WAIT, + durationMs = waitDurationMs, + ), + wait.toMotionEntity(fingerId = fingerId.databaseId, index = 2), + ) + } + + /* ======================== toMotionEntity — Release ======================== */ + + @Test + fun releaseMotion_toEntity() { + val release = GestureFingerMotion.Release(id = releaseMotionId) + + assertEquals( + GestureFingerMotionEntity( + id = releaseMotionId.databaseId, + fingerId = fingerId.databaseId, + index = 3, + motionType = GestureFingerMotionType.RELEASE, + ), + release.toMotionEntity(fingerId = fingerId.databaseId, index = 3), + ) + } + + /* ======================== toDomainGestureFinger ======================== */ + + @Test + fun fingerWithMotions_pressRelease_toDomain() { + val fingerWithMotions = GestureFingerWithMotions( + finger = GestureFingerEntity(fingerId.databaseId, actionId, fingerIndex, startTimeMs), + motions = listOf( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, fingerId = fingerId.databaseId, index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.LITERAL, yCoordinateType = CoordinatesType.LITERAL, + xLiteral = pressX, yLiteral = pressY, + xOffsetCoordinateType = CoordinatesType.LITERAL, yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = offsetX, yOffsetLiteral = offsetY, + ), + GestureFingerMotionEntity( + id = releaseMotionId.databaseId, fingerId = fingerId.databaseId, index = 1, + motionType = GestureFingerMotionType.RELEASE, + ), + ), + ) + + assertEquals( + GestureFinger( + id = fingerId, + startTimeMs = startTimeMs, + motions = listOf( + GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Coordinates(Literal(pressX), Literal(pressY)), + offset = GesturePosition.Coordinates(Literal(offsetX), Literal(offsetY)), + ), + GestureFingerMotion.Release(id = releaseMotionId), + ), + ), + fingerWithMotions.toDomainGestureFinger(), + ) + } + + @Test + fun fingerWithMotions_fullSequence_toDomain() { + val fingerWithMotions = GestureFingerWithMotions( + finger = GestureFingerEntity(fingerId.databaseId, actionId, fingerIndex, startTimeMs), + motions = listOf( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, fingerId = fingerId.databaseId, index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.LITERAL, yCoordinateType = CoordinatesType.LITERAL, + xLiteral = pressX, yLiteral = pressY, + xOffsetCoordinateType = CoordinatesType.LITERAL, yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, yOffsetLiteral = 0, + ), + GestureFingerMotionEntity( + id = moveMotionId.databaseId, fingerId = fingerId.databaseId, index = 1, + motionType = GestureFingerMotionType.MOVE, + durationMs = moveDurationMs, + xCoordinateType = CoordinatesType.LITERAL, yCoordinateType = CoordinatesType.LITERAL, + xLiteral = moveToX, yLiteral = moveToY, + xOffsetCoordinateType = CoordinatesType.LITERAL, yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, yOffsetLiteral = 0, + ), + GestureFingerMotionEntity( + id = waitMotionId.databaseId, fingerId = fingerId.databaseId, index = 2, + motionType = GestureFingerMotionType.WAIT, + durationMs = waitDurationMs, + ), + GestureFingerMotionEntity( + id = releaseMotionId.databaseId, fingerId = fingerId.databaseId, index = 3, + motionType = GestureFingerMotionType.RELEASE, + ), + ), + ) + + assertEquals( + GestureFinger( + id = fingerId, + startTimeMs = startTimeMs, + motions = listOf( + GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Coordinates(Literal(pressX), Literal(pressY)), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + ), + GestureFingerMotion.Move( + id = moveMotionId, + position = GesturePosition.Coordinates(Literal(moveToX), Literal(moveToY)), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + durationMs = moveDurationMs, + ), + GestureFingerMotion.Wait( + id = waitMotionId, + durationMs = waitDurationMs, + ), + GestureFingerMotion.Release(id = releaseMotionId), + ), + ), + fingerWithMotions.toDomainGestureFinger(), + ) + } + + @Test + fun fingerWithMotions_conditionPosition_toDomain() { + val fingerWithMotions = GestureFingerWithMotions( + finger = GestureFingerEntity(fingerId.databaseId, actionId, fingerIndex, startTimeMs), + motions = listOf( + GestureFingerMotionEntity( + id = pressMotionId.databaseId, fingerId = fingerId.databaseId, index = 0, + motionType = GestureFingerMotionType.PRESS, + xCoordinateType = CoordinatesType.CONDITION, yCoordinateType = CoordinatesType.CONDITION, + conditionId = conditionId.databaseId, + xOffsetCoordinateType = CoordinatesType.LITERAL, yOffsetCoordinateType = CoordinatesType.LITERAL, + xOffsetLiteral = 0, yOffsetLiteral = 0, + ), + GestureFingerMotionEntity( + id = releaseMotionId.databaseId, fingerId = fingerId.databaseId, index = 1, + motionType = GestureFingerMotionType.RELEASE, + ), + ), + ) + + assertEquals( + GestureFinger( + id = fingerId, + startTimeMs = startTimeMs, + motions = listOf( + GestureFingerMotion.Press( + id = pressMotionId, + position = GesturePosition.Condition(conditionId = conditionId), + offset = GesturePosition.Coordinates(Literal(0), Literal(0)), + ), + GestureFingerMotion.Release(id = releaseMotionId), + ), + ), + fingerWithMotions.toDomainGestureFinger(), + ) + } +} diff --git a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/utils/Asserts.kt b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/utils/Asserts.kt index 578287b502..dd6fcd0217 100644 --- a/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/utils/Asserts.kt +++ b/core/smart/domain/src/test/java/com/buzbuz/smartautoclicker/core/domain/utils/Asserts.kt @@ -18,9 +18,9 @@ package com.buzbuz.smartautoclicker.core.domain.utils 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.Gesture import com.buzbuz.smartautoclicker.core.domain.model.action.Intent 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.condition.ScreenCondition import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent @@ -77,7 +77,7 @@ private fun assertSameConditionNoIdCheck(expected: ScreenCondition, actual: Scre private fun assertSameActionNoIdCheck(expected: Action, actual: Action) { when { expected is Click && actual is Click -> assertSameClickNoIdCheck(expected, actual) - expected is Swipe && actual is Swipe -> assertSameSwipeNoIdCheck(expected, actual) + expected is Gesture && actual is Gesture -> assertSameGestureNoIdCheck(expected, actual) expected is Pause && actual is Pause -> assertSamePauseNoIdCheck(expected, actual) expected is Intent && actual is Intent -> assertSameIntentNoIdCheck(expected, actual) expected is ToggleEvent && actual is ToggleEvent -> assertSameToggleEventNoIdCheck(expected, actual) @@ -89,20 +89,14 @@ private fun assertSameClickNoIdCheck(expected: Click, actual: Click) = assertTru "Clicks are not the same", expected.name == actual.name && expected.pressDuration == actual.pressDuration - && expected.positionType == actual.positionType - && expected.position?.x == actual.position?.x - && expected.position?.y == actual.position?.y - && expected.clickOnConditionId == actual.clickOnConditionId + && expected.position == actual.position + && expected.offset == actual.offset ) -private fun assertSameSwipeNoIdCheck(expected: Swipe, actual: Swipe) = assertTrue( - "Swipes are not the same", +private fun assertSameGestureNoIdCheck(expected: Gesture, actual: Gesture) = assertTrue( + "Gestures are not the same", expected.name == actual.name - && expected.swipeDuration == actual.swipeDuration - && expected.from?.x == actual.from?.x - && expected.from?.y == actual.from?.y - && expected.to?.x == actual.to?.x - && expected.to?.y == actual.to?.y + && expected.fingers.size == actual.fingers.size ) private fun assertSamePauseNoIdCheck(expected: Pause, actual: Pause) = assertTrue( From 5767e6ea6cb881bc16c8a8fd92fb7c27f4b504f0 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Sat, 10 Oct 2026 09:30:27 +0200 Subject: [PATCH 07/56] Rework gesture recorder to record multi-move & multi-fingers --- .../implementation/brief/ItemsBriefMenu.kt | 10 +- .../brief/ItemsBriefOverlayViewBinding.kt | 2 +- .../layout/overlay_view_action_brief_land.xml | 2 +- .../layout/overlay_view_action_brief_port.xml | 2 +- .../common/OverlayMenuAnimationsTest.kt | 69 ++ core/common/ui/build.gradle.kts | 1 + .../core/ui/utils/ExtendedValueAnimator.kt | 2 + .../gesture/edit/GestureEditorOverlayView.kt | 313 +++++++++ .../gesture/record/GestureFingerState.kt | 207 ++++++ .../record}/GestureRecordView.kt | 10 +- .../record}/GestureRecordViewStyle.kt | 2 +- .../views/gesture/record/GestureRecorder.kt | 147 ++++ .../views/gesture/record/RecordedGesture.kt | 115 ++++ .../ui/views/gesturerecord/GestureRecorder.kt | 97 --- .../ui/views/gesturerecord/RecordedGesture.kt | 44 -- .../ui/views/itembrief/ItemBriefRenderer.kt | 2 +- .../core/ui/views/itembrief/ItemBriefView.kt | 3 + .../ui/views/itembrief/ItemBriefViewStyle.kt | 10 + .../renderers/GestureBriefRenderer.kt | 245 +++++++ .../gesture/record/GestureFingerStateTests.kt | 426 ++++++++++++ .../gesture/record/GestureRecorderTests.kt | 642 ++++++++++++++++++ 21 files changed, 2198 insertions(+), 153 deletions(-) create mode 100644 core/common/overlays/src/test/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/common/OverlayMenuAnimationsTest.kt create mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/edit/GestureEditorOverlayView.kt create mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureFingerState.kt rename core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/{gesturerecord => gesture/record}/GestureRecordView.kt (89%) rename core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/{gesturerecord => gesture/record}/GestureRecordViewStyle.kt (96%) create mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecorder.kt create mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/RecordedGesture.kt delete mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecorder.kt delete mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/RecordedGesture.kt create mode 100644 core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/itembrief/renderers/GestureBriefRenderer.kt create mode 100644 core/common/ui/src/test/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureFingerStateTests.kt create mode 100644 core/common/ui/src/test/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecorderTests.kt diff --git a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefMenu.kt b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefMenu.kt index de0bb8f48b..d4ceec726d 100644 --- a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefMenu.kt +++ b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefMenu.kt @@ -32,8 +32,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.buzbuz.smartautoclicker.core.common.overlays.R import com.buzbuz.smartautoclicker.core.common.overlays.menu.OverlayMenu import com.buzbuz.smartautoclicker.core.ui.utils.AutoHideAnimationController -import com.buzbuz.smartautoclicker.core.ui.views.itembrief.ItemBriefDescription -import com.buzbuz.smartautoclicker.core.ui.views.gesturerecord.toActionDescription +import com.buzbuz.smartautoclicker.core.ui.views.gesture.record.RecordedGesture +import com.buzbuz.smartautoclicker.core.ui.views.gesture.record.toActionDescription import androidx.core.view.isVisible abstract class ItemBriefMenu( @@ -202,7 +202,7 @@ abstract class ItemBriefMenu( } @SuppressLint("ClickableViewAccessibility") - protected fun startGestureCapture(onNewAction: (gesture: ItemBriefDescription?, isFinished: Boolean) -> Unit) { + protected fun startGestureCapture(onNewAction: (gesture: RecordedGesture?, isFinished: Boolean) -> Unit) { briefPanelAnimationController.hide() blinkingAnimator.setTarget(briefViewBinding.recordingIcon) @@ -216,7 +216,7 @@ abstract class ItemBriefMenu( var isCaptureStarted = false gestureCaptureListener = { gesture, isFinished -> - if (gesture != null && !isCaptureStarted){ + if (gesture != null && !isCaptureStarted) { isCaptureStarted = true instructionsAnimationController.hide() } @@ -227,7 +227,7 @@ abstract class ItemBriefMenu( if (isFinished) { stopGestureCapture() - onNewAction(gesture?.toActionDescription(), true) + onNewAction(gesture, true) } } } diff --git a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefOverlayViewBinding.kt b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefOverlayViewBinding.kt index f40524816a..360fcf374e 100644 --- a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefOverlayViewBinding.kt +++ b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/menu/implementation/brief/ItemsBriefOverlayViewBinding.kt @@ -25,7 +25,7 @@ import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.buzbuz.smartautoclicker.core.common.overlays.databinding.OverlayViewActionBriefLandBinding import com.buzbuz.smartautoclicker.core.common.overlays.databinding.OverlayViewActionBriefPortBinding -import com.buzbuz.smartautoclicker.core.ui.views.gesturerecord.GestureRecordView +import com.buzbuz.smartautoclicker.core.ui.views.gesture.record.GestureRecordView import com.buzbuz.smartautoclicker.core.ui.views.itembrief.ItemBriefView class ItemsBriefOverlayViewBinding private constructor( diff --git a/core/common/overlays/src/main/res/layout/overlay_view_action_brief_land.xml b/core/common/overlays/src/main/res/layout/overlay_view_action_brief_land.xml index 2b0c8d5ce5..378870612a 100644 --- a/core/common/overlays/src/main/res/layout/overlay_view_action_brief_land.xml +++ b/core/common/overlays/src/main/res/layout/overlay_view_action_brief_land.xml @@ -23,7 +23,7 @@ android:layout_height="match_parent" android:layout_gravity="bottom"> - - . + */ +package com.buzbuz.smartautoclicker.core.common.overlays.menu.implementation.common + +import android.os.Build +import android.view.View + +import androidx.test.core.app.ApplicationProvider + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +import java.util.concurrent.TimeUnit + +/** Ensures an animation completion cancels its delayed fallback callback. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class OverlayMenuAnimationsTests { + + @Test + fun showAnimation_completionDoesNotAlsoRunDelayedTimeout() { + val animations = OverlayMenuAnimations() + val view = View(ApplicationProvider.getApplicationContext()) + var completionCount = 0 + + animations.startShowAnimation(view) { completionCount++ } + + shadowOf(android.os.Looper.getMainLooper()).idleFor(300, TimeUnit.MILLISECONDS) + assertEquals(1, completionCount) + + shadowOf(android.os.Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS) + + assertEquals(1, completionCount) + } + + @Test + fun hideAnimation_completionDoesNotAlsoRunDelayedTimeout() { + val animations = OverlayMenuAnimations() + val view = View(ApplicationProvider.getApplicationContext()) + var completionCount = 0 + + animations.startHideAnimation(view) { completionCount++ } + + shadowOf(android.os.Looper.getMainLooper()).idleFor(200, TimeUnit.MILLISECONDS) + assertEquals(1, completionCount) + + shadowOf(android.os.Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS) + + assertEquals(1, completionCount) + } +} \ No newline at end of file diff --git a/core/common/ui/build.gradle.kts b/core/common/ui/build.gradle.kts index d2d04ac3c5..2d8f3f296e 100644 --- a/core/common/ui/build.gradle.kts +++ b/core/common/ui/build.gradle.kts @@ -17,6 +17,7 @@ plugins { alias(libs.plugins.buzbuz.androidLibrary) + alias(libs.plugins.buzbuz.androidUnitTest) alias(libs.plugins.buzbuz.flavour) alias(libs.plugins.buzbuz.hilt) } diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/utils/ExtendedValueAnimator.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/utils/ExtendedValueAnimator.kt index 24d3daec0b..6716f0bf48 100644 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/utils/ExtendedValueAnimator.kt +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/utils/ExtendedValueAnimator.kt @@ -48,6 +48,7 @@ class ExtendedValueAnimator private constructor(): ValueAnimator() { override fun start() { endRequested = false currentRepeatCount = 0 + handler.removeCallbacksAndMessages(null) startAnimation(startDelay) } @@ -63,6 +64,7 @@ class ExtendedValueAnimator private constructor(): ValueAnimator() { private fun stop() { endRequested = true + handler.removeCallbacksAndMessages(null) } private fun onAnimationEnded() { diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/edit/GestureEditorOverlayView.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/edit/GestureEditorOverlayView.kt new file mode 100644 index 0000000000..cdf99adc58 --- /dev/null +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/edit/GestureEditorOverlayView.kt @@ -0,0 +1,313 @@ +/* + * 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.ui.views.gesture.edit + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Point +import android.graphics.PointF +import android.view.MotionEvent +import android.view.View + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureBriefRenderer +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureBriefRendererStyle +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureDescription +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureFingerPath + +import kotlin.math.roundToInt +import kotlin.math.sqrt + + +@SuppressLint("ViewConstructor") +class GestureEditorOverlayView(context: Context) : View(context) { + + /** The waypoint currently dragged, identified by its finger index and motion id. */ + private data class DragState( + val fingerIndex: Int, + val motionId: Identifier, + val currentPos: PointF, + ) + + private val density = context.resources.displayMetrics.density + private val outerRadius = 28f * density + private val innerRadius = 3f * density + private val touchRadius = 36f * density + + private val gestureRenderer = GestureBriefRenderer( + briefView = this, + viewStyle = GestureBriefRendererStyle( + backgroundColor = EDITOR_GLOW_COLOR, + fixedBackgroundColor = EDITOR_FIXED_GLOW_COLOR, + outerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 6f * density + }, + innerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + }, + fixedOuterPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 110 + style = Paint.Style.STROKE + strokeWidth = 6f * density + }, + fixedInnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 110 + style = Paint.Style.FILL + }, + linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 200 + strokeWidth = 3f * density + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + }, + outerRadiusPx = outerRadius, + innerRadiusPx = innerRadius, + travelerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + }, + ) + ) + + /** The gesture displayed and edited by this view. */ + var gesture: GestureDescription? = null + set(value) { + field = value + // For SingleMotion: derive displayedFingerIndex from the first gesture that arrives + if (targetMotionId != null && displayedFingerIndex < 0 && value != null) { + displayedFingerIndex = value.fingers.indexOfFirst { finger -> + finger.waypoints.any { wp -> wp.isEditable && wp.motionId == targetMotionId } + } + } + + if (!isActivelyDragging) { + dragState = null + updateRenderer() + } + + invalidate() + } + + /** + * When non-null, replaces the finger at [displayedFingerIndex] in all rendering. + * Used during recording to show the live-captured path in place of the stored one. + */ + var recordingFingerPath: GestureFingerPath? = null + set(value) { + field = value + updateRenderer() + invalidate() + } + + /** Called when a waypoint drag completes (ACTION_UP). Null in single-motion mode. */ + var onPositionDragEnded: ((fingerIndex: Int, motionId: Identifier, position: Point) -> Unit)? = null + + var isSingleMotionMode: Boolean = false + + /** + * In SingleMotion mode, the motionId whose finger should be isolated in the display. + * When non-null, [displayedFingerIndex] is derived from the first [gesture] emission. + */ + var targetMotionId: Identifier? = null + + /** When >= 0, only this finger index is rendered and interactable. */ + var displayedFingerIndex: Int = -1 + + private var dragState: DragState? = null + private var isActivelyDragging = false + + /** Returns the last dragged position for the given motionId, or the current display position. */ + fun getCurrentPosition(motionId: Identifier): Point? { + val ds = dragState + if (ds != null && ds.motionId == motionId) { + return Point(ds.currentPos.x.roundToInt(), ds.currentPos.y.roundToInt()) + } + + gesture?.fingers?.forEach { finger -> + finger.waypoints.forEach { wp -> + if (wp.isEditable && wp.motionId == motionId) { + return Point(wp.position.x.roundToInt(), wp.position.y.roundToInt()) + } + } + } + return null + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + gestureRenderer.onStop() + } + + /** Ensures gradient paints are rebuilt with current waypoint positions before each draw. */ + override fun invalidate() { + gestureRenderer.onInvalidate() + super.invalidate() + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean = + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val target = if (isSingleMotionMode) findFirstEditable() + else findNearestEditable(event.x, event.y) + target ?: return false + + isActivelyDragging = true + gestureRenderer.isActivelyDragging = true + dragState = + if (isSingleMotionMode) target.copy(currentPos = PointF(event.x, event.y)) + else target + + invalidate() + true + } + + MotionEvent.ACTION_MOVE -> { + val ds = dragState ?: return false + + dragState = ds.copy(currentPos = PointF(event.x, event.y)) + gestureRenderer.updateFingerPaths(renderedFingerPaths(dragState)) + + invalidate() + true + } + + MotionEvent.ACTION_UP -> { + val ds = dragState ?: return false + val finalPos = PointF(event.x, event.y) + isActivelyDragging = false + gestureRenderer.isActivelyDragging = false + dragState = ds.copy(currentPos = finalPos) + + onPositionDragEnded?.invoke( + ds.fingerIndex, + ds.motionId, + Point(finalPos.x.roundToInt(), finalPos.y.roundToInt()), + ) + + invalidate() + true + } + + MotionEvent.ACTION_CANCEL -> { + isActivelyDragging = false + gestureRenderer.isActivelyDragging = false + dragState = null + + invalidate() + true + } + else -> false + } + + override fun onDraw(canvas: Canvas) { + gestureRenderer.onDraw(canvas) + } + + private fun updateRenderer() { + val description = gesture + if (description == null) { + gestureRenderer.onStop() + return + } + + gestureRenderer.onNewDescription( + description = description.copy(fingers = renderedFingerPaths(dragState = null)), + animate = true, + ) + } + + private fun findFirstEditable(): DragState? { + gesture?.fingers?.forEachIndexed { fingerIndex, finger -> + if (displayedFingerIndex >= 0 && fingerIndex != displayedFingerIndex) return@forEachIndexed + + finger.waypoints.forEach { wp -> + val motionId = wp.motionId + if (wp.isEditable && motionId != null) return DragState( + fingerIndex = fingerIndex, + motionId = motionId, + currentPos = PointF(wp.position.x, wp.position.y), + ) + } + } + return null + } + + private fun findNearestEditable(x: Float, y: Float): DragState? { + var nearest: DragState? = null + var nearestDist = touchRadius + + gesture?.fingers?.forEachIndexed { fingerIndex, finger -> + if (displayedFingerIndex >= 0 && fingerIndex != displayedFingerIndex) return@forEachIndexed + finger.waypoints.forEach { wp -> + val motionId = wp.motionId + if (!wp.isEditable || motionId == null) return@forEach + + val dx = wp.position.x - x + val dy = wp.position.y - y + val dist = sqrt((dx * dx + dy * dy).toDouble()).toFloat() + if (dist < nearestDist) { + nearestDist = dist + nearest = DragState( + fingerIndex = fingerIndex, + motionId = motionId, + currentPos = PointF(wp.position.x, wp.position.y), + ) + } + } + } + + return nearest + } + + private fun renderedFingerPaths(dragState: DragState?): List { + val fingers = gesture?.fingers ?: return emptyList() + + if (displayedFingerIndex >= 0) { + val finger = fingers.getOrNull(displayedFingerIndex) ?: return emptyList() + return listOf(recordingFingerPath ?: finger.withDraggedWaypoint(dragState)) + } + + return fingers.map { finger -> finger.withDraggedWaypoint(dragState) } + } + + private fun GestureFingerPath.withDraggedWaypoint(dragState: DragState?): GestureFingerPath { + dragState ?: return this + + return copy( + waypoints = waypoints.map { wp -> + if (wp.isEditable && wp.motionId == dragState.motionId) wp.copy(position = dragState.currentPos) + else wp + }, + ) + } +} + +/** Glow gradient colour for editable waypoints — matches @color/overlayActionsBriefBackground. */ +private val EDITOR_GLOW_COLOR = Color.argb(0x99, 0, 0, 0) + +/** Dimmer glow gradient colour for fixed (non-editable) waypoints. */ +private val EDITOR_FIXED_GLOW_COLOR = Color.argb(66, 0, 0, 0) diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureFingerState.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureFingerState.kt new file mode 100644 index 0000000000..1e26272c04 --- /dev/null +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureFingerState.kt @@ -0,0 +1,207 @@ +/* + * 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.ui.views.gesture.record + +import android.graphics.PointF +import android.os.Build +import android.util.Log +import kotlin.math.sqrt + +internal class GestureFingerState( + initialPosition: PointF, + private val initialEventTime: Long, + displayDensity: Float, +) { + + /** Holding a finger still requires stroke continuation, which is only available from Android O. */ + private val isWaitSupported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + + private val stillThresholdPx: Float = STILL_THRESHOLD_DP * displayDensity + private val moveAngleLockDistancePx: Float = MOVE_ANGLE_LOCK_DISTANCE_THRESHOLD_DP * displayDensity + + val motions: MutableList = mutableListOf(RecordedGestureFingerMotion.Press(initialPosition)) + + /** Position of the initial press, kept for the finger that never moves and records a simple click. */ + val pressPosition: PointF = initialPosition + + /** True until a [RecordedGestureFingerMotion.Move] is recorded for this finger. */ + val isStationary: Boolean + get() = motions.none { it is RecordedGestureFingerMotion.Move } + + /** Time elapsed since the press, whatever the motions recorded in between. */ + val pressDurationMs: Long + get() = latestEventTimeMs - initialEventTime + + /** + * Time of the last event received for this finger, of any kind. Unlike [lastEventTimeMs], it is also updated for + * the events absorbed by the still detection, making it the correct press duration reference and the end time of + * a pending wait. + */ + private var latestEventTimeMs: Long = initialEventTime + + /** + * Direction of the first step that opened the current Move segment. Never updated mid-segment so that an arc's + * cumulative turn is measured against where the segment started, not the last step. + */ + private var currentSegmentDirection: PointF? = null + + /** Start position of the current*/ + private var moveStartPosition: PointF = initialPosition + private var moveDistanceFromStart: Float = 0f + + private var stillStartPosition: PointF = initialPosition + private var stillStartEventTimeMs: Long = initialEventTime + + /** Time of the last event that ended a move step or a wait, reference for the next move duration. */ + private var lastEventTimeMs: Long = initialEventTime + + fun addMoveOrWait(newPosition: PointF, eventTime: Long) { + val previousEventTimeMs = latestEventTimeMs + latestEventTimeMs = eventTime + + val last = motions.lastOrNull() + val dtStill = eventTime - stillStartEventTimeMs + val dxStill = newPosition.x - stillStartPosition.x + val dyStill = newPosition.y - stillStartPosition.y + val distanceFromStill = sqrt(dxStill * dxStill + dyStill * dyStill) + + // Check if the event is near the reference to detect a wait. User is rarely 100% still. + if (distanceFromStill <= stillThresholdPx) { + recordWaitIfLongEnough(durationMs = dtStill) + return + } + + // Going past the threshold. A pending wait ended with the last still event, the move starts from there. + if (last is RecordedGestureFingerMotion.Wait) lastEventTimeMs = previousEventTimeMs + stillStartPosition = newPosition + stillStartEventTimeMs = eventTime + + val dtLast = eventTime - lastEventTimeMs + lastEventTimeMs = eventTime + + val dxRef = newPosition.x - moveStartPosition.x + val dyRef = newPosition.y - moveStartPosition.y + val distanceFromRef = sqrt(dxRef * dxRef + dyRef * dyRef) + val directionFromRef = PointF(dxRef / distanceFromRef, dyRef / distanceFromRef) + val segmentDirection = currentSegmentDirection + + val previousDistanceFromMoveStart = moveDistanceFromStart + moveDistanceFromStart = distanceFromRef + + // If the last recorded motion is not a Move, start a fresh one. + if (last !is RecordedGestureFingerMotion.Move) { + Log.d(TAG, "Motion is a new move, position=$newPosition") + motions.add(RecordedGestureFingerMotion.Move(moveToPosition = newPosition, moveToDurationMs = dtLast)) + + // Lock the segment direction only once we have enough distance for a reliable bearing. + currentSegmentDirection = + if (distanceFromRef > moveAngleLockDistancePx) directionFromRef + else null + + return + } + + // No angle lock yet, the line is too short. + if (segmentDirection == null) { + Log.d(TAG, "Motion has no angle lock yet, same move; new position = $newPosition") + motions[motions.lastIndex] = RecordedGestureFingerMotion.Move( + moveToPosition = newPosition, + moveToDurationMs = last.moveToDurationMs + dtLast, + ) + + if (distanceFromRef > moveAngleLockDistancePx) currentSegmentDirection = directionFromRef + return + } + + // Angle lock is set, verify if we are still moving in the same direction. + // cosAngle, close to 1 for parallel directions and decreases toward 0 as the deviation grows. + val cosAngle = directionFromRef.x * segmentDirection.x + directionFromRef.y * segmentDirection.y + val distance = distanceFromRef - previousDistanceFromMoveStart + if (cosAngle >= DIRECTION_SIMILARITY_THRESHOLD && distance > -stillThresholdPx) { + Log.d(TAG, "Motion is a move in the same direction, new position = $newPosition") + motions[motions.lastIndex] = RecordedGestureFingerMotion.Move( + moveToPosition = newPosition, + moveToDurationMs = last.moveToDurationMs + dtLast, + ) + } else { + Log.d(TAG, "Motion direction has changed, this is a new move; position=$newPosition") + motions.add(RecordedGestureFingerMotion.Move(moveToPosition = newPosition, moveToDurationMs = dtLast)) + + moveStartPosition = newPosition + currentSegmentDirection = null + } + } + + fun addRelease(eventTime: Long) { + latestEventTimeMs = eventTime + // The finger has been still since the still start, even if no event was received in the meantime. + recordWaitIfLongEnough(durationMs = eventTime - stillStartEventTimeMs) + motions.add(RecordedGestureFingerMotion.Release) + } + + fun toGestureFinger(): RecordedGestureFinger = RecordedGestureFinger( + pressedAtMs = initialEventTime, + motions = motions.toList(), + ) + + /** + * Records the still period from the still start as a wait. A pending wait is extended to the new duration; + * otherwise, a new one is created once the period is long enough. + */ + private fun recordWaitIfLongEnough(durationMs: Long) { + if (motions.lastOrNull() is RecordedGestureFingerMotion.Wait) { + Log.d(TAG, "Motion is still a wait, now of $durationMs") + motions[motions.lastIndex] = RecordedGestureFingerMotion.Wait(durationMs) + return + } + + if (!isWaitSupported || durationMs < MIN_WAIT_DURATION_MS) return + + Log.d(TAG, "Motion is a new wait of $durationMs") + motions.add(RecordedGestureFingerMotion.Wait(durationMs)) + moveStartPosition = stillStartPosition + currentSegmentDirection = null + } +} + + +/** + * Distance threshold in dp between two events to consider the finger still (not moving). + * Chosen so that the derived pixel value stays close to the original hard-coded 8 px that worked well + * on common 2–3× density devices: 3 dp → ~6 px at ×2, ~9 px at ×3. + */ +private const val STILL_THRESHOLD_DP = 5f + +/** + * Distance in dp from the segment reference point at which the direction is considered reliable enough + * to lock. Must be greater than [STILL_THRESHOLD_DP]. Converted to pixels at construction time. + */ +private const val MOVE_ANGLE_LOCK_DISTANCE_THRESHOLD_DP = 16f +/** + * Minimum cumulative still time (ms) before a pause is recorded as a [RecordedGestureFingerMotion.Wait]. + * Still events below this duration are absorbed into the surrounding moves, preventing brief + * slowdowns between strokes from generating spurious waits. + */ +private const val MIN_WAIT_DURATION_MS = 200L +/** + * Minimum dot product between two unit-vectors for them to be considered the same direction. + * Equals cos(maxAngle), so the allowed deviation from the segment's start direction is arccos of this value: + */ +private const val DIRECTION_SIMILARITY_THRESHOLD = 0.9848f + +/** Tag for logs. */ +private const val TAG = "GestureFingerState" diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordView.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordView.kt similarity index 89% rename from core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordView.kt rename to core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordView.kt index 990553ac60..4e354d7f01 100644 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordView.kt +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordView.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.core.ui.views.gesturerecord +package com.buzbuz.smartautoclicker.core.ui.views.gesture.record import android.annotation.SuppressLint import android.content.Context @@ -48,12 +48,18 @@ class GestureRecordView @JvmOverloads constructor( .obtainStyledAttributes(null, R.styleable.GestureRecordView, R.attr.gestureRecordStyle, 0) .use { ta -> ta.getGestureRecorderStyle()} - private val gestureRecorder = GestureRecorder { gesture, isFinished -> + private val gestureRecorder = GestureRecorder( + displayDensity = context.resources.displayMetrics.density, + ) { gesture, isFinished -> gestureCaptureListener?.invoke(gesture, isFinished) } var gestureCaptureListener: ((gesture: RecordedGesture?, isFinished: Boolean) -> Unit)? = null + var maxDurationMs: Long + get() = gestureRecorder.maxDurationMs + set(value) { gestureRecorder.maxDurationMs = value } + override val viewComponents: List = listOf( DisplayBorderComponent( viewStyle = DisplayBorderComponentStyle( diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordViewStyle.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordViewStyle.kt similarity index 96% rename from core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordViewStyle.kt rename to core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordViewStyle.kt index 280512180a..538c32ce43 100644 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecordViewStyle.kt +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecordViewStyle.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.core.ui.views.gesturerecord +package com.buzbuz.smartautoclicker.core.ui.views.gesture.record import android.content.res.TypedArray import android.graphics.Color diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecorder.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecorder.kt new file mode 100644 index 0000000000..eb4986e572 --- /dev/null +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/GestureRecorder.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. + * + * 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.ui.views.gesture.record + +import android.graphics.PointF +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.MotionEvent + + +internal class GestureRecorder( + private val displayDensity: Float, + private val onNewCapturedGesture: (gesture: RecordedGesture?, isFinished: Boolean) -> Unit, +) { + + private val activeFingers = LinkedHashMap() + + /** Maximum allowed gesture duration in ms. Recording stops automatically when reached. */ + var maxDurationMs: Long = Long.MAX_VALUE + + private var firstFingerEventTime: Long = 0L + private val handler = Handler(Looper.getMainLooper()) + + private val timeoutRunnable = Runnable { + val releaseEventTime = firstFingerEventTime + maxDurationMs + for (finger in activeFingers.values) finger.addRelease(releaseEventTime) + notifyListener(isFinished = true) + activeFingers.clear() + firstFingerEventTime = 0L + } + + fun processEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> + handleFingerDown(event = event, pointerIndex = 0) + MotionEvent.ACTION_POINTER_DOWN -> + if (activeFingers.size < MAX_FINGERS) handleFingerDown(event = event, pointerIndex = event.actionIndex) + MotionEvent.ACTION_MOVE -> + handleMove(event = event) + MotionEvent.ACTION_POINTER_UP -> + handleFingerUp(event = event, pointerIndex = event.actionIndex, isLast = false) + MotionEvent.ACTION_UP -> + handleFingerUp(event = event, pointerIndex = event.actionIndex, isLast = true) + MotionEvent.ACTION_CANCEL -> + clearCapture() + else -> return false + } + + return true + } + + fun clearCapture() { + Log.d(TAG, "Clear capture") + handler.removeCallbacks(timeoutRunnable) + activeFingers.clear() + firstFingerEventTime = 0L + } + + private fun handleFingerDown(event: MotionEvent, pointerIndex: Int) { + Log.d(TAG, "Handle finger down #$pointerIndex: $event") + + if (activeFingers.isEmpty()) { + firstFingerEventTime = event.eventTime + if (maxDurationMs < Long.MAX_VALUE) handler.postDelayed(timeoutRunnable, maxDurationMs) + } + + val pointerId = event.getPointerId(pointerIndex) + activeFingers[pointerId] = GestureFingerState( + initialPosition = PointF(event.getX(pointerIndex), event.getY(pointerIndex)), + initialEventTime = event.eventTime, + displayDensity = displayDensity, + ) + notifyListener(isFinished = false) + } + + private fun handleMove(event: MotionEvent) { + Log.d(TAG, "Handle move $event") + + for (pointerIndex in 0 until event.pointerCount) { + val pointerId = event.getPointerId(pointerIndex) + activeFingers[pointerId]?.addMoveOrWait( + newPosition = PointF(event.getX(pointerIndex), event.getY(pointerIndex)), + eventTime = event.eventTime, + ) + } + notifyListener(isFinished = false) + } + + private fun handleFingerUp(event: MotionEvent, pointerIndex: Int, isLast: Boolean) { + Log.d(TAG, "Handle finger up (isLast=$isLast) #$pointerIndex: $event") + + val pointerId = event.getPointerId(pointerIndex) + activeFingers[pointerId]?.addRelease(event.eventTime) + + if (isLast) { + handler.removeCallbacks(timeoutRunnable) + notifyListener(isFinished = true) + activeFingers.clear() + firstFingerEventTime = 0L + } else { + notifyListener(isFinished = false) + } + } + + private fun notifyListener(isFinished: Boolean) { + if (activeFingers.isEmpty()) { + onNewCapturedGesture(null, isFinished) + return + } + onNewCapturedGesture(activeFingers.values.toRecordedGesture(), isFinished) + } + + /** A lone finger that never moved is a click, anything else is a gesture. */ + private fun Collection.toRecordedGesture(): RecordedGesture { + val singleFinger = singleOrNull() + + return if (singleFinger != null && singleFinger.isStationary) + RecordedGesture.Click( + position = singleFinger.pressPosition, + durationMs = singleFinger.pressDurationMs.coerceAtLeast(MIN_CLICK_DURATION_MS), + ) + else + RecordedGesture.Gesture(fingers = map { it.toGestureFinger() }) + } +} + +/** Maximum number of fingers allowed on a single gesture. */ +private const val MAX_FINGERS = 5 +/** A click press duration can't be null, use this value for the taps recorded within the same millisecond. */ +private const val MIN_CLICK_DURATION_MS = 1L +/** Tag for logs. */ +private const val TAG = "GestureRecorder" diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/RecordedGesture.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/RecordedGesture.kt new file mode 100644 index 0000000000..8c4541ebcd --- /dev/null +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesture/record/RecordedGesture.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.ui.views.gesture.record + +import android.graphics.PointF +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.ItemBriefDescription +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.ClickDescription +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureAnimationSegment +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureDescription +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureFingerPath +import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.GestureWaypoint + + +sealed class RecordedGesture { + + data class Click( + val position: PointF, + val durationMs: Long, + ) : RecordedGesture() + + data class Gesture( + val fingers: List + ) : RecordedGesture() +} + +data class RecordedGestureFinger( + val pressedAtMs: Long, + val motions: List +) + +sealed class RecordedGestureFingerMotion { + data class Press(val position: PointF) : RecordedGestureFingerMotion() + data class Move(val moveToPosition: PointF, val moveToDurationMs: Long) : RecordedGestureFingerMotion() + data class Wait(val waitDurationMs: Long) : RecordedGestureFingerMotion() + data object Release : RecordedGestureFingerMotion() +} + +fun RecordedGesture.toActionDescription(): ItemBriefDescription = + when (this) { + is RecordedGesture.Click -> ClickDescription(pressDurationMs = durationMs, position = position) + is RecordedGesture.Gesture -> fingers.toGestureDescription() + } + +/** Express any recorded gesture as its fingers, a click being a single finger pressed and released in place. */ +fun RecordedGesture.toRecordedFingers(): List = + when (this) { + is RecordedGesture.Gesture -> fingers + is RecordedGesture.Click -> listOf( + RecordedGestureFinger( + pressedAtMs = 0L, + motions = listOf( + RecordedGestureFingerMotion.Press(position), + RecordedGestureFingerMotion.Wait(durationMs), + RecordedGestureFingerMotion.Release, + ), + ) + ) + } + +fun List.toGestureDescription(isEditable: Boolean = true): GestureDescription { + val gestureStartMs = minOfOrNull { it.pressedAtMs } ?: 0L + val paths = map { finger -> + finger.toFingerPath(startTimeMs = finger.pressedAtMs - gestureStartMs, isEditable = isEditable) + } + + return GestureDescription( + fingers = paths, + totalDurationMs = paths.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L, + ) +} + +fun RecordedGestureFinger.toFingerPath(startTimeMs: Long = 0L, isEditable: Boolean = true): GestureFingerPath { + val waypoints = mutableListOf() + val segments = mutableListOf() + var durationMs = 0L + + motions.forEach { motion -> + when (motion) { + is RecordedGestureFingerMotion.Press -> + waypoints += GestureWaypoint(motion.position, isEditable) + + is RecordedGestureFingerMotion.Move -> { + val fromIdx = waypoints.size - 1 + waypoints += GestureWaypoint(motion.moveToPosition, isEditable) + segments += GestureAnimationSegment.Move(fromIdx, waypoints.size - 1, motion.moveToDurationMs) + durationMs += motion.moveToDurationMs + } + + is RecordedGestureFingerMotion.Wait -> { + if (waypoints.isNotEmpty()) { + segments += GestureAnimationSegment.Wait(waypoints.size - 1, motion.waitDurationMs) + durationMs += motion.waitDurationMs + } + } + + RecordedGestureFingerMotion.Release -> Unit + } + } + + return GestureFingerPath(waypoints, startTimeMs, durationMs, segments) +} diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecorder.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecorder.kt deleted file mode 100644 index 40337a0318..0000000000 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/GestureRecorder.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (C) 2024 Kevin Buzeau - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.buzbuz.smartautoclicker.core.ui.views.gesturerecord - -import android.graphics.PointF -import android.view.MotionEvent -import kotlin.math.pow -import kotlin.math.sqrt - - -internal class GestureRecorder( - private val onNewCapturedGesture: (gesture: RecordedGesture?, isFinished: Boolean) -> Unit, -) { - - private var originEventPosition: PointF? = null - - fun processEvent(event: MotionEvent): Boolean { - if (event.pointerCount > 1) { - clearCapture() - return true - } - - if (originEventPosition == null) { - if (event.action == MotionEvent.ACTION_DOWN) { - startCapture(event) - return true - } - - return false - } - - when (event.action) { - MotionEvent.ACTION_MOVE -> updateCapture(event) - MotionEvent.ACTION_UP -> endCapture(event) - MotionEvent.ACTION_CANCEL -> clearCapture() - else -> return false - } - - return true - } - - fun clearCapture() { - originEventPosition = null - } - - private fun startCapture(event: MotionEvent) { - originEventPosition = PointF(event.x, event.y) - - // Always start with a click - onNewCapturedGesture(RecordedGesture.Click(originEventPosition!!, 1L), false) - } - - private fun updateCapture(event: MotionEvent) { - val origin = originEventPosition ?: return - onNewCapturedGesture(origin.getGestureWith(event), false) - } - - private fun endCapture(event: MotionEvent) { - val origin = originEventPosition ?: return - originEventPosition = null - onNewCapturedGesture(origin.getGestureWith(event), true) - } - - private fun PointF.getGestureWith(lastEvent: MotionEvent): RecordedGesture = - if (willBeAClick(lastEvent)) { - RecordedGesture.Click( - position = this, - durationMs = lastEvent.eventTime - lastEvent.downTime, - ) - } else { - RecordedGesture.Swipe( - from = this, - to = PointF(lastEvent.x, lastEvent.y), - durationMs = lastEvent.eventTime - lastEvent.downTime, - ) - } - - private fun PointF.willBeAClick(lastEvent: MotionEvent): Boolean { - return sqrt((x - lastEvent.x).pow(2) + (y - lastEvent.y).pow(2)) <= SWIPE_MIN_DISTANCE_PX - } -} - -private const val SWIPE_MIN_DISTANCE_PX = 40f \ No newline at end of file diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/RecordedGesture.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/RecordedGesture.kt deleted file mode 100644 index eb469bdead..0000000000 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/gesturerecord/RecordedGesture.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) 2024 Kevin Buzeau - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.buzbuz.smartautoclicker.core.ui.views.gesturerecord - -import android.graphics.PointF -import com.buzbuz.smartautoclicker.core.ui.views.itembrief.ItemBriefDescription -import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.ClickDescription -import com.buzbuz.smartautoclicker.core.ui.views.itembrief.renderers.SwipeDescription - - -sealed class RecordedGesture { - abstract val durationMs: Long - - data class Click( - val position: PointF, - override val durationMs: Long, - ) : RecordedGesture() - - data class Swipe( - val from: PointF, - val to: PointF, - override val durationMs: Long, - ) : RecordedGesture() -} - -fun RecordedGesture.toActionDescription(): ItemBriefDescription = - when (this) { - is RecordedGesture.Click -> ClickDescription(pressDurationMs = durationMs, position = position) - is RecordedGesture.Swipe -> SwipeDescription(swipeDurationMs = durationMs, from = from, to = to) - } \ No newline at end of file diff --git a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/itembrief/ItemBriefRenderer.kt b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/itembrief/ItemBriefRenderer.kt index b64cea3420..7df2ed0255 100644 --- a/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/itembrief/ItemBriefRenderer.kt +++ b/core/common/ui/src/main/java/com/buzbuz/smartautoclicker/core/ui/views/itembrief/ItemBriefRenderer.kt @@ -26,7 +26,7 @@ import android.view.View import androidx.annotation.ColorInt -internal abstract class ItemBriefRenderer + + - +