L'Application du confinement pour se déplacer

Posté le Saturday, 14 November 2020 in Programmation

Préambule: A cause du temps de validation du PlayStore, je publie cet article avec une semaine de retard.

Cela fait plus d'une semaine (quand j'écris ces lignes) que le re-confinement à commencé. Quand je vais courir, je dois me cantonner à 1km autour de chez moi. Mais quand je cours j'aimerais que mon téléphone intelligent me prévienne quand j'approche du rayon de 1km ou quand je le dépasse. Je ne souhaite pas avoir le nez sur une carte de mon téléphone.

Je regarde ce qui se fait. J'ai trouvé l'application suivante sur le play store : 1km. L'application m'avait l'air de répondre à mes critères mais ne fonctionnait pas lors de mon utilisation (en plus il y avait de la pub).

Une autre application 1km pourrait répondre à mon besoin mais je ne l'ai pas testé.

Beaucoup d'applications ont pour but de dessiner un cercle sur une carte.

J'ai finalement décidé d'écrire ma propre application (en plus elle sera open source).

Voici ce dont j'ai besoin:

  • une application open source
  • un gros bouton pour démarrer la surveillance (à partir de mon point de départ)
  • un avertissement quand on approche du rayon max des 1km.
  • un avertissement régulier qand on dépasse les 1km.

L'application

Pour développer une application Android je vais démarrer Android Studio et commencer à développer le 1er écran. Je choisis le language Java que je maîtrise plus que le language Kotlin et le SDK Minimum de Android 6.0 pour toucher 85% des utilisateurs (si l'application peut interesser d'autres personnes).

J'imagine l'application découpée en deux parties:

  • L'activité1 principale de l'application qui contiendra mon gros bouton, la position de départ, la position courante, et la distance à vol d'oiseau du point de départ.
  • Un service, dont le but est quand l'application est démarrée, de surveiller les déplacements et d'informer l'utilisateur.

Nous allons donc commencer par développer l'activité

L'activité

Je ne suis pas graphiste ni UI/UX designer. Le design de cette première interface va alors être très simple et très sobre. Un gros bouton + les différentes informations dont j'ai besoin quand l'application est démarrée :

Screenshoot page principale

Lors du démarrage de l'application, j'ai besoin que celle-ci écoute le changement, les positions de l'utilisateur afin de définir le point de départ. Comme ce service d'écoute me sera utile également pour le service, je développe une classe à coté.

ArroundLocationManager

Voici donc la classe ArroundLocationManager:

class ArroundLocationManager extends Thread {
    private static final String TAG = "ArroundLocationManager";

    private LocationManager mLocationManager = null;
    private static final int LOCATION_INTERVAL = 1000;
    private static final float LOCATION_DISTANCE = 50f;

Pour commencer définissons quelques constantes: je souhaite avoir la position, tous les 50m et au maximum toutes les secondes (m'enfin quelqu'un qui fait plus de 50m en une seconde à pied est trop fort pour moi).

    private List<ArroundLocationManager.ArrroundLocationListener> listener = new ArrayList<>();

    public interface ArrroundLocationListener {
        void updateLocation(Location startLocation);
    }

    private ArroundLocationManager.LocationListener[] mLocationListeners = new ArroundLocationManager.LocationListener[]{
            new ArroundLocationManager.LocationListener(LocationManager.GPS_PROVIDER),
            new ArroundLocationManager.LocationListener(LocationManager.NETWORK_PROVIDER)
    };

    public void addListener(ArroundLocationManager.ArrroundLocationListener l) {
        listener.add(l);
    }

