diff --git a/build.gradle b/build.gradle index 6039557578e2..478aafdea268 100644 --- a/build.gradle +++ b/build.gradle @@ -345,6 +345,8 @@ dependencies { // androidJacocoAnt "org.jacoco:org.jacoco.core:${jacocoVersion}" // androidJacocoAnt "org.jacoco:org.jacoco.report:${jacocoVersion}" // androidJacocoAnt "org.jacoco:org.jacoco.agent:${jacocoVersion}" + + implementation "com.github.stateless4j:stateless4j:2.6.0" } configurations.all { diff --git a/scripts/analysis/findbugs-results.txt b/scripts/analysis/findbugs-results.txt index 3560666719ff..b872400059e8 100644 --- a/scripts/analysis/findbugs-results.txt +++ b/scripts/analysis/findbugs-results.txt @@ -1 +1 @@ -429 +427 \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 8ddccdbd0b72..f3ce8e29b0bc 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -307,7 +307,7 @@ - + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.media.AudioManager + +/** + * Simplified audio focus values, relevant to application's media player experience. + */ +internal enum class AudioFocus { + + LOST, + DUCK, + FOCUS; + + companion object { + fun fromPlatformFocus(audioFocus: Int): AudioFocus? = when (audioFocus) { + AudioManager.AUDIOFOCUS_GAIN -> FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> FOCUS + AudioManager.AUDIOFOCUS_LOSS -> LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> DUCK + else -> null + } + } +} diff --git a/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt b/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt new file mode 100644 index 000000000000..89580abcdcf8 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/AudioFocusManager.kt @@ -0,0 +1,95 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build + +/** + * Wrapper around audio manager exposing simplified audio focus API and + * hiding platform API level differences. + * + * @param audioManger Platform audio manager + * @param onFocusChange Called when audio focus changes, including acquired and released focus states + */ +internal class AudioFocusManager( + private val audioManger: AudioManager, + private val onFocusChange: (AudioFocus) -> Unit +) { + + private val focusListener = object : AudioManager.OnAudioFocusChangeListener { + override fun onAudioFocusChange(focusChange: Int) { + val focus = when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> AudioFocus.FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> AudioFocus.FOCUS + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> AudioFocus.FOCUS + AudioManager.AUDIOFOCUS_LOSS -> AudioFocus.LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> AudioFocus.LOST + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> AudioFocus.DUCK + else -> null + } + focus?.let { onFocusChange(it) } + } + } + private var focusRequest: AudioFocusRequest? = null + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { + setWillPauseWhenDucked(true) + setOnAudioFocusChangeListener(focusListener) + }.build() + } + } + + /** + * Request audio focus. Focus is reported via callback. + * If focus cannot be gained, lost of focus is reported. + */ + fun requestFocus() { + val requestResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest?.let { audioManger.requestAudioFocus(it) } + } else { + audioManger.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + } + + if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + } else { + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + } + } + + /** + * Release audio focus. Loss of focus is reported via callback. + */ + fun releaseFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest?.let { + audioManger.abandonAudioFocusRequest(it) + } ?: AudioManager.AUDIOFOCUS_REQUEST_FAILED + } else { + audioManger.abandonAudioFocus(focusListener) + } + focusListener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + } +} diff --git a/src/main/java/com/nextcloud/client/media/ErrorFormat.kt b/src/main/java/com/nextcloud/client/media/ErrorFormat.kt new file mode 100644 index 000000000000..d785966e13cc --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/ErrorFormat.kt @@ -0,0 +1,94 @@ +/** + * Nextcloud Android client application + * + * @author David A. Velasco + * @author masensio + * @author Chris Narkiewicz + * Copyright (C) 2013 David A. Velasco + * Copyright (C) 2016 masensio + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.content.Context +import android.media.MediaPlayer +import com.owncloud.android.R + +/** + * This code has been moved from legacy media player service. + */ +@Deprecated("This legacy helper should be refactored") +@Suppress("ComplexMethod") // it's legacy code +object ErrorFormat { + + /** Error code for specific messages - see regular error codes at [MediaPlayer] */ + const val OC_MEDIA_ERROR = 0 + + @JvmStatic + fun toString(context: Context?, what: Int, extra: Int): String { + val messageId: Int + + 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) ?: "Media error" + } +} diff --git a/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt b/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt new file mode 100644 index 000000000000..fdd93606a138 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/LoadUrlTask.kt @@ -0,0 +1,49 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * @author Tobias Kaminsky + * + * Copyright (C) 2019 Chris Narkiewicz + * Copyright (C) 2018 Tobias Kaminsky + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.os.AsyncTask +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient + +internal class LoadUrlTask( + private val client: OwnCloudClient, + private val fileId: String, + private val onResult: (String?) -> Unit +) : AsyncTask() { + + override fun doInBackground(vararg args: Void): String? { + val operation = StreamMediaFileOperation(fileId) + val result = operation.execute(client) + return when (result.isSuccess) { + true -> result.data[0] as String + false -> null + } + } + + override fun onPostExecute(url: String?) { + if (!isCancelled) { + onResult(url) + } + } +} diff --git a/src/main/java/com/nextcloud/client/media/Player.kt b/src/main/java/com/nextcloud/client/media/Player.kt new file mode 100644 index 000000000000..d3fbe7b87957 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/Player.kt @@ -0,0 +1,310 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.accounts.Account +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.PowerManager +import android.widget.MediaController +import com.nextcloud.client.media.PlayerStateMachine.Event +import com.nextcloud.client.media.PlayerStateMachine.State +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +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.utils.Log_OC + +@Suppress("TooManyFunctions") +internal class Player( + private val context: Context, + private val listener: Listener? = null, + audioManager: AudioManager, + private val mediaPlayerCreator: () -> MediaPlayer = { MediaPlayer() } +) : MediaController.MediaPlayerControl { + + private companion object { + const val DEFAULT_VOLUME = 1.0f + const val DUCK_VOLUME = 0.1f + const val MIN_DURATION_ALLOWING_SEEK = 3000 + } + + interface Listener { + fun onRunning(file: OCFile) + fun onStart() + fun onPause() + fun onStop() + fun onError(error: PlayerError) + } + + private var stateMachine: PlayerStateMachine + private var loadUrlTask: LoadUrlTask? = null + + private var enqueuedFile: PlaylistItem? = null + + private var playedFile: OCFile? = null + private var startPositionMs: Int = 0 + private var autoPlay = true + private var account: Account? = null + private var dataSource: String? = null + private var lastError: PlayerError? = null + private var mediaPlayer: MediaPlayer? = null + private val focusManager = AudioFocusManager(audioManager, this::onAudioFocusChange) + + private val delegate = object : PlayerStateMachine.Delegate { + override val isDownloaded: Boolean get() = playedFile?.isDown ?: false + override val isAutoplayEnabled: Boolean get() = autoPlay + override val hasEnqueuedFile: Boolean get() = enqueuedFile != null + + override fun onStartRunning() { + trace("onStartRunning()") + enqueuedFile.let { + if (it != null) { + playedFile = it.file + startPositionMs = it.startPositionMs + autoPlay = it.autoPlay + account = it.account + dataSource = if (it.file.isDown) it.file.storagePath else null + listener?.onRunning(it.file) + } else { + throw IllegalStateException("Player started without enqueued file.") + } + } + } + + override fun onStartDownloading() { + trace("onStartDownloading()") + if (playedFile == null) { + throw IllegalStateException("File not set.") + } + playedFile?.let { + val client = buildClient() + val task = LoadUrlTask(client, it.remoteId, this@Player::onDownloaded) + task.execute() + loadUrlTask = task + } + } + + override fun onPrepare() { + trace("onPrepare()") + mediaPlayer = mediaPlayerCreator.invoke() + mediaPlayer?.setOnErrorListener(this@Player::onMediaPlayerError) + mediaPlayer?.setOnPreparedListener(this@Player::onMediaPlayerPrepared) + mediaPlayer?.setOnCompletionListener(this@Player::onMediaPlayerCompleted) + mediaPlayer?.setOnBufferingUpdateListener(this@Player::onMediaPlayerBufferingUpdate) + mediaPlayer?.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) + mediaPlayer?.setDataSource(dataSource) + mediaPlayer?.setAudioStreamType(AudioManager.STREAM_MUSIC) + mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) + mediaPlayer?.prepareAsync() + } + + override fun onStopped() { + trace("onStoppped()") + mediaPlayer?.stop() + mediaPlayer?.reset() + mediaPlayer?.release() + mediaPlayer = null + + playedFile = null + startPositionMs = 0 + account = null + autoPlay = true + dataSource = null + loadUrlTask?.cancel(true) + loadUrlTask = null + listener?.onStop() + } + + override fun onError() { + trace("onError()") + this.onStopped() + lastError?.let { + this@Player.listener?.onError(it) + } + if (lastError == null) { + this@Player.listener?.onError(PlayerError("Unknown")) + } + } + + override fun onStartPlayback() { + trace("onStartPlayback()") + mediaPlayer?.start() + listener?.onStart() + } + + override fun onPausePlayback() { + trace("onPausePlayback()") + mediaPlayer?.pause() + listener?.onPause() + } + + override fun onRequestFocus() { + trace("onRequestFocus()") + focusManager.requestFocus() + } + + override fun onReleaseFocus() { + trace("onReleaseFocus()") + focusManager.releaseFocus() + } + + override fun onAudioDuck(enabled: Boolean) { + trace("onAudioDuck(): $enabled") + if (enabled) { + mediaPlayer?.setVolume(DUCK_VOLUME, DUCK_VOLUME) + } else { + mediaPlayer?.setVolume(DEFAULT_VOLUME, DEFAULT_VOLUME) + } + } + } + + init { + stateMachine = PlayerStateMachine(delegate) + } + + fun play(item: PlaylistItem) { + if (item.file != playedFile) { + stateMachine.post(Event.STOP) + this.enqueuedFile = item + stateMachine.post(Event.PLAY) + } + } + + fun stop() { + stateMachine.post(Event.STOP) + } + + fun stop(file: OCFile) { + if (playedFile == file) { + stateMachine.post(Event.STOP) + } + } + + private fun onMediaPlayerError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + lastError = PlayerError(ErrorFormat.toString(context, what, extra)) + stateMachine.post(Event.ERROR) + return true + } + + private fun onMediaPlayerPrepared(mp: MediaPlayer) { + trace("onMediaPlayerPrepared()") + stateMachine.post(Event.PREPARED) + } + + private fun onMediaPlayerCompleted(mp: MediaPlayer) { + stateMachine.post(Event.STOP) + } + + private fun onMediaPlayerBufferingUpdate(mp: MediaPlayer, percent: Int) { + trace("onMediaPlayerBufferingUpdate(): $percent") + } + + private fun onDownloaded(url: String?) { + if (url != null) { + dataSource = url + stateMachine.post(Event.DOWNLOADED) + } else { + lastError = PlayerError(context.getString(R.string.media_err_io)) + stateMachine.post(Event.ERROR) + } + } + + private fun onAudioFocusChange(focus: AudioFocus) { + when (focus) { + AudioFocus.FOCUS -> stateMachine.post(Event.FOCUS_GAIN) + AudioFocus.DUCK -> stateMachine.post(Event.FOCUS_DUCK) + AudioFocus.LOST -> stateMachine.post(Event.FOCUS_LOST) + } + } + + // this should be refactored into a proper, injectable factory + private fun buildClient(): OwnCloudClient { + val account = this.account + if (account != null) { + val ocAccount = OwnCloudAccount(account, context) + return OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) + } else { + throw IllegalArgumentException("Account not set") + } + } + + private fun trace(fmt: String, vararg args: Any?) { + Log_OC.v(javaClass.simpleName, fmt.format(args)) + } + + // region Media player controls + + override fun isPlaying(): Boolean { + return stateMachine.isInState(State.PLAYING) + } + + override fun canSeekForward(): Boolean { + return duration > MIN_DURATION_ALLOWING_SEEK + } + + override fun canSeekBackward(): Boolean { + return duration > MIN_DURATION_ALLOWING_SEEK + } + + override fun getDuration(): Int { + val hasDuration = setOf(State.PLAYING, State.PAUSED) + .find { stateMachine.isInState(it) } != null + return if (hasDuration) { + mediaPlayer?.duration ?: 0 + } else { + 0 + } + } + + override fun pause() { + stateMachine.post(Event.PAUSE) + } + + override fun getBufferPercentage(): Int { + return 0 + } + + override fun seekTo(pos: Int) { + if (stateMachine.isInState(State.PLAYING)) { + mediaPlayer?.seekTo(pos) + } + } + + override fun getCurrentPosition(): Int { + return mediaPlayer?.currentPosition ?: 0 + } + + override fun start() { + stateMachine.post(Event.PLAY) + } + + override fun getAudioSessionId(): Int { + return 0 + } + + override fun canPause(): Boolean { + return stateMachine.isInState(State.PLAYING) + } + + // endregion +} diff --git a/src/main/java/com/nextcloud/client/media/PlayerError.kt b/src/main/java/com/nextcloud/client/media/PlayerError.kt new file mode 100644 index 000000000000..6c56b139a145 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/PlayerError.kt @@ -0,0 +1,3 @@ +package com.nextcloud.client.media + +data class PlayerError(val message: String) diff --git a/src/main/java/com/nextcloud/client/media/PlayerService.kt b/src/main/java/com/nextcloud/client/media/PlayerService.kt new file mode 100644 index 000000000000..1302d6d91970 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/PlayerService.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.accounts.Account +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.media.AudioManager +import android.os.Bundle +import android.os.IBinder +import android.widget.MediaController +import android.widget.Toast +import androidx.core.app.NotificationCompat +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.ThemeUtils +import dagger.android.AndroidInjection +import java.lang.IllegalArgumentException +import javax.inject.Inject + +class PlayerService : Service() { + + companion object { + const val EXTRA_ACCOUNT = "ACCOUNT" + const val EXTRA_FILE = "FILE" + const val EXTRA_AUTO_PLAY = "EXTRA_AUTO_PLAY" + const val EXTRA_START_POSITION_MS = "START_POSITION_MS" + const val ACTION_PLAY = "PLAY" + const val ACTION_STOP = "STOP" + const val ACTION_STOP_FILE = "STOP_FILE" + } + + class Binder(val service: PlayerService) : android.os.Binder() { + + /** + * This property returns current instance of media player interface. + * It is not cached and it is suitable for polling. + */ + val player: MediaController.MediaPlayerControl get() = service.player + } + + private val playerListener = object : Player.Listener { + + override fun onRunning(file: OCFile) { + startForeground(file) + } + + override fun onStart() { + // empty + } + + override fun onPause() { + // empty + } + + override fun onStop() { + stopForeground(true) + } + + override fun onError(error: PlayerError) { + Toast.makeText(this@PlayerService, error.message, Toast.LENGTH_SHORT).show() + } + } + + @Inject + protected lateinit var audioManager: AudioManager + + private lateinit var player: Player + private lateinit var notificationBuilder: NotificationCompat.Builder + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + player = Player(applicationContext, playerListener, audioManager) + notificationBuilder = NotificationCompat.Builder(this) + notificationBuilder.color = ThemeUtils.primaryColor(this) + val stop = Intent(this, PlayerService::class.java) + stop.action = ACTION_STOP + val pendingStop = PendingIntent.getService(this, 0, stop, 0) + notificationBuilder.addAction(0, "STOP", pendingStop) + } + + override fun onBind(intent: Intent?): IBinder? { + return Binder(this) + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + when (intent.action) { + ACTION_PLAY -> onActionPlay(intent) + ACTION_STOP -> onActionStop() + ACTION_STOP_FILE -> onActionStopFile(intent.extras) + } + return START_NOT_STICKY + } + + private fun onActionPlay(intent: Intent) { + val account: Account = intent.getParcelableExtra(EXTRA_ACCOUNT) + val file: OCFile = intent.getParcelableExtra(EXTRA_FILE) + val startPos = intent.getIntExtra(EXTRA_START_POSITION_MS, 0) + val autoPlay = intent.getBooleanExtra(EXTRA_AUTO_PLAY, true) + val item = PlaylistItem(file = file, startPositionMs = startPos, autoPlay = autoPlay, account = account) + player.play(item) + } + + private fun onActionStop() { + player.stop() + } + + private fun onActionStopFile(args: Bundle?) { + val file: OCFile = args?.getParcelable(EXTRA_FILE) ?: throw IllegalArgumentException("Missing file argument") + player.stop(file) + } + + private fun startForeground(currentFile: OCFile) { + val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) + val content = getString(R.string.media_state_playing, currentFile.getFileName()) + notificationBuilder.setSmallIcon(R.drawable.ic_play_arrow) + notificationBuilder.setWhen(System.currentTimeMillis()) + notificationBuilder.setOngoing(true) + 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()) + } +} diff --git a/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt b/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt new file mode 100644 index 000000000000..c46c4018d331 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/PlayerServiceConnection.kt @@ -0,0 +1,134 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.accounts.Account +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.widget.MediaController +import com.owncloud.android.datamodel.OCFile + +@Suppress("TooManyFunctions") // implementing large interface +class PlayerServiceConnection(private val context: Context) : MediaController.MediaPlayerControl { + + var isConnected: Boolean = false + private set + + private var binder: PlayerService.Binder? = null + + fun bind() { + val intent = Intent(context, PlayerService::class.java) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + fun unbind() { + if (isConnected) { + binder = null + isConnected = false + context.unbindService(connection) + } + } + + fun start(account: Account, file: OCFile, playImmediately: Boolean, position: Int) { + val i = Intent(context, PlayerService::class.java) + i.putExtra(PlayerService.EXTRA_ACCOUNT, account) + i.putExtra(PlayerService.EXTRA_FILE, file) + i.putExtra(PlayerService.EXTRA_AUTO_PLAY, playImmediately) + i.putExtra(PlayerService.EXTRA_START_POSITION_MS, position) + i.action = PlayerService.ACTION_PLAY + context.startService(i) + } + + fun stop(file: OCFile) { + val i = Intent(context, PlayerService::class.java) + i.putExtra(PlayerService.EXTRA_FILE, file) + i.action = PlayerService.ACTION_STOP_FILE + context.startService(i) + } + + fun stop() { + val i = Intent(context, PlayerService::class.java) + i.action = PlayerService.ACTION_STOP + context.startService(i) + } + + private val connection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName?) { + isConnected = false + binder = null + } + + override fun onServiceConnected(name: ComponentName?, localBinder: IBinder?) { + binder = localBinder as PlayerService.Binder + isConnected = true + } + } + + // region Media controller + + override fun isPlaying(): Boolean { + return binder?.player?.isPlaying ?: false + } + + override fun canSeekForward(): Boolean { + return binder?.player?.canSeekForward() ?: false + } + + override fun getDuration(): Int { + return binder?.player?.duration ?: 0 + } + + override fun pause() { + binder?.player?.pause() + } + + override fun getBufferPercentage(): Int { + return binder?.player?.bufferPercentage ?: 0 + } + + override fun seekTo(pos: Int) { + binder?.player?.seekTo(pos) + } + + override fun getCurrentPosition(): Int { + return binder?.player?.currentPosition ?: 0 + } + + override fun canSeekBackward(): Boolean { + return binder?.player?.canSeekBackward() ?: false + } + + override fun start() { + binder?.player?.start() + } + + override fun getAudioSessionId(): Int { + return 0 + } + + override fun canPause(): Boolean { + return binder?.player?.canPause() ?: false + } + + // endregion +} diff --git a/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt b/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt new file mode 100644 index 000000000000..b99783fb14f4 --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/PlayerStateMachine.kt @@ -0,0 +1,229 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import com.github.oxo42.stateless4j.StateMachine +import com.github.oxo42.stateless4j.StateMachineConfig +import com.github.oxo42.stateless4j.delegates.Action +import com.github.oxo42.stateless4j.transitions.Transition +import java.util.ArrayDeque + +/* + * To see visual representation of the state machine, install PlanUml plugin. + * http://plantuml.com/ + * + * @startuml + * + * note "> - entry action\n< - exit action\n[exp] - transition guard\nfunction() - transition action" as README + * + * [*] --> STOPPED + * STOPPED --> RUNNING: PLAY\n[hasEnqueuedFile] + * RUNNING --> STOPPED: STOP\nonStop + * RUNNING --> STOPPED: ERROR\nonError + * RUNNING: >onStartRunning + * + * state RUNNING { + * [*] --> DOWNLOADING: [!isDownloaded] + * [*] --> PREPARING: [isDownloaded] + * DOWNLOADING: >onStartDownloading + * DOWNLOADING --> PREPARING: DOWNLOADED + * + * PREPARING: >onPrepare + * PREPARING --> PLAYING: PREPARED\n[autoPlay] + * PREPARING --> PAUSED: PREPARED\n[!autoPlay] + * PLAYING --> PAUSED: PAUSE\nFOCUS_LOST + * + * PAUSED: >onPausePlayback + * PAUSED --> PLAYING: PLAY + * + * PLAYING: >onRequestFocus + * PLAYING: AWAIT_FOCUS + * AWAIT_FOCUS --> FOCUSED: FOCUS_GAIN\nonStartPlayback() + * FOCUSED -l-> DUCKED: FOCUS_DUCK + * DUCKED: >onAudioDuck(true)\n FOCUSED: FOCUS_GAIN + * } + * } + * + * @enduml + */ +internal class PlayerStateMachine(initialState: State, private val delegate: Delegate) { + + constructor(delegate: Delegate) : this(State.STOPPED, delegate) + + interface Delegate { + val isDownloaded: Boolean + val isAutoplayEnabled: Boolean + val hasEnqueuedFile: Boolean + + fun onStartRunning() + fun onStartDownloading() + fun onPrepare() + fun onStopped() + fun onError() + fun onStartPlayback() + fun onPausePlayback() + fun onRequestFocus() + fun onReleaseFocus() + fun onAudioDuck(enabled: Boolean) + } + + enum class State { + STOPPED, + RUNNING, + RUNNING_INITIAL, + DOWNLOADING, + PREPARING, + PAUSED, + PLAYING, + AWAIT_FOCUS, + FOCUSED, + DUCKED + } + + enum class Event { + PLAY, + DOWNLOADED, + PREPARED, + STOP, + PAUSE, + ERROR, + FOCUS_LOST, + FOCUS_GAIN, + FOCUS_DUCK, + IMMEDIATE_TRANSITION, + } + + private var pendingEvents = ArrayDeque() + private var isProcessing = false + private val stateMachine: StateMachine + + /** + * Immediate state machine state. This attribute provides innermost active state. + * For checking parent states, use [PlayerStateMachine.isInState]. + */ + val state: State + get() { + return stateMachine.state + } + + init { + val config = StateMachineConfig() + + config.configure(State.STOPPED) + .permitIf(Event.PLAY, State.RUNNING_INITIAL) { delegate.hasEnqueuedFile } + .onEntryFrom(Event.STOP, delegate::onStopped) + .onEntryFrom(Event.ERROR, delegate::onError) + + config.configure(State.RUNNING) + .permit(Event.STOP, State.STOPPED) + .permit(Event.ERROR, State.STOPPED) + .onEntry(delegate::onStartRunning) + + config.configure(State.RUNNING_INITIAL) + .substateOf(State.RUNNING) + .permitIf(Event.IMMEDIATE_TRANSITION, State.DOWNLOADING, { !delegate.isDownloaded }) + .permitIf(Event.IMMEDIATE_TRANSITION, State.PREPARING, { delegate.isDownloaded }) + .onEntry(this::immediateTransition) + + config.configure(State.DOWNLOADING) + .substateOf(State.RUNNING) + .permit(Event.DOWNLOADED, State.PREPARING) + .onEntry(delegate::onStartDownloading) + + config.configure(State.PREPARING) + .substateOf(State.RUNNING) + .permitIf(Event.PREPARED, State.AWAIT_FOCUS) { delegate.isAutoplayEnabled } + .permitIf(Event.PREPARED, State.PAUSED) { !delegate.isAutoplayEnabled } + .onEntry(delegate::onPrepare) + + config.configure(State.PLAYING) + .substateOf(State.RUNNING) + .permit(Event.PAUSE, State.PAUSED) + .permit(Event.FOCUS_LOST, State.PAUSED) + .onEntry(delegate::onRequestFocus) + .onExit(delegate::onReleaseFocus) + + config.configure(State.PAUSED) + .substateOf(State.RUNNING) + .permit(Event.PLAY, State.AWAIT_FOCUS) + .onEntry(delegate::onPausePlayback) + + config.configure(State.AWAIT_FOCUS) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_GAIN, State.FOCUSED) + + config.configure(State.FOCUSED) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_DUCK, State.DUCKED) + .onEntry(this::onAudioFocusGain) + + config.configure(State.DUCKED) + .substateOf(State.PLAYING) + .permit(Event.FOCUS_GAIN, State.FOCUSED) + .onEntry(Action { delegate.onAudioDuck(true) }) + .onExit(Action { delegate.onAudioDuck(false) }) + + stateMachine = StateMachine(initialState, config) + stateMachine.onUnhandledTrigger { _, _ -> /* ignore unhandled event */ } + } + + private fun immediateTransition() { + stateMachine.fire(Event.IMMEDIATE_TRANSITION) + } + + private fun onAudioFocusGain(t: Transition) { + if (t.source == State.AWAIT_FOCUS) { + delegate.onStartPlayback() + } + } + + /** + * Check if state machine is in a given state. + * Contrary to [PlayerStateMachine.state] attribute, this method checks for + * parent states. + */ + fun isInState(state: State): Boolean { + return stateMachine.isInState(state) + } + + /** + * Post state machine event to internal queue. + * + * This design ensures that we're not triggering multiple events + * from state machines callbacks before the transition is fully + * completed. + * + * Method is re-entrant. + */ + fun post(event: Event) { + pendingEvents.addLast(event) + if (!isProcessing) { + isProcessing = true + while (pendingEvents.isNotEmpty()) { + val processedEvent = pendingEvents.removeFirst() + stateMachine.fire(processedEvent) + } + isProcessing = false + } + } +} diff --git a/src/main/java/com/nextcloud/client/media/PlaylistItem.kt b/src/main/java/com/nextcloud/client/media/PlaylistItem.kt new file mode 100644 index 000000000000..219a7c110ddf --- /dev/null +++ b/src/main/java/com/nextcloud/client/media/PlaylistItem.kt @@ -0,0 +1,6 @@ +package com.nextcloud.client.media + +import android.accounts.Account +import com.owncloud.android.datamodel.OCFile + +data class PlaylistItem(val file: OCFile, val startPositionMs: Int, val autoPlay: Boolean, val account: Account) diff --git a/src/main/java/com/owncloud/android/media/MediaControlView.java b/src/main/java/com/owncloud/android/media/MediaControlView.java index 73201a0bc46f..c9ffe8659aff 100644 --- a/src/main/java/com/owncloud/android/media/MediaControlView.java +++ b/src/main/java/com/owncloud/android/media/MediaControlView.java @@ -55,17 +55,17 @@ */ public class MediaControlView extends FrameLayout implements OnClickListener, OnSeekBarChangeListener { private static final String TAG = MediaControlView.class.getSimpleName(); - - private MediaPlayerControl mPlayer; - private View mRoot; - private ProgressBar mProgress; - private TextView mEndTime; - private TextView mCurrentTime; - private boolean mDragging; private static final int SHOW_PROGRESS = 1; - private ImageButton mPauseButton; - private ImageButton mFfwdButton; - private ImageButton mRewButton; + + private MediaPlayerControl playerControl; + private View root; + private ProgressBar progressBar; + private TextView endTime; + private TextView currentTime; + private boolean isDragging; + private ImageButton pauseButton; + private ImageButton forwardButton; + private ImageButton rewindButton; public MediaControlView(Context context, AttributeSet attrs) { super(context, attrs); @@ -75,9 +75,9 @@ public MediaControlView(Context context, AttributeSet attrs) { ViewGroup.LayoutParams.MATCH_PARENT ); LayoutInflater inflate = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - mRoot = inflate.inflate(R.layout.media_control, null); - initControllerView(mRoot); - addView(mRoot, frameParams); + root = inflate.inflate(R.layout.media_control, null); + initControllerView(root); + addView(root, frameParams); setFocusable(true); setFocusableInTouchMode(true); @@ -91,47 +91,50 @@ public void onFinishInflate() { } public void setMediaPlayer(MediaPlayerControl player) { - mPlayer = player; - mHandler.sendEmptyMessage(SHOW_PROGRESS); - updatePausePlay(); + playerControl = player; + handler.sendEmptyMessage(SHOW_PROGRESS); + handler.postDelayed(()-> { + updatePausePlay(); + setProgress(); + }, 100); } public void stopMediaPlayerMessages() { - mHandler.removeMessages(SHOW_PROGRESS); + handler.removeMessages(SHOW_PROGRESS); } private void initControllerView(View v) { - mPauseButton = v.findViewById(R.id.playBtn); - if (mPauseButton != null) { - mPauseButton.requestFocus(); - mPauseButton.setOnClickListener(this); + pauseButton = v.findViewById(R.id.playBtn); + if (pauseButton != null) { + pauseButton.requestFocus(); + pauseButton.setOnClickListener(this); } - mFfwdButton = v.findViewById(R.id.forwardBtn); - if (mFfwdButton != null) { - mFfwdButton.setOnClickListener(this); + forwardButton = v.findViewById(R.id.forwardBtn); + if (forwardButton != null) { + forwardButton.setOnClickListener(this); } - mRewButton = v.findViewById(R.id.rewindBtn); - if (mRewButton != null) { - mRewButton.setOnClickListener(this); + rewindButton = v.findViewById(R.id.rewindBtn); + if (rewindButton != null) { + rewindButton.setOnClickListener(this); } - mProgress = v.findViewById(R.id.progressBar); - if (mProgress != null) { - if (mProgress instanceof SeekBar) { - SeekBar seeker = (SeekBar) mProgress; + progressBar = v.findViewById(R.id.progressBar); + if (progressBar != null) { + if (progressBar instanceof SeekBar) { + SeekBar seeker = (SeekBar) progressBar; ThemeUtils.colorHorizontalSeekBar(seeker, getContext()); seeker.setOnSeekBarChangeListener(this); } else { - ThemeUtils.colorHorizontalProgressBar(mProgress, ThemeUtils.primaryAccentColor(getContext())); + ThemeUtils.colorHorizontalProgressBar(progressBar, ThemeUtils.primaryAccentColor(getContext())); } - mProgress.setMax(1000); + progressBar.setMax(1000); } - mEndTime = v.findViewById(R.id.totalTimeText); - mCurrentTime = v.findViewById(R.id.currentTimeText); + endTime = v.findViewById(R.id.totalTimeText); + currentTime = v.findViewById(R.id.currentTimeText); } /** @@ -140,14 +143,14 @@ private void initControllerView(View v) { */ private void disableUnsupportedButtons() { try { - if (mPauseButton != null && !mPlayer.canPause()) { - mPauseButton.setEnabled(false); + if (pauseButton != null && !playerControl.canPause()) { + pauseButton.setEnabled(false); } - if (mRewButton != null && !mPlayer.canSeekBackward()) { - mRewButton.setEnabled(false); + if (rewindButton != null && !playerControl.canSeekBackward()) { + rewindButton.setEnabled(false); } - if (mFfwdButton != null && !mPlayer.canSeekForward()) { - mFfwdButton.setEnabled(false); + if (forwardButton != null && !playerControl.canSeekForward()) { + forwardButton.setEnabled(false); } } catch (IncompatibleClassChangeError ex) { // We were given an old version of the interface, that doesn't have @@ -159,13 +162,14 @@ private void disableUnsupportedButtons() { } - private Handler mHandler = new Handler() { + private Handler handler = new Handler() { @Override public void handleMessage(Message msg) { int pos; if (msg.what == SHOW_PROGRESS) { + updatePausePlay(); pos = setProgress(); - if (!mDragging) { + if (!isDragging) { msg = obtainMessage(SHOW_PROGRESS); sendMessageDelayed(msg, 1000 - (pos % 1000)); } @@ -173,7 +177,7 @@ public void handleMessage(Message msg) { } }; - private String stringForTime(int timeMs) { + private String formatTime(int timeMs) { int totalSeconds = timeMs / 1000; int seconds = totalSeconds % 60; @@ -190,26 +194,27 @@ private String stringForTime(int timeMs) { } private int setProgress() { - if (mPlayer == null || mDragging) { + if (playerControl == null || isDragging) { return 0; } - int position = mPlayer.getCurrentPosition(); - int duration = mPlayer.getDuration(); - if (mProgress != null) { + int position = playerControl.getCurrentPosition(); + int duration = playerControl.getDuration(); + if (progressBar != null) { if (duration > 0) { // use long to avoid overflow long pos = 1000L * position / duration; - mProgress.setProgress((int) pos); + progressBar.setProgress((int) pos); } - int percent = mPlayer.getBufferPercentage(); - mProgress.setSecondaryProgress(percent * 10); + int percent = playerControl.getBufferPercentage(); + progressBar.setSecondaryProgress(percent * 10); } - if (mEndTime != null) { - mEndTime.setText(stringForTime(duration)); + if (endTime != null) { + String endTime = duration > 0 ? formatTime(duration) : "--:--"; + this.endTime.setText(endTime); } - if (mCurrentTime != null) { - mCurrentTime.setText(stringForTime(position)); + if (currentTime != null) { + currentTime.setText(formatTime(position)); } return position; } @@ -226,21 +231,21 @@ public boolean dispatchKeyEvent(KeyEvent event) { if (uniqueDown) { doPauseResume(); //show(sDefaultTimeout); - if (mPauseButton != null) { - mPauseButton.requestFocus(); + if (pauseButton != null) { + pauseButton.requestFocus(); } } return true; } else if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) { - if (uniqueDown && !mPlayer.isPlaying()) { - mPlayer.start(); + if (uniqueDown && !playerControl.isPlaying()) { + playerControl.start(); updatePausePlay(); } return true; } else if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) { - if (uniqueDown && mPlayer.isPlaying()) { - mPlayer.pause(); + if (uniqueDown && playerControl.isPlaying()) { + playerControl.pause(); updatePausePlay(); } return true; @@ -250,39 +255,54 @@ public boolean dispatchKeyEvent(KeyEvent event) { } public void updatePausePlay() { - if (mRoot == null || mPauseButton == null) { + if (root == null || pauseButton == null) { return; } - if (mPlayer.isPlaying()) { - mPauseButton.setImageResource(android.R.drawable.ic_media_pause); + if (playerControl.isPlaying()) { + pauseButton.setImageResource(android.R.drawable.ic_media_pause); } else { - mPauseButton.setImageResource(android.R.drawable.ic_media_play); + pauseButton.setImageResource(android.R.drawable.ic_media_play); } + + final boolean canSeekFfd = playerControl.canSeekForward(); + if (canSeekFfd) { + forwardButton.setVisibility(View.VISIBLE); + } else { + forwardButton.setVisibility(View.INVISIBLE); + } + + final boolean canSeekBwd = playerControl.canSeekBackward(); + if (canSeekBwd) { + rewindButton.setVisibility(View.VISIBLE); + } else { + rewindButton.setVisibility(View.INVISIBLE); + } + } private void doPauseResume() { - if (mPlayer.isPlaying()) { - mPlayer.pause(); + if (playerControl.isPlaying()) { + playerControl.pause(); } else { - mPlayer.start(); + playerControl.start(); } updatePausePlay(); } @Override public void setEnabled(boolean enabled) { - if (mPauseButton != null) { - mPauseButton.setEnabled(enabled); + if (pauseButton != null) { + pauseButton.setEnabled(enabled); } - if (mFfwdButton != null) { - mFfwdButton.setEnabled(enabled); + if (forwardButton != null) { + forwardButton.setEnabled(enabled); } - if (mRewButton != null) { - mRewButton.setEnabled(enabled); + if (rewindButton != null) { + rewindButton.setEnabled(enabled); } - if (mProgress != null) { - mProgress.setEnabled(enabled); + if (progressBar != null) { + progressBar.setEnabled(enabled); } disableUnsupportedButtons(); super.setEnabled(enabled); @@ -291,7 +311,7 @@ public void setEnabled(boolean enabled) { @Override public void onClick(View v) { int pos; - boolean playing = mPlayer.isPlaying(); + boolean playing = playerControl.isPlaying(); switch (v.getId()) { case R.id.playBtn: @@ -299,21 +319,21 @@ public void onClick(View v) { break; case R.id.rewindBtn: - pos = mPlayer.getCurrentPosition(); + pos = playerControl.getCurrentPosition(); pos -= 5000; - mPlayer.seekTo(pos); + playerControl.seekTo(pos); if (!playing) { - mPlayer.pause(); // necessary in some 2.3.x devices + playerControl.pause(); // necessary in some 2.3.x devices } setProgress(); break; case R.id.forwardBtn: - pos = mPlayer.getCurrentPosition(); + pos = playerControl.getCurrentPosition(); pos += 15000; - mPlayer.seekTo(pos); + playerControl.seekTo(pos); if (!playing) { - mPlayer.pause(); // necessary in some 2.3.x devices + playerControl.pause(); // necessary in some 2.3.x devices } setProgress(); break; @@ -329,11 +349,11 @@ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { return; } - long duration = mPlayer.getDuration(); + long duration = playerControl.getDuration(); long newPosition = (duration * progress) / 1000L; - mPlayer.seekTo((int) newPosition); - if (mCurrentTime != null) { - mCurrentTime.setText(stringForTime((int) newPosition)); + playerControl.seekTo((int) newPosition); + if (currentTime != null) { + currentTime.setText(formatTime((int) newPosition)); } } @@ -344,8 +364,8 @@ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { */ @Override public void onStartTrackingTouch(SeekBar seekBar) { - mDragging = true; // monitors the duration of dragging - mHandler.removeMessages(SHOW_PROGRESS); // grants no more updates with media player progress while dragging + isDragging = true; // monitors the duration of dragging + handler.removeMessages(SHOW_PROGRESS); // grants no more updates with media player progress while dragging } @@ -354,10 +374,10 @@ public void onStartTrackingTouch(SeekBar seekBar) { */ @Override public void onStopTrackingTouch(SeekBar seekBar) { - mDragging = false; + isDragging = false; setProgress(); updatePausePlay(); - mHandler.sendEmptyMessage(SHOW_PROGRESS); // grants future updates with media player progress + handler.sendEmptyMessage(SHOW_PROGRESS); // grants future updates with media player progress } @Override diff --git a/src/main/java/com/owncloud/android/media/MediaService.java b/src/main/java/com/owncloud/android/media/MediaService.java deleted file mode 100644 index afa8f59e04cc..000000000000 --- a/src/main/java/com/owncloud/android/media/MediaService.java +++ /dev/null @@ -1,716 +0,0 @@ -/* - * 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); - } - } - } - } -} diff --git a/src/main/java/com/owncloud/android/media/MediaServiceBinder.java b/src/main/java/com/owncloud/android/media/MediaServiceBinder.java deleted file mode 100644 index 2fd1e54c90ff..000000000000 --- a/src/main/java/com/owncloud/android/media/MediaServiceBinder.java +++ /dev/null @@ -1,181 +0,0 @@ -/** - * ownCloud Android client application - * - * @author David A. Velasco - * Copyright (C) 2016 ownCloud Inc. - * - * 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.content.Intent; -import android.media.MediaPlayer; -import android.os.Binder; -import android.widget.MediaController; - -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.media.MediaService.State; - - -/** - * Binder allowing client components to perform operations on on the MediaPlayer managed by a MediaService instance. - * - * Provides the operations of {@link MediaController.MediaPlayerControl}, and an extra method to check if - * an {@link OCFile} instance is handled by the MediaService. - */ -public class MediaServiceBinder extends Binder implements MediaController.MediaPlayerControl { - - private static final String TAG = MediaServiceBinder.class.getSimpleName(); - /** - * {@link MediaService} instance to access with the binder - */ - private MediaService mService; - - /** - * Public constructor - * - * @param service A {@link MediaService} instance to access with the binder - */ - public MediaServiceBinder(MediaService service) { - if (service == null) { - throw new IllegalArgumentException("Argument 'service' can not be null"); - } - mService = service; - } - - public boolean isPlaying(OCFile mFile) { - return mFile != null && mFile.equals(mService.getCurrentFile()); - } - - @Override - public boolean canPause() { - return true; - } - - @Override - public boolean canSeekBackward() { - return true; - } - - @Override - public boolean canSeekForward() { - return true; - } - - @Override - public int getBufferPercentage() { - MediaPlayer currentPlayer = mService.getPlayer(); - if (currentPlayer != null) { - return 100; - // TODO update for streamed playback; add OnBufferUpdateListener in MediaService - } else { - return 0; - } - } - - @Override - public int getCurrentPosition() { - MediaPlayer currentPlayer = mService.getPlayer(); - if (currentPlayer != null) { - return currentPlayer.getCurrentPosition(); - } else { - return 0; - } - } - - @Override - public int getDuration() { - MediaPlayer currentPlayer = mService.getPlayer(); - if (currentPlayer != null) { - return currentPlayer.getDuration(); - } else { - return 0; - } - } - - - /** - * Reports if the MediaService is playing a file or not. - * - * Considers that the file is being played when it is in preparation because the expected - * client of this method is a {@link MediaController} , and we do not want that the 'play' - * button is shown when the file is being prepared by the MediaService. - */ - @Override - public boolean isPlaying() { - MediaService.State currentState = mService.getState(); - return currentState == State.PLAYING || (currentState == State.PREPARING && mService.playOnPrepared); - } - - - @Override - public void pause() { - Log_OC.d(TAG, "Pausing through binder..."); - mService.processPauseRequest(); - } - - @Override - public void seekTo(int pos) { - Log_OC.d(TAG, "Seeking " + pos + " through binder..."); - MediaPlayer currentPlayer = mService.getPlayer(); - MediaService.State currentState = mService.getState(); - if (currentPlayer != null && currentState != State.PREPARING && currentState != State.STOPPED) { - currentPlayer.seekTo(pos); - } - } - - @Override - public void start() { - Log_OC.d(TAG, "Starting through binder..."); - mService.processPlayRequest(); // this will finish the service if there is no file preloaded to play - } - - public void start(Account account, OCFile file, boolean playImmediately, int position) { - Log_OC.d(TAG, "Loading and starting through binder..."); - Intent i = new Intent(mService, MediaService.class); - i.putExtra(MediaService.EXTRA_ACCOUNT, account); - i.putExtra(MediaService.EXTRA_FILE, file); - i.putExtra(MediaService.EXTRA_PLAY_ON_LOAD, playImmediately); - i.putExtra(MediaService.EXTRA_START_POSITION, position); - i.setAction(MediaService.ACTION_PLAY_FILE); - mService.startService(i); - } - - - public void registerMediaController(MediaControlView mediaController) { - mService.setMediaController(mediaController); - } - - public void unregisterMediaController(MediaControlView mediaController) { - if (mediaController != null && mediaController == mService.getMediaController()) { - mService.setMediaController(null); - } - - } - - public boolean isInPlaybackState() { - MediaService.State currentState = mService.getState(); - return currentState == MediaService.State.PLAYING || currentState == MediaService.State.PAUSED; - } - - @Override - public int getAudioSessionId() { - return 1; // not really used - } -} - - diff --git a/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java b/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java index a8381c81a663..7bb1c491aa10 100644 --- a/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java @@ -56,6 +56,7 @@ import com.google.android.material.snackbar.Snackbar; import com.nextcloud.client.appinfo.AppInfo; import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.media.PlayerServiceConnection; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.preferences.AppPreferences; import com.owncloud.android.MainApp; @@ -77,8 +78,6 @@ import com.owncloud.android.lib.resources.shares.OCShare; import com.owncloud.android.lib.resources.shares.ShareType; import com.owncloud.android.lib.resources.status.OwnCloudVersion; -import com.owncloud.android.media.MediaService; -import com.owncloud.android.media.MediaServiceBinder; import com.owncloud.android.operations.CopyFileOperation; import com.owncloud.android.operations.CreateFolderOperation; import com.owncloud.android.operations.CreateShareViaLinkOperation; @@ -207,9 +206,6 @@ public class FileDisplayActivity extends FileActivity private Collection mDrawerMenuItemstoShowHideList; - private MediaServiceBinder mMediaServiceBinder; - private MediaServiceConnection mMediaServiceConnection; - public static final String KEY_IS_SEARCH_OPEN = "IS_SEARCH_OPEN"; public static final String KEY_SEARCH_QUERY = "SEARCH_QUERY"; @@ -217,6 +213,7 @@ public class FileDisplayActivity extends FileActivity private boolean searchOpen; private SearchView searchView; + private PlayerServiceConnection mPlayerConnection; @Inject AppPreferences preferences; @@ -284,6 +281,8 @@ protected void onCreate(Bundle savedInstanceState) { if (Intent.ACTION_VIEW.equals(getIntent().getAction())) { handleOpenFileViaIntent(getIntent()); } + + mPlayerConnection = new PlayerServiceConnection(this); } @Override @@ -1784,36 +1783,6 @@ public void onServiceDisconnected(ComponentName component) { } } - private MediaServiceConnection newMediaConnection(){ - return new MediaServiceConnection(); - } - - /** Defines callbacks for service binding, passed to bindService() */ - private class MediaServiceConnection implements ServiceConnection { - - @Override - public void onServiceConnected(ComponentName component, IBinder service) { - - if (component.equals(new ComponentName(FileDisplayActivity.this, MediaService.class))) { - Log_OC.d(TAG, "Media service connected"); - mMediaServiceBinder = (MediaServiceBinder) service; - - }else { - return; - } - - } - - @Override - public void onServiceDisconnected(ComponentName component) { - if (component.equals(new ComponentName(FileDisplayActivity.this, - MediaService.class))) { - Log_OC.e(TAG, "Media service disconnected"); - mMediaServiceBinder = null; - } - } - } - /** * Updates the view associated to the activity after the finish of some operation over files * in the current account. @@ -1945,14 +1914,10 @@ private void onRestoreFileVersionOperationFinish(RemoteOperationResult result) { } } - public void setMediaServiceConnection() { - mMediaServiceConnection = newMediaConnection();// mediaServiceConnection; - bindService(new Intent(this, MediaService.class), mMediaServiceConnection, Context.BIND_AUTO_CREATE); - } - private void tryStopPlaying(OCFile file) { - if (mMediaServiceConnection != null && MimeTypeUtil.isAudio(file) && mMediaServiceBinder.isPlaying(file)) { - mMediaServiceBinder.pause(); + // placeholder for stop-on-delete future code + if(mPlayerConnection != null) { + mPlayerConnection.stop(file); } } diff --git a/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java b/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java index d6cba983b279..f0c176cbceee 100644 --- a/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java +++ b/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java @@ -2,7 +2,9 @@ * ownCloud Android client application * * @author David A. Velasco + * @author Chris Narkiewicz * Copyright (C) 2016 ownCloud Inc. + * Copyright (C) 2019 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -21,10 +23,8 @@ import android.accounts.Account; import android.app.Activity; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.ServiceConnection; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Bitmap; @@ -37,7 +37,6 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import android.os.IBinder; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -51,11 +50,12 @@ import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; -import android.widget.Toast; import android.widget.VideoView; import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.media.ErrorFormat; +import com.nextcloud.client.media.PlayerServiceConnection; import com.owncloud.android.R; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.files.FileMenuFilter; @@ -66,10 +66,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.media.MediaControlView; -import com.owncloud.android.media.MediaService; -import com.owncloud.android.media.MediaServiceBinder; import com.owncloud.android.ui.activity.FileActivity; -import com.owncloud.android.ui.activity.FileDisplayActivity; import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; import com.owncloud.android.ui.fragment.FileFragment; @@ -83,7 +80,6 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; - /** * This fragment shows a preview of a downloaded media file (audio or video). * @@ -122,17 +118,14 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene private ImageView mMultiListIcon; private ProgressBar mMultiListProgress; - private MediaServiceBinder mMediaServiceBinder; private MediaControlView mMediaController; - private MediaServiceConnection mMediaServiceConnection; private boolean mAutoplay; - private static boolean mOnResume; private boolean mPrepared; + private PlayerServiceConnection mMediaPlayerServiceConnection; private Uri mVideoUri; @Inject UserAccountManager accountManager; - /** * Creates a fragment to preview a file. * @@ -172,10 +165,6 @@ public PreviewMediaFragment() { mAutoplay = true; } - - /** - * {@inheritDoc} - */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -187,12 +176,9 @@ public void onCreate(Bundle savedInstanceState) { mAccount = bundle.getParcelable(ACCOUNT); mSavedPlaybackPosition = bundle.getInt(PLAYBACK_POSITION); mAutoplay = bundle.getBoolean(AUTOPLAY); + mMediaPlayerServiceConnection = new PlayerServiceConnection(getContext()); } - - /** - * {@inheritDoc} - */ @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); @@ -214,8 +200,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, return view; } - - protected void setupMultiView(View view) { + private void setupMultiView(View view) { mMultiListContainer = view.findViewById(R.id.empty_list_view); mMultiListMessage = view.findViewById(R.id.empty_list_view_text); mMultiListHeadline = view.findViewById(R.id.empty_list_view_headline); @@ -233,7 +218,7 @@ private void setMultiListLoadingMessage() { } } - public void setMessageForMultiList(String headline, @StringRes int message, @DrawableRes int icon) { + private void setMessageForMultiList(String headline, @StringRes int message, @DrawableRes int icon) { if (mMultiListContainer != null && mMultiListMessage != null) { mMultiListHeadline.setText(headline); mMultiListMessage.setText(message); @@ -245,13 +230,8 @@ public void setMessageForMultiList(String headline, @StringRes int message, @Dra } } - - /** - * {@inheritDoc} - */ @Override public void onActivityCreated(Bundle savedInstanceState) { - mOnResume = true; super.onActivityCreated(savedInstanceState); Log_OC.v(TAG, "onActivityCreated"); @@ -307,10 +287,6 @@ private void extractAndSetCoverArt(OCFile file) { } } - - /** - * {@inheritDoc} - */ @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -326,25 +302,24 @@ public void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mSavedPlaybackPosition); outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mAutoplay); } - } - else { - if (mMediaServiceBinder != null) { - outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mMediaServiceBinder.getCurrentPosition()); - outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mMediaServiceBinder.isPlaying()); - } + } else if(mMediaPlayerServiceConnection.isConnected()) { + outState.putInt(PreviewMediaFragment.EXTRA_PLAY_POSITION, mMediaPlayerServiceConnection.getCurrentPosition()); + outState.putBoolean(PreviewMediaFragment.EXTRA_PLAYING, mMediaPlayerServiceConnection.isPlaying()); } } - @Override public void onStart() { super.onStart(); Log_OC.v(TAG, "onStart"); - OCFile file = getFile(); if (file != null) { if (MimeTypeUtil.isAudio(file)) { - bindMediaService(); + mMediaController.setMediaPlayer(mMediaPlayerServiceConnection); + mMediaPlayerServiceConnection.bind(); + mMediaPlayerServiceConnection.start(mAccount, file, mAutoplay, mSavedPlaybackPosition); + mMultiView.setVisibility(View.GONE); + mPreviewContainer.setVisibility(View.VISIBLE); } else if (MimeTypeUtil.isVideo(file)) { stopAudio(); playVideo(); @@ -352,27 +327,16 @@ public void onStart() { } } - private void stopAudio() { - Intent i = new Intent(getActivity(), MediaService.class); - i.setAction(MediaService.ACTION_STOP_ALL); - getActivity().startService(i); + mMediaPlayerServiceConnection.stop(); } - - /** - * {@inheritDoc} - */ @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.file_actions_menu, menu); } - - /** - * {@inheritDoc} - */ @Override public void onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); @@ -443,13 +407,8 @@ public void onPrepareOptionsMenu(Menu menu) { item.setEnabled(false); } } - } - - /** - * {@inheritDoc} - */ @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -479,7 +438,6 @@ public boolean onOptionsItemSelected(MenuItem item) { } } - /** * Update the file of the fragment with file value * @@ -610,7 +568,6 @@ public void onCompletion(MediaPlayer mp) { mMediaController.updatePausePlay(); } - /** * Called when an error in playback occurs. * @@ -622,9 +579,9 @@ public void onCompletion(MediaPlayer mp) { public boolean onError(MediaPlayer mp, int what, int extra) { Log_OC.e(TAG, "Error in video playback, what = " + what + ", extra = " + extra); mPreviewContainer.setVisibility(View.GONE); - if (mVideoPreview.getWindowToken() != null) { - String message = MediaService.getMessageForMediaError( - getActivity(), what, extra); + final Context context = getActivity(); + if (mVideoPreview.getWindowToken() != null && context != null) { + String message = ErrorFormat.toString(context, what, extra); mMultiView.setVisibility(View.VISIBLE); setMessageForMultiList(message, R.string.preview_sorry, R.drawable.file_movie); } @@ -633,7 +590,6 @@ public boolean onError(MediaPlayer mp, int what, int extra) { } - @Override public void onPause() { Log_OC.v(TAG, "onPause"); @@ -643,8 +599,6 @@ public void onPause() { @Override public void onResume() { super.onResume(); - mOnResume = !mOnResume; - Log_OC.v(TAG, "onResume"); } @@ -657,19 +611,7 @@ public void onDestroy() { @Override public void onStop() { Log_OC.v(TAG, "onStop"); - - mPrepared = false; - if (mMediaServiceConnection != null) { - Log_OC.d(TAG, "Unbinding from MediaService ..."); - if (mMediaServiceBinder != null && mMediaController != null) { - mMediaController.stopMediaPlayerMessages(); - mMediaServiceBinder.unregisterMediaController(mMediaController); - } - getActivity().unbindService(mMediaServiceConnection); - mMediaServiceConnection = null; - mMediaServiceBinder = null; - } - + mMediaPlayerServiceConnection.unbind(); super.onStop(); } @@ -707,103 +649,18 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { Log_OC.v(TAG, "onActivityResult " + this); super.onActivityResult(requestCode, resultCode, data); if (resultCode == Activity.RESULT_OK) { - mSavedPlaybackPosition = data.getExtras().getInt( - PreviewVideoActivity.EXTRA_START_POSITION); - mAutoplay = data.getExtras().getBoolean(PreviewVideoActivity.EXTRA_AUTOPLAY); + mSavedPlaybackPosition = data.getIntExtra(PreviewVideoActivity.EXTRA_START_POSITION, 0); + mAutoplay = data.getBooleanExtra(PreviewVideoActivity.EXTRA_AUTOPLAY, false); } } - - private void playAudio() { - OCFile file = getFile(); - if (!mMediaServiceBinder.isPlaying(file) && !mOnResume) { - Log_OC.d(TAG, "starting playback of " + file.getStoragePath()); - mMediaServiceBinder.start(mAccount, file, mAutoplay, mSavedPlaybackPosition); - } - else { - if (!mMediaServiceBinder.isPlaying() && mAutoplay) { - mMediaServiceBinder.start(); - mMediaController.updatePausePlay(); - } - } - - mOnResume = false; - } - - - private void bindMediaService() { - Log_OC.d(TAG, "Binding to MediaService..."); - if (mMediaServiceConnection == null) { - mMediaServiceConnection = new MediaServiceConnection(); - } - getActivity().bindService( new Intent(getActivity(), - MediaService.class), - mMediaServiceConnection, - Context.BIND_AUTO_CREATE); - // follow the flow in MediaServiceConnection#onServiceConnected(...) - - ((FileDisplayActivity) getActivity()).setMediaServiceConnection(); - } - - /** Defines callbacks for service binding, passed to bindService() */ - private class MediaServiceConnection implements ServiceConnection { - - @Override - public void onServiceConnected(ComponentName component, IBinder service) { - if (getActivity() != null - && component.equals(new ComponentName(getActivity(), MediaService.class))) { - Log_OC.d(TAG, "Media service connected"); - mMediaServiceBinder = (MediaServiceBinder) service; - if (mMediaServiceBinder != null) { - prepareMediaController(); - playAudio(); // do not wait for the touch of nobody to play audio - - Log_OC.d(TAG, "Successfully bound to MediaService, MediaController ready"); - - } else { - Log_OC.e(TAG, "Unexpected response from MediaService while binding"); - } - } - } - - private void prepareMediaController() { - mMultiView.setVisibility(View.GONE); - mPreviewContainer.setVisibility(View.VISIBLE); - mMediaServiceBinder.registerMediaController(mMediaController); - if (mMediaController != null) { - mMediaController.setMediaPlayer(mMediaServiceBinder); - mMediaController.setEnabled(true); - mMediaController.updatePausePlay(); - } - } - - @Override - public void onServiceDisconnected(ComponentName component) { - if (component.equals(new ComponentName(getActivity(), MediaService.class))) { - Log_OC.w(TAG, "Media service suddenly disconnected"); - if (mMediaController != null) { - mMediaController.setMediaPlayer(null); - } - else { - Toast.makeText( - getActivity(), - "No media controller to release when disconnected from media service", - Toast.LENGTH_SHORT).show(); - } - mMediaServiceBinder = null; - mMediaServiceConnection = null; - } - } - } - - /** * Opens the previewed file with an external application. */ private void openFile() { stopPreview(true); containerActivity.getFileOperationsHelper().openFile(getFile()); - finish(); + finishPreview(); } /** @@ -817,29 +674,25 @@ public static boolean canBePreviewed(OCFile file) { return file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)); } - public void stopPreview(boolean stopAudio) { OCFile file = getFile(); if (MimeTypeUtil.isAudio(file) && stopAudio) { - mMediaServiceBinder.pause(); - - } - else { - if (MimeTypeUtil.isVideo(file)) { - mVideoPreview.stopPlayback(); - } + mMediaPlayerServiceConnection.pause(); + } else if (MimeTypeUtil.isVideo(file)) { + mVideoPreview.stopPlayback(); } } - /** * Finishes the preview */ - private void finish() { - getActivity().onBackPressed(); + private void finishPreview() { + final Activity activity = getActivity(); + if (activity != null) { + activity.onBackPressed(); + } } - public int getPosition() { if (mPrepared) { mSavedPlaybackPosition = mVideoPreview.getCurrentPosition(); diff --git a/src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java b/src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java index d8a17a706009..ed6c6dde05de 100644 --- a/src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java +++ b/src/main/java/com/owncloud/android/ui/preview/PreviewVideoActivity.java @@ -32,10 +32,10 @@ import android.widget.MediaController; import android.widget.VideoView; +import com.nextcloud.client.media.ErrorFormat; import com.owncloud.android.R; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.media.MediaService; import com.owncloud.android.ui.activity.FileActivity; import com.owncloud.android.utils.MimeTypeUtil; @@ -180,7 +180,7 @@ public boolean onError(MediaPlayer mp, int what, int extra) { } if (mVideoPlayer.getWindowToken() != null) { - String message = MediaService.getMessageForMediaError(this, what, extra); + String message = ErrorFormat.toString(this, what, extra); new AlertDialog.Builder(this) .setMessage(message) .setPositiveButton(android.R.string.VideoView_error_button, diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9e7532baf878..15d43ffff3fd 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -216,17 +216,12 @@ %1$s music player %1$s (playing) - %1$s (loading) - %1$s playback finished - No media file found - The file is not in a valid account Unsupported media codec Could not read the media file The media file has incorrect encoding Attempt to play file timed out The media file can not be streamed The built-in media player is unable to play the media file - Unexpected error while trying to play %1$s Rewind button Play or pause button Fast forward button diff --git a/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt b/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt new file mode 100644 index 000000000000..c417096199de --- /dev/null +++ b/src/test/java/com/nextcloud/client/media/AudioFocusManagerTest.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.media.AudioFocusRequest +import android.media.AudioManager +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argThat +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatcher + +class AudioFocusManagerTest { + + private val audioManager = mock() + private val callback = mock<(AudioFocus)->Unit>() + private lateinit var audioFocusManager: AudioFocusManager + + val audioRequestMatcher = object : ArgumentMatcher { + override fun matches(argument: AudioFocusRequest?): Boolean = true + } + + @Before + fun setUp() { + audioFocusManager = AudioFocusManager(audioManager, callback) + whenever(audioManager.requestAudioFocus(any(), any(), any())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + whenever(audioManager.abandonAudioFocusRequest(argThat(audioRequestMatcher))) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + whenever(audioManager.abandonAudioFocusRequest(any())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + } + + @Test + fun `acquiring focus triggers callback immediately`() { + audioFocusManager.requestFocus() + verify(callback).invoke(AudioFocus.FOCUS) + } + + @Test + fun `failing to acquire focus triggers callback immediately`() { + whenever(audioManager.requestAudioFocus(any(), any(), any())) + .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_FAILED) + audioFocusManager.requestFocus() + verify(callback).invoke(AudioFocus.LOST) + } + + @Test + fun `releasing focus triggers callback immediately`() { + audioFocusManager.releaseFocus() + verify(callback).invoke(AudioFocus.LOST) + } +} diff --git a/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt b/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt new file mode 100644 index 000000000000..cb0080676b7d --- /dev/null +++ b/src/test/java/com/nextcloud/client/media/AudioFocusTest.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import android.media.AudioManager +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class AudioFocusTest { + + @Test + fun `invalid values result in null`() { + val focus = AudioFocus.fromPlatformFocus(-10000) + assertNull(focus) + } + + @Test + fun `audio focus values are converted`() { + val validValues = listOf( + AudioManager.AUDIOFOCUS_GAIN, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK + ) + validValues.forEach { + val focus = AudioFocus.fromPlatformFocus(-it) + assertNotNull(focus) + } + } +} diff --git a/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt b/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt new file mode 100644 index 000000000000..34a97a6973e6 --- /dev/null +++ b/src/test/java/com/nextcloud/client/media/PlayerStateMachineTest.kt @@ -0,0 +1,683 @@ +/** + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.media + +import com.nextcloud.client.media.PlayerStateMachine.Event +import com.nextcloud.client.media.PlayerStateMachine.State +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.inOrder +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@RunWith(Suite::class) +@Suite.SuiteClasses( + PlayerStateMachineTest.Constructor::class, + PlayerStateMachineTest.EventHandling::class, + PlayerStateMachineTest.Stopped::class, + PlayerStateMachineTest.Downloading::class, + PlayerStateMachineTest.Preparing::class, + PlayerStateMachineTest.AwaitFocus::class, + PlayerStateMachineTest.Focused::class, + PlayerStateMachineTest.Ducked::class, + PlayerStateMachineTest.Paused::class +) +internal class PlayerStateMachineTest { + + abstract class Base { + @Mock + protected lateinit var delegate: PlayerStateMachine.Delegate + protected lateinit var fsm: PlayerStateMachine + + fun setUp(initialState: State) { + MockitoAnnotations.initMocks(this) + fsm = PlayerStateMachine(initialState, delegate) + } + } + + class Constructor { + + private val delegate: PlayerStateMachine.Delegate = mock() + + @Test + fun `default state is stopped`() { + val fsm = PlayerStateMachine(delegate) + assertEquals(State.STOPPED, fsm.state) + } + + @Test + fun `inital state can be set`() { + val fsm = PlayerStateMachine(State.PREPARING, delegate) + assertEquals(State.PREPARING, fsm.state) + } + } + + class EventHandling : Base() { + + @Before + fun setUp() { + super.setUp(State.STOPPED) + } + + @Test + fun `can post multiple events from callback`() { + whenever(delegate.isDownloaded).thenReturn(false) + whenever(delegate.isAutoplayEnabled).thenReturn(false) + whenever(delegate.hasEnqueuedFile).thenReturn(true) + whenever(delegate.onStartDownloading()).thenAnswer { + fsm.post(Event.DOWNLOADED) + fsm.post(Event.PREPARED) + } + + // WHEN + // an event is posted from a state machine callback + fsm.post(Event.PLAY) // posts error() in callback + + // THEN + // enqueued events is handled triggering transitions + assertEquals(State.PAUSED, fsm.state) + verify(delegate).onStartRunning() + verify(delegate).onStartDownloading() + verify(delegate).onPrepare() + verify(delegate).onPausePlayback() + } + + @Test + fun `unhandled events are ignored`() { + // GIVEN + // state machine is in STOPPED state + // PAUSE event is not handled in this staet + + // WHEN + // state machine receives unhandled PAUSE event + fsm.post(Event.PAUSE) + + // THEN + // event is ignored + // exception is not thrown + } + } + + class Stopped : Base() { + + @Before + fun setUp() { + super.setUp(State.STOPPED) + } + + @Test + fun `initiall state is stopped`() { + assertEquals(State.STOPPED, fsm.state) + } + + @Test + fun `playing requires enqueued file`() { + // GIVEN + // no file is enqueued + whenever(delegate.hasEnqueuedFile).thenReturn(false) + + // WHEN + // play is triggered + fsm.post(Event.PLAY) + + // THEN + // remains in stopped state + assertEquals(State.STOPPED, fsm.state) + } + + @Test + fun `playing remote media triggers downloading`() { + // GIVEN + // file is enqueued + // media is not downloaded + whenever(delegate.hasEnqueuedFile).thenReturn(true) + whenever(delegate.isDownloaded).thenReturn(false) + + // WHEN + // play is requested + fsm.post(Event.PLAY) + + // THEN + // enqueued file is loaded + // media stream download starts + assertEquals(State.DOWNLOADING, fsm.state) + verify(delegate).onStartRunning() + verify(delegate).onStartDownloading() + } + + @Test + fun `playing local media triggers player preparation`() { + // GIVEN + // file is enqueued + // media is downloaded + whenever(delegate.hasEnqueuedFile).thenReturn(true) + whenever(delegate.isDownloaded).thenReturn(true) + + // WHEN + // play is requested + fsm.post(Event.PLAY) + + // THEN + // player preparation starts + assertEquals(State.PREPARING, fsm.state) + verify(delegate).onPrepare() + } + } + + class Downloading : Base() { + + // GIVEN + // player is downloading stream URL + @Before + fun setUp() { + setUp(State.DOWNLOADING) + } + + @Test + fun `stream url download is successfull`() { + // WHEN + // stream url downloaded + fsm.post(Event.DOWNLOADED) + + // THEN + // player is preparing + assertEquals(State.PREPARING, fsm.state) + verify(delegate).onPrepare() + } + + @Test + fun `stream url download failed`() { + // WHEN + // download error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onError() + } + + @Test + fun `player stopped`() { + // WHEN + // download error + fsm.post(Event.STOP) + + // THEN + // player is stopped + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onStopped() + } + + @Test + fun `player error`() { + // WHEN + // player error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + // error handler is called + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onError() + } + } + + class Preparing : Base() { + + @Before + fun setUp() { + setUp(State.PREPARING) + } + + @Test + fun `start in autoplay mode`() { + // GIVEN + // media player is preparing + // autoplay is enabled + whenever(delegate.isAutoplayEnabled).thenReturn(true) + + // WHEN + // media player is ready + fsm.post(Event.PREPARED) + + // THEN + // start playing + // request audio focus + // awaiting focus + assertEquals(State.AWAIT_FOCUS, fsm.state) + verify(delegate).onRequestFocus() + } + + @Test + fun `start in paused mode`() { + // GIVEN + // media player is preparing + // autoplay is disabled + whenever(delegate.isAutoplayEnabled).thenReturn(false) + + // WHEN + // media player is ready + fsm.post(Event.PREPARED) + + // THEN + // media player is not started + assertEquals(State.PAUSED, fsm.state) + verify(delegate, never()).onStartPlayback() + } + + @Test + fun `player is stopped during preparation`() { + // GIVEN + // media player is preparing + // WHEN + // stopped + fsm.post(Event.STOP) + + // THEN + // player is stopped + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onStopped() + } + + @Test + fun `error during preparation`() { + // GIVEN + // media player is preparing + // WHEN + // download error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + // error callback is invoked + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onError() + } + } + + class AwaitFocus : Base() { + + @Before + fun setUp() { + setUp(State.AWAIT_FOCUS) + } + + @Test + fun pause() { + // GIVEN + // media player is awaiting focus + // WHEN + // media player is paused + fsm.post(Event.PAUSE) + + // THEN + // media player enters paused state + // focus is released + assertEquals(State.PAUSED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onPausePlayback() + } + } + + @Test + fun `audio focus denied`() { + // GIVEN + // media player is awaiting focus + // WHEN + // audio focus was denied + fsm.post(Event.FOCUS_LOST) + + // THEN + // media player enters paused state + assertEquals(State.PAUSED, fsm.state) + verify(delegate).onPausePlayback() + } + + @Test + fun `audio focus granted`() { + // GIVEN + // media player is awaiting focus + // WHEN + // audio focus was granted + fsm.post(Event.FOCUS_GAIN) + + // THEN + // media player enters focused state + // playback is started + assertEquals(State.FOCUSED, fsm.state) + verify(delegate).onStartPlayback() + } + + @Test + fun stop() { + // GIVEN + // media player is awaiting focus + // WHEN + // stopped + fsm.post(Event.STOP) + + // THEN + // player is stopped + // focus is released + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onStopped() + } + } + + @Test + fun error() { + // GIVEN + // media player is playing + // WHEN + // error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + // focus is released + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onError() + } + } + } + + class Focused : Base() { + + @Before + fun setUp() { + setUp(State.FOCUSED) + } + + @Test + fun pause() { + // GIVEN + // media player is awaiting focus + // WHEN + // media player is paused + fsm.post(Event.PAUSE) + + // THEN + // media player enters paused state + // focus is released + assertEquals(State.PAUSED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onPausePlayback() + } + } + + @Test + fun `lost focus`() { + // GIVEN + // media player is awaiting focus + // WHEN + // media player lost audio focus + fsm.post(Event.FOCUS_LOST) + + // THEN + // media player enters paused state + // focus is released + assertEquals(State.PAUSED, fsm.state) + verify(delegate).onPausePlayback() + } + + @Test + fun `audio focus duck`() { + // GIVEN + // media player is playing + // WHEN + // media player focus duck is requested + fsm.post(Event.FOCUS_DUCK) + + // THEN + // media player ducks + assertEquals(State.DUCKED, fsm.state) + verify(delegate).onAudioDuck(eq(true)) + } + + @Test + fun stop() { + // GIVEN + // media player is awaiting focus + // WHEN + // stopped + fsm.post(Event.STOP) + + // THEN + // player is stopped + // focus is released + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onStopped() + } + } + + @Test + fun error() { + // GIVEN + // media player is playing + // WHEN + // error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + // focus is released + // error is signaled + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onReleaseFocus() + verify(delegate).onError() + } + } + } + + class Ducked : Base() { + + @Before + fun setUp() { + setUp(State.DUCKED) + } + + @Test + fun pause() { + // GIVEN + // media player is playing + // audio focus is ducked + // WHEN + // media player is paused + fsm.post(Event.PAUSE) + + // THEN + // audio focus duck is disabled + // focus is released + // playback is paused + assertEquals(State.PAUSED, fsm.state) + inOrder(delegate).run { + verify(delegate).onAudioDuck(eq(false)) + verify(delegate).onReleaseFocus() + verify(delegate).onPausePlayback() + } + } + + @Test + fun `lost focus`() { + // GIVEN + // media player is playing + // audio focus is ducked + // WHEN + // media player is looses focus + fsm.post(Event.FOCUS_LOST) + + // THEN + // audio focus duck is disabled + // focus is released + // playback is paused + assertEquals(State.PAUSED, fsm.state) + inOrder(delegate).run { + verify(delegate).onAudioDuck(eq(false)) + verify(delegate).onReleaseFocus() + verify(delegate).onPausePlayback() + } + // WHEN + // media player is paused + fsm.post(Event.PAUSE) + + // THEN + // audio focus duck is disabled + // focus is released + // playback is paused + assertEquals(State.PAUSED, fsm.state) + inOrder(delegate).run { + verify(delegate).onAudioDuck(eq(false)) + verify(delegate).onReleaseFocus() + verify(delegate).onPausePlayback() + } + } + + @Test + fun `audio focus is re-gained`() { + // GIVEN + // media player is playing + // audio focus is ducked + // WHEN + // media player focus duck is requested + fsm.post(Event.FOCUS_GAIN) + + // THEN + // media player is focused + // audio focus duck is disabled + // playback is not restarted + assertEquals(State.FOCUSED, fsm.state) + verify(delegate).onAudioDuck(eq(false)) + verify(delegate, never()).onStartPlayback() + } + + @Test + fun stop() { + // GIVEN + // media player is playing + // audio focus is ducked + // WHEN + // media player is stopped + fsm.post(Event.STOP) + + // THEN + // audio focus duck is disabled + // focus is released + // playback is stopped + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onAudioDuck(eq(false)) + verify(delegate).onReleaseFocus() + verify(delegate).onStopped() + } + } + + @Test + fun error() { + // GIVEN + // media player is playing + // audio focus is ducked + // WHEN + // error + fsm.post(Event.ERROR) + + // THEN + // audio focus duck is disabled + // focus is released + // playback is stopped + // error is signaled + assertEquals(State.STOPPED, fsm.state) + inOrder(delegate).run { + verify(delegate).onAudioDuck(eq(false)) + verify(delegate).onReleaseFocus() + verify(delegate).onError() + } + } + } + + class Paused : Base() { + + @Before + fun setUp() { + setUp(State.PAUSED) + } + + @Test + fun pause() { + // GIVEN + // media player is paused + // WHEN + // media player is resumed + fsm.post(Event.PLAY) + + // THEN + // media player enters playing state + // audio focus is requsted + assertEquals(State.AWAIT_FOCUS, fsm.state) + verify(delegate).onRequestFocus() + } + + @Test + fun stop() { + // GIVEN + // media player is playing + // WHEN + // stopped + fsm.post(Event.STOP) + + // THEN + // player is stopped + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onStopped() + } + + @Test + fun error() { + // GIVEN + // media player is playing + // WHEN + // error + fsm.post(Event.ERROR) + + // THEN + // player is stopped + // error callback is invoked + assertEquals(State.STOPPED, fsm.state) + verify(delegate).onError() + } + } +}