diff --git a/Armadillo/src/main/AndroidManifest.xml b/Armadillo/src/main/AndroidManifest.xml index eafc9f5..88927bf 100644 --- a/Armadillo/src/main/AndroidManifest.xml +++ b/Armadillo/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + diff --git a/Armadillo/src/main/java/com/scribd/armadillo/Util.kt b/Armadillo/src/main/java/com/scribd/armadillo/Util.kt index 9ccb077..c3be0c5 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/Util.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/Util.kt @@ -1,6 +1,8 @@ package com.scribd.armadillo import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import com.scribd.armadillo.models.Chapter @@ -47,5 +49,20 @@ fun sanitizeChapters(chapters: List): List { } } +fun isInternetAvailable(context: Context): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkCapabilities = connectivityManager.activeNetwork ?: return false + val actNw = + connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false + return when { + actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> true + else -> false + } +} + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) fun hasSnowCone() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt index 529e4d5..89d314c 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt @@ -18,6 +18,7 @@ import com.scribd.armadillo.playback.MediaMetadataCompatBuilderImpl import com.scribd.armadillo.playback.PlaybackEngineFactoryHolder import com.scribd.armadillo.playback.PlaybackStateBuilderImpl import com.scribd.armadillo.playback.PlaybackStateCompatBuilder +import com.scribd.armadillo.playback.PlayerEventListener import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelper import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelperImpl import com.scribd.armadillo.playback.mediasource.HeadersMediaSourceFactoryFactory @@ -75,4 +76,8 @@ internal class PlaybackModule { @Provides @Singleton fun drmSessionManagerProvider(stateStore: StateStore.Modifier): DrmSessionManagerProvider = ArmadilloDrmSessionManagerProvider(stateStore) + + @Provides + @Singleton + fun playerEventListener(context: Context): PlayerEventListener = PlayerEventListener(context) } \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerExt.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerExt.kt index a1d7594..979749e 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerExt.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerExt.kt @@ -14,7 +14,8 @@ internal data class TestableDownloadState(val id: Int, val url: String, val state: Int, val downloadPercentage: Int, - val downloadedBytes: Long) { + val downloadedBytes: Long, + val failureReason: Int? = null) { companion object { const val QUEUED = ExoplayerDownload.STATE_QUEUED const val COMPLETED = ExoplayerDownload.STATE_COMPLETED @@ -24,11 +25,12 @@ internal data class TestableDownloadState(val id: Int, } constructor(download: ExoplayerDownload) : this( - download.request.data.decodeToInt(), - download.request.uri.toString(), - download.state, - download.percentDownloaded.toInt(), - download.bytesDownloaded) + download.request.data.decodeToInt(), + download.request.uri.toString(), + download.state, + download.percentDownloaded.toInt(), + download.bytesDownloaded, + download.failureReason) /** * This method converts [TestableDownloadState] (a testable wrapper fo exoplayer's [DownloadManager.TaskState]) @@ -47,13 +49,15 @@ internal data class TestableDownloadState(val id: Int, } DownloadState.STARTED(percent, downloadedBytes) } + QUEUED -> return null - else -> DownloadState.FAILED + else -> DownloadState.FAILED(failureReason) } return DownloadProgressInfo( - id = id, - url = url, - downloadState = downloadState) + id = id, + url = url, + downloadState = downloadState, + exoPlayerDownloadState = state) } } \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/ExoplayerDownloadTracker.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/ExoplayerDownloadTracker.kt index a15fb8d..45b16cd 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/ExoplayerDownloadTracker.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/ExoplayerDownloadTracker.kt @@ -20,8 +20,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException -import java.lang.Exception -import java.util.HashMap import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -111,7 +109,7 @@ internal class ExoplayerDownloadTracker @Inject constructor( override fun updateProgress() { downloadManager.currentDownloads.forEach { download -> - if(downloads.containsKey(download.request.uri.toString())){ + if (downloads.containsKey(download.request.uri.toString())) { downloads[download.request.uri.toString()] = download //older usage } else { downloads[download.request.id] = download @@ -129,7 +127,7 @@ internal class ExoplayerDownloadTracker @Inject constructor( override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) { Log.v(TAG, "onDownloadChanged") - if(downloads.containsKey(download.request.uri.toString())){ + if (downloads.containsKey(download.request.uri.toString())) { downloads[download.request.uri.toString()] = download //older usage } else { downloads[download.request.id] = download @@ -155,6 +153,7 @@ internal class ExoplayerDownloadTracker @Inject constructor( @VisibleForTesting fun dispatchActionsForProgress(downloadInfo: DownloadProgressInfo) { val taskFailed = downloadInfo.isFailed() + val exoplayerDownloadState = downloadInfo.exoPlayerDownloadState val isRemoveDownloadComplete = downloadInfo.downloadState is DownloadState.REMOVED val isDownloadComplete = downloadInfo.isDownloaded() @@ -173,7 +172,13 @@ internal class ExoplayerDownloadTracker @Inject constructor( } if (taskFailed) { - actions.add(ErrorAction(DownloadFailed())) + actions.add(ErrorAction(DownloadFailed( + mapOf( + "exo_player_download_state" to exoplayerDownloadState.toString(), + "is_download_complete" to isDownloadComplete.toString(), + "failure_reason" to (downloadInfo.downloadState as? DownloadState.FAILED)?.failureReason.toString() + ) + ))) } actions.forEach { diff --git a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt index 32bfe83..a6107ae 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt @@ -57,8 +57,12 @@ class InvalidRequest(message: String) /** * Playback Errors */ -data class HttpResponseCodeException(val responseCode: Int, val url: String?, override val cause: Exception) - : ArmadilloException(cause = cause, message = "HTTP Error $responseCode.", isNetworkRelatedError = true) { +data class HttpResponseCodeException( + val responseCode: Int, + val url: String?, + override val cause: Exception, + val extraData: Map = emptyMap(), +) : ArmadilloException(cause = cause, message = "HTTP Error $responseCode.", isNetworkRelatedError = true) { override val errorCode: Int = 200 } @@ -96,6 +100,11 @@ class ConnectivityException(cause: Exception) override val errorCode: Int = 206 } +class ParsingException(cause: Exception) + : ArmadilloException(cause = cause, message = "The content cannot be parsed and it is not playable: ${cause.message}") { + override val errorCode = 207 +} + /** * Download Errors */ @@ -104,7 +113,7 @@ class MissingInfoDownloadException(message: String) override val errorCode = 301 } -class DownloadFailed +class DownloadFailed(val extraData: Map) : ArmadilloException(message = "The download has failed to finish.", isNetworkRelatedError = true) { override val errorCode = 302 } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt b/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt index cb45aa7..b489aa3 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt @@ -98,11 +98,12 @@ data class MediaControlState( data class DownloadProgressInfo( val id: Int, val url: String, - val downloadState: DownloadState) { + val downloadState: DownloadState, + val exoPlayerDownloadState: Int? = null) { fun isDownloaded(): Boolean = DownloadState.COMPLETED == downloadState - fun isFailed(): Boolean = DownloadState.FAILED == downloadState + fun isFailed(): Boolean = downloadState is DownloadState.FAILED companion object { const val PROGRESS_UNSET = C.PERCENTAGE_UNSET @@ -129,25 +130,25 @@ sealed class DownloadState { override fun toString() = "REMOVED" } - object FAILED : DownloadState() { + data class FAILED(val failureReason: Int? = null) : DownloadState() { override fun toString() = "FAILED" } } data class InternalState(val isPlaybackEngineReady: Boolean = false) -sealed class DrmState (val drmType: DrmType?, val expireMillis: Milliseconds, val isSessionValid: Boolean) { +sealed class DrmState(val drmType: DrmType?, val expireMillis: Milliseconds, val isSessionValid: Boolean) { /** This Content is not utilizing DRM protections, or is now first initializing **/ object NoDRM : DrmState(null, 0.milliseconds, true) /** Attempt to open the license and decrypt */ - class LicenseOpening(drmType: DrmType?, expireMillis: Milliseconds = 0.milliseconds): DrmState(drmType, expireMillis, true) + class LicenseOpening(drmType: DrmType?, expireMillis: Milliseconds = 0.milliseconds) : DrmState(drmType, expireMillis, true) /** A DRM License has been obtained. */ - class LicenseAcquired(drmType: DrmType, expireMillis: Milliseconds): DrmState(drmType, expireMillis, true) + class LicenseAcquired(drmType: DrmType, expireMillis: Milliseconds) : DrmState(drmType, expireMillis, true) /** The player encountered an expiration event */ - class LicenseExpired(drmType: DrmType?, expireMillis: Milliseconds): DrmState(drmType, expireMillis, false) + class LicenseExpired(drmType: DrmType?, expireMillis: Milliseconds) : DrmState(drmType, expireMillis, false) /** A DRM license exists and content is able to be decrypted. */ class LicenseUsable(drmType: DrmType?, expireMillis: Milliseconds) : DrmState(drmType, expireMillis, true) diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt index cde6af7..ca9d88e 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt @@ -1,38 +1,58 @@ package com.scribd.armadillo.playback +import android.content.Context import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.ExoPlaybackException.TYPE_RENDERER import com.google.android.exoplayer2.ExoPlaybackException.TYPE_SOURCE +import com.google.android.exoplayer2.ParserException import com.google.android.exoplayer2.audio.AudioSink import com.google.android.exoplayer2.drm.MediaDrmCallbackException +import com.google.android.exoplayer2.upstream.DataSpec import com.google.android.exoplayer2.upstream.HttpDataSource import com.scribd.armadillo.error.ArmadilloException import com.scribd.armadillo.error.ArmadilloIOException -import com.scribd.armadillo.error.HttpResponseCodeException import com.scribd.armadillo.error.ConnectivityException +import com.scribd.armadillo.error.HttpResponseCodeException +import com.scribd.armadillo.error.ParsingException import com.scribd.armadillo.error.RendererConfigurationException import com.scribd.armadillo.error.RendererInitializationException import com.scribd.armadillo.error.RendererWriteException import com.scribd.armadillo.error.UnexpectedException import com.scribd.armadillo.error.UnknownRendererException +import com.scribd.armadillo.isInternetAvailable import java.net.SocketTimeoutException import java.net.UnknownHostException -internal fun ExoPlaybackException.toArmadilloException(): ArmadilloException { +internal fun ExoPlaybackException.toArmadilloException(context: Context): ArmadilloException { return if (TYPE_SOURCE == type) { return this.sourceException.let { source -> when (source) { is HttpDataSource.InvalidResponseCodeException -> - HttpResponseCodeException(source.responseCode, source.dataSpec.uri.toString(), source) + HttpResponseCodeException(source.responseCode, source.dataSpec.uri.toString(), source, source.dataSpec.toAnalyticsMap + (context)) + is HttpDataSource.HttpDataSourceException -> - HttpResponseCodeException(0, source.dataSpec.uri.toString(), source) + HttpResponseCodeException(source.reason, source.dataSpec.uri.toString(), source, source.dataSpec.toAnalyticsMap(context)) + is MediaDrmCallbackException -> { val httpCause = source.cause as? HttpDataSource.InvalidResponseCodeException - HttpResponseCodeException(httpCause?.responseCode ?: 0, httpCause?.dataSpec?.uri.toString(), source) + HttpResponseCodeException(httpCause?.responseCode + ?: 0, httpCause?.dataSpec?.uri.toString(), source, source.dataSpec.toAnalyticsMap(context)) } + is UnknownHostException, is SocketTimeoutException -> ConnectivityException(source) - else -> ArmadilloIOException(cause = this, actionThatFailedMessage = "Exoplayer error.") + + else -> { + var cause: Throwable? = source + while (source.cause != null && cause !is ParserException) { + cause = source.cause + } + when (cause) { + is ParserException -> ParsingException(cause = this) + else -> ArmadilloIOException(cause = this, actionThatFailedMessage = "Exoplayer error.") + } + } } } } else if (TYPE_RENDERER == type) { @@ -47,4 +67,18 @@ internal fun ExoPlaybackException.toArmadilloException(): ArmadilloException { } else { UnexpectedException(cause = this, actionThatFailedMessage = "Exoplayer error") } +} + +private fun DataSpec.toAnalyticsMap(context: Context): Map { + return mapOf( + "uri" to uri.toString(), + "uriPositionOffset" to uriPositionOffset.toString(), + "httpMethod" to httpMethod.toString(), + "position" to position.toString(), + "length" to length.toString(), + "key" to key.toString(), + "flags" to flags.toString(), + "customData" to customData.toString(), + "isInternetConnected" to isInternetAvailable(context).toString(), + ) } \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackEngine.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackEngine.kt index 08d303d..fc36c00 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackEngine.kt @@ -105,7 +105,8 @@ internal class ExoplayerPlaybackEngine(private var audioPlayable: AudioPlayable) @VisibleForTesting internal lateinit var exoPlayer: ExoPlayer - private val playerEventListener = PlayerEventListener() + @Inject + internal lateinit var playerEventListener: PlayerEventListener override val currentChapterIndex: Int get() = audioPlayable.chapters.indexOf(currentChapter) @@ -155,7 +156,7 @@ internal class ExoplayerPlaybackEngine(private var audioPlayable: AudioPlayable) stateModifier.dispatch(PlaybackEngineReady(true)) stateModifier.dispatch(PlayerStateAction(PlaybackState.PAUSED)) } catch (ex: Exception) { - val armadilloException = if(ex is ArmadilloException) ex else PlaybackStartFailureException(cause = ex) + val armadilloException = if (ex is ArmadilloException) ex else PlaybackStartFailureException(cause = ex) stateModifier.dispatch(ErrorAction(armadilloException)) } } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/PlayerEventListener.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/PlayerEventListener.kt index 5271099..47a50f3 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/PlayerEventListener.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/PlayerEventListener.kt @@ -1,5 +1,6 @@ package com.scribd.armadillo.playback +import android.content.Context import android.util.Log import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.ExoPlayer @@ -22,7 +23,7 @@ import javax.inject.Inject * * It communicates changes by sending [Action]s with [StateStore.Modifier]. */ -internal class PlayerEventListener : Player.Listener { +internal class PlayerEventListener @Inject constructor(private val context: Context) : Player.Listener { init { Injector.mainComponent.inject(this) } @@ -35,7 +36,7 @@ internal class PlayerEventListener : Player.Listener { internal lateinit var stateModifier: StateStore.Modifier override fun onPlayerError(error: PlaybackException) { - val exception = (error as ExoPlaybackException).toArmadilloException() + val exception = (error as ExoPlaybackException).toArmadilloException(context) stateModifier.dispatch(ErrorAction(exception)) Log.e(TAG, "onPlayerError: $error") } diff --git a/Armadillo/src/test/java/com/scribd/armadillo/download/DownloadManagerExtKtTest.kt b/Armadillo/src/test/java/com/scribd/armadillo/download/DownloadManagerExtKtTest.kt index 112bb88..a905296 100644 --- a/Armadillo/src/test/java/com/scribd/armadillo/download/DownloadManagerExtKtTest.kt +++ b/Armadillo/src/test/java/com/scribd/armadillo/download/DownloadManagerExtKtTest.kt @@ -15,14 +15,15 @@ class DownloadManagerExtKtTest { } private lateinit var downloadState: TestableDownloadState + @Before fun setUp() { downloadState = TestableDownloadState( - ID, - URL, - TestableDownloadState.COMPLETED, - DOWNLOAD_PERCENT, - DOWNLOADED_BYTES) + ID, + URL, + TestableDownloadState.COMPLETED, + DOWNLOAD_PERCENT, + DOWNLOADED_BYTES) } @Test @@ -35,7 +36,7 @@ class DownloadManagerExtKtTest { @Test fun toDownloadInfo_downloadRemovalComplete_returnsRemoveProgress() { val state = downloadState.copy( - state = TestableDownloadState.REMOVING) + state = TestableDownloadState.REMOVING) val downloadInfo = state.toDownloadInfo()!! assertThat(downloadInfo.id).isEqualTo(ID) assertThat(downloadInfo.url).isEqualTo(URL) @@ -45,7 +46,7 @@ class DownloadManagerExtKtTest { @Test fun toDownloadInfo_downloadComplete_returnsCompletedProgress() { val state = downloadState.copy( - state = TestableDownloadState.COMPLETED) + state = TestableDownloadState.COMPLETED) val downloadInfo = state.toDownloadInfo()!! assertThat(downloadInfo.id).isEqualTo(ID) assertThat(downloadInfo.url).isEqualTo(URL) @@ -55,8 +56,8 @@ class DownloadManagerExtKtTest { @Test fun toDownloadInfo_downloadProgressJustBegan_returnsProgress() { val state = downloadState.copy( - state = TestableDownloadState.IN_PROGRESS, - downloadPercentage = DownloadProgressInfo.PROGRESS_UNSET) + state = TestableDownloadState.IN_PROGRESS, + downloadPercentage = DownloadProgressInfo.PROGRESS_UNSET) val downloadInfo = state.toDownloadInfo()!! assertThat(downloadInfo.id).isEqualTo(ID) assertThat(downloadInfo.url).isEqualTo(URL) @@ -66,7 +67,7 @@ class DownloadManagerExtKtTest { @Test fun toDownloadInfo_downloadProgressWithProgress_returnsProgress() { val state = downloadState.copy( - state = TestableDownloadState.IN_PROGRESS) + state = TestableDownloadState.IN_PROGRESS) val downloadInfo = state.toDownloadInfo()!! assertThat(downloadInfo.id).isEqualTo(ID) assertThat(downloadInfo.url).isEqualTo(URL) @@ -76,10 +77,10 @@ class DownloadManagerExtKtTest { @Test fun toDownloadInfo_unknownState_returnsFailed() { val state = downloadState.copy( - state = 1000) + state = 1000) val downloadInfo = state.toDownloadInfo()!! assertThat(downloadInfo.id).isEqualTo(ID) assertThat(downloadInfo.url).isEqualTo(URL) - assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.FAILED) + assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.FAILED()) } } \ No newline at end of file diff --git a/Armadillo/src/test/java/com/scribd/armadillo/download/ExoplayerDownloadTrackerTest.kt b/Armadillo/src/test/java/com/scribd/armadillo/download/ExoplayerDownloadTrackerTest.kt index da14219..bf46c89 100644 --- a/Armadillo/src/test/java/com/scribd/armadillo/download/ExoplayerDownloadTrackerTest.kt +++ b/Armadillo/src/test/java/com/scribd/armadillo/download/ExoplayerDownloadTrackerTest.kt @@ -45,7 +45,7 @@ class ExoplayerDownloadTrackerTest { @Test fun dispatchActionsForProgress_taskFailed_dispatchesActions() { - downloadInfo = DownloadProgressInfo(ID, URL, DownloadState.FAILED) + downloadInfo = DownloadProgressInfo(ID, URL, DownloadState.FAILED()) exoplayerDownloadTracker.dispatchActionsForProgress(downloadInfo) verify(stateModifier).dispatch(UpdateDownloadAction(downloadInfo)) verify(stateModifier).dispatch(isA())