From 5ac1db081c119030dcf3979e1ba5cc5b2f9cf456 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 26 Mar 2026 12:55:02 +0300 Subject: [PATCH 1/8] MOBILE-39: add shake and flip --- .../inapp/presentation/view/WebViewAction.kt | 9 + .../view/WebViewInappViewHolder.kt | 78 +++++ .../presentation/view/motion/MotionService.kt | 271 ++++++++++++++++++ .../mobile_sdk/managers/GatewayManager.kt | 128 ++++++++- .../view/MotionServiceResolvePositionTest.kt | 222 ++++++++++++++ 5 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/motion/MotionService.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt 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..f9610a59 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,10 @@ 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 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 @@ -69,6 +73,16 @@ internal class WebViewInAppViewHolder( private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var isMotionServiceInitialized = false + private val motionService: MotionServiceProtocol by lazy { + isMotionServiceInitialized = true + MotionService(context = appContext).also { service -> + service.onGestureDetected = { gesture, data -> + sendMotionEvent(gesture = gesture, data = data) + } + } + } + private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { bindBackAction(currentRoot) { sendBackAction(controller) } } @@ -174,6 +188,8 @@ internal class WebViewInAppViewHolder( handleHideAction(controller) } register(WebViewAction.HAPTIC, ::handleHapticAction) + register(WebViewAction.MOTION_START, ::handleMotionStartAction) + register(WebViewAction.MOTION_STOP) { handleMotionStopAction() } } } @@ -184,6 +200,66 @@ internal class WebViewInAppViewHolder( return BridgeMessage.EMPTY_PAYLOAD } + private fun handleMotionStartAction(message: BridgeMessage.Request): String { + val payload = message.payload ?: return buildMotionError("Missing payload") + val gestures = parseMotionGestures(payload) + if (gestures.isEmpty()) { + return buildMotionError("No valid gestures provided. Available: shake, flip") + } + val result: MotionStartResult = motionService.startMonitoring(gestures) + if (result.allUnavailable) { + return buildMotionError( + "No sensors available for: ${result.unavailable.joinToString { it.value }}" + ) + } + return if (result.unavailable.isEmpty()) { + BridgeMessage.SUCCESS_PAYLOAD + } else { + val unavailableJson = result.unavailable.joinToString( + prefix = "[", + postfix = "]", + separator = "," + ) { "\"${it.value}\"" } + """{"success":true,"unavailable":$unavailableJson}""" + } + } + + private fun handleMotionStopAction(): String { + if (isMotionServiceInitialized) motionService.stopMonitoring() + return BridgeMessage.SUCCESS_PAYLOAD + } + + private fun sendMotionEvent(gesture: MotionGesture, data: Map) { + val controller: WebViewController = webViewController ?: return + val payloadBuilder = StringBuilder("""{"gesture":"${gesture.value}"""") + data.forEach { (key, value) -> payloadBuilder.append(""","$key":"$value"""") } + payloadBuilder.append("}") + val message: BridgeMessage.Request = BridgeMessage.createAction( + action = WebViewAction.MOTION_EVENT, + payload = payloadBuilder.toString(), + ) + sendActionInternal(controller, message) { error -> + mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") + } + } + + private fun parseMotionGestures(payload: String): Set { + return runCatching { + val array = JSONObject(payload).optJSONArray("gestures") + ?: return emptySet() + buildSet { + for (i in 0 until array.length()) { + val name = array.optString(i) ?: continue + val gesture = MotionGesture.entries.firstOrNull { it.value == name } + if (gesture != null) add(gesture) + } + } + }.getOrDefault(emptySet()) + } + + private fun buildMotionError(message: String): String = + """{"error":"$message"}""" + private fun handleReadyAction( configuration: Configuration, insets: InAppInsets, @@ -251,6 +327,7 @@ internal class WebViewInAppViewHolder( } private fun handleCloseAction(message: BridgeMessage): String { + if (isMotionServiceInitialized) motionService.stopMonitoring() inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -688,6 +765,7 @@ internal class WebViewInAppViewHolder( override fun onClose() { hapticFeedbackExecutor.cancel() + if (isMotionServiceInitialized) motionService.stopMonitoring() stopTimer() cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> 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..9e321c06 --- /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.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.logger.mindboxLogD +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import kotlin.math.abs +import kotlin.math.sqrt + +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 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, +) : MotionServiceProtocol { + + private companion object { + const val SMOOTHING_FACTOR = 0.9f + const val COOLDOWN_MS = 800L + const val TABLET_MIN_WIDTH_DP = 600 + const val PHONE_THRESHOLD_G = 2.5f + 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 shakeThresholdMs2: 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 lastShakeX = 0f + private var lastShakeY = 0f + private var lastShakeZ = 0f + private var accumShake = 0f + private var lastShakeTimestampMs = 0L + + private var currentFlipPosition: DevicePosition? = null + + private val shakeListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processShake( + x = event.values[0], + y = event.values[1], + z = event.values[2], + ) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val flipListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + processFlip( + x = event.values[0], + y = event.values[1], + z = 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 { + stopMonitoring() + + val unavailable = mutableSetOf() + if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { + unavailable.add(MotionGesture.FLIP) + } + + activeGestures = gestures - unavailable + val result = MotionStartResult(started = activeGestures, unavailable = unavailable) + if (activeGestures.isEmpty()) return result + + addLifecycleObserver() + startSensors() + + mindboxLogI("[WebView] Motion: monitoring started for ${activeGestures.map { it.value }}") + if (unavailable.isNotEmpty()) { + mindboxLogI("[WebView] Motion: unavailable gestures: ${unavailable.map { it.value }}") + } + return result + } + + override fun stopMonitoring() { + removeLifecycleObserver() + stopSensors() + activeGestures = emptySet() + suspendedGestures = null + mindboxLogI("[WebView] Motion: monitoring stopped") + } + + private fun addLifecycleObserver() { + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) + } + + private fun removeLifecycleObserver() { + ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) + } + + private fun suspend() { + if (activeGestures.isEmpty()) return + suspendedGestures = activeGestures + stopSensors() + mindboxLogI("[WebView] Motion: suspended (app in background)") + } + + private fun resume() { + val gestures = suspendedGestures ?: return + suspendedGestures = null + activeGestures = gestures + startSensors() + mindboxLogI("[WebView] Motion: resumed (app in foreground)") + } + + private fun startSensors() { + if (activeGestures.contains(MotionGesture.SHAKE)) { + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (sensor != null) { + sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_GAME) + } + } + if (activeGestures.contains(MotionGesture.FLIP)) { + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + if (sensor != null) { + sensorManager.registerListener(flipListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } + } + + private fun stopSensors() { + sensorManager.unregisterListener(shakeListener) + sensorManager.unregisterListener(flipListener) + resetShakeState() + currentFlipPosition = null + } + + private fun resetShakeState() { + lastShakeX = 0f + lastShakeY = 0f + lastShakeZ = 0f + accumShake = 0f + lastShakeTimestampMs = 0L + } + + private fun isFlipAvailable(): Boolean = + sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) != null + + private fun processShake(x: Float, y: Float, z: Float) { + val dx = x - lastShakeX + val dy = y - lastShakeY + val dz = z - lastShakeZ + val delta = sqrt(dx * dx + dy * dy + dz * dz) + accumShake = accumShake * SMOOTHING_FACTOR + delta + + val nowMs = System.currentTimeMillis() + if (accumShake > shakeThresholdMs2 && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { + lastShakeTimestampMs = nowMs + mindboxLogD("[WebView] Motion: shake detected (accum=$accumShake, threshold=$shakeThresholdMs2)") + onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) + } + + lastShakeX = x + lastShakeY = y + lastShakeZ = z + } + + private fun processFlip(x: Float, y: Float, z: Float) { + val newPosition = resolvePosition(x = x, y = y, z = z, current = currentFlipPosition) + if (newPosition == null || newPosition == currentFlipPosition) return + + val from = currentFlipPosition + currentFlipPosition = newPosition + + if (from == null) return + + mindboxLogD("[WebView] Motion: flip detected ${from.value} -> ${newPosition.value}") + onGestureDetected?.invoke( + MotionGesture.FLIP, + mapOf("from" to from.value, "to" to newPosition.value), + ) + } + + internal fun resolvePosition( + x: Float, + y: Float, + z: Float, + current: DevicePosition?, + ): DevicePosition? { + data class Axis( + val value: Float, + val negative: DevicePosition, + val positive: DevicePosition, + ) + + val axes = listOf( + Axis(z, DevicePosition.FACE_UP, DevicePosition.FACE_DOWN), + Axis(y, DevicePosition.PORTRAIT, DevicePosition.PORTRAIT_UPSIDE_DOWN), + Axis(x, DevicePosition.LANDSCAPE_LEFT, DevicePosition.LANDSCAPE_RIGHT), + ) + + if (current != null) { + for (axis in axes) { + 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 best: DevicePosition? = null + var bestMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH + + for (axis in axes) { + val magnitude = abs(axis.value) + if (magnitude > bestMagnitude) { + bestMagnitude = magnitude + best = if (axis.value > 0f) axis.positive else axis.negative + } + } + return best + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index 7a856bef..a3528bbd 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -437,7 +437,133 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic configuration = configuration, jsonRequest = null, listener = { response -> - continuation.resume(response.toString()) + continuation.resume(""" +{ + "monitoring": { + "logs": [] + }, + "settings": { + "operations": { + "viewProduct": { + "systemName": "app.viewProduct" + }, + "viewCategory": { + "systemName": "app.viewCategory" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "01:00:01", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 20, + "maxInappsPerDay": 100, + "minIntervalBetweenShows": "00:00:01" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": true + } + }, + "inapps": [ + { + "id": "322df172-4039-48f1-9cda-513c3213cc28", + "isPriority": true, + "delayTime": null, + "sdkVersion": { + "min": 12, + "max": null + }, + "frequency": { + "kind": "session", + "${'$'}type": "once" + }, + "targeting": { + "nodes": [ + { + "kind": "gte", + "value": 1, + "${'$'}type": "visit" + } + ], + "${'$'}type": "and" + }, + "form": { + "variants": [ + { + "content": { + "background": { + "layers": [ + { + "params": { + "formId": "143364" + }, + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/stable/inapps/webview/content/index.html", + "${'$'}type": "webview" + } + ] + }, + "elements": null + }, + "imageUrl": "", + "redirectUrl": "", + "intentPayload": "", + "${'$'}type": "modal" + } + ] + } + }, + { + "id": "322df172-4039-48f1-9cda-513c3213cc28", + "isPriority": true, + "sdkVersion": { + "min": 11, + "max": 11 + }, + "frequency": { + "kind": "session", + "${'$'}type": "once" + }, + "targeting": { + "nodes": [ + { + "kind": "gte", + "value": 1, + "${'$'}type": "visit" + } + ], + "${'$'}type": "and" + }, + "form": { + "variants": [ + { + "content": { + "background": { + "layers": [ + { + "params": { + "popUpId": "143364" + }, + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/stable/inapps/webview/content/index.html", + "${'$'}type": "webview", + "type": "webview" + } + ] + } + }, + "${'$'}type": "webview" + } + ] + } + } + ] +} + + """.trimIndent()) }, errorsListener = { error -> continuation.resumeWithException(error) 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..f0e43d6c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceResolvePositionTest.kt @@ -0,0 +1,222 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.Context +import android.hardware.SensorManager +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.DevicePosition +import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService +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) + } + + // region — Определение новой позиции без текущей + + @Test + fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { + val inputZ = -enterThreshold - 0.5f + val actualPosition: DevicePosition? = motionService.resolvePosition( + 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( + 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( + 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( + 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( + 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( + x = inputX, + y = 0f, + z = 0f, + current = null, + ) + assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) + } + + // endregion + + // region — Мёртвая зона: нет позиции когда все оси ниже enterThreshold + + @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( + 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( + x = 0f, + y = 0f, + z = 0f, + current = null, + ) + assertNull(actualPosition) + } + + // endregion + + // region — Гистерезис: удержание текущей позиции выше exitThreshold + + @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( + 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( + 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( + x = inputX, + y = 0f, + z = 0f, + current = inputCurrentPosition, + ) + assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) + } + + // endregion + + // region — Сброс текущей позиции ниже exitThreshold и переход на новую + + @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( + 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( + x = 0f, + y = 0f, + z = inputZ, + current = inputCurrentPosition, + ) + assertNull(actualPosition) + } + + // endregion + + // region — Доминантная ось + + @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( + x = 0f, + y = inputY, + z = inputZ, + current = null, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } + + // endregion +} From 7fcce91c23d13b6a818ad200357082a373ed696b Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 26 Mar 2026 16:21:25 +0300 Subject: [PATCH 2/8] MOBILE-39: fix shake --- .../presentation/view/motion/MotionService.kt | 16 +-- .../view/MotionServiceResolvePositionTest.kt | 102 ++++++++++++++---- 2 files changed, 91 insertions(+), 27 deletions(-) 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 index 9e321c06..575ead27 100644 --- 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 @@ -47,10 +47,10 @@ internal class MotionService( ) : MotionServiceProtocol { private companion object { - const val SMOOTHING_FACTOR = 0.9f + const val SMOOTHING_FACTOR = 0.7f const val COOLDOWN_MS = 800L const val TABLET_MIN_WIDTH_DP = 600 - const val PHONE_THRESHOLD_G = 2.5f + 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 @@ -61,7 +61,7 @@ internal class MotionService( private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager - private val shakeThresholdMs2: Float by lazy { + 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 @@ -165,7 +165,7 @@ internal class MotionService( if (activeGestures.contains(MotionGesture.SHAKE)) { val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) if (sensor != null) { - sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_GAME) + sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) } } if (activeGestures.contains(MotionGesture.FLIP)) { @@ -200,11 +200,13 @@ internal class MotionService( val dz = z - lastShakeZ val delta = sqrt(dx * dx + dy * dy + dz * dz) accumShake = accumShake * SMOOTHING_FACTOR + delta - + mindboxLogI("Accum shake is $accumShake") val nowMs = System.currentTimeMillis() - if (accumShake > shakeThresholdMs2 && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { + if (accumShake > shakeAccelerationThreshold && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { + val detectedAccum = accumShake + accumShake = 0f lastShakeTimestampMs = nowMs - mindboxLogD("[WebView] Motion: shake detected (accum=$accumShake, threshold=$shakeThresholdMs2)") + mindboxLogD("[WebView] Motion: shake detected (accum=$detectedAccum, threshold=$shakeAccelerationThreshold)") onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } 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 index f0e43d6c..97ae123c 100644 --- 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 @@ -21,13 +21,11 @@ class MotionServiceResolvePositionTest { @Before fun setUp() { val mockContext: Context = mockk(relaxed = true) - every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) + every { mockContext.getSystemService(Context.SENSOR_SERVICE) } returns mockk(relaxed = true) every { mockContext.resources } returns mockk(relaxed = true) motionService = MotionService(context = mockContext) } - // region — Определение новой позиции без текущей - @Test fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { val inputZ = -enterThreshold - 0.5f @@ -100,10 +98,6 @@ class MotionServiceResolvePositionTest { assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) } - // endregion - - // region — Мёртвая зона: нет позиции когда все оси ниже enterThreshold - @Test fun `resolvePosition returns null when all axes are below enter threshold and no current position`() { val inputValue = enterThreshold - 0.1f @@ -127,10 +121,6 @@ class MotionServiceResolvePositionTest { assertNull(actualPosition) } - // endregion - - // region — Гистерезис: удержание текущей позиции выше exitThreshold - @Test fun `resolvePosition retains current faceUp when z is above exit threshold`() { val inputZ = -(exitThreshold + 0.1f) @@ -170,10 +160,6 @@ class MotionServiceResolvePositionTest { assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) } - // endregion - - // region — Сброс текущей позиции ниже exitThreshold и переход на новую - @Test fun `resolvePosition drops current faceUp when z falls below exit threshold and switches to portrait`() { val inputZ = -(exitThreshold - 0.1f) @@ -201,10 +187,6 @@ class MotionServiceResolvePositionTest { assertNull(actualPosition) } - // endregion - - // region — Доминантная ось - @Test fun `resolvePosition picks dominant axis when multiple axes exceed enter threshold`() { val inputZ = -(enterThreshold + 0.1f) @@ -218,5 +200,85 @@ class MotionServiceResolvePositionTest { assertEquals(DevicePosition.PORTRAIT, actualPosition) } - // endregion + @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( + 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( + 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( + 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( + 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( + x = 0f, + y = 0f, + z = inputStrongZ, + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.FACE_UP, step1ActualPosition) + + val step2ActualPosition: DevicePosition? = motionService.resolvePosition( + 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( + x = 0f, + y = inputY, + z = inputZ, + current = DevicePosition.PORTRAIT, + ) + assertEquals(DevicePosition.PORTRAIT, actualPosition) + } } From d258af061be52df4cdcd5a78dabb68315afa7d6e Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 10:10:41 +0300 Subject: [PATCH 3/8] MOBILE-39: fix flip --- .../presentation/view/motion/MotionService.kt | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) 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 index 575ead27..6d324256 100644 --- 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 @@ -8,7 +8,6 @@ import android.hardware.SensorManager import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogI import kotlin.math.abs import kotlin.math.sqrt @@ -73,7 +72,7 @@ internal class MotionService( private var lastShakeX = 0f private var lastShakeY = 0f private var lastShakeZ = 0f - private var accumShake = 0f + private var accumulateShake = 0f private var lastShakeTimestampMs = 0L private var currentFlipPosition: DevicePosition? = null @@ -93,9 +92,9 @@ internal class MotionService( private val flipListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { processFlip( - x = event.values[0], - y = event.values[1], - z = event.values[2], + x = -event.values[0], + y = -event.values[1], + z = -event.values[2], ) } @@ -187,7 +186,7 @@ internal class MotionService( lastShakeX = 0f lastShakeY = 0f lastShakeZ = 0f - accumShake = 0f + accumulateShake = 0f lastShakeTimestampMs = 0L } @@ -199,14 +198,11 @@ internal class MotionService( val dy = y - lastShakeY val dz = z - lastShakeZ val delta = sqrt(dx * dx + dy * dy + dz * dz) - accumShake = accumShake * SMOOTHING_FACTOR + delta - mindboxLogI("Accum shake is $accumShake") + accumulateShake = accumulateShake * SMOOTHING_FACTOR + delta val nowMs = System.currentTimeMillis() - if (accumShake > shakeAccelerationThreshold && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { - val detectedAccum = accumShake - accumShake = 0f + if (accumulateShake > shakeAccelerationThreshold && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { + accumulateShake = 0f lastShakeTimestampMs = nowMs - mindboxLogD("[WebView] Motion: shake detected (accum=$detectedAccum, threshold=$shakeAccelerationThreshold)") onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } @@ -224,7 +220,6 @@ internal class MotionService( if (from == null) return - mindboxLogD("[WebView] Motion: flip detected ${from.value} -> ${newPosition.value}") onGestureDetected?.invoke( MotionGesture.FLIP, mapOf("from" to from.value, "to" to newPosition.value), From 4b1f450cd134f3bc975b290b377b35a895e73b24 Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 11:07:26 +0300 Subject: [PATCH 4/8] MOBILE-39: remove stub for config --- .../mobile_sdk/managers/GatewayManager.kt | 128 +----------------- 1 file changed, 1 insertion(+), 127 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index a3528bbd..7a856bef 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -437,133 +437,7 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic configuration = configuration, jsonRequest = null, listener = { response -> - continuation.resume(""" -{ - "monitoring": { - "logs": [] - }, - "settings": { - "operations": { - "viewProduct": { - "systemName": "app.viewProduct" - }, - "viewCategory": { - "systemName": "app.viewCategory" - } - }, - "ttl": { - "inapps": "1.00:00:00" - }, - "slidingExpiration": { - "config": "01:00:01", - "pushTokenKeepalive": "14.00:00:00" - }, - "inapp": { - "maxInappsPerSession": 20, - "maxInappsPerDay": 100, - "minIntervalBetweenShows": "00:00:01" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": true - } - }, - "inapps": [ - { - "id": "322df172-4039-48f1-9cda-513c3213cc28", - "isPriority": true, - "delayTime": null, - "sdkVersion": { - "min": 12, - "max": null - }, - "frequency": { - "kind": "session", - "${'$'}type": "once" - }, - "targeting": { - "nodes": [ - { - "kind": "gte", - "value": 1, - "${'$'}type": "visit" - } - ], - "${'$'}type": "and" - }, - "form": { - "variants": [ - { - "content": { - "background": { - "layers": [ - { - "params": { - "formId": "143364" - }, - "baseUrl": "https://inapp.local/popup", - "contentUrl": "https://mobile-static.mindbox.ru/stable/inapps/webview/content/index.html", - "${'$'}type": "webview" - } - ] - }, - "elements": null - }, - "imageUrl": "", - "redirectUrl": "", - "intentPayload": "", - "${'$'}type": "modal" - } - ] - } - }, - { - "id": "322df172-4039-48f1-9cda-513c3213cc28", - "isPriority": true, - "sdkVersion": { - "min": 11, - "max": 11 - }, - "frequency": { - "kind": "session", - "${'$'}type": "once" - }, - "targeting": { - "nodes": [ - { - "kind": "gte", - "value": 1, - "${'$'}type": "visit" - } - ], - "${'$'}type": "and" - }, - "form": { - "variants": [ - { - "content": { - "background": { - "layers": [ - { - "params": { - "popUpId": "143364" - }, - "baseUrl": "https://inapp.local/popup", - "contentUrl": "https://mobile-static.mindbox.ru/stable/inapps/webview/content/index.html", - "${'$'}type": "webview", - "type": "webview" - } - ] - } - }, - "${'$'}type": "webview" - } - ] - } - } - ] -} - - """.trimIndent()) + continuation.resume(response.toString()) }, errorsListener = { error -> continuation.resumeWithException(error) From 0274725e8ad6a7c5a53d45b05ce71a817d811cbc Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 13:38:07 +0300 Subject: [PATCH 5/8] MOBILE-39: refactoring --- .../view/WebViewInappViewHolder.kt | 69 +++--- .../presentation/view/motion/MotionService.kt | 79 ++++--- .../view/MotionServiceBehaviorTest.kt | 218 ++++++++++++++++++ .../view/MotionServiceResolvePositionTest.kt | 8 +- 4 files changed, 310 insertions(+), 64 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt 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 f9610a59..fb0bd87f 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,8 @@ 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 @@ -39,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 @@ -67,6 +70,8 @@ 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 @@ -76,7 +81,11 @@ internal class WebViewInAppViewHolder( private var isMotionServiceInitialized = false private val motionService: MotionServiceProtocol by lazy { isMotionServiceInitialized = true - MotionService(context = appContext).also { service -> + MotionService( + context = appContext, + lifecycle = ProcessLifecycleOwner.get().lifecycle, + timeProvider = timeProvider, + ).also { service -> service.onGestureDetected = { gesture, data -> sendMotionEvent(gesture = gesture, data = data) } @@ -91,6 +100,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 } @@ -206,22 +216,20 @@ internal class WebViewInAppViewHolder( if (gestures.isEmpty()) { return buildMotionError("No valid gestures provided. Available: shake, flip") } - val result: MotionStartResult = motionService.startMonitoring(gestures) + val result = motionService.startMonitoring(gestures) if (result.allUnavailable) { return buildMotionError( "No sensors available for: ${result.unavailable.joinToString { it.value }}" ) } - return if (result.unavailable.isEmpty()) { - BridgeMessage.SUCCESS_PAYLOAD - } else { - val unavailableJson = result.unavailable.joinToString( - prefix = "[", - postfix = "]", - separator = "," - ) { "\"${it.value}\"" } - """{"success":true,"unavailable":$unavailableJson}""" - } + 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 { @@ -231,12 +239,15 @@ internal class WebViewInAppViewHolder( private fun sendMotionEvent(gesture: MotionGesture, data: Map) { val controller: WebViewController = webViewController ?: return - val payloadBuilder = StringBuilder("""{"gesture":"${gesture.value}"""") - data.forEach { (key, value) -> payloadBuilder.append(""","$key":"$value"""") } - payloadBuilder.append("}") + 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 = payloadBuilder.toString(), + payload = payload, ) sendActionInternal(controller, message) { error -> mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") @@ -244,21 +255,16 @@ internal class WebViewInAppViewHolder( } private fun parseMotionGestures(payload: String): Set { - return runCatching { - val array = JSONObject(payload).optJSONArray("gestures") - ?: return emptySet() - buildSet { - for (i in 0 until array.length()) { - val name = array.optString(i) ?: continue - val gesture = MotionGesture.entries.firstOrNull { it.value == name } - if (gesture != null) add(gesture) - } - } - }.getOrDefault(emptySet()) + return loggingRunCatching(defaultValue = emptySet()) { + val array = JSONObject(payload).optJSONArray(MOTION_GESTURES_KEY) + ?: return@loggingRunCatching emptySet() + (0 until array.length()) + .mapNotNull { i -> MotionGesture.entries.find { it.value == array.optString(i) } } + .toSet() + } } - private fun buildMotionError(message: String): String = - """{"error":"$message"}""" + private fun buildMotionError(message: String): String = gson.toJson(ErrorPayload(error = message)) private fun handleReadyAction( configuration: Configuration, @@ -788,6 +794,11 @@ internal class WebViewInAppViewHolder( val error: String ) + private data class MotionStartPayload( + val success: Boolean = true, + 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 index 6d324256..1df90229 100644 --- 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 @@ -6,9 +6,13 @@ 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 androidx.lifecycle.ProcessLifecycleOwner import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.utils.TimeProvider import kotlin.math.abs import kotlin.math.sqrt @@ -43,11 +47,13 @@ internal interface MotionServiceProtocol { 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 - const val COOLDOWN_MS = 800L + 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 @@ -57,8 +63,8 @@ internal class MotionService( override var onGestureDetected: ((gesture: MotionGesture, data: Map) -> Unit)? = null - private val sensorManager: SensorManager = - context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + 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 @@ -73,7 +79,7 @@ internal class MotionService( private var lastShakeY = 0f private var lastShakeZ = 0f private var accumulateShake = 0f - private var lastShakeTimestampMs = 0L + private var lastShakeTimestamp: Timestamp = Timestamp(0L) private var currentFlipPosition: DevicePosition? = null @@ -109,8 +115,10 @@ internal class MotionService( override fun startMonitoring(gestures: Set): MotionStartResult { stopMonitoring() - val unavailable = mutableSetOf() + if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { + unavailable.add(MotionGesture.SHAKE) + } if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { unavailable.add(MotionGesture.FLIP) } @@ -118,7 +126,6 @@ internal class MotionService( activeGestures = gestures - unavailable val result = MotionStartResult(started = activeGestures, unavailable = unavailable) if (activeGestures.isEmpty()) return result - addLifecycleObserver() startSensors() @@ -138,21 +145,21 @@ internal class MotionService( } private fun addLifecycleObserver() { - ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) + lifecycle.addObserver(lifecycleObserver) } private fun removeLifecycleObserver() { - ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) + lifecycle.removeObserver(lifecycleObserver) } - private fun suspend() { + internal fun suspend() { if (activeGestures.isEmpty()) return suspendedGestures = activeGestures stopSensors() mindboxLogI("[WebView] Motion: suspended (app in background)") } - private fun resume() { + internal fun resume() { val gestures = suspendedGestures ?: return suspendedGestures = null activeGestures = gestures @@ -162,22 +169,20 @@ internal class MotionService( private fun startSensors() { if (activeGestures.contains(MotionGesture.SHAKE)) { - val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - if (sensor != null) { + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { sensor -> sensorManager.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) } } if (activeGestures.contains(MotionGesture.FLIP)) { - val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) - if (sensor != null) { + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)?.let { sensor -> sensorManager.registerListener(flipListener, sensor, SensorManager.SENSOR_DELAY_NORMAL) } } } private fun stopSensors() { - sensorManager.unregisterListener(shakeListener) - sensorManager.unregisterListener(flipListener) + sensorManager?.unregisterListener(shakeListener) + sensorManager?.unregisterListener(flipListener) resetShakeState() currentFlipPosition = null } @@ -187,23 +192,27 @@ internal class MotionService( lastShakeY = 0f lastShakeZ = 0f accumulateShake = 0f - lastShakeTimestampMs = 0L + lastShakeTimestamp = Timestamp(0L) } + private fun isShakeAvailable(): Boolean = + sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + private fun isFlipAvailable(): Boolean = - sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) != null + sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY) != null - private fun processShake(x: Float, y: Float, z: Float) { + internal fun processShake(x: Float, y: Float, z: Float) { val dx = x - lastShakeX val dy = y - lastShakeY val dz = z - lastShakeZ val delta = sqrt(dx * dx + dy * dy + dz * dz) accumulateShake = accumulateShake * SMOOTHING_FACTOR + delta - val nowMs = System.currentTimeMillis() - if (accumulateShake > shakeAccelerationThreshold && nowMs - lastShakeTimestampMs > COOLDOWN_MS) { + val now: Timestamp = timeProvider.currentTimestamp() + val elapsed: Milliseconds = timeProvider.elapsedSince(lastShakeTimestamp) + if (accumulateShake > shakeAccelerationThreshold && elapsed.interval > SHAKE_COOLDOWN.interval) { accumulateShake = 0f - lastShakeTimestampMs = nowMs - onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) + lastShakeTimestamp = now + loggingRunCatching { onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } } lastShakeX = x @@ -220,10 +229,12 @@ internal class MotionService( if (from == null) return - onGestureDetected?.invoke( - MotionGesture.FLIP, - mapOf("from" to from.value, "to" to newPosition.value), - ) + loggingRunCatching { + onGestureDetected?.invoke( + MotionGesture.FLIP, + mapOf("from" to from.value, "to" to newPosition.value), + ) + } } internal fun resolvePosition( @@ -253,16 +264,16 @@ internal class MotionService( } } - var best: DevicePosition? = null - var bestMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH + var dominantPosition: DevicePosition? = null + var maxMagnitude = FLIP_ENTER_THRESHOLD_G * SensorManager.GRAVITY_EARTH for (axis in axes) { val magnitude = abs(axis.value) - if (magnitude > bestMagnitude) { - bestMagnitude = magnitude - best = if (axis.value > 0f) axis.positive else axis.negative + if (magnitude > maxMagnitude) { + maxMagnitude = magnitude + dominantPosition = if (axis.value > 0f) axis.positive else axis.negative } } - return best + return dominantPosition } } 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..33c0b183 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt @@ -0,0 +1,218 @@ +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.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(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(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(x = phoneThresholdG + 1f, y = 0f, z = 0f) + motionService.processShake(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(x = phoneThresholdG + 1f, y = 0f, z = 0f) + fakeTimeProvider.advanceBy(900L) + motionService.processShake(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(x = phoneThresholdG + 1f, y = 0f, z = 0f) + fakeTimeProvider.advanceBy(800L) + motionService.processShake(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(x = phoneThresholdG + 1f, y = 0f, z = 0f) + + assertTrue(capturedData != null && capturedData.isEmpty()) + } + + @Test + fun `processShake accumulates force across multiple frames`() { + var isDetected = false + motionService.onGestureDetected = { _, _ -> isDetected = true } + + val halfThreshold = phoneThresholdG / 2f + motionService.processShake(x = halfThreshold, y = 0f, z = 0f) + motionService.processShake(x = 0f, y = 0f, z = 0f) + motionService.processShake(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 index 97ae123c..864a86be 100644 --- 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 @@ -2,8 +2,10 @@ 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.utils.SystemTimeProvider import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals @@ -23,7 +25,11 @@ class MotionServiceResolvePositionTest { 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) + motionService = MotionService( + context = mockContext, + lifecycle = mockk(relaxed = true), + timeProvider = SystemTimeProvider(), + ) } @Test From 59cef75c564d134b8b27b8ea0c249e9350db6e4f Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 13:57:59 +0300 Subject: [PATCH 6/8] MOBILE-39: fix test --- .../inapp/presentation/view/MotionServiceBehaviorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 33c0b183..8a5a8a69 100644 --- 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 @@ -115,7 +115,7 @@ class MotionServiceShakeTest { motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) - assertTrue(capturedData != null && capturedData.isEmpty()) + assertTrue(capturedData != null && capturedData?.isEmpty() == true) } @Test From 083f14012370960e4feb2eda7631f1135c847972 Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 14:31:27 +0300 Subject: [PATCH 7/8] MOBILE-39: follow review --- .../presentation/view/WebViewInappViewHolder.kt | 14 ++++---------- .../presentation/view/motion/MotionService.kt | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) 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 fb0bd87f..d4a55211 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 @@ -211,16 +211,12 @@ internal class WebViewInAppViewHolder( } private fun handleMotionStartAction(message: BridgeMessage.Request): String { - val payload = message.payload ?: return buildMotionError("Missing payload") + val payload = requireNotNull(message.payload) { "Missing payload" } val gestures = parseMotionGestures(payload) - if (gestures.isEmpty()) { - return buildMotionError("No valid gestures provided. Available: shake, flip") - } + require(gestures.isNotEmpty()) { "No valid gestures provided. Available: shake, flip" } val result = motionService.startMonitoring(gestures) - if (result.allUnavailable) { - return buildMotionError( - "No sensors available for: ${result.unavailable.joinToString { it.value }}" - ) + require(!result.allUnavailable) { + "No sensors available for: ${result.unavailable.joinToString { it.value }}" } return buildMotionStartPayload(result) } @@ -264,8 +260,6 @@ internal class WebViewInAppViewHolder( } } - private fun buildMotionError(message: String): String = gson.toJson(ErrorPayload(error = message)) - private fun handleReadyAction( configuration: Configuration, insets: InAppInsets, 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 index 1df90229..ea9089e6 100644 --- 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 @@ -114,7 +114,7 @@ internal class MotionService( } override fun startMonitoring(gestures: Set): MotionStartResult { - stopMonitoring() + if (activeGestures.isNotEmpty()) stopMonitoring() val unavailable = mutableSetOf() if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { unavailable.add(MotionGesture.SHAKE) From 21751be1210af0e029bcc9d6648b6db8a7469866 Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 27 Mar 2026 17:35:47 +0300 Subject: [PATCH 8/8] MOBILE-39: follow review --- .../view/WebViewInappViewHolder.kt | 39 ++++---- .../presentation/view/motion/MotionService.kt | 90 +++++++++---------- .../mindbox/mobile_sdk/models/Timestamp.kt | 4 + .../view/MotionServiceBehaviorTest.kt | 25 +++--- .../view/MotionServiceResolvePositionTest.kt | 85 +++++------------- 5 files changed, 101 insertions(+), 142 deletions(-) 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 d4a55211..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 @@ -78,19 +78,7 @@ internal class WebViewInAppViewHolder( private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null - private var isMotionServiceInitialized = false - private val motionService: MotionServiceProtocol by lazy { - isMotionServiceInitialized = true - MotionService( - context = appContext, - lifecycle = ProcessLifecycleOwner.get().lifecycle, - timeProvider = timeProvider, - ).also { service -> - service.onGestureDetected = { gesture, data -> - sendMotionEvent(gesture = gesture, data = data) - } - } - } + private var motionService: MotionServiceProtocol? = null private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { bindBackAction(currentRoot) { sendBackAction(controller) } @@ -214,7 +202,7 @@ internal class WebViewInAppViewHolder( val payload = requireNotNull(message.payload) { "Missing payload" } val gestures = parseMotionGestures(payload) require(gestures.isNotEmpty()) { "No valid gestures provided. Available: shake, flip" } - val result = motionService.startMonitoring(gestures) + val result = getOrCreateMotionService().startMonitoring(gestures) require(!result.allUnavailable) { "No sensors available for: ${result.unavailable.joinToString { it.value }}" } @@ -229,7 +217,7 @@ internal class WebViewInAppViewHolder( } private fun handleMotionStopAction(): String { - if (isMotionServiceInitialized) motionService.stopMonitoring() + motionService?.stopMonitoring() return BridgeMessage.SUCCESS_PAYLOAD } @@ -247,6 +235,7 @@ internal class WebViewInAppViewHolder( ) sendActionInternal(controller, message) { error -> mindboxLogW("[WebView] Motion: failed to send motion.event to JS: $error") + motionService?.stopMonitoring() } } @@ -255,7 +244,7 @@ internal class WebViewInAppViewHolder( val array = JSONObject(payload).optJSONArray(MOTION_GESTURES_KEY) ?: return@loggingRunCatching emptySet() (0 until array.length()) - .mapNotNull { i -> MotionGesture.entries.find { it.value == array.optString(i) } } + .mapNotNull { i -> array.optString(i).enumValue() } .toSet() } } @@ -327,7 +316,7 @@ internal class WebViewInAppViewHolder( } private fun handleCloseAction(message: BridgeMessage): String { - if (isMotionServiceInitialized) motionService.stopMonitoring() + motionService?.stopMonitoring() inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -765,7 +754,7 @@ internal class WebViewInAppViewHolder( override fun onClose() { hapticFeedbackExecutor.cancel() - if (isMotionServiceInitialized) motionService.stopMonitoring() + motionService?.stopMonitoring() stopTimer() cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> @@ -780,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 ) @@ -789,7 +790,9 @@ internal class WebViewInAppViewHolder( ) private data class MotionStartPayload( + @SerializedName("success") val success: Boolean = true, + @SerializedName("unavailable") val unavailable: List? = null, ) 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 index ea9089e6..757520fb 100644 --- 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 @@ -10,11 +10,11 @@ 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.utils.loggingRunCatching 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.sqrt +import kotlin.math.hypot internal enum class MotionGesture(val value: String) { SHAKE("shake"), @@ -30,6 +30,16 @@ internal enum class DevicePosition(val value: String) { 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, @@ -75,21 +85,15 @@ internal class MotionService( private var activeGestures: Set = emptySet() private var suspendedGestures: Set? = null - private var lastShakeX = 0f - private var lastShakeY = 0f - private var lastShakeZ = 0f + private var lastShakeVector: MotionVector = MotionVector.ZERO private var accumulateShake = 0f - private var lastShakeTimestamp: Timestamp = Timestamp(0L) + private var lastShakeTimestamp: Timestamp = Timestamp.ZERO private var currentFlipPosition: DevicePosition? = null private val shakeListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { - processShake( - x = event.values[0], - y = event.values[1], - z = event.values[2], - ) + processShake(MotionVector(event.values[0], event.values[1], event.values[2])) } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit @@ -97,11 +101,7 @@ internal class MotionService( private val flipListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { - processFlip( - x = -event.values[0], - y = -event.values[1], - z = -event.values[2], - ) + processFlip(MotionVector(-event.values[0], -event.values[1], -event.values[2])) } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit @@ -115,12 +115,13 @@ internal class MotionService( override fun startMonitoring(gestures: Set): MotionStartResult { if (activeGestures.isNotEmpty()) stopMonitoring() - val unavailable = mutableSetOf() - if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { - unavailable.add(MotionGesture.SHAKE) - } - if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { - unavailable.add(MotionGesture.FLIP) + val unavailable = buildSet { + if (gestures.contains(MotionGesture.SHAKE) && !isShakeAvailable()) { + add(MotionGesture.SHAKE) + } + if (gestures.contains(MotionGesture.FLIP) && !isFlipAvailable()) { + add(MotionGesture.FLIP) + } } activeGestures = gestures - unavailable @@ -129,19 +130,20 @@ internal class MotionService( addLifecycleObserver() startSensors() - mindboxLogI("[WebView] Motion: monitoring started for ${activeGestures.map { it.value }}") + mindboxLogI("Motion: monitoring started for ${activeGestures.map { it.value }}") if (unavailable.isNotEmpty()) { - mindboxLogI("[WebView] Motion: unavailable gestures: ${unavailable.map { it.value }}") + 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("[WebView] Motion: monitoring stopped") + mindboxLogI("Motion: monitoring stopped") } private fun addLifecycleObserver() { @@ -156,7 +158,7 @@ internal class MotionService( if (activeGestures.isEmpty()) return suspendedGestures = activeGestures stopSensors() - mindboxLogI("[WebView] Motion: suspended (app in background)") + mindboxLogI("Motion: suspended (app in background)") } internal fun resume() { @@ -164,7 +166,7 @@ internal class MotionService( suspendedGestures = null activeGestures = gestures startSensors() - mindboxLogI("[WebView] Motion: resumed (app in foreground)") + mindboxLogI("Motion: resumed (app in foreground)") } private fun startSensors() { @@ -188,11 +190,9 @@ internal class MotionService( } private fun resetShakeState() { - lastShakeX = 0f - lastShakeY = 0f - lastShakeZ = 0f + lastShakeVector = MotionVector.ZERO accumulateShake = 0f - lastShakeTimestamp = Timestamp(0L) + lastShakeTimestamp = Timestamp.ZERO } private fun isShakeAvailable(): Boolean = @@ -201,11 +201,8 @@ internal class MotionService( private fun isFlipAvailable(): Boolean = sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY) != null - internal fun processShake(x: Float, y: Float, z: Float) { - val dx = x - lastShakeX - val dy = y - lastShakeY - val dz = z - lastShakeZ - val delta = sqrt(dx * dx + dy * dy + dz * dz) + 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) @@ -214,14 +211,11 @@ internal class MotionService( lastShakeTimestamp = now loggingRunCatching { onGestureDetected?.invoke(MotionGesture.SHAKE, emptyMap()) } } - - lastShakeX = x - lastShakeY = y - lastShakeZ = z + lastShakeVector = vector } - private fun processFlip(x: Float, y: Float, z: Float) { - val newPosition = resolvePosition(x = x, y = y, z = z, current = currentFlipPosition) + private fun processFlip(vector: MotionVector) { + val newPosition = resolvePosition(vector = vector, current = currentFlipPosition) if (newPosition == null || newPosition == currentFlipPosition) return val from = currentFlipPosition @@ -238,9 +232,7 @@ internal class MotionService( } internal fun resolvePosition( - x: Float, - y: Float, - z: Float, + vector: MotionVector, current: DevicePosition?, ): DevicePosition? { data class Axis( @@ -250,13 +242,13 @@ internal class MotionService( ) val axes = listOf( - Axis(z, DevicePosition.FACE_UP, DevicePosition.FACE_DOWN), - Axis(y, DevicePosition.PORTRAIT, DevicePosition.PORTRAIT_UPSIDE_DOWN), - Axis(x, DevicePosition.LANDSCAPE_LEFT, DevicePosition.LANDSCAPE_RIGHT), + 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) { - for (axis in axes) { + 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 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 index 8a5a8a69..b2371cbc 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -58,7 +59,7 @@ class MotionServiceShakeTest { var isDetected = false motionService.onGestureDetected = { gesture, _ -> isDetected = gesture == MotionGesture.SHAKE } - motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) assertTrue(isDetected) } @@ -68,7 +69,7 @@ class MotionServiceShakeTest { var isDetected = false motionService.onGestureDetected = { _, _ -> isDetected = true } - motionService.processShake(x = phoneThresholdG - 1f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG - 1f, y = 0f, z = 0f)) assertFalse(isDetected) } @@ -78,8 +79,8 @@ class MotionServiceShakeTest { var detectedCount = 0 motionService.onGestureDetected = { _, _ -> detectedCount++ } - motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) - motionService.processShake(x = 0f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) assertEquals(1, detectedCount) } @@ -89,9 +90,9 @@ class MotionServiceShakeTest { var detectedCount = 0 motionService.onGestureDetected = { _, _ -> detectedCount++ } - motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) fakeTimeProvider.advanceBy(900L) - motionService.processShake(x = 0f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) assertEquals(2, detectedCount) } @@ -101,9 +102,9 @@ class MotionServiceShakeTest { var detectedCount = 0 motionService.onGestureDetected = { _, _ -> detectedCount++ } - motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) fakeTimeProvider.advanceBy(800L) - motionService.processShake(x = 0f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = 0f, y = 0f, z = 0f)) assertEquals(1, detectedCount) } @@ -113,7 +114,7 @@ class MotionServiceShakeTest { var capturedData: Map? = null motionService.onGestureDetected = { _, data -> capturedData = data } - motionService.processShake(x = phoneThresholdG + 1f, y = 0f, z = 0f) + motionService.processShake(MotionVector(x = phoneThresholdG + 1f, y = 0f, z = 0f)) assertTrue(capturedData != null && capturedData?.isEmpty() == true) } @@ -124,9 +125,9 @@ class MotionServiceShakeTest { motionService.onGestureDetected = { _, _ -> isDetected = true } val halfThreshold = phoneThresholdG / 2f - motionService.processShake(x = halfThreshold, y = 0f, z = 0f) - motionService.processShake(x = 0f, y = 0f, z = 0f) - motionService.processShake(x = halfThreshold, y = 0f, z = 0f) + 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) } 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 index 864a86be..cefd974c 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -36,9 +37,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns faceUp when z is strongly negative and no current position`() { val inputZ = -enterThreshold - 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = null, ) assertEquals(DevicePosition.FACE_UP, actualPosition) @@ -48,9 +47,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns faceDown when z is strongly positive and no current position`() { val inputZ = enterThreshold + 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = null, ) assertEquals(DevicePosition.FACE_DOWN, actualPosition) @@ -60,9 +57,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns portrait when y is strongly negative and no current position`() { val inputY = -enterThreshold - 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = 0f, + vector = MotionVector(x = 0f, y = inputY, z = 0f), current = null, ) assertEquals(DevicePosition.PORTRAIT, actualPosition) @@ -72,9 +67,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns portraitUpsideDown when y is strongly positive and no current position`() { val inputY = enterThreshold + 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = 0f, + vector = MotionVector(x = 0f, y = inputY, z = 0f), current = null, ) assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) @@ -84,9 +77,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns landscapeLeft when x is strongly negative and no current position`() { val inputX = -enterThreshold - 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = inputX, - y = 0f, - z = 0f, + vector = MotionVector(x = inputX, y = 0f, z = 0f), current = null, ) assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) @@ -96,9 +87,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns landscapeRight when x is strongly positive and no current position`() { val inputX = enterThreshold + 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = inputX, - y = 0f, - z = 0f, + vector = MotionVector(x = inputX, y = 0f, z = 0f), current = null, ) assertEquals(DevicePosition.LANDSCAPE_RIGHT, actualPosition) @@ -108,9 +97,7 @@ class MotionServiceResolvePositionTest { 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( - x = 0f, - y = 0f, - z = -inputValue, + vector = MotionVector(x = 0f, y = 0f, z = -inputValue), current = null, ) assertNull(actualPosition) @@ -119,9 +106,7 @@ class MotionServiceResolvePositionTest { @Test fun `resolvePosition returns null when all axes are zero and no current position`() { val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = 0f, + vector = MotionVector(x = 0f, y = 0f, z = 0f), current = null, ) assertNull(actualPosition) @@ -132,9 +117,7 @@ class MotionServiceResolvePositionTest { val inputZ = -(exitThreshold + 0.1f) val inputCurrentPosition = DevicePosition.FACE_UP val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = inputCurrentPosition, ) assertEquals(DevicePosition.FACE_UP, actualPosition) @@ -145,9 +128,7 @@ class MotionServiceResolvePositionTest { val inputY = -(exitThreshold + 0.1f) val inputCurrentPosition = DevicePosition.PORTRAIT val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = 0f, + vector = MotionVector(x = 0f, y = inputY, z = 0f), current = inputCurrentPosition, ) assertEquals(DevicePosition.PORTRAIT, actualPosition) @@ -158,9 +139,7 @@ class MotionServiceResolvePositionTest { val inputX = -(exitThreshold + 0.1f) val inputCurrentPosition = DevicePosition.LANDSCAPE_LEFT val actualPosition: DevicePosition? = motionService.resolvePosition( - x = inputX, - y = 0f, - z = 0f, + vector = MotionVector(x = inputX, y = 0f, z = 0f), current = inputCurrentPosition, ) assertEquals(DevicePosition.LANDSCAPE_LEFT, actualPosition) @@ -172,9 +151,7 @@ class MotionServiceResolvePositionTest { val inputY = -(enterThreshold + 0.5f) val inputCurrentPosition = DevicePosition.FACE_UP val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = inputZ, + vector = MotionVector(x = 0f, y = inputY, z = inputZ), current = inputCurrentPosition, ) assertEquals(DevicePosition.PORTRAIT, actualPosition) @@ -185,9 +162,7 @@ class MotionServiceResolvePositionTest { val inputZ = -(exitThreshold - 0.1f) val inputCurrentPosition = DevicePosition.FACE_UP val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = inputCurrentPosition, ) assertNull(actualPosition) @@ -198,9 +173,7 @@ class MotionServiceResolvePositionTest { val inputZ = -(enterThreshold + 0.1f) val inputY = -(enterThreshold + 2.0f) val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = inputZ, + vector = MotionVector(x = 0f, y = inputY, z = inputZ), current = null, ) assertEquals(DevicePosition.PORTRAIT, actualPosition) @@ -211,9 +184,7 @@ class MotionServiceResolvePositionTest { val inputZ = -(enterThreshold + 1.0f) val inputY = -(enterThreshold + 0.1f) val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = inputZ, + vector = MotionVector(x = 0f, y = inputY, z = inputZ), current = null, ) assertEquals(DevicePosition.FACE_UP, actualPosition) @@ -223,9 +194,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition returns null when z is exactly at enter threshold`() { val inputZ = -enterThreshold val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = null, ) assertNull(actualPosition) @@ -235,9 +204,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition transitions from faceUp to faceDown when z flips to positive`() { val inputZ = enterThreshold + 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputZ, + vector = MotionVector(x = 0f, y = 0f, z = inputZ), current = DevicePosition.FACE_UP, ) assertEquals(DevicePosition.FACE_DOWN, actualPosition) @@ -247,9 +214,7 @@ class MotionServiceResolvePositionTest { fun `resolvePosition transitions from portrait to portraitUpsideDown when y flips to positive`() { val inputY = enterThreshold + 0.5f val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = 0f, + vector = MotionVector(x = 0f, y = inputY, z = 0f), current = DevicePosition.PORTRAIT, ) assertEquals(DevicePosition.PORTRAIT_UPSIDE_DOWN, actualPosition) @@ -259,17 +224,13 @@ class MotionServiceResolvePositionTest { fun `resolvePosition handles multi-step transition from portrait through faceUp to faceDown`() { val inputStrongZ = -(enterThreshold + 0.5f) val step1ActualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = inputStrongZ, + vector = MotionVector(x = 0f, y = 0f, z = inputStrongZ), current = DevicePosition.PORTRAIT, ) assertEquals(DevicePosition.FACE_UP, step1ActualPosition) val step2ActualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = 0f, - z = enterThreshold + 0.5f, + vector = MotionVector(x = 0f, y = 0f, z = enterThreshold + 0.5f), current = DevicePosition.FACE_UP, ) assertEquals(DevicePosition.FACE_DOWN, step2ActualPosition) @@ -280,9 +241,7 @@ class MotionServiceResolvePositionTest { val inputY = -(exitThreshold + 0.5f) val inputZ = -(enterThreshold - 1.0f) val actualPosition: DevicePosition? = motionService.resolvePosition( - x = 0f, - y = inputY, - z = inputZ, + vector = MotionVector(x = 0f, y = inputY, z = inputZ), current = DevicePosition.PORTRAIT, ) assertEquals(DevicePosition.PORTRAIT, actualPosition)