    private void callListener(Location location) {
        for( ArroundLocationManager.ArrroundLocationListener l : listener) {
            l.updateLocation(location);
        }
    }

Viens ensuite la définition d'un listener pour que les applications qui s'abonnent à cette classe puissent bénéficier d'un listener et recevoir des notifications lors de la mise à jour des positions.

Comme on peut le constater je fais tourner cette classe dans un thread. Lors de mon développement je me suis rendu compte que lorsque je quittais l'application, le service était tué également. Une des raisons à cela est que le service est dans le même thread que l'activité principale. L'ajout de ce thread (ainsi que d'autre chose) ont résolu le problème. (Mais il est possible que ce soit plus lié aux autres choses qu'au thread lui même).

Voici le coeur du thread:

    public void run() {
        Looper.prepare();

        initializeLocationManager();

        Looper.loop();

        // Never called, loop is killed when activity or thread stopped
        finalizeLocationManager();
    }

    private ArroundLocationManager.LocationListener[] mLocationListeners = new ArroundLocationManager.LocationListener[]{
        new ArroundLocationManager.LocationListener(LocationManager.GPS_PROVIDER),
        new ArroundLocationManager.LocationListener(LocationManager.NETWORK_PROVIDER)
    };

    public void initializeLocationManager() {
        try {
            mLocationManager.requestLocationUpdates(
                    LocationManager.NETWORK_PROVIDER, LOCATION_INTERVAL, LOCATION_DISTANCE,
                    mLocationListeners[1]);
        } catch (java.lang.SecurityException ex) {
            Log.i(TAG, "fail to request location update, ignore", ex);
        } catch (IllegalArgumentException ex) {
            Log.d(TAG, "network provider does not exist, " + ex.getMessage());
        }

        try {
            mLocationManager.requestLocationUpdates(
                    LocationManager.GPS_PROVIDER, LOCATION_INTERVAL, LOCATION_DISTANCE,
                    mLocationListeners[0]);
        } catch (java.lang.SecurityException ex) {
            Log.i(TAG, "fail to request location update, ignore", ex);
        } catch (IllegalArgumentException ex) {
            Log.d(TAG, "gps provider does not exist " + ex.getMessage());
        }
    }

On utilise le service LocationManager d'android pour écouter la position de l'utilisateur. On écoute la position venant du Network qui permet d'avoir une position moins fiable mais rapide, puis celle venant du GPS permettant d'avoir une position fiable (mais lente à obtenir).

Afin d'avoir une position la plus précise aussi, je me suis inspiré du code suivant: Obtaining the Current Location. Le code en question permet de choisir entre deux positions la plus précise (entre la position NETWORK et la position GPS).

On notifie les appelants:

    private class LocationListener implements android.location.LocationListener {
        public LocationListener(String provider) {
            Log.e(TAG, "LocationListener " + provider);
            mLocation = new Location(provider);
        }

        @Override
        public void onLocationChanged(Location location) {
            Log.e(TAG, "onLocationChanged: " + location);
            if (isBetterLocation(location, mLocation)) {
                mLocation.set(location);
            }

            callListener(mLocation);
        }
        ...
    }

Enfin on a une méthode pour calculer les distances avec Android.:

    public static float getDistance(Location startLocation, Location lastLocation) {
        float[] results = new float[1];
        Location.distanceBetween(startLocation.getLatitude(), startLocation.getLongitude(), lastLocation.getLatitude(), lastLocation.getLongitude(), results);
        float distance = results[0];
        return distance;
    }

MainActivity

Retournons dans notre activité principale. Je passe la création du layout qui est fort simple.

Dans l'activité, nous allons commencer par implémenter la phase de création du cycle de vie de notre activité:

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

Depuis Android 6.0, il faut demander à l'utilisateur la permission d'utiliser la position de l'utilisateur. Du coup on commence par demander la permission à l'utilisateur d'avoir accès à sa position2.

        requestPermissionsIfNecessary(new String[]{
                Manifest.permission.ACCESS_FINE_LOCATION,
        });

Ensuite on appelle notre location manager et on écoute les changements de positions. Pour chaque changement de position on met à jour la position et on met à jour les textes.

        locationManager = new ArroundLocationManager(this);
        locationManager.addListener((location) -> {
            if (!isMyServiceRunning(ArroundService.class)) {
                startLocation = location;
            }
            MainActivity.this.updateTextLocation();
        });
        locationManager.start();

Enfin on initialise l'IHM.

        distanceText = findViewById(R.id.distance);
        startText = findViewById(R.id.start);
        locationText = findViewById(R.id.location);

        goButton = findViewById(R.id.goButton);
        stopButton = findViewById(R.id.stopButton);
        goButton.setOnClickListener(v -> {
            setStart(true);
        });
        stopButton.setOnClickListener(v -> {
            setStart(false);
        });
        setStart(isMyServiceRunning(ArroundService.class));
        MainActivity.this.updateTextLocation();
    }

