Creare un’applicazione in modalità kiosk in Android – 1

Un’applicazione di tipo “kiosk” non è semplicemente un’app la cui main activity parte in modalità full screen, ma è un’app che continua a mostrare i propri contenuti in maniera permanente, senza interruzioni di sorta (pensate alla sospensione e/o al blocco dello schermo, o a un’altra app che si riattiva in background) e con una limitata, se non nulla, interazione da parte dell’utente. Pensiamo ad esempio alle sempre più diffuse bacheche elettroniche che chiedono agli utenti di segnalare il loro indice di gradimento di un determinato servizio, come quelle che si trovano installate dirimpetto ai bagni degli aeroporti: l’interazione è limitata al click su una fra alcune emoticon, esprimenti gradi di giudizio crescente, senza la possibilità di tornare alla home, di navigare attraverso il menu del device, né tanto meno di spegnerlo.

In questo articolo vedremo i vari step necessari alla creazione di un’applicazione con le caratteristiche appena elencate per device non rootati.

Creazione del progetto e dell’activity principale in modalità full screen

Il primo passo da realizzare è la creazione della main activity, che dovrà apparire in modalità full screen, e cioè nascondendo la barre delle notifiche e quella dei pulsanti di sistema, contenente i tasti “indietro”, “home” e “app recenti”. Andiamo quindi in Android Studio, selezioniamo dal menu la voce “Start a new Android Studio Project”, inseriamo l’application name e scegliamo il minimum sdk. In questo caso ho scelto di supportare versioni meno recenti del sistema e di partire da Jelly Bean (Api 17). A questo punto selezioniamo, tra i template preimpostati, quello denominato “FullScreen Activity”.  Una volta portata a termine la procedura, verrà creata un’activity con tutti i flag necessari per la partenza a tutto schermo e con un layout preimpostato, che va benissimo per gli scopi dimostrativi di questo tutorial.

Una via d’uscita

Una prima cosa da tenere in considerazione è l’implementazione di una modalità di uscita dall’app più o meno nascosta, di cui ovviamente informeremo il proprietario del device. Una volta installato il progetto finale, infatti, sarà praticamente impossibile per un utente standard trovare una via d’uscita dall’applicazione, rendendo di fatto il dispositivo inutilizzabile (a meno di un factory reset o di una disinstallazione via pc).  Modifichiamo quindi il metodo mDelayHideTouchListener creato dal templete dell’ambiente di sviluppo e aggiungiamo il seguente codice prima del return finale:

finish();

Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_HOME);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);

In questo modo al click del bottone torneremo alla home. Ovviamente in un’applicazione reale bisognerà implementare un meccanismo meno evidente, ad esempio, un certo numero di touch in un certo angolo dello schermo.

Disabilitazione dei tasti volume

Le applicazioni kiosk che ho visto finora erano installate su tablet inglobati in una cornice protettiva, che di fatto impediva l’accesso fisico ai tasti hardware. Tuttavia potrebbe essere utile disabilitare i tasti volume, evitando che qualcuno inavvertitamente ci giochi. Creiamo quindi un ArraList contenente i codici dei tasti Up e Down

private final List volumeKeys = new ArrayList(Arrays.asList(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP));

ed eseguiamo l’override del metodo dispatchKeyEvent della nostra activity, in modo da bypassarlo completamente:

@Override
public boolean dispatchKeyEvent(KeyEvent event) {
    if (volumeKeys.contains(event.getKeyCode())) {
        return true;
    } else {
        return super.dispatchKeyEvent(event);
    }
}

Disabilitazione del tasto indietro

Non c’è modo di impedire che il menu di sistema riappaia al touch dell’utente, perlomeno su tablet non rootati, quindi dobbiamo impedire che la pressione sul tasto “indietro” faccia chiudere la nostra app. Anche in questo caso dobbiamo eseguire l’override del metodo onBackPressed dell’activity

@Override
public void onBackPressed() {

}

Lasciando vuoto il metodo, bypasseremo completamente l’evento.

Disabilitazione dei tasti home e app recenti

