Ludovic ROLAND

Blog technique sur mes expériences de développeur.

Android : Détecter quand le téléphone est retourné

6 septembre 2022

Il y a quelques jours, en faisant un peu de ménage, je suis retombé sur mon Instant Lab. Pour ceux qui ne connaissent pas, il s’agit d’un petit appareil édité par l’entreprise Impossible et dont l’objectif était de pouvoir tirer des Polaroids à l’aide d’une application mobile dédiée en plaçant le téléphone sur l’appareil.

Victime de son succès, l’entreprise Impossible a été rachetée par l’entreprise Polaroid qui a arrêté de produire l’Instant Lab et arrêté le développement de l’application associée pour produire son propre appareil et sa propre application. Si l’application Impossible est toujours disponible sur le Google Play Store, force est de constater que malheureusement, elle ne semble pas fonctionner correctement.

En effet, si l’application supporte officiellement plusieurs terminaux Android dont le Google Nexus 5, lors de mes derniers tests, celle-ci crash systèmatiquement sur ce terminal, que ça soit sous Android 5 ou 6. Sur des terminaux plus récents comme le Google Pixel 2 ou le dernier Google Pixel 6, si l’application tourne, il m’a été impossible d’aller jusqu’à l’impression d’un Polaroid.

Afin de pouvoir continuer à utiliser mon Instant Lab avec un terminal Android récent, je me suis lancé pour objectif de redévelopper la fonctionnalité “Instant Lab” de l’application Impossible. Mon avancement sur ce projet et les différents défis techniques qui l’accompagnent donneront lieu à différents billets de blog.

Le premier de ces billets concernent la détection du retournement du téléphone (facedown pour les anglais). En effet, afin de lancer le processus d’exposition de la photo à l’Instant Lab, il convient de détecter quand le téléphone est retourné afin de commencer à tracker les points de contact avec l’Instant Lab (ce qui devrait donner naissance à un autre billet de blog). Mais avant tout, il est nécessaire de détecter quand le téléphone est retourné et comment faire remonter l’information jusqu’à l’Activitypour adapter l’interface graphique.

Détecter le retournement du téléphone

Pour détecter la position du téléphone et donc comprendre s’il est retourné ou non, il convient d’utiliser ce que l’on appelle les sensors et plus précisément dans notre cas l’accéléromètre. Pour ce faire, un Context est nécessaire :

val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)

Sur la variable sensor, il convient alors de brancher un listener afin de pouvour écouter les mouvements du téléphone et en déduire sa position. Pour se faoire, il convient donc d’utiliser l’interface SensorEventListener :

val callback = object: SensorEventListener
{

  override fun onSensorChanged(event: SensorEvent?)
  {
  }

  override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int)
  {
  }

}

sensorManager.registerListener(callback, sensor, SensorManager.SENSOR_DELAY_NORMAL)

Dans notre cas, c’est la méthode onSensorChanged que nous allons exploiter afin de determiner la position du terminal dans l’espace. Cette méthode fournit en argument une variable de la classe SensorEvent. C’est cet argument qui va nous permettre de récupérer la position du terminal dans l’espace et interpréter sa position.

Avant d’aller plus loin, il est important de noter que dans un espace 3D, un terminal peut bouger selon 3 axes : X, Y et Z.

Comme vous pouvez le constater sur l’image :

  • L’axe X est l’axe horizontal et pointe vers la droite ;
  • l’axe Y est l’axe vertical et pointe vers le haut ;
  • l’axe Z pointe vers l’extérieur de la face avant de l’écran.

Pour notre cas d’usage, c’est donc l’axe Z qui nous intéresse. Quand le téléphone bouge, les valeurs X, Y et Z sont disponibles dans un tableau values sur la variable event. La valeur X se trouvant dans la case dont l’indice est 0, la valeur Y dans la case dont l’indice est 1 et la valeur Z dans la case dont l’indice est Z.

La première étape consiste donc à récupérer le contenu de la case ayant pour indice 2 :

override fun onSensorChanged(event: SensorEvent?)
{
  event?.values?.get(2)?.let {
    //TODO
  }
}

Il convient alors de vérifier la valeur obtenue pour en déduire si le téléphone est retournée ou pas. Pour savoir à quelle valeur comparer la donnée obtenue, il est possible de lire la documentation ou de jouer un peu avec un emulateur :

Dans la capture d’écran, il est possible de voir que lorsque le téléphone est retournée, la valeur Z liée à l’accéléromètre est visiblement comprise entre -10 et -9. A noter que lorsque le téléphone est parfaitement à plat la valeur de Z est de -9,81.

Pour mon cas d’usage, je souhaite lancer le mode d’exposition dès que le device commence à avoir la tête en bas. Aussi, je peux me donner de la latitude :

override fun onSensorChanged(event: SensorEvent?)
{
  event?.values?.get(2)?.let {
    if (it < -7)
    {
      //facedown
    }
    else
    {
      //faceup
    }
  }
}

