package com.marverenic.music.player; import android.app.ActivityManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.IBinder; import android.os.Process; import android.os.RemoteException; import android.support.annotation.DrawableRes; import android.support.annotation.StringRes; import android.support.v4.media.session.MediaButtonReceiver; import android.support.v4.media.session.MediaSessionCompat; import android.support.v7.app.NotificationCompat; import android.view.KeyEvent; import com.marverenic.music.BuildConfig; import com.marverenic.music.IPlayerService; import com.marverenic.music.R; import com.marverenic.music.data.store.ImmutablePreferenceStore; import com.marverenic.music.data.store.MediaStoreUtil; import com.marverenic.music.model.Song; import com.marverenic.music.utils.Internal; import com.marverenic.music.utils.MediaStyleHelper; import java.io.IOException; import java.util.Collections; import java.util.List; import timber.log.Timber; public class PlayerService extends Service implements MusicPlayer.OnPlaybackChangeListener { public static final String ACTION_STOP = "PlayerService.stop"; private static final String EXTRA_START_SILENT = "PlayerService.SILENT_START"; public static final int NOTIFICATION_ID = 1; /** * The service instance in use (singleton) */ private static PlayerService instance; /** * Used in binding and unbinding this service to the UI process */ private static IBinder binder; // Instance variables /** * The media player for the service instance */ @Internal MusicPlayer musicPlayer; /** * Used to to prevent errors caused by freeing resources twice */ private boolean finished; /** * Used to keep track of whether the notification has been dismissed or not */ private boolean mStopped; /** * When set to true, notifications will not be displayed until the service enters the foreground */ private boolean mBeQuiet; public static Intent newIntent(Context context, boolean silent) { Intent intent = new Intent(context, PlayerService.class); intent.putExtra(EXTRA_START_SILENT, silent); return intent; } @Override public IBinder onBind(Intent intent) { Timber.i("onBind called"); if (binder == null) { binder = new Stub(this); } return binder; } /** * @inheritDoc */ @Override public void onCreate() { super.onCreate(); Timber.i("onCreate() called"); if (!MediaStoreUtil.hasPermission(this)) { Timber.w("Attempted to start service without Storage permission. Aborting."); stopSelf(); return; } if (instance == null) { instance = this; } else { Timber.w("Attempted to create a second PlayerService"); stopSelf(); return; } if (musicPlayer == null) { musicPlayer = new MusicPlayer(this); } mStopped = false; finished = false; musicPlayer.setPlaybackChangeListener(this); musicPlayer.loadState(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Timber.i("onStartCommand called"); super.onStartCommand(intent, flags, startId); if (intent != null && MediaStoreUtil.hasPermission(this)) { mBeQuiet = intent.getBooleanExtra(EXTRA_START_SILENT, false); if (intent.hasExtra(Intent.EXTRA_KEY_EVENT)) { MediaButtonReceiver.handleIntent(musicPlayer.getMediaSession(), intent); Timber.i(intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT).toString()); } else if (ACTION_STOP.equals(intent.getAction())) { stop(); } } return START_STICKY; } @Override public void onDestroy() { Timber.i("Called onDestroy"); finish(); /* By default, when this service stops, Android will keep a cached version of it so it can be restarted easily. When this happens, the service enters a state where the main app can no longer bind to it when it is started the next time. We therefore prevent this entirely by not allowing Android to keep the service process cached. This is a VERY bad idea, so make sure that this service always has its own process, and make sure to be very careful about cleaning up all resources before this method returns. */ Process.killProcess(Process.myPid()); } @Override public void onTaskRemoved(Intent rootIntent) { Timber.i("onTaskRemoved called"); /* When the application is removed from the overview page, we make the notification dismissible on Lollipop and higher devices if music is paused. To do this, we have to move the service out of the foreground state. As soon as this happens, ActivityManager will kill the service because it isn't in the foreground. Because the service is sticky, it will get queued to be restarted. Because our service has a chance of getting recreated as a result of this event in the lifecycle, we have to save the state of the media player under the assumption that we're about to be killed. If we are killed, this state will just be reloaded when the service is recreated, and all the user sees is their notification temporarily disappearing when they pause music and swipe Jockey out of their recent apps. There is no other way I'm aware of to implement a remote service that transitions between the foreground and background (as required by the platform's media style since it can't have a close button on L+ devices) without being recreated and requiring this workaround. */ try { musicPlayer.saveState(); } catch (IOException exception) { Timber.e(exception, "Failed to save music player state"); } if (mStopped || !musicPlayer.isPlaying()) { NotificationManager mgr = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); mgr.cancel(NOTIFICATION_ID); finish(); } else { notifyNowPlaying(); } } public static PlayerService getInstance() { return instance; } /** * Generate and post a notification for the current player status * Posts the notification by starting the service in the foreground */ public void notifyNowPlaying() { Timber.i("notifyNowPlaying called"); if (musicPlayer.getNowPlaying() == null) { Timber.i("Not showing notification -- nothing is playing"); return; } MediaSessionCompat mediaSession = musicPlayer.getMediaSession(); if (mediaSession == null) { Timber.i("Not showing notification. Media session is uninitialized"); return; } NotificationCompat.Builder builder = MediaStyleHelper.from(this, mediaSession); setupNotificationActions(builder); builder.setSmallIcon(getNotificationIcon()) .setDeleteIntent(getStopIntent()) .setStyle( new NotificationCompat.MediaStyle() .setShowActionsInCompactView(0, 1, 2) .setShowCancelButton(true) .setCancelButtonIntent(getStopIntent()) .setMediaSession(musicPlayer.getMediaSession().getSessionToken())); showNotification(builder.build()); } @DrawableRes private int getNotificationIcon() { if (musicPlayer.isPlaying()) { return R.drawable.ic_play_arrow_24dp; } else { return R.drawable.ic_pause_24dp; } } private void setupNotificationActions(NotificationCompat.Builder builder) { addNotificationAction(builder, R.drawable.ic_skip_previous_36dp, R.string.action_previous, KeyEvent.KEYCODE_MEDIA_PREVIOUS); if (musicPlayer.isPlaying()) { addNotificationAction(builder, R.drawable.ic_pause_36dp, R.string.action_pause, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); } else { addNotificationAction(builder, R.drawable.ic_play_arrow_36dp, R.string.action_play, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); } addNotificationAction(builder, R.drawable.ic_skip_next_36dp, R.string.action_skip, KeyEvent.KEYCODE_MEDIA_NEXT); } private void addNotificationAction(NotificationCompat.Builder builder, @DrawableRes int icon, @StringRes int string, int keyEvent) { PendingIntent intent = MediaStyleHelper.getActionIntent(this, keyEvent); builder.addAction(new NotificationCompat.Action(icon, getString(string), intent)); } private PendingIntent getStopIntent() { Intent intent = new Intent(this, PlayerService.class); intent.setAction(ACTION_STOP); return PendingIntent.getService(this, 0, intent, 0); } private void showNotification(Notification notification) { if ((mBeQuiet || mStopped) && !musicPlayer.isPlaying()) { return; } mStopped = false; mBeQuiet &= !musicPlayer.isPlaying(); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { startForeground(NOTIFICATION_ID, notification); } else if (!musicPlayer.isPlaying()) { Timber.i("Removing service from foreground"); /* The following call to startService is a workaround for API 21 and 22 devices. If the main UI process is not running, then calling stopForeground() here will end the service completely. We therefore have the service start itself. If the service does then get killed, it will be restarted automatically. If the service doesn't get killed (regardless of API level), then this call does nothing since Android won't start a second instance of a service. */ startService(new Intent(this, PlayerService.class)); stopForeground(false); Timber.i("Bringing service into background"); NotificationManager mgr = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); mgr.notify(NOTIFICATION_ID, notification); } else { startForeground(NOTIFICATION_ID, notification); } } private boolean isUiProcessRunning() { ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { List<ActivityManager.RunningAppProcessInfo> processes = am.getRunningAppProcesses(); for (int i = 0; i < processes.size(); i++) { if (processes.get(i).processName.equals(BuildConfig.APPLICATION_ID)) { return true; } } return false; } else { return !am.getAppTasks().isEmpty(); } } public void stop() { Timber.i("stop called"); mStopped = true; // If the UI process is still running, don't kill the process, only remove its notification if (isUiProcessRunning()) { musicPlayer.pause(); stopForeground(true); return; } // If the service is being completely stopped by the user, turn off the sleep timer musicPlayer.setSleepTimer(0); // If the UI process has already ended, kill the service and close the player finish(); } public void finish() { Timber.i("finish() called"); if (!finished) { if (musicPlayer != null) { try { musicPlayer.saveState(); } catch (IOException exception) { Timber.e(exception, "Failed to save player state"); } musicPlayer.release(); musicPlayer = null; } stopForeground(true); instance = null; stopSelf(); finished = true; } } @Override public void onPlaybackChange() { notifyNowPlaying(); } public static class Stub extends IPlayerService.Stub { private PlayerService mService; public Stub(PlayerService service) { mService = service; } private boolean isMusicPlayerReady() { return mService != null && mService.musicPlayer != null; } @Override public void stop() throws RemoteException { try { mService.stop(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.stop() failed"); throw exception; } } @Override public void skip() throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.skip(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.skip(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.skip() failed"); throw exception; } } @Override public void previous() throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.skip(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.skipPrevious(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.previous() failed"); throw exception; } } @Override public void togglePlay() throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.togglePlay(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.togglePlay(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.togglePlay() failed"); throw exception; } } @Override public void play() throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.play(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.play(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.play() failed"); throw exception; } } @Override public void pause() throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.pause(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.pause(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.pause() failed"); throw exception; } } @Override public void setPreferences(ImmutablePreferenceStore preferences) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.setPreferences(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.updatePreferences(preferences); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.setPreferences(...) failed"); throw exception; } } @Override public void setQueue(List<Song> newQueue, int newPosition) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.setQueue(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.setQueue(newQueue, newPosition); if (newQueue.isEmpty()) { mService.stop(); } } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.setQueue(...) failed"); throw exception; } } @Override public void changeSong(int position) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.changeSong(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.changeSong(position); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.changeSong(...) failed"); throw exception; } } @Override public void editQueue(List<Song> newQueue, int newPosition) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.editQueue(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.editQueue(newQueue, newPosition); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.editQueue(...) failed"); throw exception; } } @Override public void queueNext(Song song) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.queueNext(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.queueNext(song); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.queueNext(...) failed"); throw exception; } } @Override public void queueNextList(List<Song> songs) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.queueNextList(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.queueNext(songs); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.queueNextList(...) failed"); throw exception; } } @Override public void queueLast(Song song) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.queueLast(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.queueLast(song); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.queueLast() failed"); throw exception; } } @Override public void queueLastList(List<Song> songs) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.queueLastList(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.queueLast(songs); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.queueLastList(...) failed"); throw exception; } } @Override public void seekTo(int position) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.seekTo(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.seekTo(position); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.seekTo() failed"); throw exception; } } @Override public boolean isPlaying() throws RemoteException { if (!isMusicPlayerReady()) { return false; } try { return mService.musicPlayer.isPlaying(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.isPlaying() failed"); throw exception; } } @Override public Song getNowPlaying() throws RemoteException { if (!isMusicPlayerReady()) { return null; } try { return mService.musicPlayer.getNowPlaying(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getNowPlaying() failed"); throw exception; } } @Override public List<Song> getQueue() throws RemoteException { if (!isMusicPlayerReady()) { return Collections.emptyList(); } try { return mService.musicPlayer.getQueue(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.editQueue() failed"); throw exception; } } @Override public int getQueuePosition() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getQueuePosition(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getQueuePosition() failed"); throw exception; } } @Override public int getQueueSize() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getQueueSize(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getQueueSize() failed"); throw exception; } } @Override public int getCurrentPosition() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getCurrentPosition(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getCurrentPosition() failed"); throw exception; } } @Override public int getDuration() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getDuration(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getDuration() failed"); throw exception; } } @Override public PlayerState getPlayerState() throws RemoteException { if (!isMusicPlayerReady()) { return null; } try { return mService.musicPlayer.getState(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getPlayerState() failed"); throw exception; } } @Override public void restorePlayerState(PlayerState state) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("restorePlayerState(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.restorePlayerState(state); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.restorePlayerState() failed"); throw exception; } } @Override public int getMultiRepeatCount() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getMultiRepeatCount(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getMultiRepeatCount() failed"); throw exception; } } @Override public void setMultiRepeatCount(int count) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.setMultiRepeat(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.setMultiRepeat(count); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.setMultiRepeatCount() failed"); throw exception; } } @Override public long getSleepTimerEndTime() throws RemoteException { if (!isMusicPlayerReady()) { return 0; } try { return mService.musicPlayer.getSleepTimerEndTime(); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.getSleepTimerEndTime() failed"); throw exception; } } @Override public void setSleepTimerEndTime(long timestampInMillis) throws RemoteException { if (!isMusicPlayerReady()) { Timber.i("PlayerService.setSleepTimer(): Service is not ready. Dropping command"); return; } try { mService.musicPlayer.setSleepTimer(timestampInMillis); } catch (RuntimeException exception) { Timber.e(exception, "Remote call to PlayerService.setSleepTimerEndTime() failed"); } } } }