From b616d060bd7f2d9b44b30bad8a65275a99323b94 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Wed, 12 Aug 2026 10:16:49 +0200 Subject: [PATCH 1/5] [#1042] Fix Number condition test rectangle color --- .../conditiontry/TryImageConditionViewModel.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/conditiontry/TryImageConditionViewModel.kt b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/conditiontry/TryImageConditionViewModel.kt index 3189aba8b..3bb9cb924 100644 --- a/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/conditiontry/TryImageConditionViewModel.kt +++ b/feature/smart-debugging/src/main/java/com/buzbuz/smartautoclicker/feature/smart/debugging/ui/dialog/live/conditiontry/TryImageConditionViewModel.kt @@ -54,6 +54,7 @@ class TryImageConditionViewModel @Inject constructor( .distinctUntilChanged() private val userThreshold: MutableStateFlow = MutableStateFlow(0) + private val useUserThreshold: MutableStateFlow = MutableStateFlow(true) private val detectionResult: Flow = detectionResultUseCase(filterNotFulfilled = false) .combine(isPlaying) { results, playing -> if (playing) results else null } @@ -63,8 +64,12 @@ class TryImageConditionViewModel @Inject constructor( } val displayResults: Flow = - combine(userThreshold, detectionResult) { userThreshold, result -> - result?.copy(positive = (1.0 - (userThreshold / 100.0)) < result.confidenceRate) + combine(userThreshold, useUserThreshold, detectionResult) { userThreshold, useUserThreshold, result -> + result?.copy( + positive = + if (useUserThreshold) (1.0 - (userThreshold / 100.0)) < result.confidenceRate + else result.positive + ) } val thresholdText: Flow = @@ -77,12 +82,13 @@ class TryImageConditionViewModel @Inject constructor( } } - fun startTry(context: Context, scenario: Scenario, imageCondition: ScreenCondition) { + fun startTry(context: Context, scenario: Scenario, screenCondition: ScreenCondition) { viewModelScope.launch { - userThreshold.value = imageCondition.threshold + useUserThreshold.value = screenCondition !is ScreenCondition.Number + userThreshold.value = screenCondition.threshold delay(500.milliseconds) - smartProcessingRepository.tryScreenCondition(context, scenario, imageCondition) + smartProcessingRepository.tryScreenCondition(context, scenario, screenCondition) } } From 220ddbd8004944be2251b6d86ebea2ae4b3a6495 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Mon, 17 Aug 2026 10:43:02 +0200 Subject: [PATCH 2/5] [#1046] Fix possible race condition upon slow gesture processing --- core/common/actions/build.gradle.kts | 1 + .../common/actions/gesture/GestureExecutor.kt | 72 ++++------ .../actions/gesture/GestureExecutorTests.kt | 126 ++++++++++++++++++ 3 files changed, 153 insertions(+), 46 deletions(-) create mode 100644 core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt diff --git a/core/common/actions/build.gradle.kts b/core/common/actions/build.gradle.kts index 3d01fc140..f30e5be0b 100644 --- a/core/common/actions/build.gradle.kts +++ b/core/common/actions/build.gradle.kts @@ -16,6 +16,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/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 5f4c88dee..be96ab983 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 @@ -37,9 +37,6 @@ import kotlin.time.Duration.Companion.milliseconds @Singleton internal class GestureExecutor @Inject constructor() : Dumpable { - private var resultCallback: GestureResultCallback? = null - private var currentContinuation: Continuation? = null - private var completedGestures: Long = 0L private var cancelledGestures: Long = 0L private var errorGestures: Long = 0L @@ -49,66 +46,41 @@ internal class GestureExecutor @Inject constructor() : Dumpable { completedGestures = 0L cancelledGestures = 0L errorGestures = 0L - - resultCallback = null - currentContinuation = null } suspend fun dispatchGesture(service: AccessibilityService, gesture: GestureDescription): Boolean { - if (currentContinuation != null) { - Log.w(TAG, "Previous gesture result is not available yet, clearing listener to avoid stale events") - resultCallback = null - currentContinuation = null - } - - resultCallback = resultCallback ?: newGestureResultCallback() - - val timeoutMs = gesture.timeoutDurationMs() - val result = withTimeoutOrNull(timeoutMs.milliseconds) { + val result = withTimeoutOrNull(gesture.timeoutDurationMs().milliseconds) { suspendCancellableCoroutine { continuation -> - currentContinuation = continuation - try { - service.dispatchGesture(gesture, resultCallback, null) + 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) - errorGestures++ - resumeExecution(gestureError = true) + continuation.safeResume(false) } } } if (result == null) { - Log.w(TAG, "Gesture timed out after ${timeoutMs}ms, no callback received") + Log.w(TAG, "Gesture error, timeout or system error occurred.") errorGestures++ - currentContinuation = null + return false } - return result ?: false - } - - private fun resumeExecution(gestureError: Boolean) { - currentContinuation?.let { continuation -> - currentContinuation = null - - try { - continuation.resume(!gestureError) - } catch (isEx: IllegalStateException) { - Log.w(TAG, "Continuation have already been resumed. Did the same event got two results ?", isEx) - } - } ?: Log.w(TAG, "Can't resume continuation. Did the same event got two results ?") - } - - private fun newGestureResultCallback() = object : GestureResultCallback() { - override fun onCompleted(gestureDescription: GestureDescription?) { - completedGestures++ - resumeExecution(gestureError = false) + if (!result) { + Log.w(TAG, "Gesture has been cancelled.") + cancelledGestures ++ + return false } - override fun onCancelled(gestureDescription: GestureDescription?) { - cancelledGestures++ - resumeExecution(gestureError = false) - } + completedGestures++ + return true } override fun dump(writer: PrintWriter, prefix: CharSequence) { @@ -123,6 +95,14 @@ 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 GestureDescription.durationMs(): Long { var maxEndTime = 0L for (i in 0 until strokeCount) { 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 new file mode 100644 index 000000000..9d35489ae --- /dev/null +++ b/core/common/actions/src/test/java/com/buzbuz/smartautoclicker/core/common/actions/gesture/GestureExecutorTests.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.core.common.actions.gesture + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityService.GestureResultCallback +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.os.Build + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class GestureExecutorTests { + + @Test + fun dispatchGesture_completedCallback_returnsTrue() = runTest { + val service = mock(AccessibilityService::class.java) + val executor = GestureExecutor() + val callbackCaptor = ArgumentCaptor.forClass(GestureResultCallback::class.java) + + val result = async { executor.dispatchGesture(service, gesture()) } + runCurrent() + verify(service).dispatchGesture(any(), callbackCaptor.capture(), any()) + + callbackCaptor.value.onCompleted(null) + + assertTrue(result.await()) + } + + @Test + fun dispatchGesture_missingCallback_timesOut_andReturnsFalse() = runTest { + val service = mock(AccessibilityService::class.java) + val executor = GestureExecutor() + + val result = async { executor.dispatchGesture(service, gesture()) } + runCurrent() + advanceTimeBy(200.milliseconds) + + assertFalse(result.await()) + } + + @Test + fun dispatchGesture_dispatchException_returnsFalse() = runTest { + val service = mock(AccessibilityService::class.java) + doThrow(IllegalStateException("Accessibility service is unavailable")) + .`when`(service) + .dispatchGesture(any(), any(), any()) + + assertFalse(GestureExecutor().dispatchGesture(service, gesture())) + } + + @Test + fun dispatchGesture_lateCallbackAfterTimeout_doesNotCompleteNextGesture() = runTest { + val service = mock(AccessibilityService::class.java) + val executor = GestureExecutor() + val callbackCaptor = ArgumentCaptor.forClass(GestureResultCallback::class.java) + + val timedOutResult = async { executor.dispatchGesture(service, gesture()) } + runCurrent() + verify(service).dispatchGesture(any(), callbackCaptor.capture(), any()) + val timedOutCallback = callbackCaptor.value + + advanceTimeBy(200.milliseconds) + assertFalse(timedOutResult.await()) + + val nextResult = async { executor.dispatchGesture(service, gesture()) } + runCurrent() + verify(service, times(2)).dispatchGesture(any(), callbackCaptor.capture(), any()) + + timedOutCallback.onCompleted(null) + runCurrent() + assertFalse(nextResult.isCompleted) + + callbackCaptor.allValues.last().onCompleted(null) + + assertTrue(nextResult.await()) + } + + private fun gesture(): GestureDescription = GestureDescription.Builder() + .addStroke( + GestureDescription.StrokeDescription( + Path().apply { + moveTo(0f, 0f) + lineTo(1f, 1f) + }, + 0L, + 100L, + ) + ) + .build() +} \ No newline at end of file From 7387317f4ef3c97110918445bc7e2f86cd9c6165 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Mon, 17 Aug 2026 11:31:07 +0200 Subject: [PATCH 3/5] [#1045] Make counter creation dialog more robust --- .../creation/CounterCreationViewModel.kt | 7 +- .../creation/CounterCreationViewModelTests.kt | 103 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModelTests.kt diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModel.kt index e0d5ea8c5..bdd5198ad 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModel.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModel.kt @@ -53,10 +53,11 @@ class CountersCreationViewModel @Inject constructor( fun createCounter() { val scenarioId = editionRepository.editionState.getScenario()?.id ?: return - val counterName = name.value ?: return + val counterName = name.value val startingValue = startingValue.value - if (editionRepository.editionState.getCounter(counterName) != null) return + if (counterName.isNullOrBlank() || editionRepository.editionState.getCounter(counterName) != null) return + editionRepository.addNewCounter( Counter( counterName = counterName, @@ -67,7 +68,7 @@ class CountersCreationViewModel @Inject constructor( } private fun toUiState(name: String?): CounterCreationUiState { - val nameIsValid = name?.isNotBlank() == true + val nameIsValid = !name.isNullOrBlank() val isAlreadyDefined = nameIsValid && editionRepository.editionState.getCounter(name) != null return CounterCreationUiState( diff --git a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModelTests.kt b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModelTests.kt new file mode 100644 index 000000000..a2bc2620d --- /dev/null +++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/counter/creation/CounterCreationViewModelTests.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.counter.creation + +import android.os.Build + +import com.buzbuz.smartautoclicker.core.base.identifier.Identifier +import com.buzbuz.smartautoclicker.core.bitmaps.BitmapRepository +import com.buzbuz.smartautoclicker.core.domain.IRepository +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.domain.model.scenario.Scenario +import com.buzbuz.smartautoclicker.feature.smart.config.domain.EditionRepository +import io.mockk.coEvery +import io.mockk.mockk + +import kotlinx.coroutines.test.runTest + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class CounterCreationViewModelTests { + + @Test + fun createCounter_ignoresBlankName() = runTest { + val scenario = Scenario(Identifier(databaseId = 1L), "Scenario", detectionQuality = 600) + val existingCounter = Counter("existing", 1.0, scenario.id) + val editionRepository = createEditionRepository(scenario, counters = listOf(existingCounter)) + val viewModel = CountersCreationViewModel(editionRepository) + + viewModel.setName(" ") + viewModel.createCounter() + + assertEquals(listOf(existingCounter), editionRepository.editionState.getAllEditedCounters()) + } + + @Test + fun createCounter_ignoresDuplicateName() = runTest { + val scenario = Scenario(Identifier(databaseId = 1L), "Scenario", detectionQuality = 600) + val existingCounter = Counter("existing", 1.0, scenario.id) + val editionRepository = createEditionRepository(scenario, counters = listOf(existingCounter)) + val viewModel = CountersCreationViewModel(editionRepository) + + viewModel.setName(existingCounter.counterName) + viewModel.createCounter() + + assertEquals(listOf(existingCounter), editionRepository.editionState.getAllEditedCounters()) + } + + @Test + fun createCounter_usesEditedScenarioAndSelectedStartingValue() = runTest { + val scenario = Scenario(Identifier(databaseId = 1L), "Scenario", detectionQuality = 600) + val editionRepository = createEditionRepository(scenario) + val viewModel = CountersCreationViewModel(editionRepository) + + viewModel.setName("score") + viewModel.setStartingValue(42.5) + viewModel.createCounter() + + assertEquals( + listOf(Counter(counterName = "score", defaultValue = 42.5, scenarioId = scenario.id)), + editionRepository.editionState.getAllEditedCounters(), + ) + } + + private suspend fun createEditionRepository( + scenario: Scenario, + screenEvents: List = emptyList(), + triggerEvents: List = emptyList(), + counters: List = emptyList(), + ): EditionRepository { + val repository = mockk { + coEvery { getScenario(scenario.id.databaseId) } returns scenario + coEvery { getScreenEvents(scenario.id.databaseId) } returns screenEvents + coEvery { getTriggerEvents(scenario.id.databaseId) } returns triggerEvents + coEvery { getCounters(scenario.id.databaseId) } returns counters + } + + return EditionRepository(repository, mockk(relaxed = true)).also { + check(it.startEdition(scenario.id.databaseId)) + } + } +} \ No newline at end of file From 201af030cbace86ce15d80f55e4fc510fed3a964 Mon Sep 17 00:00:00 2001 From: Nain57 Date: Mon, 17 Aug 2026 12:06:39 +0200 Subject: [PATCH 4/5] [#1050] Ensure capture UI restoration upon failure Color was OK, but not regular capture. Added tests --- .../color/capture/ColorCaptureViewModel.kt | 3 +- .../screen/image/CaptureViewModel.kt | 2 +- .../capture/ColorCaptureViewModelTest.kt | 374 ++++++++++++++++++ .../screen/image/CaptureViewModelTest.kt | 160 ++++++++ 4 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModelTest.kt create mode 100644 feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModelTest.kt 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 af1c05341..89fa19848 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 @@ -43,6 +43,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds class ColorCaptureViewModel @Inject constructor( @param:Dispatcher(Main) private val mainDispatcher: CoroutineDispatcher, @@ -70,7 +71,7 @@ class ColorCaptureViewModel @Inject constructor( _uiState.update { capturingState() } screenshotJob = viewModelScope.launch(ioDispatcher) { - delay(200L) // Wait a bit to ensure menu is effectively invisible and a new screen frame is available + delay(200L.milliseconds) // Wait a bit to ensure menu is effectively invisible and a new screen frame is available val screenshot = displayRecorder.takeScreenshot() _uiState.update { diff --git a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModel.kt b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModel.kt index 27d9266c5..04f556bda 100644 --- a/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModel.kt +++ b/feature/smart-config/src/main/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModel.kt @@ -45,7 +45,7 @@ class CaptureViewModel @Inject constructor( fun takeScreenshot(resultCallback: (Bitmap?) -> Unit) { viewModelScope.launch(Dispatchers.IO) { delay(200L.milliseconds) - val screenshot = displayRecorder.takeScreenshot() ?: return@launch + val screenshot = displayRecorder.takeScreenshot() withContext(Dispatchers.Main) { resultCallback(screenshot) diff --git a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModelTest.kt b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModelTest.kt new file mode 100644 index 000000000..8f24ecc00 --- /dev/null +++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/color/capture/ColorCaptureViewModelTest.kt @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.buzbuz.smartautoclicker.feature.smart.config.ui.condition.screen.color.capture + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Point +import android.graphics.PointF +import android.os.Build + +import com.buzbuz.smartautoclicker.core.common.tutorial.domain.MonitoredViewsManager +import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredViewType +import com.buzbuz.smartautoclicker.core.display.config.DisplayConfig +import com.buzbuz.smartautoclicker.core.display.config.DisplayConfigManager +import com.buzbuz.smartautoclicker.core.display.recorder.DisplayRecorder + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class ColorCaptureViewModelTest { + + // Shared scheduler so advanceTimeBy in runTest controls delays in the ViewModel's coroutines. + private val testScheduler = TestCoroutineScheduler() + private val testDispatcher = UnconfinedTestDispatcher(testScheduler) + + private val mockDisplayConfigManager: DisplayConfigManager = mockk() + private val mockDisplayRecorder: DisplayRecorder = mockk() + private val mockMonitoredViewsManager: MonitoredViewsManager = mockk(relaxed = true) + + private lateinit var viewModel: ColorCaptureViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockDisplayConfigManager.displayConfig } returns displayConfig(1080, 1920) + viewModel = ColorCaptureViewModel( + mainDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + displayConfigManager = mockDisplayConfigManager, + displayRecorder = mockDisplayRecorder, + monitoredViewsManager = mockMonitoredViewsManager, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // region Initial state + + @Test + fun initialState_captureStep_isScreenshotSelection() { + assertEquals(ColorCaptureMenuStep.SCREENSHOT_SELECTION, viewModel.uiState.value.captureStep) + } + + @Test + fun initialState_menuIsVisible() { + assertEquals(true, viewModel.uiState.value.menuVisibility) + } + + @Test + fun initialState_topButtonIsEnabled() { + assertEquals(true, viewModel.uiState.value.topButtonEnabled) + } + + @Test + fun initialState_showHideButtonIsDisabled() { + assertEquals(false, viewModel.uiState.value.showHideButtonEnabled) + } + + @Test + fun initialState_pixelSelectionUiState_isNull() { + assertNull(viewModel.uiState.value.pixelSelectionUiState) + } + + // endregion + + // region captureScreen + + @Test + fun captureScreen_immediatelyTransitionsToCapturingStep() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + + assertEquals(ColorCaptureMenuStep.CAPTURING, viewModel.uiState.value.captureStep) + } + + @Test + fun captureScreen_hidesMenuDuringCapture() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + + assertEquals(false, viewModel.uiState.value.menuVisibility) + } + + @Test + fun captureScreen_withSuccessfulScreenshot_transitionsToPixelSelectionStep() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + assertEquals(ColorCaptureMenuStep.PIXEL_SELECTION, viewModel.uiState.value.captureStep) + } + + @Test + fun captureScreen_withSuccessfulScreenshot_showsMenu() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + assertEquals(true, viewModel.uiState.value.menuVisibility) + } + + @Test + fun captureScreen_withSuccessfulScreenshot_enablesShowHideButton() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + assertEquals(true, viewModel.uiState.value.showHideButtonEnabled) + } + + @Test + fun captureScreen_withSuccessfulScreenshot_populatesPixelSelectionUiState() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + assertNotNull(viewModel.uiState.value.pixelSelectionUiState) + } + + @Test + fun captureScreen_withNullScreenshot_staysInCapturingStep() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns null + + viewModel.captureScreen(null) + advanceTimeBy(201) + + assertEquals(ColorCaptureMenuStep.CAPTURING, viewModel.uiState.value.captureStep) + } + + @Test + fun captureScreen_withInitialFocusPosition_usesItAsSelectedPosition() = runTest(testDispatcher) { + val initialPosition = PointF(100f, 200f) + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(initialPosition) + advanceTimeBy(201) + + assertEquals(initialPosition, viewModel.uiState.value.pixelSelectionUiState?.selectedPosition) + } + + @Test + fun captureScreen_withNullInitialPosition_usesCenterOfDisplay() = runTest(testDispatcher) { + val displaySize = Point(1080, 1920) + every { mockDisplayConfigManager.displayConfig } returns displayConfig(displaySize.x, displaySize.y) + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + val expected = PointF(displaySize.x / 2f, displaySize.y / 2f) + assertEquals(expected, viewModel.uiState.value.pixelSelectionUiState?.selectedPosition) + } + + @Test + fun captureScreen_withSuccessfulScreenshot_notifiesMonitoredViewsManager() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + + viewModel.captureScreen(null) + advanceTimeBy(201) + + verify { mockMonitoredViewsManager.notifyClick(MonitoredViewType.SCREEN_CONDITION_CAPTURE_MENU_BUTTON_CAPTURE) } + } + + @Test + fun captureScreen_withNullScreenshot_doesNotNotifyMonitoredViewsManager() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns null + + viewModel.captureScreen(null) + advanceTimeBy(201) + + verify(exactly = 0) { mockMonitoredViewsManager.notifyClick(any()) } + } + + // endregion + + // region cancelCapture + + @Test + fun cancelCapture_resetsToScreenshotSelectionStep() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns null + viewModel.captureScreen(null) // Go to CAPTURING + + viewModel.cancelCapture() + + assertEquals(ColorCaptureMenuStep.SCREENSHOT_SELECTION, viewModel.uiState.value.captureStep) + } + + @Test + fun cancelCapture_clearsPixelSelectionUiState() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + viewModel.captureScreen(null) + advanceTimeBy(201) // Reach PIXEL_SELECTION + + viewModel.cancelCapture() + + assertNull(viewModel.uiState.value.pixelSelectionUiState) + } + + // endregion + + // region getPixelSelection + + @Test + fun getPixelSelection_returnsNull_inInitialState() { + assertNull(viewModel.getPixelSelection()) + } + + @Test + fun getPixelSelection_returnsNull_inCapturingState() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns null + viewModel.captureScreen(null) + + assertNull(viewModel.getPixelSelection()) + } + + @Test + fun getPixelSelection_returnsPositionAndColor_inPixelSelectionState() = runTest(testDispatcher) { + val pixelColor = Color.RED + val initialPosition = PointF(100f, 200f) + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap(pixelColor) + + viewModel.captureScreen(initialPosition) + advanceTimeBy(201) + + val result = viewModel.getPixelSelection() + assertNotNull(result) + assertEquals(initialPosition, result!!.first) + assertEquals(pixelColor, result.second) + } + + // endregion + + // region updateSelectedPosition + + @Test + fun updateSelectedPosition_updatesPositionInPixelSelectionUiState() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + viewModel.captureScreen(null) + advanceTimeBy(201) + + val newPosition = PointF(300f, 400f) + viewModel.updateSelectedPosition(newPosition) + + assertEquals(newPosition, viewModel.uiState.value.pixelSelectionUiState?.selectedPosition) + } + + @Test + fun updateSelectedPosition_updatesColorFromBitmapPixel() = runTest(testDispatcher) { + val pixelColor = Color.BLUE + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap(pixelColor) + viewModel.captureScreen(PointF(0f, 0f)) + advanceTimeBy(201) + + val newPosition = PointF(50f, 50f) + viewModel.updateSelectedPosition(newPosition) + + assertEquals(pixelColor, viewModel.uiState.value.pixelSelectionUiState?.selectedColor) + } + + @Test + fun updateSelectedPosition_withNonNullPosition_enablesTopButton() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + viewModel.captureScreen(null) + advanceTimeBy(201) + + viewModel.updateSelectedPosition(PointF(100f, 100f)) + + assertEquals(true, viewModel.uiState.value.topButtonEnabled) + } + + @Test + fun updateSelectedPosition_withNull_disablesTopButton() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + viewModel.captureScreen(null) + advanceTimeBy(201) + + viewModel.updateSelectedPosition(null) + + assertEquals(false, viewModel.uiState.value.topButtonEnabled) + } + + @Test + fun updateSelectedPosition_withNull_setsSelectedPositionToNull() = runTest(testDispatcher) { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockBitmap() + viewModel.captureScreen(null) + advanceTimeBy(201) + + viewModel.updateSelectedPosition(null) + + assertNull(viewModel.uiState.value.pixelSelectionUiState?.selectedPosition) + } + + @Test + fun updateSelectedPosition_notInPixelSelectionState_doesNotChangeState() { + val stateBefore = viewModel.uiState.value + + viewModel.updateSelectedPosition(PointF(100f, 200f)) + + assertEquals(stateBefore, viewModel.uiState.value) + } + + // endregion + + // region helpers + + private fun mockBitmap(pixelColor: Int = Color.RED): Bitmap = mockk { + every { width } returns 1080 + every { height } returns 1920 + every { getPixel(any(), any()) } returns pixelColor + } + + private fun displayConfig(width: Int, height: Int): DisplayConfig = mockk { + every { sizePx } returns Point(width, height) + } + + // endregion +} diff --git a/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModelTest.kt b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModelTest.kt new file mode 100644 index 000000000..e19b2072a --- /dev/null +++ b/feature/smart-config/src/test/java/com/buzbuz/smartautoclicker/feature/smart/config/ui/condition/screen/image/CaptureViewModelTest.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.feature.smart.config.ui.condition.screen.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Build + +import com.buzbuz.smartautoclicker.core.common.tutorial.domain.MonitoredViewsManager +import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.monitoring.MonitoredViewType +import com.buzbuz.smartautoclicker.core.display.recorder.DisplayRecorder +import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.feature.smart.config.domain.EditedItemsBuilder +import com.buzbuz.smartautoclicker.feature.smart.config.domain.EditionRepository + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +class CaptureViewModelTest { + + // Set main to unconfined so withContext(Dispatchers.Main) in the viewmodel runs + // directly on the IO thread instead of posting to the blocked test thread. + private val testDispatcher = UnconfinedTestDispatcher() + + private val mockDisplayRecorder: DisplayRecorder = mockk() + private val mockEditedItemsBuilder: EditedItemsBuilder = mockk() + private val mockEditionRepository: EditionRepository = mockk { + every { editedItemsBuilder } returns mockEditedItemsBuilder + } + private val mockMonitoredViewsManager: MonitoredViewsManager = mockk(relaxed = true) + + private lateinit var viewModel: CaptureViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = CaptureViewModel(mockDisplayRecorder, mockEditionRepository, mockMonitoredViewsManager) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun takeScreenshot_callsCallbackWithBitmapFromRecorder() { + val bitmap = mockk() + coEvery { mockDisplayRecorder.takeScreenshot() } returns bitmap + val latch = CountDownLatch(1) + var capturedBitmap: Bitmap? = null + + viewModel.takeScreenshot { + capturedBitmap = it + latch.countDown() + } + + assertTrue("Callback not called within timeout", latch.await(1, TimeUnit.SECONDS)) + assertEquals(bitmap, capturedBitmap) + } + + @Test + fun takeScreenshot_callsCallbackWithNull_whenRecorderReturnsNull() { + coEvery { mockDisplayRecorder.takeScreenshot() } returns null + val latch = CountDownLatch(1) + var capturedBitmap: Bitmap? = mockk() // intentionally non-null sentinel + + viewModel.takeScreenshot { + capturedBitmap = it + latch.countDown() + } + + assertTrue("Callback not called within timeout", latch.await(1, TimeUnit.SECONDS)) + assertNull(capturedBitmap) + } + + @Test + fun takeScreenshot_notifiesMonitoredViewsManager() { + coEvery { mockDisplayRecorder.takeScreenshot() } returns mockk() + val latch = CountDownLatch(1) + + viewModel.takeScreenshot { latch.countDown() } + + assertTrue("Callback not called within timeout", latch.await(1, TimeUnit.SECONDS)) + verify { mockMonitoredViewsManager.notifyClick(MonitoredViewType.SCREEN_CONDITION_CAPTURE_MENU_BUTTON_CAPTURE) } + } + + @Test + fun createImageCondition_callsCompletedWithCreatedCondition() { + val context = mockk() + val area = mockk() + val bitmap = mockk() + val condition = mockk() + coEvery { mockEditedItemsBuilder.createNewImageCondition(context, area, bitmap) } returns condition + val latch = CountDownLatch(1) + var capturedCondition: ScreenCondition.Image? = null + + viewModel.createImageCondition(context, area, bitmap) { + capturedCondition = it + latch.countDown() + } + + assertTrue("Completed callback not called within timeout", latch.await(1, TimeUnit.SECONDS)) + assertEquals(condition, capturedCondition) + } + + @Test + fun createImageCondition_delegatesToEditionRepository() { + val context = mockk() + val area = mockk() + val bitmap = mockk() + val condition = mockk() + coEvery { mockEditedItemsBuilder.createNewImageCondition(context, area, bitmap) } returns condition + val latch = CountDownLatch(1) + + viewModel.createImageCondition(context, area, bitmap) { latch.countDown() } + + assertTrue("Completed callback not called within timeout", latch.await(1, TimeUnit.SECONDS)) + // Verify the builder was called with exact arguments + io.mockk.coVerify { mockEditedItemsBuilder.createNewImageCondition(context, area, bitmap) } + } +} From 04217e0ef636116e5decd1da2d06e3a20182aebb Mon Sep 17 00:00:00 2001 From: Nain57 Date: Mon, 17 Aug 2026 12:30:38 +0200 Subject: [PATCH 5/5] [#1054] Fix invalid dialog scrolling --- .../common/tutorial/impl/TutorialRepositoryImpl.kt | 4 ++++ .../impl/monitoring/MonitoredViewsManagerImpl.kt | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt index b1ab6961a..1070081d2 100644 --- a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt +++ b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/TutorialRepositoryImpl.kt @@ -35,6 +35,7 @@ import com.buzbuz.smartautoclicker.core.common.tutorial.domain.model.state.Tutor import com.buzbuz.smartautoclicker.core.common.tutorial.impl.data.TipsStateDataSource import com.buzbuz.smartautoclicker.core.common.tutorial.impl.data.TutorialCompletionStateDataSource import com.buzbuz.smartautoclicker.core.common.tutorial.impl.engine.TutorialEngine +import com.buzbuz.smartautoclicker.core.common.tutorial.impl.monitoring.MonitoredViewsManagerImpl import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -57,6 +58,7 @@ internal class TutorialRepositoryImpl @Inject constructor( private val accessibilityServiceConnection: LocalAccessibilityServiceConnection, private val smartRepository: IRepository, private val tutorialEngine: TutorialEngine, + private val monitoredViewsManager: MonitoredViewsManagerImpl, private val tutorialTipsStateDataSource: TipsStateDataSource, private val tutorialCompletionStateDataSource: TutorialCompletionStateDataSource, ) : TutorialRepository { @@ -105,6 +107,7 @@ internal class TutorialRepositoryImpl @Inject constructor( data = mpData, ) + monitoredViewsManager.setViewMonitoringState(true) tutorialEngine.startTutorial(tutorial) } } @@ -117,6 +120,7 @@ internal class TutorialRepositoryImpl @Inject constructor( return@launch } + monitoredViewsManager.setViewMonitoringState(false) tutorialEngine.stopTutorial() localService.stopScenario() diff --git a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/monitoring/MonitoredViewsManagerImpl.kt b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/monitoring/MonitoredViewsManagerImpl.kt index 06a74a945..67abe788d 100644 --- a/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/monitoring/MonitoredViewsManagerImpl.kt +++ b/core/common/tutorial/src/main/java/com/buzbuz/smartautoclicker/core/common/tutorial/impl/monitoring/MonitoredViewsManagerImpl.kt @@ -29,7 +29,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -48,11 +50,20 @@ internal class MonitoredViewsManagerImpl @Inject constructor( private var textMonitoringJob: Job? = null private var numberMonitoringJob: Job? = null + private var isViewMonitoringEnabled: MutableStateFlow = MutableStateFlow(false) + + + internal fun setViewMonitoringState(isEnabled: Boolean) { + isViewMonitoringEnabled.update { isEnabled } + } + override fun attach( type: MonitoredViewType, monitoredView: View, positioningType: ViewPositioningType, ) { + if (!isViewMonitoringEnabled.value) return + if (!monitoredViews.contains(type)) monitoredViews[type] = ViewMonitor(displayConfigManager) monitoredViews[type]?.attachView(monitoredView, positioningType) }