Comme le LocationManager est un thread, nous devons faire attention à repasser dans le thread de l'UI afin de mettre à jour les labels :

    public void updateTextLocation() {
        runOnUiThread(() -> {
            if (this.startLocation != null && this.runLocation != null) {
                distanceText.setText(getString(R.string.distanceLabel, (int) ArroundLocationManager.getDistance(startLocation, runLocation)));
            } else {
                distanceText.setText("");
            }
            if (this.startLocation != null) {
                startText.setText(getAddress(startLocation));
            } else {
                startText.setText("");
            }
            if (this.runLocation != null) {
                locationText.setText(getAddress(runLocation));
            } else {
                locationText.setText("");
            }
        });
    }

Pour ma part je ne connais pas la position GPS de ma maison par coeur, ni de là ou je me trouve. Cela tombe bien. Android propose une API pour geocoder une adresse. C'est à dire que l'on transforme une position en latitude, longitude en adresse lisible:

    String getAddress(Location location) {
        Geocoder geocoder;
        List<Address> addresses;

        try {
            geocoder = new Geocoder(this, Locale.getDefault());

            addresses = geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1);

            if (addresses.size() > 0 && addresses.get(0).getMaxAddressLineIndex() >= 0) {
                return addresses.get(0).getAddressLine(0);
            }
            return "Unknown";
        } catch (IOException e) {
            return e.getMessage();
        }
    }

Le service

Le service est démarré par l'application et doit ensuite survivre à la fermeture de l'application. Pour cela nous allons créer un foreground service qui, contrairement aux services en tâche de fond qui sont déclenchés sur un évènement avec une durée de vie relativement courte, va tourner au premier plan en affichant une notification.

Pour démarrer le service depuis l'activité principale nous avons ajouté la méthode startServer:

    private void startServer() {
        if (!mBounded) {
            Intent mIntent = new Intent(this, ArroundService.class);
            mIntent.putExtra("startLocation", startLocation);
            ContextCompat.startForegroundService(this, mIntent);
            bindService(mIntent, mConnection, BIND_AUTO_CREATE);
        }
    }

Ce qui est important c'est la méthode startForegroundService dont le but est de démarrer le service en mode Foreground. Cette méthode a son pendant dans le service qui est startForeground. Si cette dernière n'est pas appelée dans le service une erreur sera remontée par Android.

Du coup on implémente le service et on commence par l'initialisation :

    @Override
    public void onCreate() {
        super.onCreate();
        notificationManager = new ArroundNotificationManager(this);
        locationManager = new ArroundLocationManager(this);
        locationManager.addListener(location -> {
            runLocation = location;
            callListener(location);
            notificationManager.send(getDistance());
            speakDistance();
        });
        locationManager.start();
        textToSpeech = new TextToSpeech(this, this);
    }

On démarre le notification manager qui a pour but de notifier l'utilisateur de l'existence d'un service qui tourne au 1er plan. La notification est d'ailleurs nécessaire pour un service foreground.

On écoute aussi notre ArroundLocationManager qui lors des modifications s'occupe de mettre à jour la nouvelle position et communique le changement de distance à l'utilisateur par voix et par notification.

On retrouve un callListener pour avertir l'utilisateur sur l'activité principale quand cette dernière est démarrée.

Ensuite le service démarre:

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.e(TAG, "onStartCommand");

        PowerManager powerService = (PowerManager) getSystemService(Context.POWER_SERVICE);
        wakeLock = powerService.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ArroundService::lock");
        wakeLock.acquire();

        Bundle extras = intent.getExtras();
        Location location = (Location) extras.get("startLocation");
        if (location != null) {
            this.startLocation = location;
        }

        Notification notification = notificationManager.notifyDistance(0);
        startForeground(ArroundNotificationManager.ARROUND_ID, notification);

        return START_STICKY;
    }

Afin qu'Android et son système de gestion d'nergie ne tuent pas notre service pendant que l'on court, nous commençons par mettre un PARTIAL_WAKE_LOCK. Ensuite nous récupérons la position (sauf si nous sommes issus d'un redémarrage de l'application) et appelons la méthode startForeground avec une notification persistante que nous avons créé.

Surtout après l'acquision du Wake Lock, il est important de le relâcher lors de la fermeture (quand l'utilisateur clique sur stop):

    public void stop() {
        if (wakeLock != null) {
            if (wakeLock.isHeld()) {
                wakeLock.release();
                wakeLock = null;
            }
        }
        stopForeground(true);
        stopSelf();
    }