Ce code pourrait bien évidemment être écrit au sein d’une Activité, mais dans notre cas, nous allons mettre en place quelque chose d’un peu plus moderne. Nous allons écrire le code dans une classe dédiée qui aura pour objectif d’emettre des Flow avec la position du terminal. Il sera alors possible de collecter l’information et obtenir quelque chose de dynamique.

Commençons par écrire un peu de code dont le but est de pouvoir exposer les positions du terminal qui nous intéressent :

sealed class DeviceOrientation
{

  object FaceDown : DeviceOrientation()

  object Other : DeviceOrientation()

}

Maintenant, passons à l’écriture de notre sensor. Pour permettre à notre classe d’émettre des Flow à travers notre callback, nous allons utiliser la fonction callbackFlow.

On obtient donc :

class InstantLabSensorManager
{

  private val context: Context by inject()

  fun trackDeviceOrientation(context: Context) = callbackFlow {
    val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
    val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)

    val callback = object: SensorEventListener
    {

      override fun onSensorChanged(event: SensorEvent?)
      {
        event?.values?.get(2)?.let {
          if (it < -7)
          {
            trySend(DeviceOrientation.FaceDown)
          }
          else
          {
            trySend(DeviceOrientation.Other)
          }
        }
      }

      override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int)
      {
      }

    }

    sensorManager.registerListener(callback, sensor, SensorManager.SENSOR_DELAY_NORMAL)

    awaitClose {
      Timber.d("Close the sensor manager")
      sensorManager.unregisterListener(callback)
    }

  }

}

Tracker les changements d’orientation du téléphone

Maintenant que l’on est capable de détecter les changements d’orientation du téléphone, il est possible de les collecter au sein d’un ViewModel pour ensuite exposer à l’UI.

Dans mon cas, il y a une logique métier “complexe” autour des différentes étapes de l’exposition de la photo à l’Instant Lab. Aussi, je ne peux pas faire passe plat et remonter l’information directement à l’UI. Je suis obligé d’associer l’orientation du téléphone à une étape de l’exposition.

Je suis donc d’abord obligé de définir les différentes étapes de mon processus :

sealed class ExposureStep
{

  object FaceUp : ExposureStep()

  class FaceDown(var isWaitingForExposure: Boolean = false, var pointersCoordinates: PointersCoordinates)
    : ExposureStep()

  class Exposing(val pointersCoordinates: PointersCoordinates)
    : ExposureStep()

  object WaitingForExposureEnd : ExposureStep()

  object Completed : ExposureStep()

}

où la classe PointersCoordinates est une classe qui me permet dans l’étape d’après de stocker les points de contact avec l’Instant Lab. Nous aurons tout le loisir d’y revenir dans un billet de blog futur :

data class PointersCoordinates(val x1: Float,
                               val y1: Float,
                               val x2: Float,
                               val y2: Float,
                               val x3: Float,
                               val y3: Float)

Nous allons donc créer un ViewModel dans lequel nous allons stocker l’étape en cours de l’exposition et tracker la position du téléphone :

class ExposerActivityViewModel(application: Application, savedStateHandle: SavedStateHandle)
  : AndroidViewModel(application, savedStateHandle)
{

  val currentExposureStep = MutableStateFlow<ExposureStep>(ExposureStep.FaceUp)

  private val sensorManager = InstantLabSensorManager()

  init
  {
    startDeviceOrientationTracking()
  }

  private fun startDeviceOrientationTracking()
  {
    viewModelScope.launch {
      sensorManager.trackDeviceOrientation(getApplication()).flowOn(Dispatchers.IO).collect {
        when (it)
        {
          DeviceOrientation.FaceDown ->
          {
            if (currentExposureStep.value == ExposureStep.FaceUp)
            {
              currentExposureStep.value = ExposureStep.FaceDown(false, PointersCoordinates(0f, 0f, 0f, 0f, 0f, 0f))
            }
          }
          DeviceOrientation.Other    ->
          {
            if(currentExposureStep.value != ExposureStep.Completed)
            {
              Timber.d("startDeviceOrientationTracking is cancelling all jobs")

              currentExposureStep.value = ExposureStep.FaceUp
            }
          }
        }
      }
    }
  }
}

Mettre à jour l’interface graphique

Maintenant que le ViewModel est en place, il ne reste plus qu’à faire remonter l’information à la vue. Dans mon cas, j’ai fait le choix de Jetpack Compose.

Aussi, sans forcément tout écrire, voici ce que donne le code du Composable :

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
override fun Content()
{
  val currentExposureState = viewModel?.currentExposureStep?.collectAsStateWithLifecycle()

  Timber.d("current exposure state: $currentExposureState")

  when (currentExposureState?.value)
  {
    ExposureStep.FaceUp                -> FaceUp()
    is ExposureStep.FaceDown           -> FaceDown()
    is ExposureStep.Exposing           -> Exposing((viewModel?.currentExposureStep?.value as? ExposureStep.Exposing)?.pointersCoordinates ?: PointersCoordinates(0f, 0f, 0f, 0f, 0f, 0f))
    ExposureStep.WaitingForExposureEnd -> WaitingForExposureEnd()
    ExposureStep.Completed             -> Completed()
    null                               -> TODO()
  }
}

Commentaires