From 93eaed8e9690312680cc5ffadf98cd68116566a0 Mon Sep 17 00:00:00 2001 From: Katherine Blizard <414924+kabliz@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:49:12 -0700 Subject: [PATCH 1/2] Interface for DRM Visibility - Adds DrmState to ArmadilloState, giving full visibility into DRM status to the client, including the expiration date for content. - Splits SocketTimeout from HttpResponseCodeException, into ConnectivityException to better differentiate connectivity difficulties from developer error. - Ensures that ArmadilloState is updated from the main thread consistently. --- .../scribd/armadillo/ArmadilloDebugView.kt | 16 +++ .../armadillo/ArmadilloPlayerChoreographer.kt | 2 +- .../main/java/com/scribd/armadillo/Reducer.kt | 48 ++++++- .../java/com/scribd/armadillo/StateStore.kt | 17 ++- .../com/scribd/armadillo/actions/Action.kt | 31 ++++ .../analytics/PlaybackActionTransmitter.kt | 23 ++- .../java/com/scribd/armadillo/di/AppModule.kt | 2 +- .../com/scribd/armadillo/di/MainComponent.kt | 3 +- .../com/scribd/armadillo/di/PlaybackModule.kt | 4 +- .../drm/ArmadilloDrmSessionManagerProvider.kt | 136 ++++++++++++++++++ .../download/drm/DashDrmLicenseDownloader.kt | 39 +++-- .../events/WidevineSessionEventListener.kt | 37 +++++ .../armadillo/error/ArmadilloException.kt | 22 ++- .../scribd/armadillo/models/ArmadilloState.kt | 25 ++++ .../playback/ExoPlaybackExceptionExt.kt | 8 +- .../mediasource/DashMediaSourceGenerator.kt | 45 ++++-- .../layout/arm_audio_engine_debug_layout.xml | 9 ++ Armadillo/src/main/res/values/strings.xml | 7 + .../scribd/armadillo/DaggerComponentRule.kt | 2 + .../java/com/scribd/armadillo/ReducerTest.kt | 56 +++++++- .../PlaybackActionTransmitterImplTest.kt | 7 +- RELEASE.md | 6 + gradle.properties | 2 +- 23 files changed, 504 insertions(+), 43 deletions(-) create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/events/WidevineSessionEventListener.kt diff --git a/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloDebugView.kt b/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloDebugView.kt index bf6bd93..70e336d 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloDebugView.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloDebugView.kt @@ -11,6 +11,7 @@ import android.widget.TextView import com.scribd.armadillo.models.ArmadilloState import com.scribd.armadillo.models.AudioPlayable import com.scribd.armadillo.models.DownloadState +import com.scribd.armadillo.models.DrmState import java.util.concurrent.TimeUnit /** @@ -43,6 +44,7 @@ class ArmadilloDebugView : FrameLayout { private val playbackSpeedTv: TextView by lazy { findViewById(R.id.playbackSpeedTv) } private val downloadStateTv: TextView by lazy { findViewById(R.id.downloadStateTv) } private val downloadPercentTv: TextView by lazy { findViewById(R.id.downloadPercentTv) } + private val drmStatusTv: TextView by lazy { findViewById(R.id.drmStatusTv) } fun update(state: ArmadilloState, audioPlayable: AudioPlayable) { val activity = context as Activity @@ -51,6 +53,20 @@ class ArmadilloDebugView : FrameLayout { appStateUpdateCountTv.text = appStateCountText actionsDispatchedTv.text = state.debugState.getActionHistoryDisplayString() + drmStatusTv.text = when (state.drmPlaybackState) { + is DrmState.NoDRM -> context.getString(R.string.arm_drm_none) + is DrmState.LicenseOpening -> context.getString(R.string.arm_drm_opening) + is DrmState.LicenseReleased -> context.getString(R.string.arm_drm_released) + is DrmState.LicenseExpired -> context.getString(R.string.arm_drm_expired) + is DrmState.LicenseError -> context.getString(R.string.arm_drm_error) + is DrmState.LicenseAcquired, + is DrmState.LicenseUsable -> { + "${context.getString(R.string.arm_drm_using)} ${state.drmPlaybackState.drmType.toString()} : ${context.getString(R.string + .arm_drm_expiring)} " + + "${state.drmPlaybackState.expireMillis}" + } + } + val playbackInfo = state.playbackInfo if (playbackInfo != null) { titleTv.text = activity.getString(R.string.arm_title, state.playbackInfo.audioPlayable.title) diff --git a/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloPlayerChoreographer.kt b/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloPlayerChoreographer.kt index a90744b..653544d 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloPlayerChoreographer.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/ArmadilloPlayerChoreographer.kt @@ -70,7 +70,7 @@ interface ArmadilloPlayer { fun beginPlayback(audioPlayable: AudioPlayable, config: ArmadilloConfiguration = ArmadilloConfiguration()) /** - * Provide new media request data to the currently playing content. + * Provide new media request data to the currently playing content, such as request headers or DRM updates. * It is an error to call this with a different URL from the currently playing media. * It is also an error to call this when no content is currently loaded. * If you want to start playback from a new URL, use [beginPlayback]. diff --git a/Armadillo/src/main/java/com/scribd/armadillo/Reducer.kt b/Armadillo/src/main/java/com/scribd/armadillo/Reducer.kt index 76aba82..9234877 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/Reducer.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/Reducer.kt @@ -8,10 +8,17 @@ import com.scribd.armadillo.actions.ContentEndedAction import com.scribd.armadillo.actions.CustomMediaSessionAction import com.scribd.armadillo.actions.ErrorAction import com.scribd.armadillo.actions.FastForwardAction +import com.scribd.armadillo.actions.LicenseAcquiredAction +import com.scribd.armadillo.actions.LicenseDrmErrorAction +import com.scribd.armadillo.actions.LicenseExpirationDetermined +import com.scribd.armadillo.actions.LicenseExpiredAction +import com.scribd.armadillo.actions.LicenseKeyIsUsableAction +import com.scribd.armadillo.actions.LicenseReleasedAction import com.scribd.armadillo.actions.LoadingAction import com.scribd.armadillo.actions.MediaRequestUpdateAction import com.scribd.armadillo.actions.MetadataUpdateAction import com.scribd.armadillo.actions.NewAudioPlayableAction +import com.scribd.armadillo.actions.OpeningLicenseAction import com.scribd.armadillo.actions.PlaybackEngineReady import com.scribd.armadillo.actions.PlaybackProgressAction import com.scribd.armadillo.actions.PlaybackSpeedAction @@ -33,6 +40,7 @@ import com.scribd.armadillo.extensions.filterOutCompletedItems import com.scribd.armadillo.extensions.removeItemsByUrl import com.scribd.armadillo.extensions.replaceDownloadProgressItemsByUrl import com.scribd.armadillo.models.ArmadilloState +import com.scribd.armadillo.models.DrmState import com.scribd.armadillo.models.MediaControlState import com.scribd.armadillo.models.PlaybackInfo import com.scribd.armadillo.models.PlaybackProgress @@ -46,6 +54,7 @@ import com.scribd.armadillo.time.Millisecond */ internal object Reducer { const val TAG = "Reducer" + /** * Due to floating point math, adding up the duration of the each chapter to determine the audioPlayable duration will result in some * level of inaccuracy. Generally, we seek to have the duration be slightly shorter then the actual playlist length. This value is @@ -140,7 +149,8 @@ internal object Reducer { playbackSpeed = Constants.DEFAULT_PLAYBACK_SPEED, controlState = MediaControlState(isStartingNewAudioPlayable = true), skipDistance = oldState.playbackInfo?.skipDistance ?: Constants.AUDIO_SKIP_DURATION, - isLoading = true)) + isLoading = true), + drmPlaybackState = DrmState.NoDRM) .apply { debugState = newDebug } } is MediaRequestUpdateAction -> { @@ -261,6 +271,42 @@ internal object Reducer { )) .apply { debugState = newDebug } } + is OpeningLicenseAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseOpening(action.type ?: oldDrm.drmType)) + } + is LicenseAcquiredAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseAcquired(action.type, oldDrm.expireMillis)) + } + is LicenseExpiredAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseExpired(oldDrm.drmType, oldDrm.expireMillis)) + } + is LicenseExpirationDetermined -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseUsable(oldDrm.drmType, action.expirationMilliseconds)) + } + is LicenseReleasedAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseReleased(oldDrm.drmType,oldDrm.expireMillis, oldDrm.isSessionValid)) + } + is LicenseKeyIsUsableAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseUsable(oldDrm.drmType, oldDrm.expireMillis)) + } + is LicenseDrmErrorAction -> { + val oldDrm = oldState.drmPlaybackState + return oldState + .copy(drmPlaybackState = DrmState.LicenseError(oldDrm.drmType, oldDrm.expireMillis)) + } + else -> throw UnrecognizedAction(action) } } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt b/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt index 1b05638..0f3dc36 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt @@ -1,5 +1,7 @@ package com.scribd.armadillo +import android.content.Context +import android.os.Handler import com.scribd.armadillo.actions.Action import com.scribd.armadillo.actions.ClearErrorAction import com.scribd.armadillo.error.MissingDataException @@ -25,7 +27,7 @@ internal interface StateStore { } } -internal class ArmadilloStateStore(private val reducer: Reducer) : +internal class ArmadilloStateStore(private val reducer: Reducer, private val appContext: Context) : StateStore.Modifier, StateStore.Provider, StateStore.Initializer { private companion object { @@ -37,11 +39,14 @@ internal class ArmadilloStateStore(private val reducer: Reducer) : override fun init(state: ArmadilloState) = armadilloStateObservable.onNext(state) override fun dispatch(action: Action) { - val newAppState = reducer.reduce(currentState, action) - armadilloStateObservable.onNext(newAppState) - - if (currentState.error != null) { - dispatch(ClearErrorAction) + //run on consistent thread + Handler(appContext.mainLooper).post { + val newAppState = reducer.reduce(currentState, action) + armadilloStateObservable.onNext(newAppState) + + if (currentState.error != null) { + dispatch(ClearErrorAction) + } } } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/actions/Action.kt b/Armadillo/src/main/java/com/scribd/armadillo/actions/Action.kt index 59788c0..e6e8b43 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/actions/Action.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/actions/Action.kt @@ -6,6 +6,7 @@ import com.scribd.armadillo.models.ArmadilloState import com.scribd.armadillo.models.AudioPlayable import com.scribd.armadillo.models.Chapter import com.scribd.armadillo.models.DownloadProgressInfo +import com.scribd.armadillo.models.DrmType import com.scribd.armadillo.models.PlaybackState // Exoplayer State Updates @@ -103,6 +104,36 @@ internal object ContentEndedAction : Action { override val name = "ContentEndedAction" } +internal data class OpeningLicenseAction(val type: DrmType?) : Action { + override val name: String = "OpeningLicenseAction" +} + +internal data class LicenseAcquiredAction(val type: DrmType) : Action { + override val name = "LicenseAcquiredAction" +} + +internal data class LicenseExpirationDetermined(val expirationMilliseconds: Milliseconds) : Action { + override val name: String + get() = "LicenseExpirationDetermined" +} + +/** session can be recovering or resuming */ +internal object LicenseKeyIsUsableAction : Action { + override val name: String = "LicenseKeyUsableAction" +} + +internal object LicenseExpiredAction : Action { + override val name: String = "LicenseExpiredAction" +} + +internal object LicenseReleasedAction : Action { + override val name = "LicenseReleasedAction" +} + +internal object LicenseDrmErrorAction : Action { + override val name: String = "LicenseDrmErrorAction" +} + // Errors internal data class ErrorAction(val error: ArmadilloException) : Action { diff --git a/Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionTransmitter.kt b/Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionTransmitter.kt index e9d66e3..d18ea3c 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionTransmitter.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionTransmitter.kt @@ -1,7 +1,6 @@ package com.scribd.armadillo.analytics import android.app.ForegroundServiceStartNotAllowedException -import android.os.Build import androidx.annotation.VisibleForTesting import com.scribd.armadillo.Constants import com.scribd.armadillo.Milliseconds @@ -11,6 +10,7 @@ import com.scribd.armadillo.error.ActionListenerException import com.scribd.armadillo.hasSnowCone import com.scribd.armadillo.models.ArmadilloState import com.scribd.armadillo.models.AudioPlayable +import com.scribd.armadillo.models.DrmState import com.scribd.armadillo.models.InternalState import com.scribd.armadillo.models.PlaybackState import com.scribd.armadillo.time.milliseconds @@ -34,7 +34,12 @@ internal class PlaybackActionTransmitterImpl(private val stateProvider: StateSto private var disposables = CompositeDisposable() private var seekStartState: ArmadilloState? = null - private var currentState: ArmadilloState = ArmadilloState(null, emptyList(), InternalState(), null) + private var currentState: ArmadilloState = ArmadilloState( + playbackInfo = null, + downloadInfo = emptyList(), + drmPlaybackState = DrmState.NoDRM, + internalState = InternalState(), + error = null) private var lastState: ArmadilloState = currentState private var pollingIntervalMillis: Milliseconds = 500.milliseconds @@ -78,7 +83,12 @@ internal class PlaybackActionTransmitterImpl(private val stateProvider: StateSto override fun destroy() { disposables.clear() - currentState = ArmadilloState(null, emptyList(), InternalState(), null) + currentState = ArmadilloState( + playbackInfo = null, + downloadInfo = emptyList(), + drmPlaybackState = DrmState.NoDRM, + internalState = InternalState(), + error = null) lastState = currentState seekStartState = null } @@ -116,7 +126,12 @@ internal class PlaybackActionTransmitterImpl(private val stateProvider: StateSto if (current.playbackInfo?.controlState?.isStopping == true && lastState.playbackInfo?.controlState?.isStopping == false) { actionListeners.dispatch { listener, state -> listener.onStop(state) } playbackStateListener?.onPlaybackEnd() - stateToRetainForLast = ArmadilloState(null, emptyList(), InternalState(), null) + stateToRetainForLast = ArmadilloState( + playbackInfo = null, + downloadInfo = emptyList(), + drmPlaybackState = DrmState.NoDRM, + internalState = InternalState(), + error = null) seekStartState = null } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt index 235cf24..71d5f50 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt @@ -33,7 +33,7 @@ internal class AppModule(private val context: Context) { @Singleton @PrivateModule @Provides - fun stateStore(reducer: Reducer): ArmadilloStateStore = ArmadilloStateStore(reducer) + fun stateStore(reducer: Reducer): ArmadilloStateStore = ArmadilloStateStore(reducer, context) @Singleton @Provides diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/MainComponent.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/MainComponent.kt index 65009df..4a8efc7 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/MainComponent.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/MainComponent.kt @@ -4,7 +4,7 @@ import com.scribd.armadillo.ArmadilloPlayerChoreographer import com.scribd.armadillo.ArmadilloPlayerFactory import com.scribd.armadillo.analytics.PlaybackActionTransmitterImpl import com.scribd.armadillo.download.DefaultExoplayerDownloadService -import com.scribd.armadillo.download.HeaderAwareDownloaderFactory +import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener import com.scribd.armadillo.playback.ExoplayerPlaybackEngine import com.scribd.armadillo.playback.MediaSessionCallback import com.scribd.armadillo.playback.PlaybackService @@ -28,4 +28,5 @@ internal interface MainComponent { fun inject(playerEventListener: PlayerEventListener) fun inject(playbackActionTransmitterImpl: PlaybackActionTransmitterImpl) fun inject(mediaSourceRetrieverImpl: MediaSourceRetrieverImpl) + fun inject(widevineSessionEventListener: WidevineSessionEventListener) } \ 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 308b230..a8191b5 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt @@ -4,10 +4,12 @@ import android.app.Application import android.content.Context import com.google.android.exoplayer2.drm.DefaultDrmSessionManagerProvider import com.google.android.exoplayer2.drm.DrmSessionManagerProvider +import com.scribd.armadillo.StateStore import com.scribd.armadillo.broadcast.ArmadilloNoisyReceiver import com.scribd.armadillo.broadcast.ArmadilloNoisySpeakerReceiver import com.scribd.armadillo.broadcast.ArmadilloNotificationDeleteReceiver import com.scribd.armadillo.broadcast.NotificationDeleteReceiver +import com.scribd.armadillo.download.drm.ArmadilloDrmSessionManagerProvider import com.scribd.armadillo.mediaitems.ArmadilloMediaBrowse import com.scribd.armadillo.mediaitems.MediaContentSharer import com.scribd.armadillo.playback.ArmadilloAudioAttributes @@ -73,5 +75,5 @@ internal class PlaybackModule { @Provides @Singleton - fun drmSessionManagerProvider(): DrmSessionManagerProvider = DefaultDrmSessionManagerProvider() + fun drmSessionManagerProvider(stateStore: StateStore.Modifier): DrmSessionManagerProvider = ArmadilloDrmSessionManagerProvider(stateStore) } \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt new file mode 100644 index 0000000..ed6869c --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt @@ -0,0 +1,136 @@ +package com.scribd.armadillo.download.drm + +import android.media.MediaDrm +import android.util.Log +import androidx.annotation.GuardedBy +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaItem.DrmConfiguration +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager +import com.google.android.exoplayer2.drm.DrmSessionManager +import com.google.android.exoplayer2.drm.DrmSessionManagerProvider +import com.google.android.exoplayer2.drm.ExoMediaDrm +import com.google.android.exoplayer2.drm.FrameworkMediaDrm +import com.google.android.exoplayer2.drm.HttpMediaDrmCallback +import com.google.android.exoplayer2.upstream.DataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.util.Assertions +import com.google.android.exoplayer2.util.Util +import com.google.common.collect.UnmodifiableIterator +import com.google.common.primitives.Ints +import com.scribd.armadillo.StateStore +import com.scribd.armadillo.actions.LicenseDrmErrorAction +import com.scribd.armadillo.actions.LicenseExpirationDetermined +import com.scribd.armadillo.actions.LicenseExpiredAction +import com.scribd.armadillo.actions.LicenseKeyIsUsableAction +import com.scribd.armadillo.time.milliseconds +import java.util.UUID +import javax.inject.Inject + +/** Forks [DefaultDrmSessionManagerProvider], but changes the [ExoMediaDrm.Provider] instance + * + * note: if moving to media3, maybe AppManagedProvider may help*/ +internal class ArmadilloDrmSessionManagerProvider @Inject constructor(private val stateStore: StateStore.Modifier) : + DrmSessionManagerProvider { + + //////////////////////////////////////////////////////////////// + // These fields identical to DefaultDrmSessionManagerProvider // + + private val lock = Any() + + @GuardedBy("lock") + private var drmConfiguration: DrmConfiguration? = null + + @GuardedBy("lock") + private var manager: DrmSessionManager? = null + private val drmHttpDataSourceFactory: DataSource.Factory? = null + private val userAgent: String? = null + + // End DefaultDrmSessionManagerProvider fields // + ///////////////////////////////////////////////// + + companion object { + const val TAG = "ArmadilloDrmSMProvider" + } + + /** Identical to DefaultDrmSessionManagerProvider method, */ + override fun get(mediaItem: MediaItem): DrmSessionManager { + Assertions.checkNotNull(mediaItem.localConfiguration) + val drmConfiguration = mediaItem.localConfiguration!!.drmConfiguration + if (drmConfiguration != null && Util.SDK_INT >= 18) { + synchronized(this.lock) { + if (!Util.areEqual(drmConfiguration, this.drmConfiguration)) { + this.drmConfiguration = drmConfiguration + this.manager = this.createManager(drmConfiguration) + } + return Assertions.checkNotNull(this.manager) + } + } else { + return DrmSessionManager.DRM_UNSUPPORTED + } + } + + /** Near identical to DefaultDrmSessionManagerProvider method, except for the indicated line */ + private fun createManager(drmConfiguration: DrmConfiguration): DrmSessionManager { + val dataSourceFactory = this.drmHttpDataSourceFactory + ?: DefaultHttpDataSource.Factory().setUserAgent(this.userAgent) + val httpDrmCallback = HttpMediaDrmCallback(if (drmConfiguration.licenseUri == null) null else drmConfiguration.licenseUri.toString(), drmConfiguration.forceDefaultLicenseUri, (dataSourceFactory)!!) + val var4: UnmodifiableIterator<*> = drmConfiguration.licenseRequestHeaders.entries.iterator() + + while (var4.hasNext()) { + val entry: Map.Entry = var4.next() as Map.Entry + httpDrmCallback.setKeyRequestProperty(entry.key, entry.value) + } + + val drmSessionManager = DefaultDrmSessionManager.Builder() + //this line is different, changing the DrmProvider + .setUuidAndExoMediaDrmProvider(drmConfiguration.scheme, ArmadilloDrmProvider(stateStore)) + .setMultiSession(drmConfiguration.multiSession) + .setPlayClearSamplesWithoutKeys(drmConfiguration.playClearContentWithoutKey) + .setUseDrmSessionsForClearContent(*Ints.toArray(drmConfiguration.forcedSessionTrackTypes)) + .build(httpDrmCallback) + drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, drmConfiguration.keySetId) + return drmSessionManager + } + + /** New original provider for this class */ + class ArmadilloDrmProvider(private val stateStore: StateStore.Modifier) : ExoMediaDrm.Provider { + private var drmExpirationMillis: Long? = null + override fun acquireExoMediaDrm(uuid: UUID): ExoMediaDrm { + Log.i(TAG, "drm start") + //uses the main MediaDrm object that this class uses originally, then after we add new listeners to it. + val instance = FrameworkMediaDrm.newInstance(uuid) + + //ExoMediaDrm.OnEventListener doesn't do anything at all, so its not used + if (Util.SDK_INT >= 23) { + instance.setOnKeyStatusChangeListener { exoMediaDrm, sessionId, keyStatuses, b -> + keyStatuses.firstOrNull()?.let { keyStatus -> + //See MediaPlayer.onPlayerError() for playback-affecting errors. This block is more for transparency. + when(keyStatus.statusCode) { + MediaDrm.KeyStatus.STATUS_USABLE -> { + stateStore.dispatch(LicenseKeyIsUsableAction) + } + MediaDrm.KeyStatus.STATUS_EXPIRED -> { + stateStore.dispatch(LicenseExpiredAction) + } + MediaDrm.KeyStatus.STATUS_OUTPUT_NOT_ALLOWED -> { + stateStore.dispatch(LicenseDrmErrorAction) + } + MediaDrm.KeyStatus.STATUS_PENDING -> {} + MediaDrm.KeyStatus.STATUS_INTERNAL_ERROR -> { + stateStore.dispatch(LicenseDrmErrorAction) + } + MediaDrm.KeyStatus.STATUS_USABLE_IN_FUTURE -> {} + } + } + } + //this listener often fires later than the above one + instance.setOnExpirationUpdateListener { exoMediaDrm, sessionId, expireMillis -> + Log.i(TAG, "drm event key expires ${expireMillis}") + drmExpirationMillis = expireMillis + stateStore.dispatch(LicenseExpirationDetermined(expireMillis.milliseconds)) + } + } + return instance + } + } +} \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt index ca2a2a2..560da84 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -2,6 +2,7 @@ package com.scribd.armadillo.download.drm import android.content.Context import android.net.Uri +import android.os.Handler import com.google.android.exoplayer2.C import com.google.android.exoplayer2.drm.DrmSessionEventListener import com.google.android.exoplayer2.drm.OfflineLicenseHelper @@ -9,6 +10,8 @@ import com.google.android.exoplayer2.source.dash.DashUtil import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.google.android.exoplayer2.util.Log import com.scribd.armadillo.Constants +import com.scribd.armadillo.StateStore +import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener import com.scribd.armadillo.error.DrmDownloadException import com.scribd.armadillo.models.DrmDownload import com.scribd.armadillo.models.DrmInfo @@ -17,11 +20,15 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : DrmLicenseDownloader { +internal class DashDrmLicenseDownloader @Inject constructor(context: Context, private val stateStore: StateStore.Modifier) + : DrmLicenseDownloader { private val drmDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) private val audioDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) private val drmEventDispatcher = DrmSessionEventListener.EventDispatcher() + private val drmHandler = Handler(context.mainLooper) + + private var offlineHelper: OfflineLicenseHelper? = null override suspend fun downloadDrmLicense( requestUrl: String, @@ -34,19 +41,18 @@ internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : audioDataSourceFactory.setDefaultRequestProperties(customRequestHeaders) // Create helper to download DRM license - val offlineHelper = when (drmInfo.drmType) { - DrmType.WIDEVINE -> OfflineLicenseHelper.newWidevineInstance(drmInfo.licenseServer, drmDataSourceFactory, drmEventDispatcher) - } + offlineHelper = findOfflineHelper(drmInfo.drmType, drmInfo.licenseServer) return try { val audioDataSource = audioDataSourceFactory.createDataSource() val manifest = DashUtil.loadManifest(audioDataSource, Uri.parse(requestUrl)) val format = DashUtil.loadFormatWithDrmInitData(audioDataSource, manifest.getPeriod(0)) format?.let { + val keyId = offlineHelper!!.downloadLicense(it) DrmDownload( - drmKeyId = offlineHelper.downloadLicense(format), + drmKeyId = keyId, drmType = drmInfo.drmType, licenseServer = drmInfo.licenseServer, - audioType = C.TYPE_DASH, + audioType = C.CONTENT_TYPE_DASH, ) } ?: throw IllegalStateException("No media format retrieved for audio request") } catch (e: Exception) { @@ -55,10 +61,25 @@ internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : } } - override suspend fun releaseDrmLicense(drmDownload: DrmDownload) { - val offlineHelper = when (drmDownload.drmType) { - DrmType.WIDEVINE -> OfflineLicenseHelper.newWidevineInstance(drmDownload.licenseServer, drmDataSourceFactory, drmEventDispatcher) + private fun findOfflineHelper(type: DrmType, licenseServerUrl: String): OfflineLicenseHelper = + when (type) { + DrmType.WIDEVINE -> { + OfflineLicenseHelper.newWidevineInstance( + licenseServerUrl, + drmDataSourceFactory, + drmEventDispatcher + ).also { + //streaming equivalent is in DashMediaSourceGenerator + drmEventDispatcher.addEventListener( + drmHandler, + WidevineSessionEventListener() + ) + } + } } + + override suspend fun releaseDrmLicense(drmDownload: DrmDownload) { + val offlineHelper = offlineHelper ?: findOfflineHelper(drmDownload.drmType, drmDownload.licenseServer) try { offlineHelper.releaseLicense(drmDownload.drmKeyId) } catch (e: Exception) { diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/events/WidevineSessionEventListener.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/events/WidevineSessionEventListener.kt new file mode 100644 index 0000000..2c19ee2 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/events/WidevineSessionEventListener.kt @@ -0,0 +1,37 @@ +package com.scribd.armadillo.download.drm.events + +import android.content.Context +import com.google.android.exoplayer2.drm.DrmSessionEventListener +import com.google.android.exoplayer2.source.MediaSource +import com.scribd.armadillo.StateStore +import com.scribd.armadillo.actions.LicenseAcquiredAction +import com.scribd.armadillo.actions.LicenseReleasedAction +import com.scribd.armadillo.di.Injector +import com.scribd.armadillo.encryption.SecureStorage +import com.scribd.armadillo.models.DrmType +import javax.inject.Inject + +internal class WidevineSessionEventListener + : DrmSessionEventListener { + + @Inject + internal lateinit var stateStore: StateStore.Modifier + + @Inject + internal lateinit var secureStorage: SecureStorage + + @Inject + internal lateinit var context: Context + + init { + Injector.mainComponent.inject(this) + } + + override fun onDrmSessionAcquired(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, state: Int) { + stateStore.dispatch(LicenseAcquiredAction(type = DrmType.WIDEVINE)) + } + + override fun onDrmSessionReleased(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?) { + stateStore.dispatch(LicenseReleasedAction) + } +} \ No newline at end of file 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 86b8db3..2f3bb58 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt @@ -1,8 +1,11 @@ package com.scribd.armadillo.error +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException import com.scribd.armadillo.actions.Action +import java.net.SocketTimeoutException sealed class ArmadilloException(cause: Throwable? = null, + isNetworkRelatedError: Boolean = false, message: String = "Unhandled Armadillo Error") : Exception(message, cause) { abstract val errorCode: Int @@ -54,7 +57,7 @@ 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.") { + : ArmadilloException(cause = cause, message = "HTTP Error $responseCode.", isNetworkRelatedError = true) { override val errorCode: Int = 200 } @@ -84,6 +87,14 @@ class IncorrectChapterMetadataException override val errorCode = 205 } +class ConnectivityException(cause: Exception) + : ArmadilloException( + cause = cause, + message = "Internet connection to remote is not reliable.", + isNetworkRelatedError = true) { + override val errorCode: Int = 206 +} + /** * Download Errors */ @@ -93,7 +104,7 @@ class MissingInfoDownloadException(message: String) } class DownloadFailed - : ArmadilloException(message = "The download has failed to finish.") { + : ArmadilloException(message = "The download has failed to finish.", isNetworkRelatedError = true) { override val errorCode = 302 } @@ -115,7 +126,7 @@ data class DownloadServiceLaunchedInBackground(val id: Int, override val cause: } class UnexpectedDownloadException(throwable: Throwable) - : ArmadilloException(cause = throwable, message = "Unknown problem while downloading."){ + : ArmadilloException(cause = throwable, message = "Unknown problem while downloading.", isNetworkRelatedError = throwable is HttpDataSourceException) { override val errorCode = 305 } @@ -167,7 +178,10 @@ data class DrmContentTypeUnsupportedException(val contentType: Int) } class DrmDownloadException(cause: Exception) - : ArmadilloException(cause = cause, "Failed to process DRM license for downloading.") { + : ArmadilloException( + cause = cause, + message = "Failed to process DRM license for downloading.", + isNetworkRelatedError = (cause is HttpDataSourceException) || (cause is SocketTimeoutException)) { override val errorCode = 701 } 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 937f481..cb45aa7 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/models/ArmadilloState.kt @@ -14,6 +14,7 @@ import java.util.Arrays data class ArmadilloState( val playbackInfo: PlaybackInfo? = null, val downloadInfo: List, + val drmPlaybackState: DrmState = DrmState.NoDRM, val internalState: InternalState = InternalState(), val error: ArmadilloException? = null) { @@ -135,6 +136,30 @@ sealed class DownloadState { data class InternalState(val isPlaybackEngineReady: Boolean = false) +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) + + /** A DRM License has been obtained. */ + 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) + + /** A DRM license exists and content is able to be decrypted. */ + class LicenseUsable(drmType: DrmType?, expireMillis: Milliseconds) : DrmState(drmType, expireMillis, true) + + /** The content with the previously retrieved license has been released. */ + class LicenseReleased(drmType: DrmType?, expireMillis: Milliseconds = 0.milliseconds, isSessionValid: Boolean) + : DrmState(drmType, expireMillis, isSessionValid) + + /** An error occurred for the DRM license. This might not affect playback; see [ArmadilloState.error] for playback issues. */ + class LicenseError(drmType: DrmType?, expireMillis: Milliseconds) : DrmState(drmType, expireMillis, false) +} + /** * Debugging information for [ArmadilloState] * [appStateUpdateCount] is the number of times the [ArmadilloPlayer] has provided an updated state to observers 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 41c5c70..14cd799 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt @@ -4,10 +4,12 @@ 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.audio.AudioSink +import com.google.android.exoplayer2.drm.MediaDrmCallbackException 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.RendererConfigurationException import com.scribd.armadillo.error.RendererInitializationException import com.scribd.armadillo.error.RendererWriteException @@ -24,7 +26,11 @@ internal fun ExoPlaybackException.toArmadilloException(): ArmadilloException { HttpResponseCodeException(source.responseCode, source.dataSpec.uri.toString(), source) is HttpDataSource.HttpDataSourceException -> HttpResponseCodeException(0, source.dataSpec.uri.toString(), source) - is SocketTimeoutException -> HttpResponseCodeException(0, null, source) + is MediaDrmCallbackException -> { + val httpCause = source.cause as? HttpDataSource.InvalidResponseCodeException + HttpResponseCodeException(httpCause?.responseCode ?: 0, httpCause?.dataSpec?.uri.toString(), source) + } + is SocketTimeoutException -> ConnectivityException(source) is UnknownHostException -> HttpResponseCodeException(0, source.message, source) // Message is supposed to be the host for UnknownHostException else -> ArmadilloIOException(cause = this, actionThatFailedMessage = "Exoplayer error.") diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt index 1b1c390..b8975c7 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt @@ -1,36 +1,63 @@ package com.scribd.armadillo.playback.mediasource import android.content.Context +import android.os.Handler import com.google.android.exoplayer2.drm.DrmSessionManagerProvider import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.DownloadHelper import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.scribd.armadillo.StateStore +import com.scribd.armadillo.actions.OpeningLicenseAction import com.scribd.armadillo.download.DownloadTracker +import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener import com.scribd.armadillo.extensions.toUri import com.scribd.armadillo.models.AudioPlayable +import com.scribd.armadillo.models.DrmType import javax.inject.Inject +/** For playback, both streaming and downloaded */ internal class DashMediaSourceGenerator @Inject constructor( + context: Context, private val mediaSourceHelper: HeadersMediaSourceHelper, private val downloadTracker: DownloadTracker, private val drmMediaSourceHelper: DrmMediaSourceHelper, private val drmSessionManagerProvider: DrmSessionManagerProvider, + private val stateStore: StateStore.Modifier, ) : MediaSourceGenerator { + private val drmHandler = Handler(context.mainLooper) + override fun generateMediaSource(context: Context, request: AudioPlayable.MediaRequest): MediaSource { + if (request.drmInfo != null) { + stateStore.dispatch(OpeningLicenseAction(request.drmInfo.drmType)) + } val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request) - downloadTracker.getDownload(request.url.toUri())?.let { - if (it.state != Download.STATE_FAILED) { - val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = true) - return DownloadHelper.createMediaSource(it.request, dataSourceFactory, drmSessionManagerProvider.get(mediaItem)) - } - } + val download = downloadTracker.getDownload(request.url.toUri()) + val isDownloaded = download != null && download.state != Download.STATE_FAILED + val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = isDownloaded) - val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = false) - return DashMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItem) + return if (isDownloaded) { + val drmManager = drmSessionManagerProvider.get(mediaItem) + DownloadHelper.createMediaSource(download!!.request, dataSourceFactory, drmManager) + } else { + DashMediaSource.Factory(dataSourceFactory) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .createMediaSource(mediaItem).also { source -> + //download equivalent is in DashDrmLicenseDownloader + when (request.drmInfo?.drmType) { + DrmType.WIDEVINE -> { + source.addDrmEventListener( + drmHandler, + WidevineSessionEventListener() + ) + } + + else -> Unit //no DRM + } + } + } } override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) = mediaSourceHelper.updateMediaSourceHeaders(request) diff --git a/Armadillo/src/main/res/layout/arm_audio_engine_debug_layout.xml b/Armadillo/src/main/res/layout/arm_audio_engine_debug_layout.xml index d98fa85..141b35e 100644 --- a/Armadillo/src/main/res/layout/arm_audio_engine_debug_layout.xml +++ b/Armadillo/src/main/res/layout/arm_audio_engine_debug_layout.xml @@ -102,4 +102,13 @@ android:textColor="@android:color/black" tools:text="UpdateAction, Random Action"/> + + \ No newline at end of file diff --git a/Armadillo/src/main/res/values/strings.xml b/Armadillo/src/main/res/values/strings.xml index 207124d..08e484e 100644 --- a/Armadillo/src/main/res/values/strings.xml +++ b/Armadillo/src/main/res/values/strings.xml @@ -6,6 +6,13 @@ %1$s / %2$s (%3$s) Unknown Playback Speed: %.1f + No DRM + DRM Opening + DRM Released + DRM Error + DRM Expired + Using DRM: + expiring: diff --git a/Armadillo/src/test/java/com/scribd/armadillo/DaggerComponentRule.kt b/Armadillo/src/test/java/com/scribd/armadillo/DaggerComponentRule.kt index da66c0b..0264aec 100644 --- a/Armadillo/src/test/java/com/scribd/armadillo/DaggerComponentRule.kt +++ b/Armadillo/src/test/java/com/scribd/armadillo/DaggerComponentRule.kt @@ -4,6 +4,7 @@ import com.scribd.armadillo.analytics.PlaybackActionTransmitterImpl import com.scribd.armadillo.di.Injector import com.scribd.armadillo.di.MainComponent import com.scribd.armadillo.download.DefaultExoplayerDownloadService +import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener import com.scribd.armadillo.playback.ExoplayerPlaybackEngine import com.scribd.armadillo.playback.MediaSessionCallback import com.scribd.armadillo.playback.PlaybackService @@ -31,6 +32,7 @@ class DaggerComponentRule : TestRule { private class MockMainComponent : MainComponent { override fun inject(playbackActionTransmitterImpl: PlaybackActionTransmitterImpl) = Unit override fun inject(mediaSourceRetrieverImpl: MediaSourceRetrieverImpl) = Unit + override fun inject(widevineSessionEventListener: WidevineSessionEventListener) = Unit override fun inject(exoplayerPlaybackEngine: ExoplayerPlaybackEngine) = Unit override fun inject(mediaSessionCallback: MediaSessionCallback) = Unit override fun inject(armadilloPlayerChoreographer: ArmadilloPlayerChoreographer) = Unit diff --git a/Armadillo/src/test/java/com/scribd/armadillo/ReducerTest.kt b/Armadillo/src/test/java/com/scribd/armadillo/ReducerTest.kt index 87c37d1..fd8cc57 100644 --- a/Armadillo/src/test/java/com/scribd/armadillo/ReducerTest.kt +++ b/Armadillo/src/test/java/com/scribd/armadillo/ReducerTest.kt @@ -3,9 +3,15 @@ package com.scribd.armadillo import com.scribd.armadillo.actions.Action import com.scribd.armadillo.actions.ContentEndedAction import com.scribd.armadillo.actions.FastForwardAction +import com.scribd.armadillo.actions.LicenseAcquiredAction +import com.scribd.armadillo.actions.LicenseDrmErrorAction +import com.scribd.armadillo.actions.LicenseExpirationDetermined +import com.scribd.armadillo.actions.LicenseKeyIsUsableAction +import com.scribd.armadillo.actions.LicenseReleasedAction import com.scribd.armadillo.actions.MediaRequestUpdateAction import com.scribd.armadillo.actions.MetadataUpdateAction import com.scribd.armadillo.actions.NewAudioPlayableAction +import com.scribd.armadillo.actions.OpeningLicenseAction import com.scribd.armadillo.actions.PlaybackSpeedAction import com.scribd.armadillo.actions.PlayerStateAction import com.scribd.armadillo.actions.RewindAction @@ -21,6 +27,8 @@ import com.scribd.armadillo.models.ArmadilloState import com.scribd.armadillo.models.AudioPlayable import com.scribd.armadillo.models.Chapter import com.scribd.armadillo.models.DownloadState +import com.scribd.armadillo.models.DrmState +import com.scribd.armadillo.models.DrmType import com.scribd.armadillo.models.InternalState import com.scribd.armadillo.models.PlaybackProgress import com.scribd.armadillo.models.PlaybackState @@ -77,6 +85,7 @@ class ReducerTest { assertThat(newPlaybackInfo.isLoading).isTrue assertThat(newPlaybackInfo.controlState.isStartingNewAudioPlayable).isTrue assertThat(newPlaybackInfo.controlState.isStopping).isFalse + assertThat(newState.drmPlaybackState).isEqualTo(DrmState.NoDRM) } @Test @@ -325,9 +334,54 @@ class ReducerTest { assertThat(appState.playbackInfo!!.controlState.hasContentEnded).isTrue } + @Test + fun reduce_drmLicenseOpening_updatesDrmState() { + val appState = Reducer.reduce(MockModels.appState(), OpeningLicenseAction(DrmType.WIDEVINE)) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseOpening::class.java) + assertThat(appState.drmPlaybackState.drmType).isEqualTo(DrmType.WIDEVINE) + } + + @Test + fun reduce_drmLicenseAcquired_updatesDrmState() { + val appState = Reducer.reduce(MockModels.appState(), LicenseAcquiredAction(DrmType.WIDEVINE)) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseAcquired::class.java) + assertThat(appState.drmPlaybackState.drmType).isEqualTo(DrmType.WIDEVINE) + } + + @Test + fun reduce_drmLicenseUsable_updatesDrmState() { + val appState = Reducer.reduce(MockModels.appState(), LicenseKeyIsUsableAction) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseUsable::class.java) + } + + @Test + fun reduce_drmLicenseExpirationGet_updatesDrmState() { + val expires = 8.milliseconds + val appState = Reducer.reduce(MockModels.appState(), LicenseExpirationDetermined(expires)) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseUsable::class.java) + assertThat(appState.drmPlaybackState.expireMillis).isEqualTo(expires) + } + + @Test + fun reduce_drmLicenseError_updatesDrmState() { + val appState = Reducer.reduce(MockModels.appState(), LicenseDrmErrorAction) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseError::class.java) + } + + @Test + fun reduce_drmLicenseReleased_updatesDrmState() { + val appState = Reducer.reduce(MockModels.appState(), LicenseReleasedAction) + assertThat(appState.drmPlaybackState).isInstanceOf(DrmState.LicenseReleased::class.java) + } + @Test fun reduce_mediaRequestUpdateActionNotReady_error() { - val oldState = ArmadilloState(null, emptyList(), InternalState(false), null) + val oldState = ArmadilloState( + playbackInfo = null, + downloadInfo = emptyList(), + drmPlaybackState = DrmState.NoDRM, + internalState = InternalState(false), + error = null) val action = MediaRequestUpdateAction(AudioPlayable.MediaRequest.createHttpUri( "https://www.github.com/scribd/armadillo" )) diff --git a/Armadillo/src/test/java/com/scribd/armadillo/analytics/PlaybackActionTransmitterImplTest.kt b/Armadillo/src/test/java/com/scribd/armadillo/analytics/PlaybackActionTransmitterImplTest.kt index 2bd6460..abd3e6a 100644 --- a/Armadillo/src/test/java/com/scribd/armadillo/analytics/PlaybackActionTransmitterImplTest.kt +++ b/Armadillo/src/test/java/com/scribd/armadillo/analytics/PlaybackActionTransmitterImplTest.kt @@ -4,6 +4,7 @@ import com.scribd.armadillo.DaggerComponentRule import com.scribd.armadillo.StateStore import com.scribd.armadillo.models.ArmadilloState import com.scribd.armadillo.models.AudioPlayable +import com.scribd.armadillo.models.DrmState import com.scribd.armadillo.models.InternalState import com.scribd.armadillo.models.MediaControlState import com.scribd.armadillo.models.PlaybackInfo @@ -66,7 +67,7 @@ class PlaybackActionTransmitterImplTest { progress0 = mock() controls0 = mock() playback0 = mock() - state0 = ArmadilloState(playback0, emptyList(), InternalState(), null) + state0 = ArmadilloState(playback0, emptyList(), DrmState.NoDRM, InternalState(), null) whenever(playback0.audioPlayable).thenReturn(audiobook0) whenever(playback0.controlState).thenReturn(controls0) whenever(playback0.progress).thenReturn(progress0) @@ -75,7 +76,7 @@ class PlaybackActionTransmitterImplTest { progress1 = mock() controls1 = mock() playback1 = mock() - state1 = ArmadilloState(playback1, emptyList(), InternalState(), null) + state1 = ArmadilloState(playback1, emptyList(), DrmState.NoDRM, InternalState(), null) whenever(playback1.audioPlayable).thenReturn(audiobook1) whenever(playback1.controlState).thenReturn(controls1) whenever(playback1.progress).thenReturn(progress1) @@ -84,7 +85,7 @@ class PlaybackActionTransmitterImplTest { progress2 = mock() controls2 = mock() playback2 = mock() - state2 = ArmadilloState(playback2, emptyList(), InternalState(), null) + state2 = ArmadilloState(playback2, emptyList(), DrmState.NoDRM, InternalState(), null) whenever(playback2.audioPlayable).thenReturn(audiobook2) whenever(playback2.controlState).thenReturn(controls2) whenever(playback2.progress).thenReturn(progress2) diff --git a/RELEASE.md b/RELEASE.md index 67f8099..087b9be 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,11 @@ # Project Armadillo Release Notes +## 1.5.1 +- Adds DrmState to ArmadilloState, giving full visibility into DRM status to the client, including the expiration date for content. +- Splits SocketTimeout from HttpResponseCodeException, into ConnectivityException to better differentiate connectivity difficulties from + developer error. +- Ensures that ArmadilloState is updated from the main thread consistently. + ## 1.5 - Fixes Error and Exception handling to not hide underlying exceptions, and to clearly explain the nature of errors. diff --git a/gradle.properties b/gradle.properties index 2f7ee6e..29c7a00 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ org.gradle.jvmargs=-Xmx1536m # org.gradle.parallel=true PACKAGE_NAME=com.scribd.armadillo GRADLE_PLUGIN_VERSION=7.2.0 -LIBRARY_VERSION=1.5 +LIBRARY_VERSION=1.5.1 EXOPLAYER_VERSION=2.19.1 RXJAVA_VERSION=2.2.4 RXANDROID_VERSION=2.0.1 From 4b0a2c4d611e6a3441d69b25f936321e64bf0c05 Mon Sep 17 00:00:00 2001 From: Katherine Blizard <414924+kabliz@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:41:53 -0700 Subject: [PATCH 2/2] Interface for DRM Visibility - Adds DrmState to ArmadilloState, giving full visibility into DRM status to the client, including the expiration date for content. - Splits SocketTimeout from HttpResponseCodeException, into ConnectivityException to better differentiate connectivity difficulties from developer error. - Ensures that ArmadilloState is updated from the main thread consistently. --- .../main/java/com/scribd/armadillo/StateStore.kt | 7 +++---- .../java/com/scribd/armadillo/di/AppModule.kt | 3 ++- .../drm/ArmadilloDrmSessionManagerProvider.kt | 15 +++++---------- .../mediasource/DashMediaSourceGenerator.kt | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt b/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt index 0f3dc36..8fa82ca 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt @@ -1,6 +1,5 @@ package com.scribd.armadillo -import android.content.Context import android.os.Handler import com.scribd.armadillo.actions.Action import com.scribd.armadillo.actions.ClearErrorAction @@ -27,8 +26,8 @@ internal interface StateStore { } } -internal class ArmadilloStateStore(private val reducer: Reducer, private val appContext: Context) : - StateStore.Modifier, StateStore.Provider, StateStore.Initializer { +internal class ArmadilloStateStore(private val reducer: Reducer, private val handler: Handler) : + StateStore.Modifier, StateStore.Provider, StateStore.Initializer { private companion object { const val TAG = "ArmadilloStateStore" @@ -40,7 +39,7 @@ internal class ArmadilloStateStore(private val reducer: Reducer, private val app override fun dispatch(action: Action) { //run on consistent thread - Handler(appContext.mainLooper).post { + handler.post { val newAppState = reducer.reduce(currentState, action) armadilloStateObservable.onNext(newAppState) diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt index 71d5f50..4e810ab 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt @@ -1,6 +1,7 @@ package com.scribd.armadillo.di import android.content.Context +import android.os.Handler import com.scribd.armadillo.ArmadilloStateStore import com.scribd.armadillo.Constants import com.scribd.armadillo.Reducer @@ -33,7 +34,7 @@ internal class AppModule(private val context: Context) { @Singleton @PrivateModule @Provides - fun stateStore(reducer: Reducer): ArmadilloStateStore = ArmadilloStateStore(reducer, context) + fun stateStore(reducer: Reducer): ArmadilloStateStore = ArmadilloStateStore(reducer, Handler(context.mainLooper)) @Singleton @Provides diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt index ed6869c..1098150 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/ArmadilloDrmSessionManagerProvider.kt @@ -1,7 +1,6 @@ package com.scribd.armadillo.download.drm import android.media.MediaDrm -import android.util.Log import androidx.annotation.GuardedBy import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem.DrmConfiguration @@ -15,7 +14,6 @@ import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.google.android.exoplayer2.util.Assertions import com.google.android.exoplayer2.util.Util -import com.google.common.collect.UnmodifiableIterator import com.google.common.primitives.Ints import com.scribd.armadillo.StateStore import com.scribd.armadillo.actions.LicenseDrmErrorAction @@ -73,11 +71,10 @@ internal class ArmadilloDrmSessionManagerProvider @Inject constructor(private va private fun createManager(drmConfiguration: DrmConfiguration): DrmSessionManager { val dataSourceFactory = this.drmHttpDataSourceFactory ?: DefaultHttpDataSource.Factory().setUserAgent(this.userAgent) - val httpDrmCallback = HttpMediaDrmCallback(if (drmConfiguration.licenseUri == null) null else drmConfiguration.licenseUri.toString(), drmConfiguration.forceDefaultLicenseUri, (dataSourceFactory)!!) - val var4: UnmodifiableIterator<*> = drmConfiguration.licenseRequestHeaders.entries.iterator() + val license = if (drmConfiguration.licenseUri == null) null else drmConfiguration.licenseUri.toString() + val httpDrmCallback = HttpMediaDrmCallback(license, drmConfiguration.forceDefaultLicenseUri, dataSourceFactory) - while (var4.hasNext()) { - val entry: Map.Entry = var4.next() as Map.Entry + drmConfiguration.licenseRequestHeaders.entries.forEach { entry -> httpDrmCallback.setKeyRequestProperty(entry.key, entry.value) } @@ -92,17 +89,16 @@ internal class ArmadilloDrmSessionManagerProvider @Inject constructor(private va return drmSessionManager } - /** New original provider for this class */ + /** New original provider for this class to supply DRM events to the StateStore */ class ArmadilloDrmProvider(private val stateStore: StateStore.Modifier) : ExoMediaDrm.Provider { private var drmExpirationMillis: Long? = null override fun acquireExoMediaDrm(uuid: UUID): ExoMediaDrm { - Log.i(TAG, "drm start") //uses the main MediaDrm object that this class uses originally, then after we add new listeners to it. val instance = FrameworkMediaDrm.newInstance(uuid) //ExoMediaDrm.OnEventListener doesn't do anything at all, so its not used if (Util.SDK_INT >= 23) { - instance.setOnKeyStatusChangeListener { exoMediaDrm, sessionId, keyStatuses, b -> + instance.setOnKeyStatusChangeListener { exoMediaDrm, sessionId, keyStatuses, hasNewUsableKey -> keyStatuses.firstOrNull()?.let { keyStatus -> //See MediaPlayer.onPlayerError() for playback-affecting errors. This block is more for transparency. when(keyStatus.statusCode) { @@ -125,7 +121,6 @@ internal class ArmadilloDrmSessionManagerProvider @Inject constructor(private va } //this listener often fires later than the above one instance.setOnExpirationUpdateListener { exoMediaDrm, sessionId, expireMillis -> - Log.i(TAG, "drm event key expires ${expireMillis}") drmExpirationMillis = expireMillis stateStore.dispatch(LicenseExpirationDetermined(expireMillis.milliseconds)) } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt index b8975c7..73e1aea 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt @@ -35,7 +35,7 @@ internal class DashMediaSourceGenerator @Inject constructor( val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request) val download = downloadTracker.getDownload(request.url.toUri()) - val isDownloaded = download != null && download.state != Download.STATE_FAILED + val isDownloaded = download != null && download.state == Download.STATE_COMPLETED val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = isDownloaded) return if (isDownloaded) {