diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index f8551578..ce539766 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -63,6 +63,15 @@ public enum class WebViewAction { @SerializedName(value = "settings.open") SETTINGS_OPEN, + + @SerializedName("motion.start") + MOTION_START, + + @SerializedName("motion.stop") + MOTION_STOP, + + @SerializedName("motion.event") + MOTION_EVENT, } @InternalMindboxApi diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 1341ab5b..41d8ba2e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -24,6 +24,12 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionServiceProtocol +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionStartResult import cloud.mindbox.mobile_sdk.inapp.webview.* import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE @@ -35,6 +41,7 @@ import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import com.google.gson.Gson import com.google.gson.annotations.SerializedName import kotlinx.coroutines.CancellationException @@ -63,12 +70,16 @@ internal class WebViewInAppViewHolder( private const val JS_BRIDGE = "$JS_BRIDGE_CLASS.emit" private const val JS_CALL_BRIDGE = "(()=>{try{$JS_BRIDGE(%s);return!0}catch(_){return!1}})()" private const val JS_CHECK_BRIDGE = "(() => typeof $JS_BRIDGE_CLASS !== 'undefined' && typeof $JS_BRIDGE === 'function')()" + private const val MOTION_GESTURE_KEY = "gesture" + private const val MOTION_GESTURES_KEY = "gestures" } private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var motionService: MotionServiceProtocol? = null + private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { bindBackAction(currentRoot) { sendBackAction(controller) } } @@ -77,6 +88,7 @@ internal class WebViewInAppViewHolder( ConcurrentHashMap() private val gson: Gson by mindboxInject { this.gson } + private val timeProvider: TimeProvider by mindboxInject { timeProvider } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -174,6 +186,8 @@ internal class WebViewInAppViewHolder( handleHideAction(controller) } register(WebViewAction.HAPTIC, ::handleHapticAction) + register(WebViewAction.MOTION_START, ::handleMotionStartAction) + register(WebViewAction.MOTION_STOP) { handleMotionStopAction() } } } @@ -184,6 +198,57 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } + private fun handleMotionStartAction(message: BridgeMessage.Request): String { + val payload = requireNotNull(message.payload) { "Missing payload" } + val gestures = parseMotionGestures(payload) + require(gestures.isNotEmpty()) { "No valid gestures provided. Available: shake, flip" } + val result = getOrCreateMotionService().startMonitoring(gestures) + require(!result.allUnavailable) { + "No sensors available for: ${result.unavailable.joinToString { it.value }}" + } + return buildMotionStartPayload(result) + } + + private fun buildMotionStartPayload(result: MotionStartResult): String { + if (result.unavailable.isEmpty()) return BridgeMessage.SUCCESS_PAYLOAD + return gson.toJson( + MotionStartPayload(unavailable = result.unavailable.map { it.value }) + ) + } + + private fun handleMotionStopAction(): String { + motionService?.stopMonitoring() + return BridgeMessage.SUCCESS_PAYLOAD + } + + private fun sendMotionEvent(gesture: MotionGesture, data: Map) { + val controller: WebViewController = webViewController ?: return + val payload = JSONObject() + .apply { + put(MOTION_GESTURE_KEY, gesture.value) + data.forEach { (key, value) -> put(key, value) } + } + .toString() + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.MOTION_EVENT, + payload = payload, + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") + motionService?.stopMonitoring() + } + } + + private fun parseMotionGestures(payload: String): Set { + return loggingRunCatching(defaultValue = emptySet()) { + val array = JSONObject(payload).optJSONArray(MOTION_GESTURES_KEY) + ?: return@loggingRunCatching emptySet() + (0 until array.length()) + .mapNotNull { i -> array.optString(i).enumValue() } + .toSet() + } + } + private fun handleReadyAction( configuration: Configuration, insets: InAppInsets, @@ -251,6 +316,7 @@ internal class WebViewInAppViewHolder( } private fun handleCloseAction(message: BridgeMessage): String { + motionService?.stopMonitoring() inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -688,6 +754,7 @@ internal class WebViewInAppViewHolder( override fun onClose() { hapticFeedbackExecutor.cancel() + motionService?.stopMonitoring() stopTimer() cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> @@ -702,6 +769,18 @@ internal class WebViewInAppViewHolder( super.onClose() } + private fun getOrCreateMotionService(): MotionServiceProtocol = + motionService ?: MotionService( + context = appContext, + lifecycle = ProcessLifecycleOwner.get().lifecycle, + timeProvider = timeProvider, + ).also { service -> + service.onGestureDetected = { gesture, data -> + sendMotionEvent(gesture = gesture, data = data) + } + motionService = service + } + private data class NavigationInterceptedPayload( val url: String ) @@ -710,6 +789,13 @@ internal class WebViewInAppViewHolder( val error: String ) + private data class MotionStartPayload( + @SerializedName("success") + val success: Boolean = true, + @SerializedName("unavailable") + val unavailable: List? = null, + ) + private data class SettingsOpenRequest( @SerializedName("target") val target: String, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt new file mode 100644 index 00000000..757520fb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt @@ -0,0 +1,271 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view.motion + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import kotlin.math.abs +import kotlin.math.hypot + +internal enum class MotionGesture(val value: String) { + SHAKE("shake"), + FLIP("flip"), +} + +internal enum class DevicePosition(val value: String) { + FACE_UP("faceUp"), + FACE_DOWN("faceDown"), + PORTRAIT("portrait"), + PORTRAIT_UPSIDE_DOWN("portraitUpsideDown"), + LANDSCAPE_LEFT("landscapeLeft"), + LANDSCAPE_RIGHT("landscapeRight"), +} + +internal data class MotionVector(val x: Float, val y: Float, val z: Float) { + companion object { + val ZERO: MotionVector = MotionVector(0f, 0f, 0f) + } + + operator fun minus(other: MotionVector): MotionVector = MotionVector(x - other.x, y - other.y, z - other.z) + + fun magnitude(): Float = hypot(hypot(x, y), z) +} + +internal data class MotionStartResult( + val started: Set, + val unavailable: Set, +) { + val allUnavailable: Boolean get() = started.isEmpty() && unavailable.isNotEmpty() +} + +internal interface MotionServiceProtocol { + var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? + + fun startMonitoring(gestures: Set): MotionStartResult + + fun stopMonitoring() +} + +internal class MotionService( + private val context: Context, + private val lifecycle: Lifecycle, + private val timeProvider: TimeProvider, +) : MotionServiceProtocol { + + private companion object { + const val SMOOTHING_FACTOR = 0.7f + val SHAKE_COOLDOWN = Milliseconds(800L) + const val TABLET_MIN_WIDTH_DP = 600 + const val PHONE_THRESHOLD_G = 3.0f + const val TABLET_THRESHOLD_G = 1.5f + const val FLIP_ENTER_THRESHOLD_G = 0.8f + const val FLIP_EXIT_THRESHOLD_G = 0.6f + } + + override var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? = null + + private val sensorManager: SensorManager? = + context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + + private val shakeAccelerationThreshold: Float by lazy { + val isTablet = context.resources.configuration.smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP + val thresholdG = if (isTablet) TABLET_THRESHOLD_G else PHONE_THRESHOLD_G + thresholdG * SensorManager.GRAVITY_EARTH + } + + private var activeGestures: Set = emptySet() + private var suspendedGestures: Set? = null + + private var lastShakeVector: MotionVector = MotionVector.ZERO + private var accumulateShake = 0f + private var lastShakeTimestamp: Timestamp = Timestamp.ZERO + + private var currentFlipPosition: DevicePosition? = null + + private val shakeListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processShake(MotionVector(event.values[0], event.values[1], event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val flipListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processFlip(MotionVector(-event.values[0], -event.values[1], -event.values[2])) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) = suspend() + + override fun onStart(owner: LifecycleOwner) = resume() + } + + override fun startMonitoring(gestures: Set): MotionStartResult { + if (activeGestures.isNotEmpty()) stopMonitoring() + val unavailable = buildSet { + if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { + add(MotionGesture.SHAKE) + } + if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { + add(MotionGesture.FLIP) + } + } + + activeGestures = gestures - unavailable + val result = MotionStartResult(started = activeGestures, unavailable = unavailable) + if (activeGestures.isEmpty()) return result + addLifecycleObserver() + startSensors() + + mindboxLogI("Motion: monitoring started for ${activeGestures.map { it.value }}") + if (unavailable.isNotEmpty()) { + mindboxLogI("Motion: unavailable gestures: ${unavailable.map { it.value }}") + } + return result + } + + override fun stopMonitoring() { + if (activeGestures.isEmpty() && suspendedGestures == null) return + removeLifecycleObserver() + stopSensors() + activeGestures = emptySet() + suspendedGestures = null + mindboxLogI("Motion: monitoring stopped") + } + + private fun addLifecycleObserver() { + lifecycle.addObserver(lifecycleObserver) + } + + private fun removeLifecycleObserver() { + lifecycle.removeObserver(lifecycleObserver) + } + + internal fun suspend() { + if (activeGestures.isEmpty()) return + suspendedGestures = activeGestures + stopSensors() + mindboxLogI("Motion: suspended (app in background)") + } + + internal fun resume() { + val gestures = suspendedGestures ?: return + suspendedGestures = null + activeGestures = gestures + startSensors() + mindboxLogI("Motion: resumed (app in foreground)") + } + + private fun startSensors() { + if (activeGestures.contains(MotionGesture.SHAKE)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { sensor -> + sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + if (activeGestures.contains(MotionGesture.FLIP)) { + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)?.let { sensor -> + sensorManager.registerListener(flipListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + } + + private fun stopSensors() { + sensorManager?.unregisterListener(shakeListener) + sensorManager?.unregisterListener(flipListener) + resetShakeState() + currentFlipPosition = null + } + + private fun resetShakeState() { + lastShakeVector = MotionVector.ZERO + accumulateShake = 0f + lastShakeTimestamp = Timestamp.ZERO + } + + private fun isShakeAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + + private fun isFlipAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY) != null + + internal fun processShake(vector: MotionVector) { + val delta = (vector - lastShakeVector).magnitude() + accumulateShake = accumulateShake * SMOOTHING_FACTOR + delta + val now: Timestamp = timeProvider.currentTimestamp() + val elapsed: Milliseconds = timeProvider.elapsedSince(lastShakeTimestamp) + if (accumulateShake > shakeAccelerationThreshold && elapsed.interval > SHAKE_COOLDOWN.interval) { + accumulateShake = 0f + lastShakeTimestamp = now + loggingRunCatching { onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } + } + lastShakeVector = vector + } + + private fun processFlip(vector: MotionVector) { + val newPosition = resolvePosition(vector = vector, current = currentFlipPosition) + if (newPosition == null || newPosition == currentFlipPosition) return + + val from = currentFlipPosition + currentFlipPosition = newPosition + + if (from == null) return + + loggingRunCatching { + onGestureDetected?.invoke( + MotionGesture.FLIP, + mapOf("from" to from.value, "to" to newPosition.value), + ) + } + } + + internal fun resolvePosition( + vector: MotionVector, + current: DevicePosition?, + ): DevicePosition? { + data class Axis( + val value: Float, + val negative: DevicePosition, + val positive: DevicePosition, + ) + + val axes = listOf( + Axis(vector.z, DevicePosition.FACE_UP, DevicePosition.FACE_DOWN), + Axis(vector.y, DevicePosition.PORTRAIT, DevicePosition.PORTRAIT_UPSIDE_DOWN), + Axis(vector.x, DevicePosition.LANDSCAPE_LEFT, DevicePosition.LANDSCAPE_RIGHT), + ) + + if (current != null) { + axes.forEach { axis -> + val position = if (axis.value > 0f) axis.positive else axis.negative + if (position == current && abs(axis.value) > FLIP_EXIT_THRESHOLD_G * SensorManager.GRAVITY_EARTH) { + return current + } + } + } + + var dominantPosition: DevicePosition? = null + var maxMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH + + for (axis in axes) { + val magnitude = abs(axis.value) + if (magnitude > maxMagnitude) { + maxMagnitude = magnitude + dominantPosition = if (axis.value > 0f) axis.positive else axis.negative + } + } + return dominantPosition + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt index feb99328..65deadc5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Timestamp.kt @@ -12,6 +12,10 @@ internal value class Timestamp(val ms: Long) { operator fun plus(milliseconds: Long): Timestamp = Timestamp(ms + milliseconds) operator fun minus(timestamp: Timestamp): Timestamp = Timestamp(ms - timestamp.ms) + + companion object { + val ZERO: Timestamp = Timestamp(0L) + } } internal fun Long.toTimestamp(): Timestamp = Timestamp(this) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt new file mode 100644 index 00000000..b2371cbc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt @@ -0,0 +1,219 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import cloud.mindbox.mobile_sdk.utils.TimeProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeTimeProvider(private var nowMs: Long = 0L) : TimeProvider { + override fun currentTimeMillis(): Long = nowMs + + override fun currentTimestamp(): Timestamp = Timestamp(nowMs) + + override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = + Milliseconds(nowMs - startTimeMillis.ms) + + fun advanceBy(ms: Long) { + nowMs += ms + } +} + +class MotionServiceShakeTest { + + private val phoneThresholdG = 3.0f * SensorManager.GRAVITY_EARTH + + private lateinit var fakeTimeProvider: FakeTimeProvider + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + fakeTimeProvider = FakeTimeProvider(nowMs = 10_000L) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = fakeTimeProvider, + ) + } + + @Test + fun `processShake fires callback when accumulated force exceeds threshold`() { + var isDetected = false + motionService.onGestureDetected = { gesture, _ -> isDetected = gesture == MotionGesture.SHAKE } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(isDetected) + } + + @Test + fun `processShake does not fire callback when force is below threshold`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + motionService.processShake(MotionVector(x = phoneThresholdG - 1f, y = 0f, z = 0f)) + + assertFalse(isDetected) + } + + @Test + fun `processShake does not fire callback during cooldown`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake fires again after cooldown expires`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(900L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(2, detectedCount) + } + + @Test + fun `processShake does not fire after exactly cooldown boundary`() { + var detectedCount = 0 + motionService.onGestureDetected = { _, _ -> detectedCount++ } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + fakeTimeProvider.advanceBy(800L) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + + assertEquals(1, detectedCount) + } + + @Test + fun `processShake sends empty data map for shake gesture`() { + var capturedData: Map? = null + motionService.onGestureDetected = { _, data -> capturedData = data } + + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + + assertTrue(capturedData != null && capturedData?.isEmpty() == true) + } + + @Test + fun `processShake accumulates force across multiple frames`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + val halfThreshold = phoneThresholdG / 2f + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = halfThreshold, y = 0f, z = 0f)) + + assertTrue(isDetected) + } +} + +class MotionServiceLifecycleTest { + + private lateinit var mockSensorManager: SensorManager + private lateinit var mockContext: Context + private lateinit var motionService: MotionService + + @Before + fun setUp() { + val mockSensor = mockk(relaxed = true) + mockSensorManager = mockk(relaxed = true) + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } returns mockSensor + every { mockSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) } returns mockSensor + + mockContext = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockSensorManager + every { mockContext.resources } returns mockk(relaxed = true) + + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `startMonitoring registers sensor listener`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `suspend stops sensors when monitoring is active`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + + motionService.suspend() + + verify { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `suspend does nothing when monitoring is not active`() { + motionService.suspend() + + verify(exactly = 0) { mockSensorManager.unregisterListener(any()) } + } + + @Test + fun `resume restarts sensors after suspend`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + + motionService.resume() + + verify(exactly = 2) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `resume does nothing without prior suspend`() { + motionService.resume() + + verify(exactly = 0) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring after suspend prevents resume from restarting sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE)) + motionService.suspend() + motionService.stopMonitoring() + + motionService.resume() + + verify(exactly = 1) { mockSensorManager.registerListener(any(), any(), any()) } + } + + @Test + fun `stopMonitoring unregisters all sensors`() { + motionService.startMonitoring(setOf(MotionGesture.SHAKE, MotionGesture.FLIP)) + + motionService.stopMonitoring() + + verify(atLeast = 1) { mockSensorManager.unregisterListener(any()) } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt new file mode 100644 index 00000000..cefd974c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt @@ -0,0 +1,249 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.SensorManager +import androidx.lifecycle.Lifecycle +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.DevicePosition +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionVector +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MotionServiceResolvePositionTest { + + private lateinit var motionService: MotionService + + private val enterThreshold = 0.8f * SensorManager.GRAVITY_EARTH + private val exitThreshold = 0.6f * SensorManager.GRAVITY_EARTH + + @Before + fun setUp() { + val mockContext: Context = mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.resources } returns mockk(relaxed = true) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) + } + + @Test + fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { + val inputZ = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns faceDown when z is strongly positive and no current position`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns portrait when y is strongly negative and no current position`() { + val inputY = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns portraitUpsideDown when y is strongly positive and no current position`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeLeft when x is strongly negative and no current position`() { + val inputX = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition returns landscapeRight when x is strongly positive and no current position`() { + val inputX = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are below enter threshold and no current position`() { + val inputValue = enterThreshold - 0.1f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = -inputValue), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition returns null when all axes are zero and no current position`() { + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = 0f), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition retains current faceUp when z is above exit threshold`() { + val inputZ = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition retains current portrait when y is above exit threshold`() { + val inputY = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.PORTRAIT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition retains current landscapeLeft when x is above exit threshold`() { + val inputX = -(exitThreshold + 0.1f) + val inputCurrentPosition = DevicePosition.LANDSCAPE_LEFT + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = inputX, y = 0f, z = 0f), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + @Test + fun `resolvePosition drops current faceUp when z falls below exit threshold and switches to portrait`() { + val inputZ = -(exitThreshold - 0.1f) + val inputY = -(enterThreshold + 0.5f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition returns null when current position is lost and no axis exceeds enter threshold`() { + val inputZ = -(exitThreshold - 0.1f) + val inputCurrentPosition = DevicePosition.FACE_UP + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = inputCurrentPosition, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition picks dominant axis when multiple axes exceed enter threshold`() { + val inputZ = -(enterThreshold + 0.1f) + val inputY = -(enterThreshold + 2.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + @Test + fun `resolvePosition picks z axis when z magnitude exceeds y magnitude`() { + val inputZ = -(enterThreshold + 1.0f) + val inputY = -(enterThreshold + 0.1f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = null, + ) + assertEquals(DevicePosition.FACE_UP, actualPosition) + } + + @Test + fun `resolvePosition returns null when z is exactly at enter threshold`() { + val inputZ = -enterThreshold + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = null, + ) + assertNull(actualPosition) + } + + @Test + fun `resolvePosition transitions from faceUp to faceDown when z flips to positive`() { + val inputZ = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputZ), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition transitions from portrait to portraitUpsideDown when y flips to positive`() { + val inputY = enterThreshold + 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = 0f), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) + } + + @Test + fun `resolvePosition handles multi-step transition from portrait through faceUp to faceDown`() { + val inputStrongZ = -(enterThreshold + 0.5f) + val step1ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = inputStrongZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.FACE_UP, step1ActualPosition) + + val step2ActualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = 0f, z = enterThreshold + 0.5f), + current = DevicePosition.FACE_UP, + ) + assertEquals(DevicePosition.FACE_DOWN, step2ActualPosition) + } + + @Test + fun `resolvePosition retains portrait when y is above exit threshold even though z is below enter threshold`() { + val inputY = -(exitThreshold + 0.5f) + val inputZ = -(enterThreshold - 1.0f) + val actualPosition: DevicePosition? = motionService.resolvePosition( + vector = MotionVector(x = 0f, y = inputY, z = inputZ), + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } +}