Robot di sorveglianza parte 2

Eccoci giunti, come promesso, alla seconda parte della nostra guida. Creeremo un'applicazione Android in grado gestire il nostro robot. Nota: è indispensabile aver letto la prima parte e aver creato e lanciato tutti gli script.

robot2_0

La nostra applicazione è abbastanza semplice e, come si vede nell'immagine, consiste di 4 pulsanti di direzione, uno stop di emergenza, un pulsante di spegnimento, cambia ip e dell'input della telecamera. L'applicazione è sviluppata in Kotlin qui un breve ma esaustivo corso e conta una sola activity. Nota: per tutta la guida daremo per scontato che Android Studio sia installato e perfettamente funzionante e che il progetto sia già stato creato

File Manifest: permessi e orientazione

Pur lavorando principalmente in rete locale la nostra app ha bisogno del permesso di accedere ad internet, aggiungiamo la riga seguente al file AndroidManifest.xml (subito prima di <application>):

<uses-permission android:name="android.permission.INTERNET"/>

L'accesso a Internet è uno dei permessi che Google etichetta come a basso rischio quindi anche se compiliamo su Android 6+ non dobbiamo richiedere l'autorizzazione all'utente.

Per semplificarmi il lavoro ho bloccato il layout dell'activity principale in landscape (orizzontale):

<activity android:name=".MainActivity" android:screenOrientation="landscape">
...
...
...
</activity>

Potrebbe essere un buon esercizio creare un layout anche per la modalità verticale.

Layout dell'activity

Ho lasciato il più possibile il layout di default (anche per cercare di capirci qualcosa), poteva venire meglio ma sul mio tablet da 8 pollici il risultato è più che ottimo (con schermi più grandi e/o risoluzioni più alte avanza un po' di spazio di troppo, essendo un'app prettamente a uso personale ognuno può adattarla alle proprie esigenze). Ecco il file di layout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/darker_gray"
    tools:context=".MainActivity">
    <TableLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/tl1"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="MissingConstraints"
        android:layout_margin="10dp">
        <TableRow
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingLeft="5dp"
            android:paddingBottom="10dp">
            <ImageButton
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/avanti"
                android:src="@drawable/baseline_keyboard_arrow_up_white_18dp"
                android:layout_span="3"/>
        </TableRow>
        <TableRow
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingLeft="5dp"
            android:paddingBottom="10dp">
            <ImageButton
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/sinistra"
                android:src="@drawable/baseline_keyboard_arrow_left_white_18dp"
                android:paddingRight="10dp"/>
            <ImageButton
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:onClick="stop"
                android:text="Stop"
                android:src="@drawable/baseline_stop_white_18dp"
                android:paddingRight="10dp"/>
            <ImageButton
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/destra"
                android:src="@drawable/baseline_keyboard_arrow_right_white_18dp"
                android:text="Destra"/>
        </TableRow>
        <TableRow
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingLeft="5dp">
            <ImageButton
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/indietro"
                android:src="@drawable/baseline_keyboard_arrow_down_white_18dp"
                android:layout_span="3"/>
        </TableRow>
    </TableLayout>

    <TableLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@id/tl1"
        android:layout_margin="10dp"
        tools:ignore="MissingConstraints">
        <TableRow>
            <WebView
            android:id="@+id/webv"
            android:layout_width="800px"
            android:layout_height="600px"/>
        </TableRow>
    </TableLayout>
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        tools:ignore="MissingConstraints"
        android:layout_margin="10dp"
        android:layout_marginLeft="10dp"
        android:orientation="horizontal">
        <ImageButton
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="spegni"
            android:layout_marginStart="5dp"
            android:src="@drawable/baseline_power_off_white_18dp"/> ù
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="http://"
            android:textColor="@android:color/white"
            android:layout_marginLeft="10dp"/>
        <EditText
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:id="@+id/ip"
            android:textColor="@android:color/white"
            android:background="@android:color/darker_gray"
            android:text="192.168.1.108"/>
        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@android:color/white"
            android:text="Cambia IP/Ricarica"
            android:onClick="cambia"
            android:layout_marginLeft="10dp"/>
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

Il layout ConstraintLayout è un contenitore quindi ho dovuto creare dei layout di suddivisione:

  • due TableLayout utili ad allineare in maniera corretta i pulsanti e la telecamera
  • un LinearLayout per gestire il resto dei componenti (la parte inferiore).

