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 ouquand 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 maisje 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 lelanguage Java que je maîtrise plus que le language Kotlin et le SDK Minimum de Android 6.0 pour toucher 85% desutilisateurs (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. Ungros bouton + les différentes informations dont j'ai besoin quand l'application est démarrée :

Lors du démarrage de l'application, j'ai besoin que celle-ci écoute le changement, les positions de l'utilisateur afin dedé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 :
classArroundLocationManagerextendsThread{privatestaticfinalStringTAG="ArroundLocationManager";privateLocationManagermLocationManager=null;privatestaticfinalintLOCATION_INTERVAL=1000;privatestaticfinalfloatLOCATION_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).
privateList<ArroundLocationManager.ArrroundLocationListener>listener=newArrayList<>();publicinterfaceArrroundLocationListener{voidupdateLocation(LocationstartLocation);}privateArroundLocationManager.LocationListener[]mLocationListeners=newArroundLocationManager.LocationListener[]{newArroundLocationManager.LocationListener(LocationManager.GPS_PROVIDER),newArroundLocationManager.LocationListener(LocationManager.NETWORK_PROVIDER)};publicvoidaddListener(ArroundLocationManager.ArrroundLocationListenerl){listener.add(l);}privatevoidcallListener(Locationlocation){for(ArroundLocationManager.ArrroundLocationListenerl: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'unlistener 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 quelorsque je quittais l'application, le service était tué également. Une des raisons à cela est que le service est dans le mêmethread que l'activité principale. L'ajout de ce thread (ainsi que d'autre chose) ont résolu le problème. (Mais il est possibleque ce soit plus lié aux autres choses qu'au thread lui même).
Voici le coeur du thread:
publicvoidrun(){Looper.prepare();initializeLocationManager();Looper.loop();// Never called, loop is killed when activity or thread stoppedfinalizeLocationManager();}privateArroundLocationManager.LocationListener[]mLocationListeners=newArroundLocationManager.LocationListener[]{newArroundLocationManager.LocationListener(LocationManager.GPS_PROVIDER),newArroundLocationManager.LocationListener(LocationManager.NETWORK_PROVIDER)};publicvoidinitializeLocationManager(){try{mLocationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER,LOCATION_INTERVAL,LOCATION_DISTANCE,mLocationListeners[1]);}catch(java.lang.SecurityExceptionex){Log.i(TAG,"fail to request location update, ignore",ex);}catch(IllegalArgumentExceptionex){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.SecurityExceptionex){Log.i(TAG,"fail to request location update, ignore",ex);}catch(IllegalArgumentExceptionex){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 venantdu Network qui permet d'avoir une position moins fiable mais rapide, puis celle venant du GPS permettant d'avoir uneposition 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:
privateclassLocationListenerimplementsandroid.location.LocationListener{publicLocationListener(Stringprovider){Log.e(TAG,"LocationListener "+provider);mLocation=newLocation(provider);}@OverridepublicvoidonLocationChanged(Locationlocation){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.:
publicstaticfloatgetDistance(LocationstartLocation,LocationlastLocation){float[]results=newfloat[1];Location.distanceBetween(startLocation.getLatitude(),startLocation.getLongitude(),lastLocation.getLatitude(),lastLocation.getLongitude(),results);floatdistance=results[0];returndistance;}
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é:
@OverridepublicvoidonCreate(BundlesavedInstanceState){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 coupon commence par demander la permission à l'utilisateur d'avoir accès à sa position 2 .
requestPermissionsIfNecessary(newString[]{Manifest.permission.ACCESS_FINE_LOCATION,});
Ensuite on appelle notre location manager et on écoute les changements de positions. Pour chaque changement de positionon met à jour la position et on met à jour les textes.
locationManager=newArroundLocationManager(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 :
publicvoidupdateTextLocation(){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. Androidpropose une API pour geocoder une adresse. C'est à dire que l'on transforme une position en latitude, longitude en adresselisible:
StringgetAddress(Locationlocation){Geocodergeocoder;List<Address>addresses;try{geocoder=newGeocoder(this,Locale.getDefault());addresses=geocoder.getFromLocation(location.getLatitude(),location.getLongitude(),1);if(addresses.size()>0&&addresses.get(0).getMaxAddressLineIndex()>=0){returnaddresses.get(0).getAddressLine(0);}return"Unknown";}catch(IOExceptione){returne.getMessage();}}
Le service
Le service est démarré par l'application et doit ensuite survivre à la fermeture de l'application. Pour cela nousallons créer un foreground service qui, contrairement aux services en tâche de fond qui sont déclenchés sur un évènement avecune 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
:
privatevoidstartServer(){if(!mBounded){IntentmIntent=newIntent(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 serviceune erreur sera remontée par Android.
Du coup on implémente le service et on commence par l'initialisation :
@OverridepublicvoidonCreate(){super.onCreate();notificationManager=newArroundNotificationManager(this);locationManager=newArroundLocationManager(this);locationManager.addListener(location->{runLocation=location;callListener(location);notificationManager.send(getDistance());speakDistance();});locationManager.start();textToSpeech=newTextToSpeech(this,this);}
On démarre le notification manager qui a pour but de notifier l'utilisateur de l'existence d'un service qui tourne au1er 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 positionet 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:
@OverridepublicintonStartCommand(Intentintent,intflags,intstartId){Log.e(TAG,"onStartCommand");PowerManagerpowerService=(PowerManager)getSystemService(Context.POWER_SERVICE);wakeLock=powerService.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,"ArroundService::lock");wakeLock.acquire();Bundleextras=intent.getExtras();Locationlocation=(Location)extras.get("startLocation");if(location!=null){this.startLocation=location;}Notificationnotification=notificationManager.notifyDistance(0);startForeground(ArroundNotificationManager.ARROUND_ID,notification);returnSTART_STICKY;}
Afin qu'Android et son système de gestion d'nergie ne tuent pas notre service pendant que l'on court, nous commençonspar mettre un PARTIAL_WAKE_LOCK. Ensuite nous récupérons la position (sauf si nous sommes issus d'un redémarrage del'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'utilisateurclique sur stop):
publicvoidstop(){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 utilisationest fort simple et se fait lors de l'appel à speakDistance
:
privatevoidspeakDistance(){intdistance=(int)getDistance();if(Math.abs(lastDistance-distance)>100){intstringId;if(distance>1000){stringId=R.string.speaker_meters_alert;}elseif(distance>900){stringId=R.string.speaker_meters_warn;}else{stringId=R.string.speaker_meters_info;}Stringtext=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 doitassocier une notification à un channel. Cela permet à Android de présenter à l'utilisateur une interface avec lesnotifications 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.
privatestaticfinalStringCHANNEL_DEFAULT_IMPORTANCE="Running";publicstaticfinalintARROUND_ID=1;privatevoidcreateNotificationChannel(){if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){CharSequencename=context.getString(R.string.channel_name);Stringdescription=context.getString(R.string.channel_description);NotificationChannelchannel=newNotificationChannel(CHANNEL_DEFAULT_IMPORTANCE,name,NotificationManager.IMPORTANCE_DEFAULT);channel.setDescription(description);NotificationManagernotificationManager=context.getSystemService(NotificationManager.class);notificationManager.createNotificationChannel(channel);}}publicNotificationcreateNotification(floatdistance){IntentnotificationIntent=newIntent(context,MainActivity.class);PendingIntentpendingIntent=PendingIntent.getActivity(context,0,notificationIntent,0);returnnewNotificationCompat.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();}publicvoidnotifyDistance(floatdistance){Notificationn=this.createNotification(distance);NotificationManagernotificationManager=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 afinque 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 pristrès peu de temps de développement.
Vous pouvez retrouver le code source sur Github: phoenix741/1kmarround et sur le PlayStore (à ce jour l'applicationn'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.
Une activité est l'équivalent d'un écran dans Android. ↩
Pour demander les permissions je me suis basé sur le code suivant:
@Overridepublic void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { ArrayList 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 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); }}
↩

Original post of Ulrich Van Den Hekke .Votez pour ce billet sur Planet Libre .