Non c’è possibilità di eseguire l’override del tasto home in android, per ovvie ragioni di sicurezza, tuttavia possiamo fare in modo che la nostra app torni in primo piano ogni volta un’altra activity prenda il focus o che qualunque altro evento la ponga in background.  Creiamo quindi il servizio ActivityMonitor, che ogni secondo verifica se l’app è in background, riattivandola se necessario:

public class ActivityMonitor extends Service {

    //intervallo di due secondi per verificare lo stato della main activity (foreground o background?)
    private static final long INTERVAL = TimeUnit.SECONDS.toMillis(1);
    private static final String TAG = ActivityMonitor.class.getSimpleName();

    private Thread t = null;
    private Context ctx = null;
    private boolean running = false;

    @Override
    public void onDestroy() {
        Log.i(TAG, "Stopping service 'ActivityMonitor'");
        running =false;
        super.onDestroy();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.i(TAG, "Starting service 'ActivityMonitor'");
        running = true;
        ctx = this;

        // verifichiamo a intervalli regolari che l'app non sia andata in background
        t = new Thread(new Runnable() {
            @Override
            public void run() {
                do {
                    checkKioskMode();
                    try {
                        Thread.sleep(INTERVAL);
                    } catch (InterruptedException e) {
                        Log.i(TAG, "Thread interrupted: 'ActivityMonitor'");
                    }
                }while(running);
                stopSelf();
            }
        });

        t.start();
        return Service.START_NOT_STICKY;
    }

    private void checkKioskMode() {

        Context ctx = getApplicationContext();

        // verifico se la main activity è in foreground o meno
        if(isBackgroundRunning(ctx)) {
            //se è in background la riavvio
            restoreApp();
        }

    }

    private boolean isBackgroundRunning(Context context) {
        ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);

        List<ActivityManager.RunningAppProcessInfo> runningProcesses = am.getRunningAppProcesses();

        for (ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) {

            if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {

                for (String activeProcess : processInfo.pkgList) {
                    if (activeProcess.equals(context.getPackageName())) {

                        return false;
                    }
                }
            }
        }


        return true;
    }



    //riavviamo l'activity
    private void restoreApp() {

        Intent i = new Intent(ctx, FullscreenActivity.class);
        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        ctx.startActivity(i);
    }


    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

Più avanti inseriremo l’avvio del servizio in una classe che estenda la classe Application.

Disabilitazione del tasto di accensione

Il tasto di accensione in android ha due comportamenti: in caso di click breve attiva il blocco schermo, in caso di click prolungato fa apparire il dialog contentente la richiesta di riavvio o spegnimento. Ovviamente dobbiamo che entrambe le condizioni si verifichino.

Per prima cosa, eseguiamo l’override del metodo onWindowFocusChanged e inviamo un broadcast di chiusura alla finestra che sta per apparire:

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    if(!hasFocus) {
        //chiudiamo il dialog di sistema al suo apparire
        Intent systemDialog = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
        sendBroadcast(systemDialog);
    }
}

Successivamente, estendiamo la classe BroadCastReceiver e acquisiamo il wake lock, per evitare che lo schermo si oscuri e che venga attivato il blocco:

public class ShortPowerButtonReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        if(Intent.ACTION_SCREEN_OFF.equals(intent.getAction())){
            CustomApplication ctx = (CustomApplication) context.getApplicationContext();
            //otteniamo il wake lock e rilasciamo il precedente
            PowerManager.WakeLock wakeLock = ctx.getWakeLock();
            if (wakeLock.isHeld()) {
                wakeLock.release();
            }

            // creiamo un wake lock e lo rilasciamo
            wakeLock.acquire();
            wakeLock.release();
        }
    }
}

Infine, estendiamo la classe Application e registriamo sia il BroadCastReceiver appena creato, che il servizio ActivityMonitor

public class CustomApplication extends Application {

    private CustomApplication instance;
    private PowerManager.WakeLock wakeLock;
    private ShortPowerButtonReceiver powerButtonReceiver;

    @Override
    public void onCreate() {
        super.onCreate();
        instance = this;
        registerKioskModePowerButtonReceiver();
        startActivityMonitorService();
    }