Differentemente dai pulsanti stop, spegni e cambia, i pulsanti di direzione richiedono il click prolungato quindi non riportano l'attributo onClick ma l'evento viene intercettato via codice.

Icone

Per ogni ImageButton e per l'applicazione ho utilizzato delle icone, sono andato a pescarle online e ho le ho inserite nella cartella res/drawable.

Codice Kotlin

Kotlin è il nuovo linguaggio di programmazione scelto da Google per la programmazione in ambito Android quindi secondo me è da utilizzare se si crea una nuova app. Come sintassi non differisce troppo da Java salvo la sparizione del mitico ";" e una gestione dei componenti più veloce. Ecco il sorgente:

package it.wearegeek.macchinapi

import android.os.Bundle
import android.os.StrictMode
import android.view.MotionEvent
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_main.*
import java.net.HttpURLConnection
import java.net.URL

class MainActivity : AppCompatActivity() {
    val porta: String = ":8000"
    var indirizzo: String = ""

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        indirizzo = "http://"+ip.text.toString()
        webv.loadUrl(indirizzo + ":8001/stream.mjpg")
        webv.setWebViewClient(object : WebViewClient() {
            override fun onReceivedError(
                view: WebView,
                errorCode: Int,
                description: String,
                failingUrl: String
            ) {
                //webv.loadUrl("about:blank")
                var index  = resources.assets.open("index.html").bufferedReader().use {
                    it.readText()
                }
                webv.loadData(index, "text/html", "utf-8")
            }
        })

        avanti.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/up_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

        indietro.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/down_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

        destra.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/right_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

        sinistra.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/left_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

    }

    fun stop(view: View){
        faiGet(indirizzo+porta+"/stop")
    }

    fun spegni(view: View) {
        var snack_spegni = Snackbar.make(view,  resources.getString(R.string.spegnimento1), Snackbar.LENGTH_LONG)
        snack_spegni.setAction(resources.getString(R.string.conferma),View.OnClickListener {
            faiGet(indirizzo+porta+"/halt")
            Snackbar.make(view, resources.getString(R.string.spegnimento2), Snackbar.LENGTH_SHORT).show()
            onDestroy()
        })
        snack_spegni.show()
    }

    fun cambia(view: View) {
        Snackbar.make(view, resources.getString(R.string.contatto), Snackbar.LENGTH_LONG).show()
        indirizzo = "http://"+ip.text.toString()
        webv.loadUrl(indirizzo + ":8001/stream.mjpg")
    }

    fun faiGet(page: String) {
        //Fix per android.os.NetworkOnMainThreadException
        val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
        StrictMode.setThreadPolicy(policy)
        //Fine fix
        val url = URL(page)
        with(url.openConnection() as HttpURLConnection) {
            requestMethod = "GET"  // optional default is GET

            println("\nSent 'GET' request to URL : $url; Response Code : $responseCode")
        }
    }
}

Grazie all'import della libreria kotlinx.android.synthetic.main.activity_main possiamo interagire direttamente con i componenti definiti nel layout semplicemente chiamando l'id associato. Ad esempio questa è la gestione dell'ImageButton avanti:<

avanti.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/up_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

Si nota subito che la sintassi è molto più snella e leggibile dello stesso codice scritto in Java che invece somiglierebbe molto al seguente (si può provare la conversione da Kotlin a Java tramite un'apposita funzione di Android Studio):

ImageButton avanti = (ImageButton) findViewById(R.id.avanti);
avanti.setOnTouchListener(new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction())
        {
                 case MotionEvent.ACTION_DOWN:
                           faiGet(indirizzo+porta+"/up_side");
                           break;
                 case MotionEvent.ACTION_UP:{
                           faiGet(indirizzo+porta+"/stop");
                           break;
                 default:  break;
        }
         return true;
    }
});

Molto meglio no?

Preliminari

