Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/common/actions/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>? = null

private var completedGestures: Long = 0L
private var cancelledGestures: Long = 0L
private var errorGestures: Long = 0L
Expand All @@ -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) {
Expand All @@ -123,6 +95,14 @@ internal class GestureExecutor @Inject constructor() : Dumpable {
}
}

private fun <T> Continuation<T>.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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -105,6 +107,7 @@ internal class TutorialRepositoryImpl @Inject constructor(
data = mpData,
)

monitoredViewsManager.setViewMonitoringState(true)
tutorialEngine.startTutorial(tutorial)
}
}
Expand All @@ -117,6 +120,7 @@ internal class TutorialRepositoryImpl @Inject constructor(
return@launch
}

monitoredViewsManager.setViewMonitoringState(false)
tutorialEngine.stopTutorial()
localService.stopScenario()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,11 +50,20 @@ internal class MonitoredViewsManagerImpl @Inject constructor(
private var textMonitoringJob: Job? = null
private var numberMonitoringJob: Job? = null

private var isViewMonitoringEnabled: MutableStateFlow<Boolean> = 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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading