/*
* ownCloud Android client application
*
* @author David A. Velasco
* Copyright (C) 2016 ownCloud Inc.
*
* @author Tobias Kaminsky
* Copyright (C) 2018 Tobias Kaminsky
* Copyright (C) 2018 Nextcloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.owncloud.android.media;
import android.accounts.Account;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.media.MediaPlayer.OnErrorListener;
import android.media.MediaPlayer.OnPreparedListener;
import android.net.wifi.WifiManager;
import android.net.wifi.WifiManager.WifiLock;
import android.os.AsyncTask;
import android.os.IBinder;
import android.os.PowerManager;
import android.widget.Toast;
import com.owncloud.android.R;
import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.files.StreamMediaFileOperation;
import com.owncloud.android.lib.common.OwnCloudAccount;
import com.owncloud.android.lib.common.OwnCloudClient;
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory;
import com.owncloud.android.lib.common.accounts.AccountUtils;
import com.owncloud.android.lib.common.operations.RemoteOperationResult;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.ui.activity.FileActivity;
import com.owncloud.android.ui.activity.FileDisplayActivity;
import com.owncloud.android.ui.notifications.NotificationUtils;
import com.owncloud.android.utils.ThemeUtils;
import java.io.IOException;
import java.lang.ref.WeakReference;
import androidx.core.app.NotificationCompat;
import lombok.Getter;
import lombok.Setter;
/**
* Service that handles media playback, both audio and video.
*
* Waits for Intents which signal the service to perform specific operations: Play, Pause,
* Rewind, etc.
*/
public class MediaService extends Service implements OnCompletionListener, OnPreparedListener,
OnErrorListener, AudioManager.OnAudioFocusChangeListener {
private static final String TAG = MediaService.class.getSimpleName();
private static final String MY_PACKAGE = MediaService.class.getPackage() != null ?
MediaService.class.getPackage().getName() : "com.owncloud.android.media";
/// Intent actions that we are prepared to handle
public static final String ACTION_PLAY_FILE = MY_PACKAGE + ".action.PLAY_FILE";
public static final String ACTION_STOP_ALL = MY_PACKAGE + ".action.STOP_ALL";
/// PreferenceKeys to add extras to the action
public static final String EXTRA_FILE = MY_PACKAGE + ".extra.FILE";
public static final String EXTRA_ACCOUNT = MY_PACKAGE + ".extra.ACCOUNT";
public static final String EXTRA_START_POSITION = MY_PACKAGE + ".extra.START_POSITION";
public static final String EXTRA_PLAY_ON_LOAD = MY_PACKAGE + ".extra.PLAY_ON_LOAD";
/** Error code for specific messages - see regular error codes at {@link MediaPlayer} */
public static final int OC_MEDIA_ERROR = 0;
/** Time To keep the control panel visible when the user does not use it */
public static final int MEDIA_CONTROL_SHORT_LIFE = 4000;
/** Time To keep the control panel visible when the user does not use it */
public static final int MEDIA_CONTROL_PERMANENT = 0;
/** Volume to set when audio focus is lost and ducking is allowed */
private static final float DUCK_VOLUME = 0.1f;
/** Media player instance */
@Getter private MediaPlayer player;
/** Reference to the system AudioManager */
private AudioManager audioManager;
/** Values to indicate the state of the service */
enum State {
STOPPED,
PREPARING,
PLAYING,
PAUSED
}
/** Current state */
@Getter private State state = State.STOPPED;
/** Possible focus values */
enum AudioFocus {
NO_FOCUS,
NO_FOCUS_CAN_DUCK,
FOCUS
}
/** Current focus state */
private AudioFocus audioFocus = AudioFocus.NO_FOCUS;
/** Wifi lock kept to prevents the device from shutting off the radio when streaming a file. */
private WifiLock wifiLock;
private static final String MEDIA_WIFI_LOCK_TAG = MY_PACKAGE + ".WIFI_LOCK";
/** Notification to keep in the notification bar while a song is playing */
private NotificationManager notificationManager;
/** File being played */
@Getter private OCFile currentFile;
/** Account holding the file being played */
private Account account;
/** Flag signaling if the audio should be played immediately when the file is prepared */
protected boolean playOnPrepared;
/** Position, in milliseconds, where the audio should be started */
private int startPosition;
/** Interface to access the service through binding */
private IBinder binder;
/** Control panel shown to the user to control the playback, to register through binding */
@Getter @Setter private MediaControlView mediaController;
/** Notification builder to create notifications, new reuse way since Android 6 */
private NotificationCompat.Builder notificationBuilder;
/**
* Helper method to get an error message suitable to show to users for errors occurred in media playback,
*
* @param context A context to access string resources.
* @param what See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* @param extra See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* @return Message suitable to users.
*/
public static String getMessageForMediaError(Context context, int what, int extra) {
int messageId;
if (what == OC_MEDIA_ERROR) {
messageId = extra;
} else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) {
/* Added in API level 17
Bitstream is conforming to the related coding standard or file spec,
but the media framework does not support the feature.
Constant Value: -1010 (0xfffffc0e)
*/
messageId = R.string.media_err_unsupported;
} else if (extra == MediaPlayer.MEDIA_ERROR_IO) {
/* Added in API level 17
File or network related operation errors.
Constant Value: -1004 (0xfffffc14)
*/
messageId = R.string.media_err_io;
} else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) {
/* Added in API level 17
Bitstream is not conforming to the related coding standard or file spec.
Constant Value: -1007 (0xfffffc11)
*/
messageId = R.string.media_err_malformed;
} else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) {
/* Added in API level 17
Some operation takes too long to complete, usually more than 3-5 seconds.
Constant Value: -110 (0xffffff92)
*/
messageId = R.string.media_err_timeout;
} else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) {
/* Added in API level 3
The video is streamed and its container is not valid for progressive playback i.e the video's index
(e.g moov atom) is not at the start of the file.
Constant Value: 200 (0x000000c8)
*/
messageId = R.string.media_err_invalid_progressive_playback;
} else {
/* MediaPlayer.MEDIA_ERROR_UNKNOWN
Added in API level 1
Unspecified media player error.
Constant Value: 1 (0x00000001)
*/
/* MediaPlayer.MEDIA_ERROR_SERVER_DIED)
Added in API level 1
Media server died. In this case, the application must release the MediaPlayer
object and instantiate a new one.
Constant Value: 100 (0x00000064)
*/
messageId = R.string.media_err_unknown;
}
return context.getString(messageId);
}
/**
* Initialize a service instance
*
* {@inheritDoc}
*/
@Override
public void onCreate() {
super.onCreate();
Log_OC.d(TAG, "Creating ownCloud media service");
wifiLock = ((WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE)).
createWifiLock(WifiManager.WIFI_MODE_FULL, MEDIA_WIFI_LOCK_TAG);
notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
notificationBuilder = new NotificationCompat.Builder(this);
notificationBuilder.setColor(ThemeUtils.primaryColor(this));
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
binder = new MediaServiceBinder(this);
}
/**
* Entry point for Intents requesting actions, sent here via startService.
*
* {@inheritDoc}
*/
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent.getAction();
if (ACTION_PLAY_FILE.equals(action)) {
processPlayFileRequest(intent);
} else if (ACTION_STOP_ALL.equals(action)) {
processStopRequest(true);
}
return START_NOT_STICKY; // don't want it to restart in case it's killed.
}
/**
* Processes a request to play a media file received as a parameter
*
* TODO If a new request is received when a file is being prepared, it is ignored. Is this what we want?
*
* @param intent Intent received in the request with the data to identify the file to play.
*/
private void processPlayFileRequest(Intent intent) {
if (state != State.PREPARING) {
currentFile = intent.getExtras().getParcelable(EXTRA_FILE);
account = intent.getExtras().getParcelable(EXTRA_ACCOUNT);
playOnPrepared = intent.getExtras().getBoolean(EXTRA_PLAY_ON_LOAD, false);
startPosition = intent.getExtras().getInt(EXTRA_START_POSITION, 0);
tryToGetAudioFocus();
playMedia();
}
}
/**
* Processes a request to play a media file.
*/
protected void processPlayRequest() {
// request audio focus
tryToGetAudioFocus();
// actually play the song
if (state == State.STOPPED) {
// (re)start playback
playMedia();
} else if (state == State.PAUSED) {
// continue playback
state = State.PLAYING;
setUpAsForeground(String.format(getString(R.string.media_state_playing), currentFile.getFileName()));
configAndStartMediaPlayer();
}
}
/**
* Makes sure the media player exists and has been reset. This will create the media player
* if needed. reset the existing media player if one already exists.
*/
protected void createMediaPlayerIfNeeded() {
if (player == null) {
player = new MediaPlayer();
// make sure the CPU won't go to sleep while media is playing
player.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
// the media player will notify the service when it's ready preparing, and when it's done playing
player.setOnPreparedListener(this);
player.setOnCompletionListener(this);
player.setOnErrorListener(this);
} else {
player.reset();
}
}
/**
* Processes a request to pause the current playback
*/
protected void processPauseRequest() {
if (state == State.PLAYING) {
state = State.PAUSED;
player.pause();
releaseResources(false); // retain media player in pause
// TODO polite audio focus, instead of keep it owned; or not?
}
}
/**
* Processes a request to stop the playback.
*
* @param force When 'true', the playback is stopped no matter the value of state
*/
protected void processStopRequest(boolean force) {
if (state != State.PREPARING || force) {
state = State.STOPPED;
currentFile = null;
account = null;
releaseResources(true);
giveUpAudioFocus();
stopSelf(); // service is no longer necessary
}
}
/**
* Releases resources used by the service for playback. This includes the "foreground service"
* status and notification, the wake locks and possibly the MediaPlayer.
*
* @param releaseMediaPlayer Indicates whether the Media Player should also be released or not
*/
protected void releaseResources(boolean releaseMediaPlayer) {
// stop being a foreground service
stopForeground(true);
// stop and release the Media Player, if it's available
if (releaseMediaPlayer && player != null) {
player.reset();
player.release();
player = null;
}
// release the Wifi lock, if holding it
if (wifiLock.isHeld()) {
wifiLock.release();
}
}
/**
* Fully releases the audio focus.
*/
private void giveUpAudioFocus() {
if (audioFocus == AudioFocus.FOCUS
&& audioManager != null
&& AudioManager.AUDIOFOCUS_REQUEST_GRANTED == audioManager.abandonAudioFocus(this)) {
audioFocus = AudioFocus.NO_FOCUS;
}
}
/**
* Reconfigures MediaPlayer according to audio focus settings and starts/restarts it.
*/
protected void configAndStartMediaPlayer() {
if (player == null) {
throw new IllegalStateException("player is NULL");
}
if (audioFocus == AudioFocus.NO_FOCUS) {
if (player.isPlaying()) {
player.pause(); // have to be polite; but state is not changed, to resume when focus is received again
}
} else {
if (audioFocus == AudioFocus.NO_FOCUS_CAN_DUCK) {
player.setVolume(DUCK_VOLUME, DUCK_VOLUME);
} else {
player.setVolume(1.0f, 1.0f); // full volume
}
if (!player.isPlaying()) {
player.start();
}
}
}
/**
* Requests the audio focus to the Audio Manager
*/
private void tryToGetAudioFocus() {
if (audioFocus != AudioFocus.FOCUS
&& audioManager != null
&& AudioManager.AUDIOFOCUS_REQUEST_GRANTED == audioManager.requestAudioFocus(this,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN)
) {
audioFocus = AudioFocus.FOCUS;
}
}
/**
* Starts playing the current media file.
*/
protected void playMedia() {
state = State.STOPPED;
releaseResources(false); // release everything except MediaPlayer
try {
if (currentFile == null) {
Toast.makeText(this, R.string.media_err_nothing_to_play, Toast.LENGTH_LONG).show();
processStopRequest(true);
return;
} else if (account == null) {
Toast.makeText(this, R.string.media_err_not_in_owncloud, Toast.LENGTH_LONG).show();
processStopRequest(true);
return;
}
createMediaPlayerIfNeeded();
player.setAudioStreamType(AudioManager.STREAM_MUSIC);
if (currentFile.isDown()) {
player.setDataSource(currentFile.getStoragePath());
preparePlayer();
} else {
OwnCloudAccount ocAccount = new OwnCloudAccount(account, getBaseContext());
OwnCloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton().
getClientFor(ocAccount, getBaseContext());
new LoadStreamUrl(this, client).execute(currentFile.getLocalId());
}
} catch (AccountUtils.AccountNotFoundException | OperationCanceledException | AuthenticatorException e) {
Log_OC.e(TAG, "Loading stream url not possible: " + e.getMessage());
} catch (SecurityException | IOException | IllegalStateException | IllegalArgumentException e) {
Log_OC.e(TAG, e.getClass().getSimpleName() + " playing " + account.name + currentFile.getRemotePath(), e);
Toast.makeText(this, String.format(getString(R.string.media_err_playing), currentFile.getFileName()),
Toast.LENGTH_LONG).show();
processStopRequest(true);
}
}
private void preparePlayer() {
state = State.PREPARING;
setUpAsForeground(String.format(getString(R.string.media_state_loading), currentFile.getFileName()));
// starts preparing the media player in background
player.prepareAsync();
}
/** Called when media player is done playing current song. */
public void onCompletion(MediaPlayer player) {
Toast.makeText(this, String.format(getString(R.string.media_event_done), currentFile.getFileName()), Toast.LENGTH_LONG).show();
if (mediaController != null) {
// somebody is still bound to the service
player.seekTo(0);
processPauseRequest();
mediaController.updatePausePlay();
} else {
// nobody is bound
processStopRequest(true);
}
}
/**
* Called when media player is done preparing.
*
* Time to start.
*/
public void onPrepared(MediaPlayer player) {
state = State.PLAYING;
updateNotification(String.format(getString(R.string.media_state_playing), currentFile.getFileName()));
if (mediaController != null) {
mediaController.setEnabled(true);
}
player.seekTo(startPosition);
configAndStartMediaPlayer();
if (!playOnPrepared) {
processPauseRequest();
}
if (mediaController != null) {
mediaController.updatePausePlay();
}
}
/**
* Updates the status notification
*/
private void updateNotification(String content) {
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
// TODO check if updating the Intent is really necessary
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, currentFile);
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, account);
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
notificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
(int) System.currentTimeMillis(),
showDetailsIntent,
PendingIntent.FLAG_UPDATE_CURRENT));
notificationBuilder.setWhen(System.currentTimeMillis());
notificationBuilder.setTicker(ticker);
notificationBuilder.setContentTitle(ticker);
notificationBuilder.setContentText(content);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
notificationBuilder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA);
}
notificationManager.notify(R.string.media_notif_ticker, notificationBuilder.build());
}
/**
* Configures the service as a foreground service.
*
* The system will avoid finishing the service as much as possible when resources as low.
*
* A notification must be created to keep the user aware of the existence of the service.
*/
private void setUpAsForeground(String content) {
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
/// creates status notification
// TODO put a progress bar to follow the playback progress
notificationBuilder.setSmallIcon(R.drawable.ic_play_arrow);
//mNotification.tickerText = text;
notificationBuilder.setWhen(System.currentTimeMillis());
notificationBuilder.setOngoing(true);
/// includes a pending intent in the notification showing the details view of the file
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, currentFile);
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, account);
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
notificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
(int) System.currentTimeMillis(),
showDetailsIntent,
PendingIntent.FLAG_UPDATE_CURRENT));
notificationBuilder.setContentTitle(ticker);
notificationBuilder.setContentText(content);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
notificationBuilder.setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA);
}
startForeground(R.string.media_notif_ticker, notificationBuilder.build());
}
/**
* Called when there's an error playing media.
*
* Warns the user about the error and resets the media player.
*/
public boolean onError(MediaPlayer mp, int what, int extra) {
Log_OC.e(TAG, "Error in audio playback, what = " + what + ", extra = " + extra);
String message = getMessageForMediaError(this, what, extra);
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show();
processStopRequest(true);
return true;
}
/**
* Called by the system when another app tries to play some sound.
*
* {@inheritDoc}
*/
@Override
public void onAudioFocusChange(int focusChange) {
if (focusChange > 0) {
// focus gain; check AudioManager.AUDIOFOCUS_* values
audioFocus = AudioFocus.FOCUS;
// restart media player with new focus settings
if (state == State.PLAYING) {
configAndStartMediaPlayer();
}
} else if (focusChange < 0) {
// focus loss; check AudioManager.AUDIOFOCUS_* values
boolean canDuck = AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK == focusChange;
audioFocus = canDuck ? AudioFocus.NO_FOCUS_CAN_DUCK : AudioFocus.NO_FOCUS;
// start/restart/pause media player with new focus settings
if (player != null && player.isPlaying()) {
configAndStartMediaPlayer();
}
}
}
/**
* Called when the service is finished for final clean-up.
*
* {@inheritDoc}
*/
@Override
public void onDestroy() {
state = State.STOPPED;
releaseResources(true);
giveUpAudioFocus();
stopForeground(true);
super.onDestroy();
}
/**
* Provides a binder object that clients can use to perform operations on the MediaPlayer managed by the MediaService.
*/
@Override
public IBinder onBind(Intent arg) {
return binder;
}
/**
* Called when ALL the bound clients were onbound.
*
* The service is destroyed if playback stopped or paused
*/
@Override
public boolean onUnbind(Intent intent) {
if (state == State.PAUSED || state == State.STOPPED) {
processStopRequest(false);
}
return false; // not accepting rebinding (default behaviour)
}
private static class LoadStreamUrl extends AsyncTask {
private OwnCloudClient client;
private WeakReference mediaServiceWeakReference;
public LoadStreamUrl(MediaService mediaService, OwnCloudClient client) {
this.client = client;
this.mediaServiceWeakReference = new WeakReference<>(mediaService);
}
@Override
protected String doInBackground(String... fileId) {
StreamMediaFileOperation sfo = new StreamMediaFileOperation(fileId[0]);
RemoteOperationResult result = sfo.execute(client);
if (!result.isSuccess()) {
return null;
}
return (String) result.getData().get(0);
}
@Override
protected void onPostExecute(String url) {
MediaService mediaService = mediaServiceWeakReference.get();
if (mediaService != null && mediaService.getCurrentFile() != null) {
if (url != null) {
try {
mediaService.player.setDataSource(url);
// prevent the Wifi from going to sleep when streaming
mediaService.wifiLock.acquire();
mediaService.preparePlayer();
} catch (IOException e) {
Log_OC.e(TAG, "Streaming not possible: " + e.getMessage());
}
} else {
// we already show a toast with error from media player
mediaService.processStopRequest(true);
}
}
}
}
}