Dopo aver creato la classe MainActivity la quale estende AppCompatActivity, definiamo variabili (con l'istruzione var) e costanti (istruzione val, in questo caso il valore è obbligatorio) e la nostro metodo principale cioè onCreate va a sostituire quello proprio della classe estesa ma, essendo una semplice estensione dell'originale, incorporiamo il metodo principale (istruzione super.). Dopo di che associamo il nostro layout (non basta importarlo) Nota per la variabile indirizzo ci serviamo di quanto immesso (o trovato di default) nella TextView del layout, anche qui il codice è molto più stringato rispetto a Java.

WebView

Passiamo adesso alla gestione della WebView la quale va a mostrare, nel mio caso, quanto reperibile all'indirizzo http://192.168.1.108:8001/stream.mjpg cioè quanto generato dallo script python camera_new.py (vedi tutorial precedente per i dettagli). Per evitare che con robot spento (o Wireless non attivo sul dispositivo) ci si pari davanti una pagina di errore gestiamo l'eccezione e carichiamo HTML da noi creato (e inserito nella cartella app/src/main/assets) in sostituzione (ho lasciato commentato about:blank per chi avesse voglia di semplicità).
Riporto la porzione di codice relativa:

indirizzo = "http://"+ip.text.toString()
        webv.loadUrl(indirizzo + ":8001/stream.mjpg")
        webv.setWebViewClient(object : WebViewClient() {
            override fun onReceivedError(
                view: WebView,
                errorCode: Int,
                description: String,
                failingUrl: String
            ) {
                //webv.loadUrl("about:blank")
                var index  = resources.assets.open("index.html").bufferedReader().use {
                    it.readText()
                }
                webv.loadData(index, "text/html", "utf-8")
            }
        })

Per completezza riporto anche il codice della pagina Web di errore (finezza: il colore di background della pagina di errore è lo stesso scelto per l'activity):

<html>

<body style="background-color:#a9a9a9;">
<br>
<br>
<br>
<br>
<br>
<br>
<h1 style="color:white;text-align:center;">Connessione assente!!!</h1>
<p style="color:white;text-align:center;">Controlla la connessione con il robot e ricarica.<br>
Inoltre assicurati che la connessione Wifi sia attiva sul tuo dispositivo.</p>
</body>
</html>

Pulsanti sinistra, destra, avanti e indietro

Analogamente alla pagina Web i nostri pulsanti di direzione spingono il robot finché non vengono rilasciati, dobbiamo quindi gestire l'evento onTouch al posto del classico evento onClick:

avanti.setOnTouchListener { view, motionEvent ->
            when (motionEvent.actionMasked)
            {
                MotionEvent.ACTION_DOWN -> faiGet(indirizzo+porta+"/up_side")
                MotionEvent.ACTION_UP -> faiGet(indirizzo+porta+"/stop")
            }
            true
        }

Quando viene intercettato l'evento ACTION_DOWN (pulsante premuto) facciamo sul server in ascolto sulla porta 8000 una chiamata GET (vedremo dopo la funzione faiGet) alla pagina desiderata (creata dallo script python macchina_new.py), nel mio caso http://192.168.1.108:8000/up_side, e al rilascio, ACTION_UP, chiamiamo la pagina di stop.

Altre funzioni

Le funzioni cambia, spegni e stop vengono richiamate grazie all'attributo onClick del layout e si occupano nello specifico di:

  • ricaricare la webview al cambio di ip (o URL)
  • gestire la chiamata GET di spegnimento del robot
  • gestire la chiamata GET diretta alla pagina di stop nel caso in cui al rilascio del pulsante il robot continui ad avanzare (non si sa mai).

Funzione faiGet

Abbiamo davanti al funzione più complessa in quanto pur facendo ausilio di classi Java (ebbene si Kotlin fa anche questo) mi ha dato non pochi grattacapi (non volevo usare librerie esterne).

fun faiGet(page: String) {
        //Fix per android.os.NetworkOnMainThreadException
        val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
        StrictMode.setThreadPolicy(policy)
        //Fine fix
        val url = URL(page)
        with(url.openConnection() as HttpURLConnection) {
            requestMethod = "GET"  // optional default is GET

            println("\nSent 'GET' request to URL : $url; Response Code : $responseCode")
        }
    }

Prima di tutto inserisco un fix per correggere un'eccezione che mi ha fatto diventare matto una serata intera in quanto ero sicurissimo che il codice fosse corretto (infatti lo era dovevo solo scavalcare una misura di sicurezza), poi trasformo la nostra stringa in un oggetto URL così da poter operare nella maniera corretta.

Conclusioni

La nostra App è pronta e utilizzabile. Buon divertimento