On en profite pour arrêter la notification (avec ̀stopForeground) et arrêter le service (avec stopSelf).

Pour lire à l'utilisateur la distance, nous utilisons android.speech.tts.TextToSpeech. Son utilisation est fort simple et se fait lors de l'appel à speakDistance:

    private void speakDistance() {
        int distance = (int) getDistance();
        if (Math.abs(lastDistance - distance) > 100) {
            int stringId;
            if (distance > 1000) {
                stringId = R.string.speaker_meters_alert;
            } else if (distance > 900) {
                stringId = R.string.speaker_meters_warn;
            } else {
                stringId = R.string.speaker_meters_info;
            }

            String text = getString(stringId, distance);

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                textToSpeech.speak(text, TextToSpeech.QUEUE_FLUSH, null, null);
            } else {
                textToSpeech.speak(text, TextToSpeech.QUEUE_FLUSH, null);
            }

            lastDistance = distance;
        }
    }

Enfin le dernier point concerne la création des notifications. Depuis la version Android O, l'application doit associer une notification à un channel. Cela permet à Android de présenter à l'utilisateur une interface avec les notifications possibles et de pouvoir désactiver/activer ces dernières au cas par cas.

AndroidNotificationManager est là pour ce but. Il va créer le channel de notification et notifier la distance à l'utilisateur.

    private static final String CHANNEL_DEFAULT_IMPORTANCE = "Running";
    public static final int ARROUND_ID = 1;

    private void createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            CharSequence name = context.getString(R.string.channel_name);
            String description = context.getString(R.string.channel_description);
            NotificationChannel channel = new NotificationChannel(CHANNEL_DEFAULT_IMPORTANCE, name, NotificationManager.IMPORTANCE_DEFAULT);
            channel.setDescription(description);
            NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
            notificationManager.createNotificationChannel(channel);
        }
    }

    public Notification createNotification(float distance) {
        Intent notificationIntent = new Intent(context, MainActivity.class);
        PendingIntent pendingIntent =
                PendingIntent.getActivity(context, 0, notificationIntent, 0);

        return new NotificationCompat.Builder(context, CHANNEL_DEFAULT_IMPORTANCE)
                .setContentTitle(context.getText(R.string.notification_title))
                .setContentText(context.getString(R.string.notification_message, (int) distance))
                .setSmallIcon(android.R.drawable.ic_menu_mylocation)
                .setContentIntent(pendingIntent)
                .build();
    }

    public void notifyDistance(float distance) {
        Notification n = this.createNotification(distance);
        NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
        notificationManager.notify(ARROUND_ID, n);
    }

Il est important lors de l'appel à notifyDistance de toujours utiliser le même identifiant de notification afin que cette dernière soit remplacée (et non ajouté). Cela permet de mettre à jour le contenu de la notification.

Pour finir

Pour finir je suis content d'avoir développé cette application qui n'est pas exempte de bug, mais qui m'a pris très peu de temps de développement.

Vous pouvez retrouver le code source sur Github: phoenix741/1kmarround et sur le PlayStore (à ce jour l'application n'a pas encore été validé dans les stores et n'est donc pas disponible).

Le plus long dans ce développement aura été:

  • faire l'icône (à partir d'une image se trouvant sur undraw.co)
  • faire le layout (même si elle est simple)
  • remplir la fiche du playstore pour mettre l'application dans les stores.

  1. Une activité est l'équivalent d'un écran dans Android. 

  2. Pour demander les permissions je me suis basé sur le code suivant:

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        ArrayList<String> permissionsToRequest = new ArrayList<>();
        for (int i = 0; i < grantResults.length; i++) {
            permissionsToRequest.add(permissions[i]);
        }
        if (permissionsToRequest.size() > 0) {
            ActivityCompat.requestPermissions(this, permissionsToRequest.toArray(new String[0]), REQUEST_PERMISSIONS_REQUEST_CODE);
        }
    }
    
    private void requestPermissionsIfNecessary(String[] permissions) {
        ArrayList<String> permissionsToRequest = new ArrayList<>();
        for (String permission : permissions) {
            if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
                // Permission is not granted
                permissionsToRequest.add(permission);
            }
        }
        if (permissionsToRequest.size() > 0) {
            ActivityCompat.requestPermissions(this, permissionsToRequest.toArray(new String[0]), REQUEST_PERMISSIONS_REQUEST_CODE);
        }
    }