I. Objectif▲
Cet article montre comment mettre en place un serveur de SMS local avec un budget très réduit (un vieux téléphone Android et une carte SIM à 2 €).
L'objectif de cette présentation est de développer un service web permettant l'envoi de SMS.
Dans le cas d'une supervision de réseau, l'administrateur est prévenu par email d'un défaut, mais lorsqu'il s'agit d'un problème demandant une intervention immédiate, il faut recourir à un fournisseur qui relaiera par SMS le défaut.
On peut retrouver ce besoin dans le monde des objets où une minicarte à base d'ESP8266 demande à prévenir d'un événement dans une smart house, un élevage agricole…
L'interface graphique renseigne du socket d'écoute et donne le contenu du dernier SMS envoyé ainsi que le numéro cible.
L'activity : la classe Android gérant l'application est nommée MainActivity et, dans notre cas, gère le serveur MyHTTPD. Le serveur minimaliste MyHTTPD hérite de NanoHttpd. Cette dernière classe s'appuie sur le parser JSONDecodeur qui est là pour extraire du POST les champs contenant les informations du SMS à envoyer.
Ainsi n'importe quel client REST/JSON connecté au réseau local pourra envoyer un SMS via cette passerelle.
Côté smartphone, un simple appareil vieillissant avec une carte SIM à forfait minimaliste fera l'affaire.
Les fichiers source sont présentés ici par fonction. Une archive est également disponible ici.
II. Implémentation du service web▲
La présentation utilise Android Studio.
Une introduction à Android Studio est disponible ici (tuto android). Pour notre application, un contexte Empty Activity est suffisant avec une version d'Android de cible Android correspondant à votre téléphone (cf. version api android). Aucun besoin d'avoir le dernier cri !
II-A. Bibliothèques▲
Notre application aura besoin des bibliothèques de nanohttpd (serveur léger HTTP) et de json-simple (bibliothèque d'encodage et de décodage de JSON). Afin de les ajouter à Android Studio, il faut ajouter ces deux lignes dans la rubrique dependencies du fichier build.gradle de l'app :
compile group: 'org.nanohttpd', name: 'nanohttpd-webserver', version: '2.3.1'
compile group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'
II-B. Les permissions▲
Pour avoir les permissions d'accéder au Wi-Fi, Internet (réseau) et l'envoi de SMS, il faut ajouter dans le manifest :
<uses-permission
android
:
name
=
"android.permission.ACCESS_Wi-Fi_STATE"
/>
<uses-permission
android
:
name
=
"android.permission.SEND_SMS"
/>
<uses-permission
android
:
name
=
"android.permission.INTERNET"
/>
II-C. L'IHM▲
L'UI est décrite par six widgets à insérer dans un LinearLayout.
Le socket est affiché ainsi que le numéro et le contenu du dernier SMS envoyé.
L'interface graphique est spartiate, mais cette application ne nécessite pas plus.
II-D. Le programme▲
II-D-1. La classe MainActivity▲
La classe principale remplit comme habituellement les rôles de liaison avec les widgets graphiques ainsi que la gestion du cycle de vie de l'activity.
Les objets liés aux widgets sont déclarés en portée privée pour l'ensemble de la classe. Ces variables permettent ensuite d'accéder et de mettre à jour l'écran depuis l'activity ou une autre classe à partir d'un Handler.
public
class
MainActivity extends
Activity {
private
TextView numTexto;
private
TextView txtTexto;
private
TextView edPort;
private
MyHTTPD server;
private
TextView textIpaddr;
private
static
final
int
PERMISSION_REQUEST_CODE =
1
;
La méthode onCreate connecte les widgets aux objets du programme afin de pouvoir interagir avec. On y demande également la permission spécifique pour l'envoi de SMS ; obligatoire selon l'API, car de niveau « dangerous ».
@Override
public
void
onCreate
(
Bundle savedInstanceState) {
super
.onCreate
(
savedInstanceState);
setContentView
(
R.layout.activity_main);
textIpaddr =
(
TextView) findViewById
(
R.id.ipaddr);
numTexto =
(
TextView) findViewById
(
R.id.numSMS);
txtTexto =
(
TextView) findViewById
(
R.id.txtSMS);
edPort =
(
EditText) findViewById
(
R.id.port);
ActivityCompat.requestPermissions
(
this
, new
String[]{
Manifest.permission.SEND_SMS}
, 1
);
}
Il est préférable de lancer le serveur ailleurs que dans cette méthode, car celle-ci n'est parcourue que lorsque l'application est lancée, pas quand elle repasse au premier plan.
La méthode onResume est appelée quand l'application s'ouvre ou quand elle revient au premier plan. Cette méthode dialogue avec le Wi-FiManager et diffuse un toast si l'accès Wi-Fi n'est pas activé ou si l'IP du téléphone n'est pas valable (=0). Cette méthode affiche le socket d'écoute pour tous les clients. Enfin, elle ouvre un serveur de type NanoHTTPD et le lance dans la foulée.
La classe Toast permet de faire apparaître temporairement un message d'information : ici que le Wi-Fi est désactivé.
@Override
protected
void
onResume
(
) {
// lance apres onCreate
super
.onResume
(
);
Wi-
FiManager Wi-
FiManager =
(
Wi-
FiManager) getApplicationContext
(
).getSystemService
(
Wi-
Fi_SERVICE);
if
(!
Wi-
FiManager.isWi-
FiEnabled
(
)) {
// test si Wi-Fi actif
Toast.makeText
(
this
, "Wi-Fi désactivé"
, Toast.LENGTH_LONG).show
(
);
}
int
ipAddress =
Wi-
FiManager.getConnectionInfo
(
).getIpAddress
(
);
if
(
ipAddress ==
0
) {
// test si @IP affectee
Toast.makeText
(
this
, "Pas d'adresse IP"
, Toast.LENGTH_LONG).show
(
);
}
// formate l'@ en norme IPV4 en faisant confiance a l'utilisateur sur les données saisies
final
String formatedIpAddress =
String.format
(
"%d.%d.%d.%d"
, (
ipAddress &
0xff
),
(
ipAddress >>
8
&
0xff
), (
ipAddress >>
16
&
0xff
), (
ipAddress >>
24
&
0xff
));
int
port =
Integer.parseInt
(
edPort.getText
(
).toString
(
)); // on recupere le port choisi par l'utilisateur
// affiche le socket d'ecoute
textIpaddr.setText
(
"Accès : http://"
+
formatedIpAddress +
":"
+
port);
try
{
if
(
server ==
null
) {
server =
new
MyHTTPD
(
port, numTexto, txtTexto); // cree le serveur NanoHTTPD si inexistant
}
server.start
(
); // lance ou relance le serveur
}
catch
(
IOException e) {
e.printStackTrace
(
);
}
}
La méthode onPause désactive le serveur et libère le port dès que l'application n'est plus active. Il suffira de remettre l'application au premier plan pour repasser par la méthode onResume.
@Override
protected
void
onPause
(
) {
super
.onPause
(
);
if
(
server !=
null
) {
server.stop
(
); // serveur non operationnel quand l'appli est en pause
}
}
II-D-2. La classe serveur héritière de NanoHTTPD▲
Cette classe écoutera et répondra aux clients postant un message JSON. Elle se comportera comme un serveur REST qui une fois l'échange terminé répondra par un code de fin de connexion.
On y trouve un Handler, nécessaire pour accéder aux widgets de l'UI, et deux objets à connecter à l'UI.
Le constructeur MyHTTPD récupère le port passé en argument et appelle le constructeur de la super-classe. Le Handler et les deux objets des widgets sont initialisés.
class
MyHTTPD extends
NanoHTTPD {
private
Handler handler; // necessaire pour actualiser l'UI
private
TextView numWidget;
private
TextView txtWidget;
final
int
MAXCHAR =
200
; // taille maximale du JSON recu
/**
* Constructeur qui initialise les variables
* et cree un handler pour se connecter a l'UI
*/
public
MyHTTPD
(
int
port, TextView numWid, TextView textWid) throws
IOException {
super
(
port);
this
.numWidget =
numWid;
this
.txtWidget =
textWid;
handler =
new
Handler
(
);
}
La méthode centrale de cette classe est serve, elle retourne la réponse à une connexion.
On y déclare un objet de la classe du décodeur/parser ainsi qu'un tableau d'octets.
Cette méthode est automatiquement appelée par la classe mère dès qu'un client s'est connecté. On va extraire le tableau d'octets envoyé par le client et contenu dans l'argument : session. Ce tableau représentant une chaîne de caractères au format Json est tout de suite converti en String.
Cette chaîne est décodée par un objet de la classe JSONdecodeur présenté ensuite.
Si la trame JSON est valide (isParsed()), les valeurs du numéro de téléphone cible et le texte du SMS sont alors envoyés à l'UI et par texto à l'intérieur du Handler précédemment créé.
Enfin, on renvoie systématiquement le code 200 de bonne fin au client.
Il n'y a pas de retour dans cette méthode d'une mauvaise trame venant du client. Les messages JSON étant postés par des clients informatiques, il y a peu de chances, une fois débogué, d'avoir à traiter une demande sans numéro de téléphone par exemple.
@Override
public
Response serve
(
IHTTPSession session) {
final
JSONdecodeur jsonDec;
byte
[] messageDuClient =
new
byte
[MAXCHAR];
int
tailleMessageClient =
0
;
try
{
// conversion d'un tableau en String
tailleMessageClient =
session.getInputStream
(
).read
(
messageDuClient,0
,MAXCHAR);
String jsonMsg =
new
String
(
messageDuClient);
jsonMsg =
jsonMsg.substring
(
0
, tailleMessageClient);
jsonDec =
new
JSONdecodeur
(
jsonMsg);
handler.post
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
// affiche le numero et le texte du SMS envoye
if
(
jsonDec.isParsed
(
)) {
// seulement si JSON valide
numWidget.setText
(
"Numéro : "
+
jsonDec.getNumTel
(
)); // met a jour l'UI
txtWidget.setText
(
"Message : "
+
jsonDec.getTxtSMS
(
));
SmsManager smsManager =
SmsManager.getDefault
(
); // envoi du SMS
smsManager.sendTextMessage
(
jsonDec.getNumTel
(
), null
, jsonDec.getTxtSMS
(
),
null
, null
);
}
else
{
numWidget.setText
(
"Format du JSON"
); // met a jour l'UI
txtWidget.setText
(
"Revoir la syntaxe"
);
}
}
}
);
}
catch
(
IOException e) {
e.printStackTrace
(
);
}
// renvoie code de fin au client
return
newFixedLengthResponse
(
java.net.HttpURLConnection.HTTP_OK +
"
\n
"
);
}
II-D-3. La classe parser▲
Cette dernière classe a pour rôle de trouver dans la trame reçue la valeur des champs number et text.
La méthode parser utilise comme argument la chaîne JSON reçue. Dans un premier temps, elle extrait la valeur du champ SMS qui est lui-même un objet JSON. Cet objet est ensuite analysé et on en retire deux champs : number et text.
La méthode getNumTel retourne le numéro de téléphone une fois extrait par parser.
La méthode getTextSMS retourne le texte du message une fois extrait par parser.
Si les champs SMS, number et text sont présents, la méthode isParsed() retourne true.
class
JSONdecodeur {
private
String numTel, txtSMS;
private
boolean
isParsed =
false
;
/**
* Methode d'analyse d'une String au format JSON
* et qui extrait les valeurs des champs "number" et "text"
*
*
@param
jsonString
*/
JSONdecodeur
(
String jsonString) {
JSONParser jsonParser =
new
JSONParser
(
);
try
{
JSONObject jsonObject =
(
JSONObject) jsonParser.parse
(
jsonString);
// retourne un JSONObject qui est la valeur du champ SMS
JSONObject jsonSMS =
(
JSONObject) jsonObject.get
(
"SMS"
);
if
(
jsonSMS !=
null
) {
numTel =
(
String) jsonSMS.get
(
"number"
); // retourne la valeur du champ number
txtSMS =
(
String) jsonSMS.get
(
"text"
); // retourne la valeur du champ text
if
(
numTel !=
null
&&
txtSMS !=
null
) {
isParsed =
true
;
}
}
}
catch
(
ParseException e) {
e.printStackTrace
(
);
}
}
/**
* Retourne le numero du SMS à envoyer, extrait du code JSON
*
*
@return
String numero du SMS
*/
String getNumTel
(
) {
return
numTel;}
/**
* Retourne le texte du SMS à envoyer, extrait du code JSON
*
*
@return
String message du SMS
*/
String getTxtSMS
(
) {
return
txtSMS;}
/**
* Retourne true si le traitement est correct et exploitable
*
*
@return
Boolean
*/
Boolean isParsed
(
) {
return
isParsed;}
}
III. Test▲
Il suffit pour tester le serveur d'utiliser une extension de navigateur telle que Client REST simple disponible dans Chrome, d'indiquer dans l'URL l'adresse IP du smartphone donnée sur l'écran au format : http://IP:PORT.
De choisir la méthode POST et d'écrire un corps sur ce format :
{
"SMS"
:
{
"number"
:
"06XXXXXXXX"
,
"text"
:
"mon message SMS"
}}
Ne pas oublier de spécifier http:// devant l'adresse ip de l'URL.
IV. Conclusion▲
Les premiers forfaits de fournisseurs de SMS sont à plus de 5 € par mois, il est donc intéressant de penser à cette solution.
Une connexion sécurisée implémentée permettra de rendre accessible ce serveur derrière une box et de transmettre des textos depuis n'importe quel navigateur ou application « maison ».
Cette application est simple et peut être améliorée en traitant l'arrivée des SMS afin de demander des actions aux clients du réseau local.
On peut aussi lancer un service et non gérer le serveur dans l'activity, cette manière permettra de toujours garder le service à l'écoute et de s'affranchir de la mise en veille du téléphone.
V. Remerciements▲
Merci à Mickael pour la supervision de l'article et son implication à developpez.com, à fearyourself pour ses remarques pertinentes et à Claude Leloup pour les fautes qui nous échappent toujours.