    private void registerKioskModePowerButtonReceiver() {

        final IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
        powerButtonReceiver = new ShortPowerButtonReceiver();
        registerReceiver(powerButtonReceiver, filter);
    }

    public PowerManager.WakeLock getWakeLock() {
        if(wakeLock == null) {

            PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
            wakeLock = pm.newWakeLock(PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, "screenon");
        }
        return wakeLock;
    }

    private void startActivityMonitorService() {
        startService(new Intent(this, ActivityMonitor.class));
    }
}

Poiché ActivityMonitor controllerà ogni secondo che l’app non sia in background, il tasto di uscita sostanzialmente non sortisce più l’effetto desiderato; fermiamo quindi il servizio, aggiungendo al touch listener il comando di stop prima di tornare alla home. L’handler del bottone verrà quindi modificato in questo modo:

private final View.OnTouchListener mDelayHideTouchListener = new View.OnTouchListener() {
    @Override
    public boolean onTouch(View view, MotionEvent motionEvent) {

        if (AUTO_HIDE) {
            delayedHide(AUTO_HIDE_DELAY_MILLIS);
        }


        stopService(new Intent(getApplicationContext(), ActivityMonitor.class));

        finish();

        Intent intent = new Intent(Intent.ACTION_MAIN);
        intent.addCategory(Intent.CATEGORY_HOME);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(intent);

        return false;
    }
};

Di conseguenza, dobbiamo eseguire l’override del metodo onResume della nostra activity, in modo da riavviare il servizio:

@Override
protected void onResume() {
    super.onResume();

    startService(new Intent(getApplicationContext(), ActivityMonitor.class));
}

Nascondimento della barra di stato

Anche nel caso della barra di stato non è possibile fare molto, se non un altro hack, come creare un ViewGroup e posizionarlo al di sopra. Creiamo quindi un metodo ad hoc:

private void disableStatusBarExpansion() {

    WindowManager manager = ((WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE));

    WindowManager.LayoutParams localLayoutParams = new WindowManager.LayoutParams();
    localLayoutParams.type = WindowManager.LayoutParams.TYPE_STATUS_BAR;
    localLayoutParams.gravity = Gravity.TOP;
    localLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE|WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL|WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;

    localLayoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;

    int resId = getResources().getIdentifier("status_bar_height", "dimen", "android");
    int result = 0;
    if (resId > 0) {
        result = getResources().getDimensionPixelSize(resId);
    } else {
        
        result = 60; 
    }

    localLayoutParams.height = result;
    localLayoutParams.format = PixelFormat.TRANSPARENT;

    HideStatusBarViewGroup view = new HideStatusBarViewGroup(getApplicationContext());
    manager.addView(view, localLayoutParams);
}

Tocco finale

Un’applicazione kiosk che si rispetti, deve partire all’avvio del sistema, per questo aggiungiamo al manifest un broadcast receiver che abbia BOOT_COMPLETED come intent filter:

<receiver android:name=".BootCompletedReceiver">
    <intent-filter >
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
    </intent-filter>
</receiver>

public class BootCompletedReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        Intent myIntent = new Intent(context, FullscreenActivity.class);
        myIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(myIntent);
    }
}

I permessi

Aggiungeremo nel manifest i permessi necessari a tutte le funzionalità appena implementate:

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

    con il quale facciamo avviare l’app ad avvio di sistema completato

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

    necessario al servizio ActivityMonitor per pescare i processi in background

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

    utilizzato per acquisire il wake lock e bypassare il click breve sul tasto di accensione del device

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

    usato dal metodo disableStatusBarExpansion per disegnare sopra la status bar una ViewGroup custom.
    Nota bene: come riportato dalla documentazione ufficiale, a partire dall’Api level 23, l’utente deve concedere esplicitamente all’app i permessi per gestire lo schermo, oppure l’app stessa può chiederli a runtime. Ho preferito andare in Impostazioni/Apps/Disegna sulle altre apps

I sorgenti di questo progetto di esempio possono essere scaricati da questo repository github.