Android Code Camp, Urbino — 2012 SimpleAudioPlayer Realizzazione di un semplice player Audio. Introduzione Programmiamo una semplice applicazione Android che ci consenta di riprodurre dei file audio locali e/o remoti. Per rendere l’applicazione più completa ed utilizzabile nella vita quotidiana di uno smartphone utilizzeremo un service per la riproduzione in background e registreremo dei BroadcastReceiver per reagire a degli eventi di sistema particolarmente importanti per la nostra applicazione. Lo sviluppo sarà diviso in passi incrementali. I file coinvolti ad ogni passo saranno indicati insieme alla descrizione del procedimento. Questo tutorial è incentrato sulla programmazione di applicazioni su piattaforma Android perciò alcune classi responsabili di funzionalità che esulano dallo scopo saranno già fornite nel progetto baseSimpleFeedReader. Semplice riproduzione di un file negli asset File coinvolti: Version1.java Nel nostro progetto sono presenti due sample audio in formato .ogg (prelevati dal sito wikipedia) che utilizzeremo per le nostre prove. Android mette a disposizione nel proprio SDK un oggetto chiamato MediaPlayer che consente con discreta facilità la riproduzione di vari formati multimediali: per un dettaglio dei formati disponibili consultare la pagina http://developer.android.com/guide/appendix/media-formats.html. In questa prima versione provvediamo all’inizializzazione dell’oggetto MediaPlayer di Android nel metodo onStart() dell’activity e successivamente provvediamo a far partire la riproduzione nel metodo onResume() dell’activity. @Override public void onStart(){ super.onStart(); mp = MediaPlayer.create(this, R.raw.yesterdaysample); } @Override public void onResume(){ Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO } super.onResume(); mp.start(); A questo punto se facciamo partire l’app sentiremo riprodurre il file audio ma se chiudiamo l’activity la riproduzione non si interromperà. In questo caso dobbiamo uccidere a mano il processo e possiamo farlo tramite la prospettiva DDMS in Eclipse. Se perdiamo il riferimento in Java all’oggetto MediaPlayer abbiamo memory leak e non riusciremo più ad ottenere il controllo sull’oggetto appena perso. Pagina 2 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO Interfaccia grafica e stop del MediaPlayer File coinvolti: Version2.java Per evitare il memory leak fermiamo la riproduzione del MediaPlayer e rilasciamolo per liberarne le risorse nel motodo onStop() dell’activity. @Override public void onStop (){ super.onStop(); mp.stop(); mp.release(); mp = null; } Aggiungiamo inoltre un layout all’activity con i classici pulsanti di pausa, play, avanti, indietro. Per ora attiveremo solo il pulsante di play/pausa. Per gestire meglio il ciclo di vita del media player aggiungiamo inoltre una variabile intera in cui annoteremo lo stato del riproduttore. @Override public void onCreate (Bundle savedInstanceState){ super.onCreate(savedInstanceState); setContentView(R.layout.version2); this.prevButton = (Button) this.findViewById(R.id.PrevButton); this.playPauseButton = (Button) this.findViewById(R.id.PlayPauseButton); this.nextButton = (Button) this.findViewById(R.id.NextButton); this.playPauseButton.setOnClickListener(new View.OnClickListener() { }); } @Override public void onClick(View v) { if(mpState == STATE_PLAYING){ mp.pause(); mpState = STATE_PAUSED; playPauseButton.setText("Play"); }else{ mp.start(); mpState = STATE_PLAYING; playPauseButton.setText("Pause"); } } mpState = STATE_UNDEFINED; Pagina 3 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO Interfaccia grafica evoluta File coinvolti: Version3.java In questa nuova versione aggiungeremo al layout una SeekBar e due TextView per avere un impatto visivo dell’istante corrente di riproduzione. Quindi dopo aver modificato il layout dovremo ottenere il riferimento ai nuovo widget anche nell’activity Version3.java. Oltre a questo registreremo un listener per reagire agli spostamenti sulla SeekBar richiesti dall’utente in modo da scorrere il file riprodotto all’istante desiderato. @Override public void onCreate (Bundle savedInstanceState){ // … this.progressBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onStopTrackingTouch(SeekBar seekBar) { } @Override public void onStartTrackingTouch(SeekBar seekBar) { } } }); @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { // we should distinguish between a seek caused by an // user, or by an update through the polling thread if (fromUser) { mp.seekTo(progress); } } È necessario inoltre inizializzare correttamente la SeekBar con il massimo valore che può raggiungere (nel nostro caso la durata del file audio) ed il suo stato iniziale. Vogliamo anche che la SeekBar si resetti quando il file audio finisce: per questo l’oggetto MediaPlayer permette di registrare un listener che viene invocato nel momento in cui il termine del file viene raggiunto. @Override public void onStart (){ // … mp.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { Pagina 4 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO }); } @Override public void onCompletion(MediaPlayer mp) { playPauseButton.setText("Play"); progressBar.setProgress(0); mpState = STATE_PREPARED; } mpState = STATE_PREPARED; // initialize properly the progress bar // with the info about the current track progressBar.setMax(mp.getDuration()); progressBar.setProgress(mp.getCurrentPosition()); // … A questo punto se avviassimo l’applicazione noteremmo che la SeekBar non viene aggiornata durante la riproduzione del file: ed effettivamente è giusto così, perché in nessun punto abbiamo codificato il concetto di aggiornarla periodicamente. Per questo compito il MediaPlayer non prevede nessun metodo o listener particolare, perciò l’unica alternativa che abbiamo è creare un thread che vada periodicamente a valutare lo stato di riproduzione del MediaPlayer. Il concetto di thread in Android non ha differenze rispetto ai concetti classici: quello su cui dobbiamo fare attenzione è il seguente segmento di codice: // … if (mp != null && (mpState == STATE_PLAYING || mpState == STATE_PAUSED)) { Version3.this.runOnUiThread(new Runnable() { @Override public void run() { // update progress bar progressBar.setProgress(mp.getCurrentPosition()); // and progress/duration textview currentProgressTV.setText( TimeUtil.formatProgress(mp.getCurrentPosition())); durationTV.setText( TimeUtil.formatProgress(mp.getDuration())); } }); } else { Version3.this.runOnUiThread(new Runnable() { @Override public void run() { progressBar.setProgress(0); currentProgressTV.setText(TimeUtil.formatProgress(-1)); durationTV.setText(TimeUtil.formatProgress(-1)); } }); } Pagina 5 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO // … Questa parte di codice appena mostrata viene eseguita nel metodo run() di una sottoclasse di Thread, quindi viene eseguita da un Thread differente rispetto a quello dell’interfaccia grafica. In Android, l’interfaccia grafica NON può essere modificata da un Thread che non sia quello principale dell’UI: per questo se provassimo a modificarla direttamente dal Thread in polling sul MediaPlayer verrebbe sollevata un’eccezione. Il sistema ci aiuta mettendoci a disposizione alcune tecniche per far eseguire del codice nel Thread UI: la tecnica utilizzata in questo caso è la possibilità di passare un Runnable con il codice da eseguire nel metodo runOnUiThread() della classe Activity. Il codice dell’esempio è molto semplice ed auto esplicativo: semplicemente viene aggiornato il progresso nella SeekBar e l’istante di riproduzione nelle TextView. Pagina 6 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO Disaccoppiamento grafica-servizio File coinvolti: Version4.java ServiceVersion4.java Il software fin qui creato permette di riprodurre una canzone, scorrerla e metterla in pausa: ma per farlo dobbiamo per forza continuare ad avere la nostra activity attiva. Per risolvere questo problema, in questa nuova versione introdurremo un Service in cui sposteremo la gestione del MediaPlayer in modo che continui a funzionare correttamente anche chiudendo l’activity principale. In particolare vogliamo un foreground service, tramite il quale magari ripristinare l’activity di gestione del player. Per questo, nel file ServiceVersion4.java, i passi cruciali sono riportati nel codice seguente: // … @Override public int onStartCommand(Intent intent, int flags, int startId) { // make the service "foreground", so it acquires max priority Intent i = new Intent(this, Version4.class); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); pi = PendingIntent.getActivity(this, 1, i, 0); // create the notification that will be shown in the system bar notification = new Notification(android.R.drawable.ic_media_play, "Audio Tutorial", System.currentTimeMillis()); notification.setLatestEventInfo(this, "Audio Tutorial", "Service started", pi); notification.flags |= Notification.FLAG_ONGOING_EVENT; startForeground(NOTIFICATION_ID, notification); // we want this service to continue running until it is explicitly // stopped, so return sticky return START_STICKY; } // … @Override public IBinder onBind(Intent arg0) { clientsBound = true; return binder; } @Override public boolean onUnbind(Intent i) { clientsBound = false; return super.onUnbind(i); } /** Pagina 7 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO * Custom binder for the AudioTutorial service * */ public class ServiceBinder extends Binder { ServiceVersion4 getService() { return ServiceVersion4.this; } } Nel metodo onStartCommand() creiamo dapprima una notifica che verrà visualizzato nella StatusBar di Android: la cosa interessante è che passiamo un PendingIntent che fa riferimento a Version4.class che conterrà l’UI per controllare la riproduzione audio, in modo che quando un utente aprirà la notifica verrà avviata questa Activity. Impostiamo inoltre il flag FLAG_ONGOING_EVENT per indicare che la notifica non deve essere rimossa dalla StatusBar; infine, chiamando il metodo startForeground() avvieremo il servizio come foreground service. I servizi in Android possono essere legati ad un’activity: i metodi onBind e onUnbind forniscono le chiamate eseguite rispettivamente all’atto di instaurazione e rilascio del legame. In particolare nel metodo onBind dobbiamo ritornare un oggetto che fornisca un Binder con il servizio attuale, in modo che il chiamante possa operare in qualche maniera diretta con il servizio. Nel nostro caso abbiamo creato una sottoclasse di Binder che restituisce semplicemente il servizio di cui si è effettuato il legame, in modo che il chiamante possa accedere alla sua interfaccia. Vedremo poi nell’activity come questo legame ci sarà utile. Oltre a queste implementazioni, il resto del servizio non presenta nulla di particolare dato che presenta fondamentale un semplice wrapper dell’oggetto MediaPlayer. Passando ora in esamine l’activity, le novità interessanti sono riportate qui di seguito: // … private ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder s) { // called when the connection is made Log.i(TAG, "Getting a reference to the foreground service"); service = ((ServiceVersion4.ServiceBinder) s).getService(); } @Override public void onServiceDisconnected(ComponentName name) { Pagina 8 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO } Log.i(TAG, "Service disconnected"); // received when the service unexpectedly disconnects service = null; }; @Override public void onStart() { super.onStart(); // initialize a polling thread with an interval of a second pollingThread = new PollingThread(POLLING_DELAY); // start the polling thread pollingThread.start(); // start service Intent serviceIntent = new Intent(this, ServiceVersion4.class); startService(serviceIntent); // and bind it bindService(serviceIntent, mConnection, Context.BIND_AUTO_CREATE); } @Override public void onStop() { super.onStop(); // stop the polling thread pollingThread.stopPolling(); pollingThread = null; // unbind the service unbindService(mConnection); } // … Notiamo subito che i metodi onStart() e onStop() presentano delle chiamate per avviare il servizio (startService()), effettuarne il binding (bindService()) e rilasciare il binding(unbindService()). In particolare la chiamata bindService() si aspetta tra i parametri un oggetto molto importante che è il gestore della connessione (ServiceConnection). Nel nostro caso passiamo mConnection che semplicemente inizializzerà l’attributo service al momento della connessione del servizio e lo renderà null nel momento della disconnessione: tra la connessione e la disconnessione, potremo chiamare direttamente i metodi pubblici del nostro servizio. Ed infatti, il resto delle modifiche prevede semplicemente di chiamare i vari metodi (play/pause/seekTo/etc..) direttamente sul servizio. Pagina 9 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO Reagire ad eventi broadcast File coinvolti: ServiceVersion5.java ExtActivity.java NoiseBroadcastReceiver.java AndroidManifest.xml Ora provvediamo ad implementare alcune caratteristiche particolari che ci permetteranno di valutare l’utilizzo di un importante componente di Android: il BroadcastReceiver. Nel nostro caso vogliamo semplicemente registrare un BroadcastReceiver che venga chiamato quando l’audio del device potrebbe diventare rumoroso, ad esempio perché sono stati appena scollegati gli auricolari e quindi l’audio verrebbe riprodotto direttamente dagli speaker del device: in questa caso, vogliamo che la nostra applicazione semplicemente metta in pausa il player per evitare possibili situazione di imbarazzo. Il codice (all’interno di NoiseBroadcastReceiver.java) che svolgerà questa funzionalità è molto semplice. // … @Override public void onReceive(Context c, Intent intent) { if (intent.getAction().equals( android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY)) { // retrieve our service if it's running Intent serviceIntent = new Intent(c, ServiceVersion4.class); IBinder serviceBinder = this.peekService(c, serviceIntent); if (serviceBinder != null) { ServiceVersion5 service = ((ServiceVersion5.ServiceBinder) serviceBinder).getService(); if (service.getState() == PlayerState.STATE_PLAYING) { // since the audio is becoming noisy and the player // is playing, we should pause it, otherwise the // user may be angry with us service.pauseSong(); } } else { Log.i(TAG, "Service not started, so do nothing"); } } } // … Il metodo onReceive() di un BroadcastReceiver viene chiamato quando accade un evento broadcast per il quale lo abbiamo registrato. Nel nostro caso Pagina 10 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO l’evento è android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY: in questa situazione cerchiamo di ottenere il binder al nostro servizio e, se il servizio è attivo e sta riproducendo, mettere in pausa la riproduzione. Per registrare un BroadcastReceiver abbiamo due possibilità: farlo in maniera dinamica dal codice Java o in maniera statica nell’AndroidManifest. Vediamo qui di seguito la seconda possibilità, il Manifest: <!-- … --> <receiver android:name=".NoiseBroadcastReceiver" > <intent-filter> <action android:name="android.media.AUDIO_BECOMING_NOISY" /> </intent-filter> </receiver> <!-- … --> Anche le applicazioni possono lanciare Intent Broadcast; per questo modifichiamo il servizio creato precedentemente inserendo un nuovo metodo come di seguito: // … public static final String BROADCAST_INTENT = "it.uniurb.androidcoursecamp.audioplayer.STATUS_CHANGED"; public static final String EXTRA_STATE = "state"; private void broadcastState() { // send broadcast intent Intent bi = new Intent(ServiceVersion5.BROADCAST_INTENT); bi.putExtra(EXTRA_STATE, mpState); this.sendBroadcast(bi); } // … Chiameremo questo metodo ogni qual volta i metodi all’interno del servizio facciano cambiare di stato il MediaPlayer: in questa maniera eventuali applicazioni esterne potranno accorgersi di questo cambiamento ed operare di conseguenza. Per esemplificare questa possibilità e mostrare anche la tecnica di registrazione dinamica dei BroadcastReceiver osserviamo il codice della classe ExtActivity.java: // … @Override public void onStart() { super.onStart(); // register our receiver for ServiceVersion4 intent IntentFilter f = new IntentFilter(ServiceVersion5.BROADCAST_INTENT); this.registerReceiver(mReceiver, f); } Pagina 11 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO @Override public void onStop() { super.onStop(); // unregister our receiver this.unregisterReceiver(mReceiver); } private BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent i) { if(i.getAction().equals(ServiceVersion5.BROADCAST_INTENT)){ String msg; // retrieve state from the intent int state=i.getIntExtra(ServiceVersion5.EXTRA_STATE, -1); if (state == PlayerState.STATE_PLAYING) { msg = "Now playing"; } else if (state == PlayerState.STATE_PAUSED) { msg = "Player paused"; } else if (state == PlayerState.STATE_STOPPED) { msg = "Player stopped"; } else { msg = "Undefined state"; Log.w(TAG, “I have not found any state information in the broadcast intent!"); } }; // … } } // notify user the new state Toast.makeText(context, msg, Toast.LENGTH_LONG).show(); Quando l’activity viene avviata (onStart()) creiamo un IntentFilter per filtrare solo gli eventi a cui siamo interessati, nel nostro caso ServiceVersion5.BROADCAST_INTENT. Quindi registriamo il BroadcastReceiver (che è stato definito nelle linee di codice successive) con il filtro appena specificato tramite la chiamata registerReceiver(). Specularmente, quando l’activity viene fermata (onStop()) rimuoviamo la registrazione del BroadcastReceiver perché non siamo più interessati ad essere notificati per l’evento. Il BroadcastReceiver definito è molto semplice e segue la struttura del BroadcastReceiver spiegato precedentemente: quando capita l’evento per cui ci siamo registrati, controlliamo lo stato del servizio e emaniamo un Toast informativo appropriato. Ovviamente questo esempio è molto banale, ma il Pagina 12 di 13 Android Code Camp, Urbino — 2012 TITOLO DEL DOCUMENTO funzionamento di base è uguale per tutti i BroadcastReceiver, quindi prevedere comportamenti più complessi ed allo stesso tempo utili è solo questione di fantasia ed ingegno. Pagina 13 di 13