Coverage Summary for Class: DevicePosition (cloud.mindbox.mobile_sdk.inapp.presentation.view.motion)

Class Class, % Method, % Branch, % Line, % Instruction, %
DevicePosition 100% (1/1) 100% (2/2) 100% (7/7) 100% (57/57)


 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<MotionGesture>,
     val unavailable: Set<MotionGesture>,
 ) {
     val allUnavailable: Boolean get() = started.isEmpty() && unavailable.isNotEmpty()
 }
 
 internal interface MotionServiceProtocol {
     var onGestureDetected: ((gesture: MotionGesture, data: Map<String, String>) -> Unit)?
 
     fun startMonitoring(gestures: Set<MotionGesture>): 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<String, String>) -> 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<MotionGesture> = emptySet()
     private var suspendedGestures: Set<MotionGesture>? = 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<MotionGesture>): 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
     }
 }