├── Armadillo ├── .gitignore ├── .keystore ├── src │ ├── test │ │ ├── resources │ │ │ └── mockito-extensions │ │ │ │ └── org.mockito.plugins.MockMaker │ │ └── java │ │ │ └── com │ │ │ └── scribd │ │ │ └── armadillo │ │ │ ├── ExtensionsTest.kt │ │ │ ├── models │ │ │ ├── ArmadilloStateTest.kt │ │ │ ├── ModelsTest.kt │ │ │ └── TimeTest.kt │ │ │ ├── DaggerComponentRule.kt │ │ │ ├── download │ │ │ ├── ExoplayerDownloadTrackerTest.kt │ │ │ └── DownloadManagerExtKtTest.kt │ │ │ └── MockModels.kt │ └── main │ │ ├── java │ │ └── com │ │ │ └── scribd │ │ │ └── armadillo │ │ │ ├── extensions │ │ │ ├── StringExt.kt │ │ │ ├── ByteArrayExt.kt │ │ │ ├── StateProviderExt.kt │ │ │ ├── IntExt.kt │ │ │ ├── DownloadProgressInfoExt.kt │ │ │ ├── PrintExt.kt │ │ │ ├── ExoplayerDownloadExt.kt │ │ │ ├── SharedPrefExt.kt │ │ │ └── TransportControlsExt.kt │ │ │ ├── download │ │ │ ├── DownloadNotificationHolder.kt │ │ │ ├── DownloadNotificationFactory.kt │ │ │ ├── DefaultDownloadNotificationFactory.kt │ │ │ ├── ArmadilloDatabaseProvider.kt │ │ │ ├── drm │ │ │ │ ├── DrmLicenseDownloader.kt │ │ │ │ ├── events │ │ │ │ │ └── WidevineSessionEventListener.kt │ │ │ │ ├── OfflineDrmManager.kt │ │ │ │ └── DashDrmLicenseDownloader.kt │ │ │ ├── DownloadManagerFactory.kt │ │ │ ├── DefaultExoplayerDownloadService.kt │ │ │ ├── HeaderAwareDownloaderFactory.kt │ │ │ ├── DownloadManagerExt.kt │ │ │ └── ExoplayerNotificationUtil.kt │ │ │ ├── playback │ │ │ ├── PlaybackNotificationBuilderHolder.kt │ │ │ ├── mediasource │ │ │ │ ├── HeadersMediaSourceFactoryFactory.kt │ │ │ │ ├── MediaSourceGenerator.kt │ │ │ │ ├── HlsMediaSourceGenerator.kt │ │ │ │ ├── ProgressiveMediaSourceGenerator.kt │ │ │ │ ├── MediaSourceRetriever.kt │ │ │ │ ├── HeadersMediaSourceFactoryFactoryImpl.kt │ │ │ │ ├── DrmMediaSourceHelper.kt │ │ │ │ └── DashMediaSourceGenerator.kt │ │ │ ├── PlaybackEngineFactoryHolder.kt │ │ │ ├── PlaybackNotificationBuilder.kt │ │ │ ├── ArmadilloAudioAttributes.kt │ │ │ ├── DefaultPlaybackNotificationBuilder.kt │ │ │ ├── MediaMetadataCompatBuilder.kt │ │ │ ├── error │ │ │ │ └── ArmadilloHttpErrorHandlingPolicy.kt │ │ │ ├── PlaybackNotificationManager.kt │ │ │ ├── MediaSessionConnection.kt │ │ │ ├── ExoplayerExt.kt │ │ │ ├── PlaybackStateCompatBuilder.kt │ │ │ ├── PlayerEventListener.kt │ │ │ └── ExoPlaybackExceptionExt.kt │ │ │ ├── analytics │ │ │ ├── PlaybackActionListenerHolder.kt │ │ │ └── PlaybackActionListener.kt │ │ │ ├── di │ │ │ ├── Injector.kt │ │ │ ├── MainComponent.kt │ │ │ ├── AppModule.kt │ │ │ └── PlaybackModule.kt │ │ │ ├── HeadersStore.kt │ │ │ ├── time │ │ │ └── license.md │ │ │ ├── encryption │ │ │ └── ExoplayerEncryption.kt │ │ │ ├── StateStore.kt │ │ │ ├── broadcast │ │ │ ├── ArmadilloNoisySpeakerReceiver.kt │ │ │ └── NotificationDeleteReceiver.kt │ │ │ ├── ArmadilloPlayerFactory.kt │ │ │ ├── ManifestGenerator.kt │ │ │ ├── Util.kt │ │ │ ├── ArmadilloConfiguration.kt │ │ │ ├── Constants.kt │ │ │ └── mediaitems │ │ │ ├── ArmadilloMediaBrowse.kt │ │ │ └── MediaContentSharer.kt │ │ ├── res │ │ ├── drawable │ │ │ ├── arm_debug_view_bg.xml │ │ │ └── arm_download_icon.xml │ │ ├── values │ │ │ └── strings.xml │ │ └── layout │ │ │ └── arm_audio_engine_debug_layout.xml │ │ └── AndroidManifest.xml └── proguard-rules.pro ├── TestApp ├── .gitignore ├── .keystore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── drawable │ │ │ │ └── ic_favicon_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── dimens.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── xml │ │ │ │ ├── automotive_app_desc.xml │ │ │ │ ├── data_extraction_rules.xml │ │ │ │ ├── network_security_config.xml │ │ │ │ └── backup.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable-v24 │ │ │ │ ├── arm_play.xml │ │ │ │ ├── arm_pause.xml │ │ │ │ ├── arm_skipback.xml │ │ │ │ ├── arm_skipforward.xml │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── layout │ │ │ │ ├── activity_main.xml │ │ │ │ └── playable_view_item.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── scribd │ │ │ │ └── armadillotestapp │ │ │ │ └── presentation │ │ │ │ ├── di │ │ │ │ ├── UtilModule.kt │ │ │ │ ├── AppModule.kt │ │ │ │ ├── MainComponent.kt │ │ │ │ └── AudioplayerModule.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── ContentAdapter.kt │ │ │ │ ├── AudioDownloadManager.kt │ │ │ │ ├── extensions │ │ │ │ └── DataAudiobookExt.kt │ │ │ │ ├── analytics │ │ │ │ └── ArmadilloPlaybackActionListener.kt │ │ │ │ └── PlaybackNotificationManager.kt │ │ └── AndroidManifest.xml │ └── test │ │ └── java │ │ └── com │ │ └── scribd │ │ └── armadillotestapp │ │ └── ExampleUnitTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── armadillo.webp ├── armadillo_arch.png ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── shared.gradle ├── libs └── exoplayer-core-release.aar ├── .gradle-cache-buster ├── CONTRIBUTING.md ├── .gitignore ├── LICENSE ├── gradle.properties └── gradlew.bat /Armadillo/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /TestApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':TestApp', ':Armadillo' 2 | -------------------------------------------------------------------------------- /armadillo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/armadillo.webp -------------------------------------------------------------------------------- /TestApp/.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/.keystore -------------------------------------------------------------------------------- /armadillo_arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/armadillo_arch.png -------------------------------------------------------------------------------- /Armadillo/.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/Armadillo/.keystore -------------------------------------------------------------------------------- /Armadillo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /libs/exoplayer-core-release.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/libs/exoplayer-core-release.aar -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable/ic_favicon_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/drawable/ic_favicon_round.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /TestApp/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40dp 4 | -------------------------------------------------------------------------------- /TestApp/src/main/res/xml/automotive_app_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scribd/armadillo/HEAD/TestApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/StringExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import android.net.Uri 4 | 5 | fun String.toUri(): Uri = Uri.parse(this) -------------------------------------------------------------------------------- /gradle/shared.gradle: -------------------------------------------------------------------------------- 1 | project.buildscript { 2 | repositories { 3 | jcenter() 4 | google() 5 | } 6 | } 7 | 8 | project.repositories { 9 | jcenter() 10 | google() 11 | } 12 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/ByteArrayExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import java.nio.ByteBuffer 4 | 5 | internal fun ByteArray.decodeToInt(): Int = ByteBuffer.wrap(this).int -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DownloadNotificationHolder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | object DownloadNotificationHolder { 4 | var downloadNotificationFactory: DownloadNotificationFactory? = null 5 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /TestApp/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/StateProviderExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import com.scribd.armadillo.StateStore 4 | 5 | internal fun StateStore.Provider.getCurrentlyPlayingId() = currentState.playbackInfo?.audioPlayable?.id -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TestApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TestApp/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gradle-cache-buster: -------------------------------------------------------------------------------- 1 | ########################################################### 2 | # UPDATE THIS FILE TO BREAK GITHUB ACTIONS GRADLE CACHE # 3 | # # 4 | # RUN `uuidgen` TO GENERATE A NEW TOKEN # 5 | ########################################################### 6 | 7 | D7587EC9-3AAE-4F5A-998B-D821648DC678 -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackNotificationBuilderHolder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | /** 4 | * This class: 5 | * - makes the client's [PlaybackNotificationBuilder] accessible to [PlaybackService] 6 | */ 7 | internal object PlaybackNotificationBuilderHolder { 8 | var builder: PlaybackNotificationBuilder? = null 9 | } 10 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionListenerHolder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.analytics 2 | 3 | import com.scribd.armadillo.playback.PlaybackService 4 | 5 | 6 | internal object PlaybackActionListenerHolder { 7 | var actionlisteners = mutableListOf() 8 | var stateListener : PlaybackService.PlaybackStateListener? = null 9 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/di/UtilModule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.di 2 | 3 | import com.scribd.armadillotestapp.data.Content 4 | import com.scribd.armadillotestapp.data.TestContent 5 | import dagger.Module 6 | import dagger.Provides 7 | 8 | @Module 9 | class UtilModule { 10 | @Provides 11 | fun content(content: TestContent): Content = content 12 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.di 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import javax.inject.Singleton 7 | 8 | @Module 9 | class AppModule(private val context: Context) { 10 | 11 | @Singleton 12 | @Provides 13 | fun provideAppContext(): Context = context.applicationContext 14 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /TestApp/src/main/res/xml/backup.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TestApp/src/test/java/com/scribd/armadillotestapp/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DownloadNotificationFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.app.Notification 4 | import android.content.Context 5 | import com.scribd.armadillo.models.DownloadProgressInfo 6 | 7 | /** 8 | * Used to build notifications for in-progress downloads. 9 | */ 10 | interface DownloadNotificationFactory { 11 | fun getForegroundNotification(context: Context, downloadStates: Array): Notification 12 | } -------------------------------------------------------------------------------- /Armadillo/src/main/res/drawable/arm_debug_view_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 13 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/IntExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import java.nio.ByteBuffer 4 | 5 | internal fun Int.encodeInByteArray(): ByteArray { 6 | // Creates a ByteBuffer instance with a 4 bytes capacity(32/8). 7 | // If you put more than one integer (or 4 bytes) in the ByteBuffer instance, a java.nio.BufferOverflowException is thrown. 8 | return ByteBuffer.allocate(Integer.SIZE / 8).apply { 9 | putInt(this@encodeInByteArray) 10 | }.array() 11 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/HeadersMediaSourceFactoryFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.upstream.DataSource 5 | import com.scribd.armadillo.models.AudioPlayable 6 | 7 | internal interface HeadersMediaSourceFactoryFactory { 8 | fun createDataSourceFactory(context: Context, request: AudioPlayable.MediaRequest): DataSource.Factory 9 | fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) 10 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/di/Injector.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.di 2 | 3 | import android.content.Context 4 | 5 | /** 6 | * Manages the dagger instance for dependency injection 7 | */ 8 | internal object Injector { 9 | lateinit var mainComponent: MainComponent 10 | fun buildDependencyGraph(context: Context) { 11 | mainComponent = DaggerMainComponent.builder() 12 | .appModule(AppModule(context)) 13 | .playbackModule(PlaybackModule()) 14 | .build() 15 | } 16 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable-v24/arm_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackEngineFactoryHolder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import com.scribd.armadillo.models.AudioPlayable 4 | 5 | internal interface PlaybackEngineFactory { 6 | fun createPlaybackEngine(audioPlayable: AudioPlayable): AudioPlaybackEngine 7 | } 8 | 9 | internal object PlaybackEngineFactoryHolder { 10 | 11 | val factory = object : PlaybackEngineFactory { 12 | override fun createPlaybackEngine(audioPlayable: AudioPlayable): AudioPlaybackEngine = ExoplayerPlaybackEngine(audioPlayable) 13 | } 14 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/ExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import com.scribd.armadillo.extensions.decodeToInt 4 | import com.scribd.armadillo.extensions.encodeInByteArray 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.Test 7 | 8 | class ExtensionsTest { 9 | @Test 10 | fun encodeId_encodesProperly() { 11 | val expectedId = 356898828 12 | val encoded = expectedId.encodeInByteArray() 13 | val decodedId = encoded.decodeToInt() 14 | assertThat(expectedId).isEqualTo(decodedId) 15 | } 16 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute # 2 | 3 | ## Reporting issues ## 4 | 5 | We use the [GitHub issue tracker](https://github.com/scribd/armadillo/issues) 6 | to track bugs, feature requests and questions. 7 | 8 | Before filing a new issue, please search the tracker to check if it's already 9 | covered by an existing report. 10 | 11 | ## Contributing ## 12 | 13 | 1. [Fork it](https://github.com/scribd/armadillo/fork) 14 | 2. Create your feature branch (`git checkout -b my-new-feature`) 15 | 3. Commit your changes (`git commit -m 'Add some feature'`) 16 | 4. Push to the branch (`git push origin my-new-feature`) 17 | 5. Create a new Pull Request into the `main` branch 18 | -------------------------------------------------------------------------------- /Armadillo/src/main/res/drawable/arm_download_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackNotificationBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.app.Notification 4 | import android.support.v4.media.session.MediaSessionCompat 5 | import com.scribd.armadillo.models.AudioPlayable 6 | 7 | /** 8 | * This class 9 | * - should be implemented by the client in order to construct a [Notification] for the [PlaybackService] 10 | */ 11 | interface PlaybackNotificationBuilder { 12 | val notificationId: Int 13 | val channelId: String 14 | fun build(audioPlayable: AudioPlayable, 15 | currentChapterIndex: Int, 16 | isPlaying: Boolean, 17 | token: MediaSessionCompat.Token): Notification 18 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/MediaSourceGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.source.MediaSource 5 | import com.scribd.armadillo.models.AudioPlayable 6 | 7 | /** Creates a MediaSource for starting playback in Exoplayer when this 8 | * class is initialized. */ 9 | internal interface MediaSourceGenerator { 10 | companion object { 11 | const val TAG = "MediaSourceGenerator" 12 | } 13 | 14 | fun generateMediaSource(mediaId: String, context: Context, request: AudioPlayable.MediaRequest): MediaSource 15 | 16 | fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) 17 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DefaultDownloadNotificationFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.app.Notification 4 | import android.content.Context 5 | import com.scribd.armadillo.R 6 | import com.scribd.armadillo.models.DownloadProgressInfo 7 | 8 | internal class DefaultDownloadNotificationFactory : DownloadNotificationFactory { 9 | override fun getForegroundNotification(context: Context, downloadStates: Array): Notification { 10 | return ExoplayerNotificationUtil.buildProgressNotification(context, R.drawable.arm_download_icon, 11 | DefaultExoplayerDownloadService.CHANNEL_ID, null, context.getString(R.string.arm_downloading), downloadStates) 12 | } 13 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable-v24/arm_pause.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/HeadersStore.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import javax.inject.Inject 4 | import javax.inject.Singleton 5 | 6 | @Singleton 7 | class HeadersStore @Inject constructor() { 8 | private val headersMap = mutableMapOf>().withDefault { emptyMap() } 9 | 10 | fun headersForKey(key: String): Map? = headersMap[key] 11 | 12 | fun setHeaders(key: String, headers: Map) { 13 | headersMap[key] = headers 14 | } 15 | 16 | fun keyForUrl(url: String): String? { 17 | // Matches a numeric segment of a URL. For example, "12345" in www.coolhost.com/audio/12345/master.m3u8 18 | // TODO [29]: Remove this once exoplayer is fixed 19 | return Regex("""/(\d+)/""").find(url)?.value 20 | } 21 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ArmadilloTestApp 3 | 4 | Downloads 5 | 6 | Downloading 7 | Downloading %d titles 8 | 9 | Set speed: %.1f 10 | Pause 11 | Play 12 | Skip Backward 13 | Skip Forward 14 | 15 | Clear Playback Cache: %s 16 | Remove All Downloads: %s 17 | 18 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/ArmadilloDatabaseProvider.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.database.DatabaseProvider 5 | import com.google.android.exoplayer2.database.ExoDatabaseProvider 6 | import com.google.android.exoplayer2.upstream.cache.SimpleCache 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * An optimization for improving the performance of [SimpleCache] by caching indexes to improve downloading speeds. 12 | */ 13 | interface ArmadilloDatabaseProvider { 14 | val database: DatabaseProvider 15 | } 16 | 17 | @Singleton 18 | class ArmadilloDatabaseProviderImpl @Inject constructor(context: Context) : ArmadilloDatabaseProvider { 19 | override val database: DatabaseProvider = ExoDatabaseProvider(context) 20 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/di/MainComponent.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.di 2 | 3 | import com.scribd.armadillotestapp.presentation.AudioPlayerActivity 4 | import com.scribd.armadillotestapp.presentation.AudioPlayerApplication 5 | import com.scribd.armadillotestapp.presentation.ContentAdapter 6 | import com.scribd.armadillotestapp.presentation.MainActivity 7 | import dagger.Component 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | @Component(modules = [ 12 | (AppModule::class), 13 | (UtilModule::class), 14 | (AudioplayerModule::class)]) 15 | interface MainComponent { 16 | fun inject(mainActivity: MainActivity) 17 | fun inject(audioPlayerActivity: AudioPlayerActivity) 18 | fun inject(audioPlayerApplication: AudioPlayerApplication) 19 | fun inject(contentAdapter: ContentAdapter) 20 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/DownloadProgressInfoExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import com.scribd.armadillo.models.DownloadProgressInfo 4 | import com.scribd.armadillo.models.DownloadState 5 | 6 | internal fun List.replaceDownloadProgressItemsByUrl(newItems: List): List = 7 | this.filter { oldItem -> 8 | newItems.find { it.url == oldItem.url } == null 9 | }.plus(newItems) 10 | 11 | internal fun List.removeItemsByUrl(itemsToRemove: List): List = 12 | this.filter { oldItem -> 13 | itemsToRemove.find { it.url == oldItem.url } == null 14 | } 15 | 16 | internal fun List.filterOutCompletedItems() = 17 | this.filterNot { DownloadState.COMPLETED == it.downloadState } -------------------------------------------------------------------------------- /TestApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Retrofit 24 | -dontwarn okio.** 25 | -dontwarn javax.annotation.** 26 | -dontwarn retrofit2.Platform$Java8 27 | -dontwarn okhttp3.internal.platform.ConscryptPlatform -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # files for the dex VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # generated files 12 | bin/ 13 | gen/ 14 | proguard/ 15 | R.java 16 | 17 | # Local configuration file (sdk path, etc) 18 | local.properties 19 | 20 | # vi swap files 21 | *.swp 22 | 23 | # emacs 24 | *~ 25 | 26 | # android studio / intellij stuff 27 | .idea/ 28 | */build/* 29 | /build 30 | .gradle/ 31 | *.iml 32 | 33 | # osx stuff 34 | .DS_Store 35 | 36 | .metadata 37 | 38 | # Crashlytics 39 | Scribd/res/values/com_crashlytics_export_strings.xml 40 | 41 | # Build metadata (generated during compile then deleted, but can get left in place sometimes) 42 | Scribd/src/com/scribd/app/build/BuildMetadata.java 43 | 44 | # Generated as a side effect of 'android update project' in build scripts, but not used by the IDE 45 | project.properties 46 | proguard-project.txt 47 | build.xml 48 | 49 | # layout inspector 50 | /captures 51 | 52 | # VS code 53 | .vscode 54 | **/.project 55 | **/.settings -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download.drm 2 | 3 | import com.scribd.armadillo.models.DrmDownload 4 | import com.scribd.armadillo.models.DrmInfo 5 | 6 | /** 7 | * This is a helper class responsible for downloading the DRM license to local storage for a DRM-protected content. 8 | * This downloaded license can then be retrieved for offline usage using its key ID. 9 | */ 10 | internal interface DrmLicenseDownloader { 11 | companion object { 12 | const val TAG = "DrmLicenseDownloader" 13 | } 14 | 15 | /** 16 | * Download and persist the DRM license 17 | * @return object containing information about the downloaded DRM license 18 | */ 19 | suspend fun downloadDrmLicense( 20 | requestUrl: String, 21 | customRequestHeaders: Map, 22 | drmInfo: DrmInfo, 23 | ): DrmDownload 24 | 25 | /** 26 | * Release a downloaded DRM license so it's no longer valid for usage. 27 | */ 28 | suspend fun releaseDrmLicense(drmDownload: DrmDownload) 29 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/time/license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Kizito Nwose 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Scribd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | PACKAGE_NAME=com.scribd.armadillo 15 | GRADLE_PLUGIN_VERSION=7.2.0 16 | LIBRARY_VERSION=1.7.1 17 | EXOPLAYER_VERSION=2.19.1 18 | RXJAVA_VERSION=2.2.4 19 | RXANDROID_VERSION=2.0.1 20 | DAGGER_VERSION=2.16 21 | MAVEN_PUBLISH_VERSION=3.6.2 22 | DOKKA_VERSION=1.6.10 23 | SERIALIZATON_VERSION=1.4.1 24 | BUILD_TOOLS_VERSION=34.0.0 25 | android.useAndroidX=true 26 | android.enableJetifier=true 27 | android.nonTransitiveRClass=true 28 | android.suppressUnsupportedCompileSdk=34 -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/drm/events/WidevineSessionEventListener.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download.drm.events 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.drm.DrmSessionEventListener 5 | import com.google.android.exoplayer2.source.MediaSource 6 | import com.scribd.armadillo.StateStore 7 | import com.scribd.armadillo.actions.LicenseAcquiredAction 8 | import com.scribd.armadillo.actions.LicenseReleasedAction 9 | import com.scribd.armadillo.di.Injector 10 | import com.scribd.armadillo.models.DrmType 11 | import javax.inject.Inject 12 | 13 | internal class WidevineSessionEventListener 14 | : DrmSessionEventListener { 15 | 16 | @Inject 17 | internal lateinit var stateStore: StateStore.Modifier 18 | 19 | @Inject 20 | internal lateinit var context: Context 21 | 22 | init { 23 | Injector.mainComponent.inject(this) 24 | } 25 | 26 | override fun onDrmSessionAcquired(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, state: Int) { 27 | stateStore.dispatch(LicenseAcquiredAction(type = DrmType.WIDEVINE)) 28 | } 29 | 30 | override fun onDrmSessionReleased(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?) { 31 | stateStore.dispatch(LicenseReleasedAction) 32 | } 33 | } -------------------------------------------------------------------------------- /Armadillo/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Guava warnings 24 | # https://stackoverflow.com/questions/9120338/proguard-configuration-for-guava-with-obfuscation-and-optimization 25 | -dontwarn sun.misc.Unsafe 26 | -dontwarn afu.org.checkerframework.** 27 | -dontwarn org.checkerframework.** 28 | -dontwarn com.google.errorprone.** 29 | -dontwarn sun.misc.Unsafe 30 | -dontwarn java.lang.ClassValue 31 | -dontwarn com.google.common.** 32 | 33 | # Coroutine Flow 34 | -dontwarn kotlinx.coroutines.debug.** -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/di/AudioplayerModule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.di 2 | 3 | import android.content.Context 4 | import com.scribd.armadillo.ArmadilloPlayer 5 | import com.scribd.armadillo.ArmadilloPlayerFactory 6 | import com.scribd.armadillo.download.DownloadNotificationFactory 7 | import com.scribd.armadillo.playback.PlaybackNotificationBuilder 8 | import com.scribd.armadillotestapp.presentation.AudioDownloadManager 9 | import com.scribd.armadillotestapp.presentation.PlaybackNotificationManager 10 | import dagger.Module 11 | import dagger.Provides 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | class AudioplayerModule { 16 | 17 | @Singleton 18 | @Provides 19 | fun downloadNotification(): DownloadNotificationFactory = AudioDownloadManager() 20 | 21 | @Singleton 22 | @Provides 23 | fun playbackNotification(context: Context): PlaybackNotificationBuilder = PlaybackNotificationManager(context) 24 | 25 | @Singleton 26 | @Provides 27 | fun audioPlayer(downloadNotificationFactory: DownloadNotificationFactory, 28 | playbackNotificationBuilder: PlaybackNotificationBuilder): ArmadilloPlayer { 29 | return ArmadilloPlayerFactory.init(downloadNotificationFactory, playbackNotificationBuilder) 30 | } 31 | } -------------------------------------------------------------------------------- /Armadillo/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Armadillo 3 | Title: %s 4 | Chapter: %1$s out of %2$s 5 | %d AppState Updates 6 | %1$s / %2$s (%3$s) 7 | Unknown 8 | Playback Speed: %.1f 9 | No DRM 10 | DRM Opening 11 | DRM Released 12 | DRM Error 13 | DRM Expired 14 | Using DRM: 15 | expiring: 16 | 17 | 18 | 19 | Downloads 20 | Downloading… 21 | Download State: %s 22 | %d%% Downloaded 23 | Audiobook player 24 | 25 | -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable-v24/arm_skipback.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.database.DatabaseProvider 5 | import com.google.android.exoplayer2.offline.DefaultDownloadIndex 6 | import com.google.android.exoplayer2.offline.DownloadManager 7 | import com.google.android.exoplayer2.offline.DownloaderFactory 8 | import com.scribd.armadillo.Constants 9 | import com.scribd.armadillo.encryption.ExoplayerEncryption 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | internal interface DownloadManagerFactory { 14 | fun build(context: Context, databaseProvider: DatabaseProvider): DownloadManager 15 | } 16 | 17 | /** 18 | * All instances of Armadillo must receive the same instance of [DownloadManager]. This is probably best accomplished through DI 19 | */ 20 | @Singleton 21 | internal class ArmadilloDownloadManagerFactory @Inject constructor( 22 | private val downloaderFactory: DownloaderFactory) : DownloadManagerFactory { 23 | 24 | override fun build(context: Context, databaseProvider: DatabaseProvider): DownloadManager = 25 | DownloadManager( 26 | context, 27 | DefaultDownloadIndex(databaseProvider), 28 | downloaderFactory).apply { 29 | maxParallelDownloads = Constants.MAX_PARALLEL_DOWNLOADS 30 | } 31 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable-v24/arm_skipforward.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/ArmadilloAudioAttributes.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.media.AudioAttributes 4 | import com.google.android.exoplayer2.C 5 | 6 | /** 7 | * Creates AudioAttributes - for ExoPlayer and also for Android notifications. 8 | * 9 | * [exoPlayerAttrs] is used in Simple Exo Player to define the attributes for playback. 10 | * ExoPlayer uses the info here to know how to handle audio focus. 11 | * 12 | * [getAndroidAttributes] is useful for declaring attributes for notifications. 13 | */ 14 | interface ArmadilloAudioAttributes { 15 | val exoPlayerAttrs: com.google.android.exoplayer2.audio.AudioAttributes 16 | fun getAndroidAttributes(): AudioAttributes 17 | } 18 | 19 | class AudioAttributesBuilderImpl : ArmadilloAudioAttributes { 20 | override val exoPlayerAttrs: com.google.android.exoplayer2.audio.AudioAttributes 21 | get() = com.google.android.exoplayer2.audio.AudioAttributes.Builder().run { 22 | setUsage(C.USAGE_MEDIA) 23 | setContentType(C.CONTENT_TYPE_SPEECH) 24 | build() 25 | } 26 | 27 | override fun getAndroidAttributes(): AudioAttributes { 28 | return AudioAttributes.Builder().run { 29 | setUsage(AudioAttributes.USAGE_MEDIA) 30 | setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) 31 | build() 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 19 | 20 | 29 | 30 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/PrintExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import com.scribd.armadillo.models.AudioPlayable 4 | import com.scribd.armadillo.models.Chapter 5 | 6 | 7 | fun AudioPlayable.toPrint() : String = """ 8 | AudioPlayable( 9 | $id, 10 | "$title", 11 | ${request.toPrint()}, 12 | ${chapters.toPrint()} 13 | ) 14 | """.trim() 15 | 16 | 17 | fun AudioPlayable.MediaRequest.toPrint() : String { 18 | return if (headers.isNotEmpty()) { 19 | val headersMapForPrint = headers.keys.foldIndexed("") { i, result, key -> 20 | 21 | var map = """"$key" to "${headers[key]}"""" 22 | if (i < headers.size - 1) { 23 | map += ",\n" 24 | } 25 | result + map 26 | } 27 | 28 | """AudioPlayable.MediaRequest.createHttpUri("$url", mapOf($headersMapForPrint))""" 29 | } else { 30 | """AudioPlayable.MediaRequest.createHttpUri("$url")""" 31 | } 32 | } 33 | 34 | fun Chapter.toPrint() : String = """Chapter("$title", $part, $chapter, ${startTime.longValue}.milliseconds, ${duration.longValue}.milliseconds)""" 35 | 36 | fun List.toPrint(): String { 37 | val chaptersForPrint = foldIndexed("") {i, result, chapter-> 38 | var printChapter = chapter.toPrint() 39 | if (i < size - 1) { 40 | printChapter += ",\n" 41 | } 42 | result + printChapter 43 | } 44 | return "listOf($chaptersForPrint)" 45 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/models/ArmadilloStateTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.models 2 | 3 | import com.scribd.armadillo.MockModels 4 | import org.assertj.core.api.Assertions.assertThat 5 | import org.junit.Before 6 | import org.junit.Test 7 | 8 | class ArmadilloStateTest { 9 | 10 | private lateinit var state: ArmadilloState 11 | private lateinit var currentChapter: Chapter 12 | @Before 13 | fun setUp() { 14 | state = MockModels.appState() 15 | val playbackInfo = state.playbackInfo!! 16 | val currentChapterIndex = playbackInfo.progress.currentChapterIndex 17 | currentChapter = playbackInfo.audioPlayable.chapters[currentChapterIndex] 18 | } 19 | 20 | @Test 21 | fun positionFromChapterPercent_atBeginning_returnsStart() { 22 | val position = state.positionFromChapterPercent(0) 23 | val expectedPosition = currentChapter.startTime 24 | 25 | assertThat(position).isEqualTo(expectedPosition) 26 | } 27 | 28 | @Test 29 | fun positionFromChapterPercent_atEnd_returnsEndOfChapter() { 30 | val position = state.positionFromChapterPercent(100) 31 | val expectedPosition = currentChapter.startTime + currentChapter.duration 32 | 33 | assertThat(position).isEqualTo(expectedPosition) 34 | } 35 | 36 | @Test 37 | fun positionFromChapterPercent_inMiddle_returnsMiddleOfChapter() { 38 | val position = state.positionFromChapterPercent(50) 39 | val expectedPosition = currentChapter.startTime + (currentChapter.duration / 2) 40 | 41 | assertThat(position).isEqualTo(expectedPosition) 42 | } 43 | } -------------------------------------------------------------------------------- /TestApp/src/main/res/layout/playable_view_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | 19 | 26 | 27 | 34 | -------------------------------------------------------------------------------- /Armadillo/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/di/MainComponent.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.di 2 | 3 | import com.scribd.armadillo.ArmadilloPlayerChoreographer 4 | import com.scribd.armadillo.ArmadilloPlayerFactory 5 | import com.scribd.armadillo.analytics.PlaybackActionTransmitterImpl 6 | import com.scribd.armadillo.download.DefaultExoplayerDownloadService 7 | import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener 8 | import com.scribd.armadillo.playback.ExoplayerPlaybackEngine 9 | import com.scribd.armadillo.playback.MediaSessionCallback 10 | import com.scribd.armadillo.playback.PlaybackService 11 | import com.scribd.armadillo.playback.PlayerEventListener 12 | import com.scribd.armadillo.playback.mediasource.MediaSourceRetrieverImpl 13 | import dagger.Component 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | @Component(modules = [ 18 | (AppModule::class), 19 | (DownloadModule::class), 20 | (PlaybackModule::class)]) 21 | internal interface MainComponent { 22 | fun inject(armadilloPlayerChoreographer: ArmadilloPlayerChoreographer) 23 | fun inject(playbackService: PlaybackService) 24 | fun inject(mediaSessionCallback: MediaSessionCallback) 25 | fun inject(exoplayerPlaybackEngine: ExoplayerPlaybackEngine) 26 | fun inject(armadilloPlayerFactory: ArmadilloPlayerFactory) 27 | fun inject(defaultExoplayerDownloadService: DefaultExoplayerDownloadService) 28 | fun inject(playerEventListener: PlayerEventListener) 29 | fun inject(playbackActionTransmitterImpl: PlaybackActionTransmitterImpl) 30 | fun inject(mediaSourceRetrieverImpl: MediaSourceRetrieverImpl) 31 | fun inject(widevineSessionEventListener: WidevineSessionEventListener) 32 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/ExoplayerDownloadExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import com.google.android.exoplayer2.C 4 | import com.scribd.armadillo.ExoplayerDownload 5 | 6 | /** 7 | * Exoplayer can be mysterious at times. This method is designed for making a more printable version of [ExoplayerDownload] for debugging 8 | * purposes 9 | */ 10 | fun ExoplayerDownload.toPrint(): String { 11 | 12 | val stateStr = when (state) { 13 | ExoplayerDownload.STATE_QUEUED -> "Queued" 14 | ExoplayerDownload.STATE_STOPPED -> "Stopped" 15 | ExoplayerDownload.STATE_DOWNLOADING -> "Downloading" 16 | ExoplayerDownload.STATE_COMPLETED -> "Completed" 17 | ExoplayerDownload.STATE_FAILED -> "Failed" 18 | ExoplayerDownload.STATE_REMOVING -> "Removing" 19 | ExoplayerDownload.STATE_RESTARTING -> "Restarting" 20 | else -> "Unknown" 21 | } 22 | 23 | val failureReasonStr = when (failureReason) { 24 | ExoplayerDownload.FAILURE_REASON_NONE -> null // The download isn't failed 25 | else -> "unknown" 26 | } 27 | 28 | val percentDownloadedStr = when { 29 | percentDownloaded.toInt() == C.PERCENTAGE_UNSET -> "Unset" 30 | else -> percentDownloaded.toString() 31 | } 32 | 33 | val bytesStr = when { 34 | bytesDownloaded > 0 -> bytesDownloaded.toString() 35 | else -> null 36 | } 37 | 38 | var str = "State: $stateStr" 39 | if (failureReasonStr != null) { 40 | str += " - FailureReason: $failureReasonStr" 41 | } 42 | 43 | str += " - percentDownloaded: $percentDownloadedStr" 44 | str += " - bytes: $bytesStr" 45 | return str 46 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/encryption/ExoplayerEncryption.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.encryption 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.upstream.DataSink 5 | import com.google.android.exoplayer2.upstream.DataSource 6 | import com.google.android.exoplayer2.upstream.cache.Cache 7 | import com.google.android.exoplayer2.upstream.cache.CacheDataSink 8 | import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSink 9 | import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSource 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | interface ExoplayerEncryption { 14 | fun dataSinkFactory(downloadCache: Cache): DataSink.Factory 15 | fun dataSourceFactory(upstream: DataSource.Factory): DataSource.Factory 16 | } 17 | 18 | /** 19 | * This class provides the plumbing for encrypting downloaded content & then reading this encrypted content. 20 | */ 21 | @Singleton 22 | internal class ExoplayerEncryptionImpl @Inject constructor(applicationContext: Context, 23 | secureStorage: SecureStorage) : ExoplayerEncryption { 24 | 25 | private val secret = secureStorage.downloadSecretKey(applicationContext) 26 | 27 | override fun dataSinkFactory(downloadCache: Cache) = DataSink.Factory { 28 | val scratch = ByteArray(3897) // size selected from exoplayer unit tests. 29 | return@Factory AesCipherDataSink(secret, CacheDataSink(downloadCache, Long.MAX_VALUE), scratch) 30 | } 31 | 32 | override fun dataSourceFactory(upstream: DataSource.Factory) = 33 | DataSource.Factory { AesCipherDataSource(secret, upstream.createDataSource()) } 34 | 35 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/DefaultPlaybackNotificationBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.app.Notification 4 | import android.content.Context 5 | import android.support.v4.media.session.MediaSessionCompat 6 | import android.support.v4.media.session.PlaybackStateCompat 7 | import androidx.media.session.MediaButtonReceiver 8 | import com.scribd.armadillo.models.AudioPlayable 9 | 10 | 11 | class DefaultPlaybackNotificationBuilder(private val context: Context) : PlaybackNotificationBuilder { 12 | override val notificationId = 415 13 | override val channelId = "playback_notification" 14 | 15 | override fun build( 16 | audioPlayable: AudioPlayable, currentChapterIndex: Int, isPlaying: Boolean, token: MediaSessionCompat.Token): Notification { 17 | return androidx.core.app.NotificationCompat.Builder(context, channelId) 18 | .setSmallIcon(android.R.drawable.ic_media_play) 19 | .setContentTitle(audioPlayable.title) 20 | .setContentText(audioPlayable.title) 21 | .setTicker(audioPlayable.title) 22 | .setAutoCancel(false) 23 | .setLocalOnly(true) 24 | .setVisibility(androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC) 25 | .setPriority(androidx.core.app.NotificationCompat.PRIORITY_MAX) 26 | .setOngoing(isPlaying) 27 | .setStyle(androidx.media.app.NotificationCompat.MediaStyle() 28 | .setMediaSession(token) 29 | .setShowActionsInCompactView(0, 1, 2) //Indexes to items added in addAction(). Max 3 items. 30 | .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP)) 31 | .setShowCancelButton(true)) 32 | .build() 33 | } 34 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/MediaMetadataCompatBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.media.MediaMetadata 4 | import android.support.v4.media.MediaMetadataCompat 5 | import com.scribd.armadillo.models.AudioPlayable 6 | import com.scribd.armadillo.models.PlaybackProgress 7 | import com.scribd.armadillo.time.milliseconds 8 | 9 | /** 10 | * Used for building the [MediaMetadataCompat] for a given [AudioPlayable]. 11 | * 12 | * Necessary for enabling Bluetooth media controls. Without metadata, playback state updates (e.g. Playing/Paused) may not be fully 13 | * propagated through the OS, particularly on API 28+, leading to weird behavior like Bluetooth devices sending 'Play' commands 14 | * instead of 'Pause' when the audioPlayable is already playing. 15 | */ 16 | internal interface MediaMetadataCompatBuilder { 17 | fun build(audioPlayable: AudioPlayable, playbackProgress: PlaybackProgress? = null): MediaMetadataCompat 18 | } 19 | 20 | internal class MediaMetadataCompatBuilderImpl : MediaMetadataCompatBuilder { 21 | override fun build(audioPlayable: AudioPlayable, playbackProgress: PlaybackProgress?): MediaMetadataCompat { 22 | 23 | val chapterTitle = playbackProgress?.let { audioPlayable.chapters[it.currentChapterIndex].title } ?: "" 24 | val duration = playbackProgress?.let { audioPlayable.chapters[it.currentChapterIndex].duration } ?: (-1).milliseconds 25 | 26 | return MediaMetadataCompat.Builder() 27 | .putString(MediaMetadataCompat.METADATA_KEY_TITLE, chapterTitle) 28 | .putString(MediaMetadata.METADATA_KEY_ARTIST, audioPlayable.title) 29 | .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.longValue) 30 | .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, audioPlayable.id.toString()) 31 | .build() 32 | 33 | } 34 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/HlsMediaSourceGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.google.android.exoplayer2.MediaItem 6 | import com.google.android.exoplayer2.offline.Download 7 | import com.google.android.exoplayer2.offline.DownloadHelper 8 | import com.google.android.exoplayer2.source.MediaSource 9 | import com.google.android.exoplayer2.source.hls.HlsMediaSource 10 | import com.scribd.armadillo.download.DownloadTracker 11 | import com.scribd.armadillo.models.AudioPlayable 12 | import javax.inject.Inject 13 | 14 | /** 15 | * creates an HLS media source for [ExoPlayer] from a master playlist url 16 | * 17 | */ 18 | internal class HlsMediaSourceGenerator @Inject constructor( 19 | private val mediaSourceHelper: HeadersMediaSourceFactoryFactory, 20 | private val downloadTracker: DownloadTracker) : MediaSourceGenerator { 21 | 22 | 23 | override fun generateMediaSource(mediaId: String, context: Context, request: AudioPlayable.MediaRequest): MediaSource { 24 | val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request) 25 | 26 | downloadTracker.getDownload(id = mediaId, uri = request.url)?.let { 27 | if (it.state != Download.STATE_FAILED) { 28 | return DownloadHelper.createMediaSource(it.request, dataSourceFactory) 29 | } 30 | } 31 | 32 | if (request.drmInfo != null) { 33 | Log.e(MediaSourceGenerator.TAG, "HLS does not currently support DRM") 34 | } 35 | 36 | return HlsMediaSource.Factory(dataSourceFactory) 37 | .createMediaSource(MediaItem.fromUri(request.url)) 38 | } 39 | 40 | override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) = mediaSourceHelper.updateMediaSourceHeaders(request) 41 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation 2 | 3 | import android.os.Bundle 4 | import android.widget.TextView 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.recyclerview.widget.DividerItemDecoration 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.scribd.armadillo.Constants 10 | import com.scribd.armadillotestapp.R 11 | import com.scribd.armadillotestapp.data.Content 12 | import io.reactivex.disposables.CompositeDisposable 13 | import javax.inject.Inject 14 | 15 | class MainActivity : AppCompatActivity() { 16 | 17 | companion object { 18 | private val TAG = "MainActivity" 19 | const val AUDIOBOOK_EXTRA = "audiobook_extra" 20 | } 21 | 22 | private val armadilloVersion: TextView by lazy { findViewById(R.id.armadilloVersion) } 23 | private val recyclerView: RecyclerView by lazy { findViewById(R.id.recyclerView) } 24 | 25 | private val compositeDisposable = CompositeDisposable() 26 | 27 | @Inject 28 | lateinit var content: Content 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | setContentView(R.layout.activity_main) 33 | (application as AudioPlayerApplication).mainComponent.inject(this) 34 | 35 | armadilloVersion.text = Constants.LIBRARY_VERSION 36 | recyclerView.apply { 37 | val linearLayoutManager = LinearLayoutManager(this.context) 38 | layoutManager = linearLayoutManager 39 | adapter = ContentAdapter(application as AudioPlayerApplication, content.playables) 40 | addItemDecoration(DividerItemDecoration(this.context, linearLayoutManager.orientation)) 41 | } 42 | } 43 | 44 | override fun onDestroy() { 45 | super.onDestroy() 46 | compositeDisposable.clear() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /TestApp/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/StateStore.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import android.os.Handler 4 | import com.scribd.armadillo.actions.Action 5 | import com.scribd.armadillo.actions.ClearErrorAction 6 | import com.scribd.armadillo.error.MissingDataException 7 | import com.scribd.armadillo.models.ArmadilloState 8 | import io.reactivex.subjects.BehaviorSubject 9 | 10 | internal interface StateStore { 11 | interface Initializer { 12 | fun init(state: ArmadilloState) 13 | } 14 | 15 | interface Modifier { 16 | fun dispatch(action: Action) 17 | } 18 | 19 | interface Provider { 20 | /** 21 | * emits the most recently emitted state and all the subsequent states when an observer subscribes to it. 22 | */ 23 | val stateSubject: BehaviorSubject 24 | 25 | val currentState: ArmadilloState 26 | } 27 | } 28 | 29 | internal class ArmadilloStateStore(private val reducer: Reducer, private val handler: Handler) : 30 | StateStore.Modifier, StateStore.Provider, StateStore.Initializer { 31 | 32 | private companion object { 33 | const val TAG = "ArmadilloStateStore" 34 | } 35 | 36 | private val armadilloStateObservable = BehaviorSubject.create().also { 37 | it.onNext(ArmadilloState(downloadInfo = emptyList())) 38 | } 39 | 40 | override fun init(state: ArmadilloState) = armadilloStateObservable.onNext(state) 41 | 42 | override fun dispatch(action: Action) { 43 | val newAppState = reducer.reduce(currentState, action) 44 | armadilloStateObservable.onNext(newAppState) 45 | 46 | if (currentState.error != null) { 47 | dispatch(ClearErrorAction) 48 | } 49 | } 50 | 51 | override val stateSubject: BehaviorSubject 52 | get() = armadilloStateObservable 53 | 54 | override val currentState: ArmadilloState 55 | get() = armadilloStateObservable.value ?: throw MissingDataException("Armadillo's State should never be null") 56 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/error/ArmadilloHttpErrorHandlingPolicy.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.error 2 | 3 | import com.google.android.exoplayer2.C 4 | import com.google.android.exoplayer2.ParserException 5 | import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy 6 | import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy 7 | import java.net.SocketTimeoutException 8 | import java.net.UnknownHostException 9 | 10 | class ArmadilloHttpErrorHandlingPolicy : DefaultLoadErrorHandlingPolicy(DEFAULT_MIN_LOADABLE_RETRY_COUNT) { 11 | override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { 12 | return if (loadErrorInfo.exception is UnknownHostException || loadErrorInfo.exception is SocketTimeoutException) { 13 | //retry every 10 seconds for potential loss of internet -keep buffering - internet may later succeed. 14 | if (loadErrorInfo.errorCount > 6) { 15 | C.TIME_UNSET //stop retrying after around 10 minutes 16 | } else { 17 | //exponential backoff based on a 10 second interval 18 | (1 shl (loadErrorInfo.errorCount - 1)) * 10 * 1000L 19 | } 20 | } else if (loadErrorInfo.exception is ParserException) { 21 | /* 22 | Exoplayer by default assumes ParserExceptions only occur because source content is malformed, 23 | so Exoplayer will never retry ParserExceptions. 24 | We care about content failing to checksum correctly over the internet, so we wish to retry these. 25 | */ 26 | if (loadErrorInfo.errorCount > 3) { 27 | C.TIME_UNSET //stop retrying, the content is likely malformed 28 | } else { 29 | //This is exponential backoff based on a 1 second interval 30 | (1 shl (loadErrorInfo.errorCount - 1)) * 1000L 31 | } 32 | } else { 33 | super.getRetryDelayMsFor(loadErrorInfo) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/ProgressiveMediaSourceGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.google.android.exoplayer2.MediaItem 6 | import com.google.android.exoplayer2.source.MediaSource 7 | import com.google.android.exoplayer2.source.ProgressiveMediaSource 8 | import com.google.android.exoplayer2.upstream.DataSource 9 | import com.google.android.exoplayer2.upstream.DefaultDataSource 10 | import com.google.android.exoplayer2.upstream.DefaultHttpDataSource 11 | import com.scribd.armadillo.Constants 12 | import com.scribd.armadillo.download.CacheManager 13 | import com.scribd.armadillo.models.AudioPlayable 14 | import javax.inject.Inject 15 | 16 | internal class ProgressiveMediaSourceGenerator @Inject constructor( 17 | private val cacheManager: CacheManager) : MediaSourceGenerator { 18 | 19 | override fun generateMediaSource(mediaId: String, context: Context, request: AudioPlayable.MediaRequest): MediaSource = 20 | ProgressiveMediaSource.Factory(buildDataSourceFactory(context)).createMediaSource(MediaItem.fromUri(request.url)).also { 21 | if (request.drmInfo != null) { 22 | Log.e(MediaSourceGenerator.TAG, "Progressive media does not currently support DRM") 23 | } 24 | } 25 | 26 | override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) = Unit // Doesn't use headers 27 | 28 | private fun buildDataSourceFactory(context: Context): DataSource.Factory { 29 | val httpDataSourceFactory = DefaultHttpDataSource.Factory() 30 | .setUserAgent(Constants.getUserAgent(context)) 31 | .setConnectTimeoutMs(DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS) 32 | .setReadTimeoutMs(DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS) 33 | .setAllowCrossProtocolRedirects(true) 34 | val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) 35 | return cacheManager.playbackDataSourceFactory(context, upstreamFactory) 36 | } 37 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.di 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import com.scribd.armadillo.ArmadilloStateStore 6 | import com.scribd.armadillo.Constants 7 | import com.scribd.armadillo.Reducer 8 | import com.scribd.armadillo.StateStore 9 | import com.scribd.armadillo.analytics.PlaybackActionTransmitter 10 | import com.scribd.armadillo.analytics.PlaybackActionTransmitterImpl 11 | import dagger.Module 12 | import dagger.Provides 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.GlobalScope 15 | import javax.inject.Named 16 | import javax.inject.Qualifier 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | internal class AppModule(private val context: Context) { 21 | @Singleton 22 | @Provides 23 | fun context(): Context = context.applicationContext 24 | 25 | @Singleton 26 | @Provides 27 | @Named(Constants.DI.GLOBAL_SCOPE) 28 | fun globalScope(): CoroutineScope = GlobalScope 29 | 30 | @Singleton 31 | @Provides 32 | fun reducer(): Reducer = Reducer 33 | 34 | @Singleton 35 | @PrivateModule 36 | @Provides 37 | fun stateStore(reducer: Reducer): ArmadilloStateStore = ArmadilloStateStore(reducer, Handler(context.mainLooper)) 38 | 39 | @Singleton 40 | @Provides 41 | fun stateStoreInitializer(@PrivateModule stateStore: ArmadilloStateStore): StateStore.Initializer = stateStore 42 | 43 | @Singleton 44 | @Provides 45 | fun stateStoreModifier(@PrivateModule stateStore: ArmadilloStateStore): StateStore.Modifier = stateStore 46 | 47 | @Singleton 48 | @Provides 49 | fun stateStoreProvider(@PrivateModule stateStore: ArmadilloStateStore): StateStore.Provider = stateStore 50 | 51 | @Singleton 52 | @Provides 53 | fun playbackActionTransmitter(@PrivateModule stateStore: ArmadilloStateStore): PlaybackActionTransmitter = 54 | PlaybackActionTransmitterImpl(stateStore) 55 | 56 | @Qualifier 57 | @Retention(AnnotationRetention.RUNTIME) 58 | private annotation class PrivateModule 59 | // https://stackoverflow.com/questions/43272652/dagger-2-avoid-exporting-private-dependencies 60 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/DaggerComponentRule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import com.scribd.armadillo.analytics.PlaybackActionTransmitterImpl 4 | import com.scribd.armadillo.di.Injector 5 | import com.scribd.armadillo.di.MainComponent 6 | import com.scribd.armadillo.download.DefaultExoplayerDownloadService 7 | import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener 8 | import com.scribd.armadillo.playback.ExoplayerPlaybackEngine 9 | import com.scribd.armadillo.playback.MediaSessionCallback 10 | import com.scribd.armadillo.playback.PlaybackService 11 | import com.scribd.armadillo.playback.PlayerEventListener 12 | import com.scribd.armadillo.playback.mediasource.MediaSourceRetrieverImpl 13 | import org.junit.rules.TestRule 14 | import org.junit.runner.Description 15 | import org.junit.runners.model.Statement 16 | 17 | class DaggerComponentRule : TestRule { 18 | override fun apply(base: Statement, description: Description?): Statement { 19 | return object : Statement() { 20 | @Throws(Throwable::class) 21 | override fun evaluate() { 22 | Injector.mainComponent = MockMainComponent() 23 | try { 24 | base.evaluate() 25 | } finally { 26 | // Nothing to do after the test 27 | } 28 | } 29 | } 30 | } 31 | 32 | private class MockMainComponent : MainComponent { 33 | override fun inject(playbackActionTransmitterImpl: PlaybackActionTransmitterImpl) = Unit 34 | override fun inject(mediaSourceRetrieverImpl: MediaSourceRetrieverImpl) = Unit 35 | override fun inject(widevineSessionEventListener: WidevineSessionEventListener) = Unit 36 | override fun inject(exoplayerPlaybackEngine: ExoplayerPlaybackEngine) = Unit 37 | override fun inject(mediaSessionCallback: MediaSessionCallback) = Unit 38 | override fun inject(armadilloPlayerChoreographer: ArmadilloPlayerChoreographer) = Unit 39 | override fun inject(playbackService: PlaybackService) = Unit 40 | override fun inject(armadilloPlayerFactory: ArmadilloPlayerFactory) = Unit 41 | override fun inject(defaultExoplayerDownloadService: DefaultExoplayerDownloadService) = Unit 42 | override fun inject(playerEventListener: PlayerEventListener) = Unit 43 | } 44 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/analytics/PlaybackActionListener.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.analytics 2 | 3 | import com.scribd.armadillo.Milliseconds 4 | import com.scribd.armadillo.error.ArmadilloException 5 | import com.scribd.armadillo.models.ArmadilloState 6 | 7 | /** 8 | * This listener provides callbacks to when actions are called on the audio player 9 | * It is useful for scenarios where the developer wants to track specific events ex. analytics 10 | * For UI and most other applications, the developer should rely on [ArmadilloPlayer] 11 | */ 12 | interface PlaybackActionListener { 13 | fun onPoll(state: ArmadilloState) = Unit 14 | 15 | fun onNewAudiobook(state: ArmadilloState) = Unit 16 | fun onLoadingStart(state: ArmadilloState) = Unit 17 | fun onLoadingEnd(state: ArmadilloState) = Unit 18 | fun onPlay(state: ArmadilloState) = Unit 19 | fun onPause(state: ArmadilloState) = Unit 20 | fun onStop(state: ArmadilloState) = Unit 21 | 22 | /** The player has reached the end of the content and stopped */ 23 | fun onContentEnded(state: ArmadilloState) = Unit 24 | 25 | /** When audio begins seeking to a non-contiguous section of audio (eg, seeking, rewinding, etc).*/ 26 | fun onDiscontinuity(state: ArmadilloState) = Unit 27 | 28 | /** Fast forward completed */ 29 | fun onFastForward(beforeState: ArmadilloState, afterState: ArmadilloState) = Unit 30 | 31 | /** Rewind completed */ 32 | fun onRewind(beforeState: ArmadilloState, afterState: ArmadilloState) = Unit 33 | 34 | /** Chapter change completed */ 35 | fun onSkipToNext(beforeState: ArmadilloState, afterState: ArmadilloState) = Unit 36 | 37 | /** Chapter change completed */ 38 | fun onSkipToPrevious(beforeState: ArmadilloState, afterState: ArmadilloState) = Unit 39 | 40 | /** Arbitrary Seek action completed that is not rewinding, changing chapters, or fast forwarding */ 41 | fun onSeek(seekTarget: Milliseconds, beforeState: ArmadilloState, afterState: ArmadilloState) = Unit 42 | 43 | fun onSpeedChange(state: ArmadilloState, oldSpeed: Float, newSpeed: Float) = Unit 44 | fun onSkipDistanceChange(state: ArmadilloState, oldDistance: Milliseconds, newDistance: Milliseconds) = Unit 45 | fun onCustomAction(action: String?, state: ArmadilloState) = Unit 46 | fun onError(armadilloException: ArmadilloException, state: ArmadilloState) = Unit 47 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/ContentAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.TextView 9 | import androidx.core.content.ContextCompat.startActivity 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.scribd.armadillo.ArmadilloPlayer 12 | import com.scribd.armadillo.models.AudioPlayable 13 | import com.scribd.armadillotestapp.R 14 | import javax.inject.Inject 15 | 16 | class ContentAdapter(application: AudioPlayerApplication, private val content: List) 17 | : RecyclerView.Adapter() { 18 | 19 | @Inject 20 | internal lateinit var armadilloPlayer: ArmadilloPlayer 21 | 22 | init { 23 | application.mainComponent.inject(this) 24 | } 25 | 26 | class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { 27 | val textView: TextView = view.findViewById(R.id.title) 28 | val downloadButton: View = view.findViewById(R.id.download_button) 29 | val removeDownloadButton: View = view.findViewById(R.id.remove_download_button) 30 | } 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | val view = LayoutInflater.from(parent.context).inflate(R.layout.playable_view_item, parent, false) 34 | return ViewHolder(view) 35 | } 36 | 37 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 38 | val playable = content[position] 39 | holder.textView.text = playable.title 40 | holder.view.setOnClickListener { 41 | launchAudioPlayer(it.context, playable) 42 | } 43 | 44 | holder.downloadButton.setOnClickListener { 45 | armadilloPlayer.beginDownload(playable) 46 | } 47 | 48 | holder.removeDownloadButton.setOnClickListener { 49 | armadilloPlayer.removeDownload(playable) 50 | } 51 | } 52 | 53 | override fun getItemCount() = content.size 54 | 55 | private fun launchAudioPlayer(context: Context, audiobook: AudioPlayable) { 56 | val intent = Intent(context, AudioPlayerActivity::class.java) 57 | intent.putExtra(MainActivity.AUDIOBOOK_EXTRA, audiobook) 58 | startActivity(context, intent, null) 59 | } 60 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/broadcast/ArmadilloNoisySpeakerReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.broadcast 2 | 3 | import android.app.Application 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.IntentFilter 8 | import android.media.AudioManager 9 | import android.os.Build 10 | import android.util.Log 11 | 12 | /** 13 | * Wraps broadcast receiver for "Becoming Noisy" - handles events when a headset is disconnected 14 | * while playing. Android's default behavior will route sound to the speaker. We instead prefer 15 | * to pause. 16 | * 17 | * This receiver needs to be started when playback plays and then stopped when playback pauses. 18 | * */ 19 | interface ArmadilloNoisySpeakerReceiver { 20 | fun registerForNoisyEvent(listener: Listener) 21 | fun unregisterForNoisyEvent() 22 | 23 | interface Listener { 24 | fun onBecomingNoisy() 25 | } 26 | } 27 | 28 | class ArmadilloNoisyReceiver(val application: Application) 29 | : BroadcastReceiver(), ArmadilloNoisySpeakerReceiver { 30 | lateinit var listener: ArmadilloNoisySpeakerReceiver.Listener 31 | 32 | companion object { 33 | const val TAG = "NoisyBroadcastReceiver" 34 | val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) 35 | var isRegistered = false 36 | } 37 | 38 | override fun onReceive(context: Context?, intent: Intent?) { 39 | if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { 40 | Log.v(TAG, "becoming noisy") 41 | listener.onBecomingNoisy() 42 | } 43 | } 44 | 45 | override fun registerForNoisyEvent(listener: ArmadilloNoisySpeakerReceiver.Listener) { 46 | if(!isRegistered) { 47 | this.listener = listener 48 | Log.v(TAG, "registered for listening noisy") 49 | 50 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 51 | application.registerReceiver(this, intentFilter, Context.RECEIVER_EXPORTED) 52 | } else { 53 | application.registerReceiver(this, intentFilter) 54 | } 55 | isRegistered = true 56 | } 57 | } 58 | 59 | override fun unregisterForNoisyEvent() { 60 | if(isRegistered) { 61 | Log.v(TAG, "unregistered for listening noisy") 62 | application.unregisterReceiver(this) 63 | isRegistered = false 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/AudioDownloadManager.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation 2 | 3 | import android.app.Notification 4 | import android.content.Context 5 | import androidx.core.app.NotificationCompat 6 | import com.scribd.armadillo.download.DownloadNotificationFactory 7 | import com.scribd.armadillo.models.DownloadProgressInfo 8 | import com.scribd.armadillo.models.DownloadState 9 | import com.scribd.armadillotestapp.R 10 | 11 | 12 | class AudioDownloadManager : DownloadNotificationFactory { 13 | companion object { 14 | const val CHANNEL_ID = "test_app_downloads" 15 | const val CHANNEL_NAME_RES = R.string.downloads_channel 16 | } 17 | 18 | override fun getForegroundNotification(context: Context, downloadStates: Array): Notification { 19 | var totalPercentage = 0 20 | var downloadTaskCount = 0 21 | var allDownloadPercentagesUnknown = true 22 | var hasDownloadedAnyBytes = false 23 | 24 | downloadStates.filter { it.downloadState is DownloadState.STARTED }.forEach { downloadInfo -> 25 | val downloadState: DownloadState.STARTED = downloadInfo.downloadState as DownloadState.STARTED 26 | if (downloadState.percentComplete != DownloadProgressInfo.PROGRESS_UNSET) { 27 | allDownloadPercentagesUnknown = false 28 | totalPercentage += downloadState.percentComplete 29 | } 30 | hasDownloadedAnyBytes = hasDownloadedAnyBytes or (downloadState.downloadedBytes > 0) 31 | downloadTaskCount++ 32 | } 33 | 34 | val message = context.getString(R.string.download_message, downloadTaskCount) 35 | val haveDownloadTasks = downloadTaskCount > 0 36 | 37 | val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_favicon_round) 38 | 39 | val progress = if (haveDownloadTasks) (totalPercentage / downloadTaskCount) else 0 40 | val indeterminate = !haveDownloadTasks || allDownloadPercentagesUnknown && hasDownloadedAnyBytes 41 | return notificationBuilder.setProgress(100, progress, indeterminate) 42 | .setOngoing(true) 43 | .setShowWhen(false) 44 | .setOnlyAlertOnce(true) 45 | .setContentTitle(context.getString(R.string.download_title)) 46 | .setStyle(NotificationCompat.BigTextStyle().bigText(message)) 47 | .build() 48 | 49 | } 50 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/MediaSourceRetriever.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.C 5 | import com.google.android.exoplayer2.source.MediaSource 6 | import com.google.android.exoplayer2.util.Util 7 | import com.scribd.armadillo.di.Injector 8 | import com.scribd.armadillo.extensions.toUri 9 | import com.scribd.armadillo.models.AudioPlayable 10 | import javax.inject.Inject 11 | 12 | /** Creates a MediaSource for starting playback in Exoplayer based on what type 13 | * of audio content is passed into it. */ 14 | interface MediaSourceRetriever { 15 | fun generateMediaSource(mediaId: String, request: AudioPlayable.MediaRequest, context: Context): MediaSource 16 | 17 | fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) 18 | } 19 | 20 | class MediaSourceRetrieverImpl @Inject constructor(): MediaSourceRetriever { 21 | @Inject 22 | internal lateinit var hlsGenerator: HlsMediaSourceGenerator 23 | 24 | @Inject 25 | internal lateinit var dashGenerator: DashMediaSourceGenerator 26 | 27 | @Inject 28 | internal lateinit var progressiveMediaSourceGenerator: ProgressiveMediaSourceGenerator 29 | 30 | init { 31 | Injector.mainComponent.inject(this) 32 | } 33 | 34 | override fun generateMediaSource(mediaId: String, 35 | request: AudioPlayable.MediaRequest, 36 | context: Context): MediaSource { 37 | 38 | return buildMediaGenerator(request).generateMediaSource(mediaId = mediaId, context = context, request = request) 39 | } 40 | 41 | override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) { 42 | buildMediaGenerator(request).updateMediaSourceHeaders(request) 43 | } 44 | 45 | private fun buildMediaGenerator(request: AudioPlayable.MediaRequest): MediaSourceGenerator = buildMediaGenerator(request, null) 46 | 47 | private fun buildMediaGenerator(request: AudioPlayable.MediaRequest, overrideExtension: String?): MediaSourceGenerator { 48 | val uri = request.url.toUri() 49 | 50 | return when (@C.ContentType val type = Util.inferContentType(uri, overrideExtension)) { 51 | C.TYPE_HLS -> hlsGenerator 52 | C.TYPE_DASH -> dashGenerator 53 | C.TYPE_OTHER -> progressiveMediaSourceGenerator 54 | else -> throw IllegalStateException("Unsupported type: $type") 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/ArmadilloPlayerFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import com.scribd.armadillo.di.Injector 6 | import com.scribd.armadillo.download.DownloadNotificationFactory 7 | import com.scribd.armadillo.download.DownloadNotificationHolder 8 | import com.scribd.armadillo.download.DownloadTracker 9 | import com.scribd.armadillo.models.ArmadilloState 10 | import com.scribd.armadillo.playback.PlaybackNotificationBuilder 11 | import com.scribd.armadillo.playback.PlaybackNotificationBuilderHolder 12 | import javax.inject.Inject 13 | 14 | /** 15 | * Client will use to create an [ArmadilloPlayer] instance 16 | */ 17 | object ArmadilloPlayerFactory { 18 | @set:Inject 19 | internal lateinit var stateInitializer: StateStore.Initializer 20 | 21 | @set:Inject 22 | internal lateinit var downloadTracker: DownloadTracker 23 | 24 | /** 25 | * This should be initialized before [init]. It can be done when the app is first launched. 26 | * 27 | * Currently, the client is expected to track & stores all info related to downloads. Armadillo stores references to which tracks are 28 | * downloaded so that multi-track content can be played offline, but does not expose any methods for tracking what content is downloaded 29 | */ 30 | fun initDownloadTracker(context: Context) { 31 | Injector.buildDependencyGraph(context) 32 | Injector.mainComponent.inject(this) 33 | stateInitializer.init(ArmadilloState(downloadInfo = emptyList())) 34 | downloadTracker.init() 35 | } 36 | 37 | /** 38 | * Initialize the armadillo engine. 39 | * 40 | * @param downloadNotificationFactory This should be a simple factory class, as it will be held by the library to use to create 41 | * download notifications 42 | * @param playbackNotificationBuilder This should be a simple factory class, as it will be held by the library to use to create 43 | * playback notifications 44 | */ 45 | @SuppressLint("VisibleForTests") 46 | @JvmOverloads 47 | fun init(downloadNotificationFactory: DownloadNotificationFactory? = null, 48 | playbackNotificationBuilder: PlaybackNotificationBuilder? = PlaybackNotificationBuilderHolder.builder): ArmadilloPlayer { 49 | DownloadNotificationHolder.downloadNotificationFactory = downloadNotificationFactory 50 | PlaybackNotificationBuilderHolder.builder = playbackNotificationBuilder 51 | return ArmadilloPlayerChoreographer() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.annotation.TargetApi 4 | import android.app.Notification 5 | import android.app.NotificationChannel 6 | import android.app.NotificationManager 7 | import android.content.Context 8 | import android.os.Build 9 | import android.support.v4.media.session.MediaSessionCompat 10 | import com.scribd.armadillo.R 11 | import com.scribd.armadillo.models.AudioPlayable 12 | 13 | /** 14 | * This class: 15 | * - builds a notification for a given [MediaBrowserServiceCompat] 16 | * - creates a notification channel for Oreo and greater devices 17 | */ 18 | internal class PlaybackNotificationManager(private val context: Context, 19 | private val playbackNotificationBuilder: PlaybackNotificationBuilder) { 20 | 21 | companion object { 22 | private val TAG = "PlaybackNotificationMgr" 23 | } 24 | 25 | var notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 26 | 27 | private val hasOreo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O 28 | 29 | init { 30 | // Cancel all notifications to handle the case where the Service was killed and 31 | // restarted by the system. 32 | notificationManager.cancelAll() 33 | } 34 | 35 | fun getNotification(audioPlayable: AudioPlayable, 36 | currentChapterIndex: Int, 37 | isPlaying: Boolean, 38 | token: MediaSessionCompat.Token): Notification { 39 | maybeCreateChannel() 40 | return playbackNotificationBuilder.build(audioPlayable, currentChapterIndex, isPlaying, token) 41 | } 42 | 43 | @TargetApi(Build.VERSION_CODES.O) 44 | private fun maybeCreateChannel() { 45 | if (!hasOreo) { 46 | return 47 | } 48 | if (notificationManager.getNotificationChannel(playbackNotificationBuilder.channelId) == null) { 49 | val channel = NotificationChannel(playbackNotificationBuilder.channelId, 50 | context.getString(R.string.arm_playback_notification_channel), 51 | NotificationManager.IMPORTANCE_LOW) 52 | channel.enableLights(false) 53 | channel.enableVibration(false) 54 | channel.setShowBadge(false) 55 | channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC 56 | notificationManager.createNotificationChannel(channel) 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DefaultExoplayerDownloadService.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.app.Notification 4 | import com.google.android.exoplayer2.offline.Download 5 | import com.google.android.exoplayer2.offline.DownloadManager 6 | import com.google.android.exoplayer2.offline.DownloadService 7 | import com.google.android.exoplayer2.scheduler.PlatformScheduler 8 | import com.google.android.exoplayer2.scheduler.Scheduler 9 | import com.google.android.exoplayer2.util.Util 10 | import com.scribd.armadillo.R 11 | import com.scribd.armadillo.di.Injector 12 | import com.scribd.armadillo.models.DownloadState 13 | import javax.inject.Inject 14 | 15 | /** 16 | * This is a basic [DownloadService] that provides a simple default notification. It will store downloads in external storage. If you 17 | * need more control over the downloads or notifications, you should provide your own implementation class to the download configuration. 18 | */ 19 | internal class DefaultExoplayerDownloadService : DownloadService(FOREGROUND_NOTIFICATION_ID, 20 | DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, CHANNEL_ID, CHANNEL_NAME, 0) { 21 | 22 | init { 23 | Injector.mainComponent.inject(this) 24 | } 25 | 26 | companion object { 27 | internal const val CHANNEL_ID = "download_channel" 28 | private const val FOREGROUND_NOTIFICATION_ID = 1 29 | private const val JOB_ID = 1 30 | 31 | private val CHANNEL_NAME = R.string.arm_default_download_channel 32 | } 33 | 34 | @Inject 35 | internal lateinit var downloadManager: DownloadManager 36 | 37 | override fun getDownloadManager(): DownloadManager = downloadManager 38 | 39 | override fun getScheduler(): Scheduler? = if (Util.SDK_INT >= 21) PlatformScheduler(this, JOB_ID) else null 40 | 41 | override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification { 42 | return getNotificationFactory().getForegroundNotification(this, downloads.mapNotNull { TestableDownloadState(it).toDownloadInfo() }.filter { 43 | it.downloadState != DownloadState.REMOVED 44 | }.toTypedArray()) 45 | } 46 | 47 | // TODO: [AND-10580] expecting data to stay here is unreliable. Need a default in case the object does not exist 48 | private fun getNotificationFactory(): DownloadNotificationFactory = DownloadNotificationHolder.downloadNotificationFactory 49 | ?: DefaultDownloadNotificationFactory() 50 | 51 | //TODO: Notification for finished / cancelled. Override onTaskStateChanged 52 | } 53 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/HeadersMediaSourceFactoryFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.upstream.DataSource 5 | import com.google.android.exoplayer2.upstream.DefaultDataSource 6 | import com.google.android.exoplayer2.upstream.DefaultHttpDataSource 7 | import com.scribd.armadillo.Constants 8 | import com.scribd.armadillo.HeadersStore 9 | import com.scribd.armadillo.download.CacheManager 10 | import com.scribd.armadillo.models.AudioPlayable 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | 14 | @Singleton 15 | internal class HeadersMediaSourceFactoryFactoryImpl @Inject constructor( 16 | private val cacheManager: CacheManager, 17 | private val headersStore: HeadersStore 18 | ): HeadersMediaSourceFactoryFactory { 19 | private val previousRequests = mutableMapOf() 20 | 21 | override fun createDataSourceFactory(context: Context, request: AudioPlayable.MediaRequest): DataSource.Factory { 22 | val httpDataSourceFactory = DefaultHttpDataSource.Factory() 23 | .setUserAgent(Constants.getUserAgent(context)) 24 | .setConnectTimeoutMs(DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS) 25 | .setReadTimeoutMs(DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS) 26 | .setAllowCrossProtocolRedirects(true) 27 | 28 | previousRequests[request.url] = httpDataSourceFactory 29 | if (request.headers.isNotEmpty()) { 30 | headersStore.keyForUrl(request.url)?.let { 31 | headersStore.setHeaders(it, request.headers) 32 | } 33 | httpDataSourceFactory.setDefaultRequestProperties(request.headers) 34 | } 35 | 36 | val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) 37 | return cacheManager.playbackDataSourceFactory(context, upstreamFactory) 38 | 39 | } 40 | 41 | override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) { 42 | previousRequests[request.url]?.let { factory -> 43 | if (request.headers.isNotEmpty()) { 44 | headersStore.keyForUrl(request.url)?.let { 45 | headersStore.setHeaders(it, request.headers) 46 | } 47 | // Updating the factory instance updates future requests generated from this factory by ExoPlayer 48 | factory.setDefaultRequestProperties(request.headers) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/HeaderAwareDownloaderFactory.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.offline.DefaultDownloaderFactory 5 | import com.google.android.exoplayer2.offline.DownloadRequest 6 | import com.google.android.exoplayer2.offline.Downloader 7 | import com.google.android.exoplayer2.upstream.DefaultDataSource 8 | import com.google.android.exoplayer2.upstream.DefaultHttpDataSource 9 | import com.scribd.armadillo.Constants 10 | import com.scribd.armadillo.HeadersStore 11 | import java.util.concurrent.ExecutorService 12 | import java.util.concurrent.Executors 13 | import javax.inject.Inject 14 | 15 | /** 16 | * Downloader factory that applies headers to all requests for a download. Due to https://github.com/google/ExoPlayer/issues/6166, this 17 | * needs to recreate the factory for each download 18 | * 19 | * This factory assumes that an audiobook requiring headers will have a URI segment with an integer ID. 20 | * 21 | * // TODO [29]: Remove this once exoplayer is fixed 22 | */ 23 | class HeaderAwareDownloaderFactory @Inject constructor(private val context: Context, 24 | private val headersStore: HeadersStore, 25 | private val cacheManager: CacheManager, 26 | private val httpDataSourceFactory: DefaultHttpDataSource.Factory) : 27 | DefaultDownloaderFactory( 28 | // Create a minimal factory. We'll create a new one on the fly later to actually create the downloader 29 | cacheManager.downloadDataSourceFactory(context, httpDataSourceFactory), 30 | threadPool 31 | ) { 32 | 33 | private companion object { 34 | val threadPool: ExecutorService = Executors.newFixedThreadPool(Constants.MAX_PARALLEL_DOWNLOADS) 35 | } 36 | 37 | override fun createDownloader(request: DownloadRequest): Downloader { 38 | headersStore.keyForUrl(request.uri.toString())?.let { key -> 39 | headersStore.headersForKey(key)?.forEach { 40 | httpDataSourceFactory.setDefaultRequestProperties(mapOf(it.key to it.value)) 41 | } 42 | } 43 | 44 | val defaultDataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) 45 | 46 | val defaultDownloaderFactory = DefaultDownloaderFactory( 47 | cacheManager.downloadDataSourceFactory(context, defaultDataSourceFactory), 48 | threadPool 49 | ) 50 | return defaultDownloaderFactory.createDownloader(request) 51 | } 52 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/broadcast/NotificationDeleteReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.broadcast 2 | 3 | import android.app.Application 4 | import android.app.Notification 5 | import android.app.PendingIntent 6 | import android.content.BroadcastReceiver 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.IntentFilter 10 | import android.os.Build 11 | import com.scribd.armadillo.hasSnowCone 12 | 13 | /** 14 | * Wraps a Broadcast Receiver to allow a Listener to know when a given Notification has been deleted. 15 | */ 16 | internal interface NotificationDeleteReceiver { 17 | fun register(listener: Listener) 18 | fun unregister() 19 | fun setDeleteIntentOnNotification(notification: Notification) 20 | 21 | interface Listener { 22 | fun onNotificationDeleted() 23 | } 24 | } 25 | 26 | internal class ArmadilloNotificationDeleteReceiver(val application: Application) : NotificationDeleteReceiver, BroadcastReceiver() { 27 | 28 | private var deleteListener: NotificationDeleteReceiver.Listener? = null 29 | private var isRegistered = false 30 | 31 | companion object { 32 | const val ACTION = "com.scribd.armadillo.ACTION_NOTIFICATION_DELETED" 33 | } 34 | 35 | override fun register(listener: NotificationDeleteReceiver.Listener) { 36 | if (!isRegistered) { 37 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 38 | application.registerReceiver(this, IntentFilter(ACTION), Context.RECEIVER_NOT_EXPORTED) 39 | } else { 40 | application.registerReceiver(this, IntentFilter(ACTION)) 41 | } 42 | deleteListener = listener 43 | isRegistered = true 44 | } 45 | } 46 | 47 | override fun unregister() { 48 | if (isRegistered) { 49 | application.unregisterReceiver(this) 50 | deleteListener = null 51 | isRegistered = false 52 | } 53 | } 54 | 55 | /** 56 | * Sets a delete Intent on a Notification that will trigger this Broadcast Receiver if the Notification is deleted. 57 | */ 58 | override fun setDeleteIntentOnNotification(notification: Notification) { 59 | val intent = Intent(ArmadilloNotificationDeleteReceiver.ACTION) 60 | intent.`package` = application.packageName 61 | val intentFlag = if (hasSnowCone()) PendingIntent.FLAG_MUTABLE else 0 62 | val pendingIntent = PendingIntent.getBroadcast(application, 0, intent, intentFlag) 63 | notification.deleteIntent = pendingIntent 64 | } 65 | 66 | override fun onReceive(context: Context?, intent: Intent?) { 67 | deleteListener?.onNotificationDeleted() 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /TestApp/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.armadilloVersion = "0.21.5" 3 | } 4 | 5 | apply plugin: 'com.android.application' 6 | apply plugin: 'kotlin-android' 7 | apply plugin: 'kotlin-kapt' 8 | 9 | java { 10 | toolchain { 11 | languageVersion.set(JavaLanguageVersion.of(17)) 12 | } 13 | } 14 | 15 | android { 16 | compileSdk 34 17 | 18 | defaultConfig { 19 | applicationId "com.scribd.armadillotestapp" 20 | minSdkVersion 23 21 | targetSdkVersion 34 22 | versionCode 1 23 | versionName "1.0" 24 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 25 | } 26 | 27 | signingConfigs { 28 | Release { 29 | keyAlias 'testapp' 30 | keyPassword 'scribd' 31 | storeFile file('.keystore') 32 | storePassword 'scribd' 33 | } 34 | } 35 | 36 | buildTypes { 37 | release { 38 | minifyEnabled true 39 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 40 | signingConfig signingConfigs.Release 41 | } 42 | } 43 | 44 | productFlavors { 45 | flavorDimensions "ARMADILLO_SOURCE" 46 | 47 | armlocal { 48 | dimension "ARMADILLO_SOURCE" 49 | } 50 | armrepo { 51 | dimension "ARMADILLO_SOURCE" 52 | } 53 | } 54 | namespace 'com.scribd.armadillotestapp' 55 | } 56 | 57 | dependencies { 58 | implementation files('../libs/exoplayer-core-release.aar') 59 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 60 | implementation 'androidx.appcompat:appcompat:1.2.0' 61 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 62 | implementation 'androidx.media:media:1.2.1' 63 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 64 | implementation 'com.google.code.gson:gson:2.8.6' 65 | implementation "com.google.dagger:dagger:${DAGGER_VERSION}" 66 | kapt "com.google.dagger:dagger-compiler:${DAGGER_VERSION}" 67 | implementation 'com.squareup.retrofit2:retrofit:2.4.0' 68 | implementation 'com.squareup.retrofit2:converter-moshi:2.4.0' 69 | implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0' 70 | implementation "io.reactivex.rxjava2:rxjava:${RXJAVA_VERSION}" 71 | implementation "io.reactivex.rxjava2:rxandroid:${RXANDROID_VERSION}" 72 | testImplementation 'junit:junit:4.12' 73 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 74 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 75 | debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' 76 | 77 | armlocalImplementation project(':Armadillo') 78 | } 79 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/ManifestGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import com.scribd.armadillo.time.Interval 6 | import com.scribd.armadillo.time.Second 7 | import java.io.File 8 | 9 | interface ManifestGenerator { 10 | fun manifestUriFor(id: Int): Uri 11 | fun createManifest(audiobook: ManifestAudiobook) 12 | } 13 | 14 | data class ManifestAudiobook(val id: Int, 15 | val duration: Interval, 16 | val chapters: List) 17 | 18 | data class ManifestChapter(val duration: Interval, val uri: String) 19 | 20 | class HlsManifestGenerator(private val context: Context) : ManifestGenerator { 21 | private companion object { 22 | const val MASTER_FILE_NAME = "master.m3u" 23 | const val PLAYLIST_FILE_NAME = "masterplaylist.m3u" 24 | } 25 | 26 | override fun manifestUriFor(id: Int): Uri = Uri.fromFile(File(context.filesDir, masterExtFor(id))) 27 | 28 | override fun createManifest(audiobook: ManifestAudiobook) { 29 | val masterFile = buildMasterFile(audiobook.id) 30 | context.openFileOutput(masterExtFor(audiobook.id), Context.MODE_PRIVATE).use { 31 | it.write(masterFile.toByteArray()) 32 | } 33 | 34 | val playlistFile = buildPlaylistFile(audiobook) 35 | context.openFileOutput(playlistExtFor(audiobook.id), Context.MODE_PRIVATE).use { 36 | it.write(playlistFile.toByteArray()) 37 | } 38 | } 39 | 40 | private fun masterExtFor(id: Int) = id.toString() + MASTER_FILE_NAME 41 | 42 | private fun playlistExtFor(id: Int) = id.toString() + PLAYLIST_FILE_NAME 43 | 44 | private fun playlistUriFor(id: Int) = Uri.fromFile(File(context.filesDir, playlistExtFor(id))) 45 | 46 | private fun buildPlaylistFile(audiobook: ManifestAudiobook): String { 47 | var playlistStr = 48 | """ 49 | #EXTM3U 50 | #EXT-X-VERSION:7 51 | #EXT-X-TARGETDURATION:${audiobook.duration.value} 52 | """.trimIndent() + "\n" 53 | 54 | audiobook.chapters.forEach { 55 | playlistStr += """ 56 | #EXTINF:${it.duration.value}, 57 | ${it.uri} 58 | """.trimIndent() + "\n" 59 | } 60 | 61 | playlistStr += """ 62 | #EXT-X-ENDLIST 63 | """.trimIndent() 64 | 65 | return playlistStr 66 | } 67 | 68 | private fun buildMasterFile(id: Int): String = 69 | """ 70 | #EXTM3U 71 | #EXT-X-VERSION:7 72 | 73 | #EXT-X-STREAM-INF:BANDWIDTH=235000 74 | ${playlistUriFor(id)} 75 | """.trimIndent() 76 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/DownloadManagerExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import com.google.android.exoplayer2.offline.DownloadManager 4 | import com.scribd.armadillo.ExoplayerDownload 5 | import com.scribd.armadillo.extensions.decodeToInt 6 | import com.scribd.armadillo.models.DownloadProgressInfo 7 | import com.scribd.armadillo.models.DownloadState 8 | 9 | /** 10 | * [ExoplayerDownload] is not easy to test. It's a final class with private constructor. This class is a valuable intermediate 11 | * for being able to use this class in testing. 12 | */ 13 | internal data class TestableDownloadState(val id: Int, 14 | val url: String, 15 | val state: Int, 16 | val downloadPercentage: Int, 17 | val downloadedBytes: Long, 18 | val failureReason: Int? = null) { 19 | companion object { 20 | const val QUEUED = ExoplayerDownload.STATE_QUEUED 21 | const val COMPLETED = ExoplayerDownload.STATE_COMPLETED 22 | const val IN_PROGRESS = ExoplayerDownload.STATE_DOWNLOADING 23 | const val REMOVING = ExoplayerDownload.STATE_REMOVING 24 | const val FAILED = ExoplayerDownload.STATE_FAILED 25 | } 26 | 27 | constructor(download: ExoplayerDownload) : this( 28 | download.request.data.decodeToInt(), 29 | download.request.uri.toString(), 30 | download.state, 31 | download.percentDownloaded.toInt(), 32 | download.bytesDownloaded, 33 | download.failureReason) 34 | 35 | /** 36 | * This method converts [TestableDownloadState] (a testable wrapper fo exoplayer's [DownloadManager.TaskState]) 37 | * to armadillo's [DownloadProgressInfo]. 38 | * This method returns null when there is no need to report an intermediate progress state. 39 | */ 40 | fun toDownloadInfo(): DownloadProgressInfo? { 41 | val downloadState = when (state) { 42 | REMOVING -> DownloadState.REMOVED 43 | COMPLETED -> DownloadState.COMPLETED 44 | IN_PROGRESS -> { 45 | val percent = if (DownloadProgressInfo.PROGRESS_UNSET == downloadPercentage) { 46 | 0 47 | } else { 48 | downloadPercentage 49 | } 50 | DownloadState.STARTED(percent, downloadedBytes) 51 | } 52 | 53 | QUEUED -> return null 54 | else -> DownloadState.FAILED(failureReason) 55 | } 56 | 57 | return DownloadProgressInfo( 58 | id = id, 59 | url = url, 60 | downloadState = downloadState, 61 | exoPlayerDownloadState = state) 62 | } 63 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/models/ModelsTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.models 2 | 3 | import com.scribd.armadillo.MockModels 4 | import com.scribd.armadillo.time.milliseconds 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.Rule 7 | import org.junit.Test 8 | import org.junit.rules.ExpectedException 9 | 10 | class ModelsTest { 11 | 12 | @Rule 13 | @JvmField 14 | val exception: ExpectedException = ExpectedException.none() 15 | 16 | @Test 17 | fun findChapterAtOffset_bookHasJustBegun_returnsChapter() { 18 | val audiobook = MockModels.audiobook() 19 | 20 | val currentListeningOffset = audiobook.chapters.first().startTime 21 | 22 | val chapter = audiobook.getChapterAtOffset(currentListeningOffset) 23 | 24 | assertThat(audiobook.chapters[0]).isEqualTo(chapter) 25 | } 26 | 27 | @Test 28 | fun findChapterAtOffset_chapterHasJustBegun_returnsChapter() { 29 | val audiobook = MockModels.audiobook() 30 | 31 | val currentListeningOffset = 405582.milliseconds 32 | 33 | val chapter = audiobook.getChapterAtOffset(currentListeningOffset) 34 | 35 | assertThat(audiobook.chapters[1]).isEqualTo(chapter) 36 | } 37 | 38 | @Test 39 | fun findChapterAtOffset_chapterIsInMiddle_returnsChapter() { 40 | val audiobook = MockModels.audiobook() 41 | 42 | val currentListeningOffset = 2103104.milliseconds 43 | 44 | val chapter = audiobook.getChapterAtOffset(currentListeningOffset) 45 | 46 | assertThat(audiobook.chapters[3]).isEqualTo(chapter) 47 | } 48 | 49 | @Test 50 | fun findChapterAtOffset_atEndOfAudiobook_returnsLastChapter() { 51 | val audiobook = MockModels.audiobook() 52 | 53 | val lastChapter = audiobook.chapters.last() 54 | 55 | val listeningOffset = lastChapter.startTime + lastChapter.duration 56 | 57 | val chapter = audiobook.getChapterAtOffset(listeningOffset) 58 | 59 | assertThat(lastChapter).isEqualTo(chapter) 60 | } 61 | 62 | @Test 63 | fun findChapterAtOffset_intervalGreaterThenPlaylist_shouldReturnLastChapter() { 64 | val audiobook = MockModels.audiobook() 65 | val lastChapter = audiobook.chapters.last() 66 | 67 | val currentListeningOffset = 68 | lastChapter.startTime + lastChapter.duration + 1.milliseconds 69 | 70 | val chapter = audiobook.getChapterAtOffset(currentListeningOffset) 71 | assertThat(lastChapter).isEqualTo(chapter) 72 | } 73 | 74 | @Test 75 | fun findChapterAtOffset_intervalLessThenPlaylist_shouldGetFirst() { 76 | val audiobook = MockModels.audiobook() 77 | 78 | val currentListeningOffset = (-1).milliseconds 79 | val chapter = audiobook.getChapterAtOffset(currentListeningOffset) 80 | assertThat(audiobook.chapters[0]).isEqualTo(chapter) 81 | } 82 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/extensions/DataAudiobookExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.extensions 2 | 3 | import android.net.Uri 4 | import com.scribd.armadillo.models.AudioPlayable 5 | import com.scribd.armadillo.models.Chapter 6 | import com.scribd.armadillo.time.Interval 7 | import com.scribd.armadillo.time.Millisecond 8 | import com.scribd.armadillo.time.Second 9 | import com.scribd.armadillo.time.milliseconds 10 | 11 | data class DataAudiobook(val chapters: List, val duration: Interval) 12 | 13 | data class DataChapter( 14 | val title: String, 15 | val part: Int, 16 | val chapter: Int, 17 | val duration: Interval) 18 | 19 | fun DataAudiobook.toArmadilloAudiobook(id: Int, title: String, uri: Uri): AudioPlayable = 20 | AudioPlayable( 21 | id = id, 22 | title = title, 23 | request = AudioPlayable.MediaRequest.createHttpUri(uri.toString()), 24 | chapters = this.chapters.toArmadilloChapters()) 25 | 26 | private fun List.toArmadilloChapters(): List { 27 | val chapters = mutableListOf() 28 | 29 | this.forEachIndexed { i, displayChapter -> 30 | val chapter = when (i) { 31 | 0 -> { 32 | Chapter( 33 | title = displayChapter.title, 34 | part = displayChapter.part, 35 | chapter = displayChapter.chapter, 36 | startTime = 0.milliseconds, 37 | duration = displayChapter.duration) 38 | } 39 | this.size - 1 -> { 40 | val previousChapter = chapters[i - 1] 41 | val startTime = previousChapter.endTime 42 | 43 | // Math.floor used because chapter runtime must be slightly less then audioPlayable runtime in order to detect end of book 44 | val endTime = Math.floor(previousChapter.endTime.value + displayChapter.duration.value).milliseconds 45 | Chapter( 46 | title = displayChapter.title, 47 | part = displayChapter.part, 48 | chapter = displayChapter.chapter, 49 | startTime = startTime, 50 | duration = endTime - startTime) 51 | } 52 | else -> { 53 | val previousChapter = chapters[i - 1] 54 | Chapter( 55 | title = displayChapter.title, 56 | part = displayChapter.part, 57 | chapter = displayChapter.chapter, 58 | startTime = Math.round(previousChapter.startTime.value + previousChapter.duration.value).milliseconds, 59 | duration = Math.round(displayChapter.duration.value).milliseconds) 60 | } 61 | } 62 | chapters.add(chapter) 63 | } 64 | 65 | return chapters 66 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/download/ExoplayerDownloadTrackerTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import com.google.android.exoplayer2.offline.DownloadIndex 4 | import com.google.android.exoplayer2.offline.DownloadManager 5 | import com.scribd.armadillo.StateStore 6 | import com.scribd.armadillo.actions.ErrorAction 7 | import com.scribd.armadillo.actions.StopTrackingDownloadAction 8 | import com.scribd.armadillo.actions.UpdateDownloadAction 9 | import com.scribd.armadillo.models.DownloadProgressInfo 10 | import com.scribd.armadillo.models.DownloadState 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | import org.mockito.kotlin.any 15 | import org.mockito.kotlin.isA 16 | import org.mockito.kotlin.mock 17 | import org.mockito.kotlin.times 18 | import org.mockito.kotlin.verify 19 | import org.mockito.kotlin.verifyNoMoreInteractions 20 | import org.mockito.kotlin.whenever 21 | import org.robolectric.RobolectricTestRunner 22 | import org.robolectric.annotation.Config 23 | 24 | @Config(manifest = Config.NONE) 25 | @RunWith(RobolectricTestRunner::class) 26 | class ExoplayerDownloadTrackerTest { 27 | private companion object { 28 | const val ID = 1 29 | const val URL = "cool url" 30 | } 31 | 32 | private lateinit var exoplayerDownloadTracker: ExoplayerDownloadTracker 33 | private lateinit var downloadManager: DownloadManager 34 | private lateinit var stateModifier: StateStore.Modifier 35 | private lateinit var downloadInfo: DownloadProgressInfo 36 | 37 | @Before 38 | fun setUp() { 39 | stateModifier = mock() 40 | downloadManager = mock() 41 | val downloadIndex: DownloadIndex = mock() 42 | whenever(downloadManager.downloadIndex).thenReturn(downloadIndex) 43 | exoplayerDownloadTracker = ExoplayerDownloadTracker(mock(), downloadManager, stateModifier) 44 | } 45 | 46 | @Test 47 | fun dispatchActionsForProgress_taskFailed_dispatchesActions() { 48 | downloadInfo = DownloadProgressInfo(ID, URL, DownloadState.FAILED()) 49 | exoplayerDownloadTracker.dispatchActionsForProgress(downloadInfo) 50 | verify(stateModifier).dispatch(UpdateDownloadAction(downloadInfo)) 51 | verify(stateModifier).dispatch(isA()) 52 | verify(stateModifier).dispatch(StopTrackingDownloadAction(downloadInfo)) 53 | verify(stateModifier, times(3)).dispatch(any()) 54 | verifyNoMoreInteractions(stateModifier) 55 | } 56 | 57 | @Test 58 | fun dispatchActionsForProgress_isComplete_dispatchesActions() { 59 | downloadInfo = DownloadProgressInfo(ID, URL, DownloadState.COMPLETED) 60 | exoplayerDownloadTracker.dispatchActionsForProgress(downloadInfo) 61 | verify(stateModifier).dispatch(UpdateDownloadAction(downloadInfo)) 62 | verify(stateModifier).dispatch(StopTrackingDownloadAction(downloadInfo)) 63 | verifyNoMoreInteractions(stateModifier) 64 | } 65 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/MockModels.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import com.scribd.armadillo.actions.PlaybackProgressAction 4 | import com.scribd.armadillo.actions.UpdateDownloadAction 5 | import com.scribd.armadillo.models.ArmadilloState 6 | import com.scribd.armadillo.models.AudioPlayable 7 | import com.scribd.armadillo.models.Chapter 8 | import com.scribd.armadillo.models.DownloadProgressInfo 9 | import com.scribd.armadillo.models.DownloadState 10 | import com.scribd.armadillo.models.PlaybackInfo 11 | import com.scribd.armadillo.models.PlaybackProgress 12 | import com.scribd.armadillo.models.PlaybackState 13 | import com.scribd.armadillo.models.MediaControlState 14 | import com.scribd.armadillo.time.milliseconds 15 | 16 | internal class MockModels { 17 | companion object { 18 | private const val CURRENT_AUDIOBOOK_URL = "http://www.awesomeaudiobooks.com/books/12345" 19 | fun appState(): ArmadilloState = ArmadilloState( 20 | playbackInfo = PlaybackInfo( 21 | audioPlayable = audiobook(), 22 | playbackState = PlaybackState.NONE, 23 | playbackSpeed = 1.0f, 24 | controlState = MediaControlState(), 25 | isLoading = true, 26 | skipDistance = Constants.AUDIO_SKIP_DURATION, 27 | progress = PlaybackProgress(totalChaptersDuration = audiobook().duration)), 28 | downloadInfo = emptyList()) 29 | 30 | fun audiobook(): AudioPlayable = AudioPlayable(id = 123, title = "Into the Wild", chapters = chapters(), request = AudioPlayable.MediaRequest.createHttpUri(CURRENT_AUDIOBOOK_URL)) 31 | 32 | fun progressAction(): PlaybackProgressAction = PlaybackProgressAction( 33 | currentPosition = 100.milliseconds, 34 | playerDuration = null) 35 | 36 | fun downloadInfo(): DownloadProgressInfo = DownloadProgressInfo( 37 | id = 123456, 38 | url = CURRENT_AUDIOBOOK_URL, 39 | downloadState = DownloadState.STARTED(50, 100L)) 40 | 41 | fun updateDownloadAction(): UpdateDownloadAction = UpdateDownloadAction(downloadInfo()) 42 | 43 | fun chapters(): List { 44 | val chp1 = Chapter("Chapter 0 part 0", 0, 1, 45 | 0.milliseconds, 405582.milliseconds) 46 | val chp2 = Chapter("Chapter 1 part 1", 0, 2, 47 | 405582.milliseconds, 1645772.milliseconds) 48 | val chp3 = Chapter("Chapter 2 part 1", 0, 3, 49 | 2051354.milliseconds, 1750.milliseconds) 50 | val chp4 = Chapter("Chapter 3 part 1", 0, 4, 51 | 2053104.milliseconds, 134566.milliseconds) 52 | val chp5 = Chapter("Chapter 4 part 1", 0, 5, 53 | 2187670.milliseconds, 589376.milliseconds) 54 | 55 | return listOf(chp1, chp2, chp3, chp4, chp5) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/MediaSessionConnection.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.support.v4.media.MediaBrowserCompat 6 | import android.support.v4.media.session.MediaControllerCompat 7 | import com.scribd.armadillo.error.MissingDataException 8 | 9 | /** 10 | * This class allows the client to connect to an instance of [MediaBrowserServiceCompat] by managing a connection to [MediaBrowserCompat] 11 | * 12 | * This class provides the client with: 13 | * - [MediaControllerCompat.TransportControls] in order to dispatch actions to control the player 14 | * - [MediaControllerCompat.getPlaybackState] to get the updated player state 15 | */ 16 | internal class MediaSessionConnection( 17 | private val context: Context, 18 | private val serviceComponent: ComponentName = ComponentName(context, PlaybackService::class.java)) { 19 | 20 | interface Listener { 21 | fun onConnectionCallback(transportControls: MediaControllerCompat.TransportControls) 22 | } 23 | 24 | var mediaController: MediaControllerCompat? = null 25 | 26 | val transportControls: MediaControllerCompat.TransportControls? 27 | get() = mediaController?.transportControls 28 | 29 | private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback(context) 30 | 31 | private lateinit var connectionListener: Listener 32 | 33 | private var mediaBrowser: MediaBrowserCompat? = null 34 | 35 | fun connectToMediaSession(listener: Listener) { 36 | connectionListener = listener 37 | mediaBrowser = MediaBrowserCompat( 38 | context, 39 | serviceComponent, 40 | mediaBrowserConnectionCallback, null) 41 | .apply { connect() } 42 | } 43 | 44 | private inner class MediaBrowserConnectionCallback(private val context: Context) : MediaBrowserCompat.ConnectionCallback() { 45 | 46 | /** 47 | * Invoked after [MediaBrowserCompat.connect] when the request has successfully 48 | * completed. 49 | */ 50 | override fun onConnected() { 51 | val mediaBrowserCompat = mediaBrowser ?: throw MissingDataException("Media Browser needs to be initialized.") 52 | val mediaControllerCompat = MediaControllerCompat(context, mediaBrowserCompat.sessionToken) 53 | mediaController = mediaControllerCompat 54 | connectionListener.onConnectionCallback(mediaControllerCompat.transportControls) 55 | } 56 | 57 | /** 58 | * Invoked when the client is disconnected from the media browser. 59 | */ 60 | override fun onConnectionSuspended() { 61 | } 62 | 63 | /** 64 | * Invoked when the connection to the media browser failed. 65 | */ 66 | // TODO: [AND-10537] handle case where user is unable to get mediaBrowser 67 | override fun onConnectionFailed() { 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DrmMediaSourceHelper.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.MediaItem 5 | import com.scribd.armadillo.encryption.SecureStorage 6 | import com.scribd.armadillo.error.DrmPlaybackException 7 | import com.scribd.armadillo.models.AudioPlayable 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * This is a helper responsible for generating the correct media source for an audio request. 13 | * 14 | * This will apply the correct DRM-related information needed for content decryption (if the content is DRM-protected). 15 | * In case of a download media (the content is either downloaded or being downloaded), this includes the DRM key ID used for retrieving 16 | * the local DRM license (instead of fetching DRM license from the server). 17 | */ 18 | internal interface DrmMediaSourceHelper { 19 | fun createMediaItem( 20 | context: Context, 21 | id: String, 22 | request: AudioPlayable.MediaRequest, 23 | isDownload: Boolean, 24 | ): MediaItem 25 | } 26 | 27 | @Singleton 28 | internal class DrmMediaSourceHelperImpl @Inject constructor(private val secureStorage: SecureStorage) : DrmMediaSourceHelper { 29 | 30 | override fun createMediaItem(context: Context, id: String, request: AudioPlayable.MediaRequest, isDownload: Boolean): MediaItem = 31 | MediaItem.Builder() 32 | .setUri(request.url) 33 | .apply { 34 | try { 35 | // Apply DRM config if content is DRM-protected 36 | request.drmInfo?.let { drmInfo -> 37 | MediaItem.DrmConfiguration.Builder(drmInfo.drmType.toExoplayerConstant()) 38 | .setLicenseUri(drmInfo.licenseServer) 39 | .setLicenseRequestHeaders(drmInfo.drmHeaders) 40 | .apply { 41 | // If the content is a download content, use the saved offline DRM key id. 42 | // This ID is needed to retrieve the local DRM license for content decryption. 43 | if (isDownload) { 44 | secureStorage.getDrmDownload(context = context, id = id, drmType = drmInfo.drmType)?.let { drmDownload -> 45 | setKeySetId(drmDownload.drmKeyId) 46 | } ?: throw DrmPlaybackException(IllegalStateException("No DRM key id saved for download content")) 47 | } 48 | } 49 | .build() 50 | }?.let { drmConfig -> 51 | setDrmConfiguration(drmConfig) 52 | } 53 | } catch (ex: DrmPlaybackException) { 54 | //attempt to load unencrypted, there's a chance the user supplied excessive DRMInfo. An exception will 55 | // be raised elsewhere if this content can't be decrypted. 56 | } 57 | } 58 | .build() 59 | } 60 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/Util.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import android.os.Build 7 | import androidx.annotation.ChecksSdkIntAtLeast 8 | import com.scribd.armadillo.models.Chapter 9 | import com.scribd.armadillo.time.Interval 10 | import com.scribd.armadillo.time.Millisecond 11 | import com.scribd.armadillo.time.milliseconds 12 | import java.io.File 13 | 14 | internal typealias ExoplayerDownload = com.google.android.exoplayer2.offline.Download 15 | internal typealias Milliseconds = Interval 16 | 17 | fun exoplayerExternalDirectory(context: Context): File = 18 | File(context.getExternalFilesDir(null), Constants.Exoplayer.EXOPLAYER_DIRECTORY) 19 | 20 | fun sanitizeChapters(chapters: List): List { 21 | return chapters.mapIndexed { i, chapter -> 22 | when (i) { 23 | 0 -> { 24 | if (chapter.startTime != 0.milliseconds) { 25 | throw IllegalStateException("Chapter must begin at 0") 26 | } 27 | chapter.copy( 28 | startTime = 0.milliseconds, 29 | duration = Math.round(chapter.duration.value).milliseconds) 30 | 31 | } 32 | chapters.size - 1 -> { 33 | // Math.floor used because chapter runtime must be slightly less then audioPlayable runtime in order to detect end of book 34 | val endTime = Math.floor(chapters.last().startTime.value + chapters.last().duration.value).milliseconds 35 | val previousChapter = chapters[i - 1] 36 | val startTime = previousChapter.startTime + previousChapter.duration 37 | chapter.copy( 38 | startTime = startTime, 39 | duration = endTime - startTime) 40 | } 41 | else -> { 42 | val previousChapter = chapters[i - 1] 43 | chapter.copy( 44 | startTime = Math.round(previousChapter.startTime.value + previousChapter.duration.value).milliseconds, 45 | duration = Math.round(chapter.duration.value).milliseconds) 46 | 47 | } 48 | } 49 | } 50 | } 51 | 52 | fun isInternetAvailable(context: Context): Boolean { 53 | val connectivityManager = 54 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 55 | val networkCapabilities = connectivityManager.activeNetwork ?: return false 56 | val actNw = 57 | connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false 58 | return when { 59 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true 60 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true 61 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true 62 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> true 63 | else -> false 64 | } 65 | } 66 | 67 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) 68 | fun hasSnowCone() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/ExoplayerExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.C 5 | import com.google.android.exoplayer2.ExoPlayer 6 | import com.google.android.exoplayer2.LoadControl 7 | import com.google.android.exoplayer2.RenderersFactory 8 | import com.google.android.exoplayer2.audio.AudioAttributes 9 | import com.google.android.exoplayer2.audio.AudioCapabilities 10 | import com.google.android.exoplayer2.audio.DefaultAudioSink 11 | import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer 12 | import com.google.android.exoplayer2.mediacodec.MediaCodecSelector 13 | import com.google.android.exoplayer2.source.dash.manifest.DashManifest 14 | import com.google.android.exoplayer2.source.hls.HlsManifest 15 | import com.scribd.armadillo.Milliseconds 16 | import com.scribd.armadillo.time.milliseconds 17 | 18 | /** 19 | * Call this method before queries to player progress. 20 | * During setup, [ExoPlayer.getCurrentManifest] will be null. 21 | */ 22 | internal fun ExoPlayer.hasProgressAvailable(): Boolean { 23 | return when (val m = currentManifest) { 24 | is HlsManifest -> m.mediaPlaylist.durationUs != C.TIME_UNSET 25 | is DashManifest -> m.durationMs != C.TIME_UNSET 26 | else -> m == null && !currentTimeline.isEmpty 27 | } 28 | } 29 | 30 | /** 31 | * Current position in relation to all audio files. 32 | */ 33 | internal fun ExoPlayer.currentPositionInDuration(): Milliseconds = currentPosition.milliseconds 34 | 35 | /** 36 | * The total duration reported by the player, or null if it is not yet known 37 | */ 38 | internal fun ExoPlayer.playerDuration(): Milliseconds? = if (duration == C.TIME_UNSET) { null } else { duration.milliseconds } 39 | 40 | /** 41 | * builds [ExoPlayer] instance to be used all across [ExoplayerPlaybackEngine]. 42 | * 43 | * We provide our own renderers factory so that Proguard can remove any non-audio rendering code. 44 | */ 45 | internal fun createExoplayerInstance(context: Context, attributes: AudioAttributes, loadControl: LoadControl): ExoPlayer = 46 | ExoPlayer.Builder(context, createRenderersFactory(context)) 47 | .setLoadControl(loadControl) 48 | .build().apply { 49 | setAudioAttributes(attributes, true) 50 | } 51 | 52 | internal fun createRenderersFactory(context: Context): RenderersFactory = 53 | RenderersFactory { eventHandler, _, audioRendererEventListener, _, _ -> 54 | // Default audio sink taken from DefaultRenderersFactory. We need to provide it in order to enable offloading 55 | // Note that we need to provide a new audio sink for each call - playback fails if we reuse the sink 56 | val audioSink = DefaultAudioSink.Builder() 57 | .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) 58 | .setAudioProcessorChain(DefaultAudioSink.DefaultAudioProcessorChain()) 59 | .setEnableFloatOutput(false) 60 | .setEnableAudioTrackPlaybackParams(true) 61 | .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED) 62 | .build() 63 | arrayOf(MediaCodecAudioRenderer(context, MediaCodecSelector.DEFAULT, eventHandler, audioRendererEventListener, audioSink)) 64 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/analytics/ArmadilloPlaybackActionListener.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation.analytics 2 | 3 | import android.util.Log 4 | import com.scribd.armadillo.analytics.PlaybackActionListener 5 | import com.scribd.armadillo.models.ArmadilloState 6 | import com.scribd.armadillo.time.Interval 7 | import com.scribd.armadillo.time.Millisecond 8 | import io.reactivex.disposables.CompositeDisposable 9 | import io.reactivex.subjects.PublishSubject 10 | import java.util.concurrent.TimeUnit 11 | 12 | class ArmadilloPlaybackActionListener : PlaybackActionListener { 13 | 14 | private val disposables = CompositeDisposable() 15 | private val publishSubject = PublishSubject.create() 16 | 17 | private companion object { 18 | const val TAG = "PlaybackActionListener" 19 | } 20 | 21 | override fun onPoll(state: ArmadilloState) = publishSubject.onNext(state) 22 | 23 | override fun onNewAudiobook(state: ArmadilloState) { 24 | initPoll() 25 | Log.v(TAG, "onNewAudiobook: " + state.toString()) 26 | } 27 | 28 | override fun onLoadingStart(state: ArmadilloState) { 29 | Log.v(TAG, "onLoadStart: $state") 30 | } 31 | 32 | override fun onLoadingEnd(state: ArmadilloState) { 33 | Log.v(TAG, "onLoadEnd: $state") 34 | } 35 | 36 | override fun onPlay(state: ArmadilloState) { 37 | Log.v(TAG, "onPlay: $state") 38 | } 39 | 40 | override fun onPause(state: ArmadilloState) { 41 | Log.v(TAG, "onPause: $state") 42 | } 43 | 44 | override fun onStop(state: ArmadilloState) { 45 | Log.v(TAG, "onStop: $state") 46 | disposables.clear() 47 | } 48 | 49 | override fun onDiscontinuity(state: ArmadilloState) { 50 | Log.v(TAG, "on discontinuity") 51 | } 52 | 53 | override fun onFastForward(beforeState: ArmadilloState, afterState: ArmadilloState) { 54 | Log.v(TAG, "onFastForward: $afterState") 55 | } 56 | 57 | override fun onRewind(beforeState: ArmadilloState, afterState: ArmadilloState) { 58 | Log.v(TAG, "onRewind: $afterState") 59 | } 60 | 61 | override fun onSkipToNext(beforeState: ArmadilloState, afterState: ArmadilloState) { 62 | Log.v(TAG, "onSkipToNext: $afterState") 63 | } 64 | 65 | override fun onSkipToPrevious(beforeState: ArmadilloState, afterState: ArmadilloState) { 66 | Log.v(TAG, "onSkipToPrevious: $afterState") 67 | } 68 | 69 | override fun onSeek(seekTarget: Interval, beforeState: ArmadilloState, afterState: ArmadilloState) { 70 | Log.v(TAG, "onSeekTo: $afterState") 71 | } 72 | 73 | override fun onSkipDistanceChange(state: ArmadilloState, oldDistance: Interval, newDistance: Interval) { 74 | Log.v(TAG, "onSkipDistance: $newDistance") 75 | } 76 | 77 | override fun onSpeedChange(state: ArmadilloState, oldSpeed: Float, newSpeed: Float) { 78 | Log.v(TAG, "onSpeedChange: $newSpeed") 79 | } 80 | 81 | private fun initPoll() { 82 | disposables.clear() 83 | disposables.add(publishSubject 84 | .sample(5, TimeUnit.SECONDS) // looks into the sequence of elements and emits the last item that was produced within the duration 85 | .subscribe { 86 | Log.v(TAG, "onPoll: " + it.toString()) 87 | }) 88 | } 89 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/ArmadilloConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS 4 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS 5 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS 6 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS 7 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS 8 | import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES 9 | import com.scribd.armadillo.models.AudioPlayable 10 | import com.scribd.armadillo.time.milliseconds 11 | import java.io.Serializable 12 | 13 | /** 14 | * Used to specify various settings when starting playback on a new [AudioPlayable] 15 | * 16 | * @property initialOffset The initial position to begin playback from. 17 | * @property isAutoPlay Flag to begin playback as soon as the content is loaded. 18 | * @property maxDurationDiscrepancy Armadillo will output errors if the metadata for the audio duration doesn't match the 19 | * actual duration of playback. This value can be used to set the allowed maximum difference in seconds between stated vs. actual duration. 20 | * Can also be set to a negative value to ignore any discrepancies. 21 | * @property minBufferMs The minumum amount of audio attempted to be buffered at all times in milliseconds. 22 | * @property maxBufferMs The maximum amount of audio attempted to be buffered at all times in milliseconds. 23 | * @property bufferForPlaybackMs The duration of media that must be buffered for playback to start or 24 | * resume following a user action such as a seek, in milliseconds. 25 | * @property bufferForPlaybackAfterRebufferMs The duration of media that must be buffered for playback 26 | * to resume after a rebuffer, in milliseconds. A rebuffer is defined to be caused by buffer depletion 27 | * rather than a user action. 28 | * @property targetBufferSize The desired size of the media buffer in bytes. An unset buffer size will 29 | * will be calculated based on the selected tracks. 30 | * @property prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time constraints 31 | * over buffer size constraints. 32 | */ 33 | data class ArmadilloConfiguration(val initialOffset: Milliseconds = 0.milliseconds, 34 | val isAutoPlay: Boolean = true, 35 | val maxDurationDiscrepancy: Int = MAX_DISCREPANCY_DEFAULT, 36 | val minBufferMs: Int = DEFAULT_MIN_BUFFER_MS, 37 | val maxBufferMs: Int = DEFAULT_MAX_BUFFER_MS, 38 | val bufferForPlaybackMs: Int = DEFAULT_BUFFER_FOR_PLAYBACK_MS, 39 | val bufferForPlaybackAfterRebufferMs: Int = DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, 40 | val targetBufferSize: Int = DEFAULT_TARGET_BUFFER_BYTES, 41 | val prioritizeTimeOverSizeThresholds: Boolean = DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS): Serializable { 42 | 43 | companion object { 44 | // Default duration discrepancy values in seconds 45 | const val MAX_DISCREPANCY_DEFAULT = 1 46 | const val MAX_DISCREPANCY_DISABLE = -1 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlaybackStateCompatBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.os.SystemClock 4 | import android.support.v4.media.session.MediaSessionCompat 5 | import android.support.v4.media.session.PlaybackStateCompat 6 | import com.scribd.armadillo.models.PlaybackInfo 7 | import com.scribd.armadillo.models.PlaybackState 8 | 9 | /** 10 | * Builds a [PlaybackStateCompat] for our [MediaSessionCompat] in order to receive callbacks for media buttons 11 | */ 12 | internal interface PlaybackStateCompatBuilder { 13 | fun build(playbackInfo: PlaybackInfo): PlaybackStateCompat 14 | } 15 | 16 | internal class PlaybackStateBuilderImpl : PlaybackStateCompatBuilder { 17 | // Keeping Builder instance as per recommendation in the documentation 18 | // https://developer.android.com/guide/topics/media-apps/working-with-a-media-session#init-session 19 | private val stateBuilder = PlaybackStateCompat.Builder() 20 | 21 | override fun build(playbackInfo: PlaybackInfo): PlaybackStateCompat { 22 | val compatState = mapPlaybackState(playbackInfo.playbackState) 23 | val positionInDuration = playbackInfo.progress.positionInDuration 24 | val currentChapterStartTime = playbackInfo.audioPlayable.chapters[playbackInfo.progress.currentChapterIndex].startTime 25 | 26 | // The position in the current track. 27 | val currentPosition = positionInDuration - currentChapterStartTime 28 | 29 | stateBuilder.setState(compatState, currentPosition.longValue, playbackInfo.playbackSpeed); 30 | stateBuilder.setActions(getAvailableActions(compatState)) 31 | return stateBuilder.build() 32 | } 33 | 34 | private fun getAvailableActions(playbackState: Int): Long { 35 | var actions = (PlaybackStateCompat.ACTION_FAST_FORWARD 36 | or PlaybackStateCompat.ACTION_REWIND 37 | or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS 38 | or PlaybackStateCompat.ACTION_SKIP_TO_NEXT 39 | or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH) 40 | actions = when (playbackState) { 41 | PlaybackStateCompat.STATE_PLAYING -> actions or (PlaybackStateCompat.ACTION_PAUSE 42 | or PlaybackStateCompat.ACTION_STOP 43 | or PlaybackStateCompat.ACTION_SEEK_TO 44 | or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH) 45 | PlaybackStateCompat.STATE_PAUSED -> actions or (PlaybackStateCompat.ACTION_PLAY 46 | or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH 47 | or PlaybackStateCompat.ACTION_SEEK_TO 48 | or PlaybackStateCompat.ACTION_STOP) 49 | else -> actions or (PlaybackStateCompat.ACTION_PLAY 50 | or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH 51 | or PlaybackStateCompat.ACTION_PLAY 52 | or PlaybackStateCompat.ACTION_STOP 53 | or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH) 54 | } 55 | return actions 56 | } 57 | 58 | /** 59 | * Gets corresponding [PlaybackStateCompat] state from Armadillo [PlaybackState] 60 | */ 61 | private fun mapPlaybackState(playState: PlaybackState?): Int { 62 | return when (playState) { 63 | PlaybackState.PLAYING -> PlaybackStateCompat.STATE_PLAYING 64 | PlaybackState.PAUSED -> PlaybackStateCompat.STATE_PAUSED 65 | else -> PlaybackStateCompat.STATE_NONE 66 | } 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.util.Util 5 | import com.scribd.armadillo.time.seconds 6 | 7 | object Constants { 8 | const val LIBRARY_VERSION = BuildConfig.VERSION_NAME 9 | 10 | internal const val PLAYBACK_LOADING_STATUS = "playback_loading_status" 11 | 12 | internal const val DEFAULT_PLAYBACK_SPEED = 1.0f 13 | internal const val DEBUG_MAX_SIZE = 20 14 | 15 | internal const val MAX_PARALLEL_DOWNLOADS = 6 16 | 17 | // an arbitrarily long constant to add to seek positions from app UI 18 | internal const val AUDIO_POSITION_SHIFT_IN_MS = 5000000000L 19 | 20 | /** 21 | * A seek to previousChapter command beyond this will restart the current media source instead of skipping to the previousChapter media source 22 | */ 23 | internal val MAX_POSITION_FOR_SEEK_TO_PREVIOUS = 3.seconds.inMilliseconds 24 | internal val AUDIO_SKIP_DURATION = 30.seconds.inMilliseconds 25 | 26 | internal fun getUserAgent(context: Context): String = Util.getUserAgent(context, context.getString(APP_NAME)) 27 | 28 | private val APP_NAME = R.string.arm_app_name 29 | 30 | internal object Keys { 31 | const val KEY_ARMADILLO_CONFIG = "armadillo_config" 32 | const val KEY_AUDIO_PLAYABLE = "audio_playable" 33 | const val ANDROID_KEYSTORE_NAME= "AndroidKeyStore" 34 | } 35 | 36 | internal object DI { 37 | const val DOWNLOAD_CACHE = "download_cache" 38 | const val PLAYBACK_CACHE = "playback_cache" 39 | 40 | const val EXOPLAYER_DIRECTORY = "exoplayer_directory" 41 | const val EXOPLAYER_CACHE_DIRECTORY = "exoplayer_cache_directory" 42 | 43 | const val GLOBAL_SCOPE = "global_scope" 44 | 45 | const val DOWNLOAD_STORE_ALIAS="armadillo" 46 | const val DOWNLOAD_STORE_FILENAME="armadillo.download.secure" 47 | const val STANDARD_STORE_ALIAS="armadilloStandard" 48 | const val STANDARD_STORE_FILENAME="armadillo.standard.secure" 49 | 50 | const val STANDARD_STORAGE = "standard_storage" 51 | const val STANDARD_SECURE_STORAGE = "standard_secure_storage" 52 | const val DRM_DOWNLOAD_STORAGE = "drm_download_storage" 53 | const val DRM_SECURE_STORAGE = "drm_secure_storage" 54 | } 55 | 56 | internal object Exoplayer { 57 | const val EXOPLAYER_DIRECTORY = "exoplayer" // When changing this, please update the example in the test application backup config 58 | const val EXOPLAYER_DOWNLOADS_DIRECTORY = "downloads" 59 | const val EXOPLAYER_PLAYBACK_CACHE_DIRECTORY = "playback_cache" 60 | 61 | const val MAX_PLAYBACK_CACHE_SIZE = 25L * 1024 * 1024 // 25 MB - ~40m of audio 62 | } 63 | 64 | internal object Actions { 65 | const val SET_IS_IN_FOREGROUND = "set_is_in_foreground_action" 66 | const val SET_PLAYBACK_SPEED = "set_playback_speed_action" 67 | const val UPDATE_PROGRESS = "updated_progress_action" 68 | const val UPDATE_METADATA = "update_metadata" 69 | const val UPDATE_MEDIA_REQUEST = "update_media_request" 70 | 71 | object Extras { 72 | const val IS_IN_FOREGROUND = "is_in_foreground" 73 | const val PLAYBACK_SPEED = "playback_speed" 74 | const val METADATA_TITLE = "metadata_title" 75 | const val METADATA_CHAPTERS = "metadata_chapters" 76 | const val MEDIA_REQUEST = "media_request" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/PlayerEventListener.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.google.android.exoplayer2.ExoPlaybackException 6 | import com.google.android.exoplayer2.ExoPlayer 7 | import com.google.android.exoplayer2.PlaybackException 8 | import com.google.android.exoplayer2.Player 9 | import com.scribd.armadillo.StateStore 10 | import com.scribd.armadillo.actions.Action 11 | import com.scribd.armadillo.actions.ContentEndedAction 12 | import com.scribd.armadillo.actions.ErrorAction 13 | import com.scribd.armadillo.actions.LoadingAction 14 | import com.scribd.armadillo.actions.PlayerStateAction 15 | import com.scribd.armadillo.actions.SeekAction 16 | import com.scribd.armadillo.actions.UpdateProgressAction 17 | import com.scribd.armadillo.di.Injector 18 | import com.scribd.armadillo.models.PlaybackState 19 | import com.scribd.armadillo.time.milliseconds 20 | import javax.inject.Inject 21 | 22 | /** 23 | * Class to manage the events emitted by [ExoPlayer] 24 | * 25 | * It communicates changes by sending [Action]s with [StateStore.Modifier]. 26 | */ 27 | internal class PlayerEventListener @Inject constructor(private val context: Context) : Player.Listener { 28 | init { 29 | Injector.mainComponent.inject(this) 30 | } 31 | 32 | private companion object { 33 | const val TAG = "PlayerEventListener" 34 | } 35 | 36 | @Inject 37 | internal lateinit var stateModifier: StateStore.Modifier 38 | 39 | override fun onPlayerError(error: PlaybackException) { 40 | val exception = (error as ExoPlaybackException).toArmadilloException(context) 41 | stateModifier.dispatch(ErrorAction(exception)) 42 | Log.e(TAG, "onPlayerError: $error") 43 | } 44 | 45 | override fun onIsLoadingChanged(isLoading: Boolean) { 46 | Log.v(TAG, "onLoadingChanged --- isLoading: $isLoading") 47 | stateModifier.dispatch(LoadingAction(isLoading)) 48 | } 49 | 50 | override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { 51 | Log.v(TAG, "onPositionDiscontinuity --- reason: $reason") 52 | stateModifier.dispatch(SeekAction(false, newPosition.contentPositionMs.milliseconds)) 53 | } 54 | 55 | override fun onRepeatModeChanged(repeatMode: Int) = Unit 56 | 57 | override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) = Unit 58 | 59 | override fun onIsPlayingChanged(isPlaying: Boolean) { 60 | val playState = when { 61 | isPlaying -> PlaybackState.PLAYING 62 | else -> PlaybackState.PAUSED 63 | } 64 | stateModifier.dispatch(PlayerStateAction(playState)) 65 | Log.v(TAG, "onIsPlayingChanged --- --- isPlaying: $isPlaying") 66 | } 67 | 68 | override fun onPlaybackStateChanged(state: Int) { 69 | if (Player.STATE_ENDED == state) { 70 | stateModifier.dispatch(UpdateProgressAction(true)) 71 | stateModifier.dispatch(ContentEndedAction) 72 | } 73 | Log.v(TAG, "onPlayerStateChanged --- --- playbackState: ${playbackState(state)}") 74 | } 75 | 76 | private fun playbackState(playbackState: Int): String { 77 | return when (playbackState) { 78 | Player.STATE_IDLE -> "idle" 79 | Player.STATE_BUFFERING -> "buffering" 80 | Player.STATE_READY -> "ready" 81 | Player.STATE_ENDED -> "ended" 82 | else -> "unknown" 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/download/DownloadManagerExtKtTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | import com.scribd.armadillo.models.DownloadProgressInfo 4 | import com.scribd.armadillo.models.DownloadState 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.Before 7 | import org.junit.Test 8 | 9 | class DownloadManagerExtKtTest { 10 | private companion object { 11 | const val ID = 1234 12 | const val URL = "www.coolaudiobook.com" 13 | const val DOWNLOAD_PERCENT = 50 14 | const val DOWNLOADED_BYTES = 100L 15 | } 16 | 17 | private lateinit var downloadState: TestableDownloadState 18 | 19 | @Before 20 | fun setUp() { 21 | downloadState = TestableDownloadState( 22 | ID, 23 | URL, 24 | TestableDownloadState.COMPLETED, 25 | DOWNLOAD_PERCENT, 26 | DOWNLOADED_BYTES) 27 | } 28 | 29 | @Test 30 | fun toDownloadInfo_isQueuedAction_returnsNull() { 31 | val state = downloadState.copy(state = TestableDownloadState.QUEUED) 32 | val downloadInfo = state.toDownloadInfo() 33 | assertThat(downloadInfo).isNull() 34 | } 35 | 36 | @Test 37 | fun toDownloadInfo_downloadRemovalComplete_returnsRemoveProgress() { 38 | val state = downloadState.copy( 39 | state = TestableDownloadState.REMOVING) 40 | val downloadInfo = state.toDownloadInfo()!! 41 | assertThat(downloadInfo.id).isEqualTo(ID) 42 | assertThat(downloadInfo.url).isEqualTo(URL) 43 | assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.REMOVED) 44 | } 45 | 46 | @Test 47 | fun toDownloadInfo_downloadComplete_returnsCompletedProgress() { 48 | val state = downloadState.copy( 49 | state = TestableDownloadState.COMPLETED) 50 | val downloadInfo = state.toDownloadInfo()!! 51 | assertThat(downloadInfo.id).isEqualTo(ID) 52 | assertThat(downloadInfo.url).isEqualTo(URL) 53 | assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.COMPLETED) 54 | } 55 | 56 | @Test 57 | fun toDownloadInfo_downloadProgressJustBegan_returnsProgress() { 58 | val state = downloadState.copy( 59 | state = TestableDownloadState.IN_PROGRESS, 60 | downloadPercentage = DownloadProgressInfo.PROGRESS_UNSET) 61 | val downloadInfo = state.toDownloadInfo()!! 62 | assertThat(downloadInfo.id).isEqualTo(ID) 63 | assertThat(downloadInfo.url).isEqualTo(URL) 64 | assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.STARTED(0, DOWNLOADED_BYTES)) 65 | } 66 | 67 | @Test 68 | fun toDownloadInfo_downloadProgressWithProgress_returnsProgress() { 69 | val state = downloadState.copy( 70 | state = TestableDownloadState.IN_PROGRESS) 71 | val downloadInfo = state.toDownloadInfo()!! 72 | assertThat(downloadInfo.id).isEqualTo(ID) 73 | assertThat(downloadInfo.url).isEqualTo(URL) 74 | assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.STARTED(DOWNLOAD_PERCENT, DOWNLOADED_BYTES)) 75 | } 76 | 77 | @Test 78 | fun toDownloadInfo_unknownState_returnsFailed() { 79 | val state = downloadState.copy( 80 | state = 1000) 81 | val downloadInfo = state.toDownloadInfo()!! 82 | assertThat(downloadInfo.id).isEqualTo(ID) 83 | assertThat(downloadInfo.url).isEqualTo(URL) 84 | assertThat(downloadInfo.downloadState).isEqualTo(DownloadState.FAILED()) 85 | } 86 | } -------------------------------------------------------------------------------- /TestApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 28 | 29 | 31 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 44 | 45 | 46 | 48 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /Armadillo/src/test/java/com/scribd/armadillo/models/TimeTest.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.models 2 | 3 | 4 | import com.scribd.armadillo.time.Interval 5 | import com.scribd.armadillo.time.TimeUnit 6 | import com.scribd.armadillo.time.days 7 | import com.scribd.armadillo.time.hours 8 | import com.scribd.armadillo.time.microseconds 9 | import com.scribd.armadillo.time.milliseconds 10 | import com.scribd.armadillo.time.minus 11 | import com.scribd.armadillo.time.minutes 12 | import com.scribd.armadillo.time.nanoseconds 13 | import com.scribd.armadillo.time.plus 14 | import com.scribd.armadillo.time.seconds 15 | import org.hamcrest.core.IsEqual.equalTo 16 | import org.junit.Assert.assertEquals 17 | import org.junit.Assert.assertFalse 18 | import org.junit.Assert.assertThat 19 | import org.junit.Assert.assertTrue 20 | import org.junit.Test 21 | import java.util.Calendar 22 | 23 | class TimeTest { 24 | 25 | @Test 26 | fun `time comparisons work as expected`() { 27 | assertTrue(5.minutes > 120.seconds) 28 | assertTrue(2.days < 48.5.hours) 29 | assertTrue(1000.microseconds > 2000.nanoseconds) 30 | assertEquals(60.seconds, 60000.milliseconds) 31 | } 32 | 33 | @Test 34 | fun `time conversions work as expected`() { 35 | val twentyFourHours = 24.hours 36 | 37 | val valueInDays = twentyFourHours.inSeconds.inMinutes.inNanoseconds 38 | .inMicroseconds.inHours.inMilliseconds.inDays 39 | 40 | assertThat(valueInDays.value, equalTo(1.0)) 41 | } 42 | 43 | @Test 44 | fun `basic time operators work as expected`() { 45 | val sixtySecs = 60.seconds 46 | 47 | var newValue = sixtySecs + 2.minutes 48 | newValue -= 20.seconds 49 | newValue += 10.seconds 50 | 51 | assertThat(newValue, equalTo(170.seconds)) 52 | } 53 | 54 | @Test 55 | fun `time "in" operator works as expected`() { 56 | assertTrue(60.minutes in 4.hours) 57 | assertFalse(2.days in 24.hours) 58 | assertTrue(120.seconds in 2.minutes) 59 | } 60 | 61 | @Test 62 | fun `time operators(multiplication and division) work as expected`() { 63 | val sixtySecs = 60.seconds 64 | 65 | val multiplied = sixtySecs * 2 66 | val divided = sixtySecs / 2 67 | 68 | assertEquals(multiplied, 120.seconds) 69 | assertEquals(divided, 30.seconds) 70 | } 71 | 72 | @Test 73 | fun `time operators(increment and decrement) work as expected`() { 74 | var days = 2.days 75 | 76 | days++ 77 | assertEquals(days, 3.days) 78 | 79 | days-- 80 | assertEquals(days, 2.days) 81 | } 82 | 83 | @Test 84 | fun `ten minutes in the future is greater than now`() { 85 | val now = Calendar.getInstance() 86 | 87 | val tenMinutesLater = now + 10.minutes 88 | 89 | assertTrue(tenMinutesLater > now) 90 | } 91 | 92 | @Test 93 | fun `ten days ago is less than now`() { 94 | val now = Calendar.getInstance() 95 | 96 | val tenDaysAgo = now - 10.days 97 | 98 | assertTrue(tenDaysAgo < now) 99 | } 100 | 101 | @Test 102 | fun `custom time units work as expected`() { 103 | val twoWeeks = 2.weeks 104 | val fourteenDays = 14.days 105 | 106 | assertEquals(twoWeeks, fourteenDays) 107 | assertEquals(336.hours.inWeeks, twoWeeks) 108 | } 109 | } 110 | 111 | 112 | // Custom time unit. 113 | class Week : TimeUnit { 114 | override val timeIntervalRatio = 604800.0 115 | } 116 | 117 | val Number.weeks: Interval 118 | get() = Interval(this.toDouble()) 119 | 120 | val Interval.inWeeks: Interval 121 | get() = converted() -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/SharedPrefExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.security.keystore.KeyGenParameterSpec 6 | import android.security.keystore.KeyProperties.BLOCK_MODE_GCM 7 | import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE 8 | import android.security.keystore.KeyProperties.PURPOSE_DECRYPT 9 | import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT 10 | import android.util.Log 11 | import androidx.security.crypto.EncryptedSharedPreferences 12 | import androidx.security.crypto.MasterKeys 13 | import com.scribd.armadillo.Constants.Keys.ANDROID_KEYSTORE_NAME 14 | import java.io.File 15 | import java.security.KeyStore 16 | 17 | fun SharedPreferences.deleteEncryptedSharedPreference(context: Context, filename: String, keystoreAlias: String) { 18 | val tag = "DeletingSharedPrefs" 19 | try { 20 | //maybe deletes the shared preference file, this is not guaranteed to work. 21 | val sharedPrefsFile = File( 22 | (context.filesDir.getParent()?.plus("/shared_prefs/")) + filename + ".xml" 23 | ) 24 | 25 | edit().clear().commit() 26 | 27 | if (sharedPrefsFile.exists()) { 28 | val deleted = sharedPrefsFile.delete() 29 | Log.d(tag, "resetStorage() Shared prefs file deleted: $deleted; path: ${sharedPrefsFile.absolutePath}") 30 | } else { 31 | Log.d(tag, "resetStorage() Shared prefs file non-existent; path: ${sharedPrefsFile.absolutePath}") 32 | } 33 | 34 | val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE_NAME) 35 | keyStore.load(null) 36 | keyStore.deleteEntry(keystoreAlias) 37 | } catch (e: Exception) { 38 | Log.e(tag, "Error occurred while trying to reset shared prefs", e) 39 | } 40 | } 41 | 42 | fun createEncryptedSharedPrefKeyStoreWithRetry(context: Context, fileName: String, keystoreAlias: String): SharedPreferences? { 43 | val firstAttempt = createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias) 44 | return if (firstAttempt != null) { 45 | firstAttempt 46 | } else { 47 | context.getSharedPreferences(fileName, Context.MODE_PRIVATE).deleteEncryptedSharedPreference( 48 | context = context, 49 | filename = fileName, 50 | keystoreAlias = keystoreAlias 51 | ) 52 | createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias) 53 | } 54 | } 55 | 56 | fun createEncryptedSharedPrefsKeyStore(context: Context, fileName: String, keystoreAlias: String) 57 | : SharedPreferences? { 58 | val keySpec = KeyGenParameterSpec.Builder(keystoreAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT) 59 | .setKeySize(256) 60 | .setBlockModes(BLOCK_MODE_GCM) 61 | .setEncryptionPaddings(ENCRYPTION_PADDING_NONE) 62 | .build() 63 | 64 | return try { 65 | val keys = try { 66 | MasterKeys.getOrCreate(keySpec) 67 | } catch (ex: Exception) { 68 | //clear corrupted store, contents will be lost 69 | context.getSharedPreferences(fileName, Context.MODE_PRIVATE).deleteEncryptedSharedPreference( 70 | context = context, 71 | filename = fileName, 72 | keystoreAlias = keystoreAlias) 73 | MasterKeys.getOrCreate(keySpec) 74 | } 75 | 76 | EncryptedSharedPreferences.create( 77 | fileName, 78 | keys, 79 | context, 80 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 81 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 82 | ) 83 | } catch (ex: Exception) { 84 | null 85 | } 86 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.google.android.exoplayer2.drm.DrmSessionManagerProvider 6 | import com.scribd.armadillo.StateStore 7 | import com.scribd.armadillo.broadcast.ArmadilloNoisyReceiver 8 | import com.scribd.armadillo.broadcast.ArmadilloNoisySpeakerReceiver 9 | import com.scribd.armadillo.broadcast.ArmadilloNotificationDeleteReceiver 10 | import com.scribd.armadillo.broadcast.NotificationDeleteReceiver 11 | import com.scribd.armadillo.download.drm.ArmadilloDrmSessionManagerProvider 12 | import com.scribd.armadillo.mediaitems.ArmadilloMediaBrowse 13 | import com.scribd.armadillo.mediaitems.MediaContentSharer 14 | import com.scribd.armadillo.playback.ArmadilloAudioAttributes 15 | import com.scribd.armadillo.playback.AudioAttributesBuilderImpl 16 | import com.scribd.armadillo.playback.MediaMetadataCompatBuilder 17 | import com.scribd.armadillo.playback.MediaMetadataCompatBuilderImpl 18 | import com.scribd.armadillo.playback.PlaybackEngineFactoryHolder 19 | import com.scribd.armadillo.playback.PlaybackStateBuilderImpl 20 | import com.scribd.armadillo.playback.PlaybackStateCompatBuilder 21 | import com.scribd.armadillo.playback.PlayerEventListener 22 | import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelper 23 | import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelperImpl 24 | import com.scribd.armadillo.playback.mediasource.HeadersMediaSourceFactoryFactory 25 | import com.scribd.armadillo.playback.mediasource.HeadersMediaSourceFactoryFactoryImpl 26 | import com.scribd.armadillo.playback.mediasource.MediaSourceRetriever 27 | import com.scribd.armadillo.playback.mediasource.MediaSourceRetrieverImpl 28 | import dagger.Module 29 | import dagger.Provides 30 | import javax.inject.Singleton 31 | 32 | @Module 33 | internal class PlaybackModule { 34 | @Singleton 35 | @Provides 36 | fun playbackEngineFactory() = PlaybackEngineFactoryHolder.factory 37 | 38 | @Singleton 39 | @Provides 40 | fun noisySpeakerReceiver(context: Context): ArmadilloNoisySpeakerReceiver = 41 | ArmadilloNoisyReceiver(context.applicationContext as Application) 42 | 43 | @Provides 44 | fun playbackStateBuilder(): PlaybackStateCompatBuilder = PlaybackStateBuilderImpl() 45 | 46 | @Provides 47 | fun mediaMetadataBuilder(): MediaMetadataCompatBuilder = MediaMetadataCompatBuilderImpl() 48 | 49 | @Provides 50 | fun audioAttributes(): ArmadilloAudioAttributes = AudioAttributesBuilderImpl() 51 | 52 | @Provides 53 | fun notificationListener(context: Context): NotificationDeleteReceiver = 54 | ArmadilloNotificationDeleteReceiver(context.applicationContext as Application) 55 | 56 | @Provides 57 | @Singleton 58 | fun mediaContentSharer(contentSharer: MediaContentSharer): ArmadilloMediaBrowse.ContentSharer = contentSharer 59 | 60 | @Provides 61 | @Singleton 62 | fun mediaBrowser(contentSharer: MediaContentSharer): ArmadilloMediaBrowse.Browser = contentSharer 63 | 64 | @Provides 65 | @Singleton 66 | fun mediaSourceRetriever(mediaSourceRetrieverImpl: MediaSourceRetrieverImpl): MediaSourceRetriever = mediaSourceRetrieverImpl 67 | 68 | @Provides 69 | @Singleton 70 | fun mediaSourceHelper(mediaSourceHelperImpl: HeadersMediaSourceFactoryFactoryImpl): HeadersMediaSourceFactoryFactory = mediaSourceHelperImpl 71 | 72 | @Provides 73 | @Singleton 74 | fun drmMediaSourceHelper(drmMediaSourceHelperImpl: DrmMediaSourceHelperImpl): DrmMediaSourceHelper = drmMediaSourceHelperImpl 75 | 76 | @Provides 77 | @Singleton 78 | fun drmSessionManagerProvider(stateStore: StateStore.Modifier): DrmSessionManagerProvider = ArmadilloDrmSessionManagerProvider(stateStore) 79 | 80 | @Provides 81 | @Singleton 82 | fun playerEventListener(context: Context): PlayerEventListener = PlayerEventListener(context) 83 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/extensions/TransportControlsExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.extensions 2 | 3 | import android.os.Bundle 4 | import android.support.v4.media.session.MediaControllerCompat 5 | import com.scribd.armadillo.Constants 6 | import com.scribd.armadillo.models.AudioPlayable 7 | import com.scribd.armadillo.models.Chapter 8 | 9 | internal fun MediaControllerCompat.TransportControls.sendCustomAction(customAction: CustomAction) { 10 | sendCustomAction(customAction.action, customAction.toBundle()) 11 | } 12 | 13 | internal sealed class CustomAction { 14 | abstract fun toBundle(): Bundle 15 | abstract val action: String 16 | 17 | companion object { 18 | @JvmStatic 19 | fun build(action: String?, bundle: Bundle?): CustomAction? { 20 | return when (action) { 21 | Constants.Actions.UPDATE_PROGRESS -> UpdateProgress 22 | Constants.Actions.SET_PLAYBACK_SPEED -> { 23 | val playbackSpeed = bundle?.getFloat(Constants.Actions.Extras.PLAYBACK_SPEED, Constants.DEFAULT_PLAYBACK_SPEED) 24 | ?: Constants.DEFAULT_PLAYBACK_SPEED 25 | SetPlaybackSpeed(playbackSpeed) 26 | } 27 | Constants.Actions.SET_IS_IN_FOREGROUND -> { 28 | val isInForeground = bundle?.getBoolean(Constants.Actions.Extras.IS_IN_FOREGROUND) ?: false 29 | SetIsInForeground(isInForeground) 30 | } 31 | Constants.Actions.UPDATE_METADATA -> { 32 | val title = bundle?.getString(Constants.Actions.Extras.METADATA_TITLE)!! 33 | val chapters = bundle.getParcelableArrayList(Constants.Actions.Extras.METADATA_CHAPTERS)!! 34 | UpdatePlaybackMetadata(title, chapters) 35 | } 36 | Constants.Actions.UPDATE_MEDIA_REQUEST -> { 37 | val mediaRequest = bundle?.getSerializable(Constants.Actions.Extras.MEDIA_REQUEST) as AudioPlayable.MediaRequest 38 | UpdateMediaRequest(mediaRequest) 39 | } 40 | else -> null 41 | } 42 | } 43 | } 44 | 45 | object UpdateProgress : CustomAction() { 46 | override val action: String = Constants.Actions.UPDATE_PROGRESS 47 | override fun toBundle(): Bundle = Bundle() 48 | } 49 | 50 | data class SetPlaybackSpeed(val playbackSpeed: Float) : CustomAction() { 51 | override val action: String = Constants.Actions.SET_PLAYBACK_SPEED 52 | override fun toBundle(): Bundle = Bundle().apply { putFloat(Constants.Actions.Extras.PLAYBACK_SPEED, playbackSpeed) } 53 | } 54 | 55 | data class UpdatePlaybackMetadata(val title: String, val chapters: List) : CustomAction() { 56 | override val action: String = Constants.Actions.UPDATE_METADATA 57 | override fun toBundle(): Bundle = Bundle().apply { 58 | putString(Constants.Actions.Extras.METADATA_TITLE, title) 59 | putParcelableArrayList(Constants.Actions.Extras.METADATA_CHAPTERS, ArrayList(chapters)) 60 | } 61 | } 62 | 63 | data class UpdateMediaRequest(val mediaRequest: AudioPlayable.MediaRequest) : CustomAction() { 64 | override val action: String = Constants.Actions.UPDATE_MEDIA_REQUEST 65 | 66 | override fun toBundle(): Bundle = Bundle().apply{ 67 | putSerializable(Constants.Actions.Extras.MEDIA_REQUEST, mediaRequest) 68 | } 69 | } 70 | 71 | /** 72 | * Signals to the engine that the player is visible and needs more frequent updates 73 | */ 74 | data class SetIsInForeground(val isInForeground: Boolean) : CustomAction() { 75 | override val action: String = Constants.Actions.SET_IS_IN_FOREGROUND 76 | override fun toBundle(): Bundle = Bundle().apply { putBoolean(Constants.Actions.Extras.IS_IN_FOREGROUND, isInForeground) } 77 | } 78 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback.mediasource 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import com.google.android.exoplayer2.drm.DrmSessionManagerProvider 6 | import com.google.android.exoplayer2.offline.Download 7 | import com.google.android.exoplayer2.offline.DownloadHelper 8 | import com.google.android.exoplayer2.source.MediaSource 9 | import com.google.android.exoplayer2.source.dash.DashMediaSource 10 | import com.scribd.armadillo.StateStore 11 | import com.scribd.armadillo.actions.OpeningLicenseAction 12 | import com.scribd.armadillo.download.DownloadEngine 13 | import com.scribd.armadillo.download.DownloadTracker 14 | import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener 15 | import com.scribd.armadillo.models.AudioPlayable 16 | import com.scribd.armadillo.models.DrmType 17 | import com.scribd.armadillo.playback.error.ArmadilloHttpErrorHandlingPolicy 18 | import javax.inject.Inject 19 | 20 | /** For playback, both streaming and downloaded */ 21 | internal class DashMediaSourceGenerator @Inject constructor( 22 | context: Context, 23 | private val mediaSourceFactoryFactory: HeadersMediaSourceFactoryFactory, 24 | private val downloadTracker: DownloadTracker, 25 | private val drmMediaSourceHelper: DrmMediaSourceHelper, 26 | private val drmSessionManagerProvider: DrmSessionManagerProvider, 27 | private val downloadEngine: DownloadEngine, 28 | private val stateStore: StateStore.Modifier, 29 | ) : MediaSourceGenerator { 30 | 31 | private val drmHandler = Handler(context.mainLooper) 32 | 33 | override fun generateMediaSource(mediaId: String, context: Context, request: AudioPlayable.MediaRequest): MediaSource { 34 | if (request.drmInfo != null) { 35 | stateStore.dispatch(OpeningLicenseAction(request.drmInfo.drmType)) 36 | } 37 | val dataSourceFactory = mediaSourceFactoryFactory.createDataSourceFactory(context, request) 38 | 39 | val download = downloadTracker.getDownload(id = mediaId, uri = request.url) 40 | val isDownloaded = download != null && download.state == Download.STATE_COMPLETED 41 | val mediaItem = drmMediaSourceHelper.createMediaItem( 42 | context = context, 43 | id = mediaId, 44 | request = request, 45 | isDownload = isDownloaded 46 | ) 47 | 48 | return if (isDownloaded) { 49 | val drmManager = if (request.drmInfo != null) { 50 | drmSessionManagerProvider.get(mediaItem) 51 | } else null 52 | 53 | if (request.drmInfo?.drmType == DrmType.WIDEVINE) { 54 | downloadEngine.redownloadDrmLicense(id = mediaId, request = request) 55 | } 56 | DownloadHelper.createMediaSource(download!!.request, dataSourceFactory, drmManager) 57 | } else { 58 | var factory = DashMediaSource.Factory(dataSourceFactory) 59 | .setLoadErrorHandlingPolicy(ArmadilloHttpErrorHandlingPolicy()) 60 | if (request.drmInfo != null) { 61 | factory = factory.setDrmSessionManagerProvider(drmSessionManagerProvider) 62 | } 63 | factory.createMediaSource(mediaItem).also { source -> 64 | //download equivalent is in DashDrmLicenseDownloader 65 | when (request.drmInfo?.drmType) { 66 | DrmType.WIDEVINE -> { 67 | source.addDrmEventListener( 68 | drmHandler, 69 | WidevineSessionEventListener() 70 | ) 71 | } 72 | 73 | else -> Unit //no DRM 74 | } 75 | } 76 | } 77 | } 78 | 79 | override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) = 80 | mediaSourceFactoryFactory.updateMediaSourceHeaders(request) 81 | } -------------------------------------------------------------------------------- /TestApp/src/main/java/com/scribd/armadillotestapp/presentation/PlaybackNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillotestapp.presentation 2 | 3 | import android.app.Notification 4 | import android.app.PendingIntent 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.graphics.BitmapFactory 8 | import android.support.v4.media.session.MediaSessionCompat 9 | import android.support.v4.media.session.PlaybackStateCompat 10 | import androidx.core.app.NotificationCompat 11 | import androidx.media.session.MediaButtonReceiver 12 | import com.scribd.armadillo.hasSnowCone 13 | import com.scribd.armadillo.models.AudioPlayable 14 | import com.scribd.armadillo.playback.PlaybackNotificationBuilder 15 | import com.scribd.armadillotestapp.R 16 | 17 | class PlaybackNotificationManager(private val context: Context) : PlaybackNotificationBuilder { 18 | private val playAction = buildAction( 19 | R.drawable.arm_play, 20 | R.string.audio_notification_play_action, 21 | PlaybackStateCompat.ACTION_PLAY) 22 | 23 | private val pauseAction = buildAction( 24 | R.drawable.arm_pause, 25 | R.string.audio_notification_pause_action, 26 | PlaybackStateCompat.ACTION_PAUSE) 27 | 28 | private val forwardAction = buildAction( 29 | R.drawable.arm_skipforward, 30 | R.string.audio_skip_fwd_action, 31 | PlaybackStateCompat.ACTION_FAST_FORWARD) 32 | 33 | private val rewindAction = buildAction( 34 | R.drawable.arm_skipback, 35 | R.string.audio_skip_back_action, 36 | PlaybackStateCompat.ACTION_REWIND) 37 | 38 | override val notificationId: Int = 415 39 | override val channelId: String = "playback_notification" 40 | 41 | override fun build(audioPlayable: AudioPlayable, 42 | currentChapterIndex: Int, 43 | isPlaying: Boolean, 44 | token: MediaSessionCompat.Token): Notification = 45 | androidx.core.app.NotificationCompat.Builder(context, channelId) 46 | .setSmallIcon(android.R.drawable.ic_media_play) 47 | .setContentTitle("Content Title") 48 | .setContentIntent(buildContentIntent(audioPlayable)) 49 | .setContentText("Content Text") 50 | .setTicker("ticker") 51 | .setAutoCancel(false) 52 | .setLocalOnly(true) 53 | .setVisibility(androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC) 54 | .setPriority(androidx.core.app.NotificationCompat.PRIORITY_MAX) 55 | .addAction(rewindAction) 56 | .addAction(if (isPlaying) pauseAction else playAction) 57 | .addAction(forwardAction) 58 | .setOngoing(isPlaying) 59 | .setStyle(androidx.media.app.NotificationCompat.MediaStyle() 60 | .setMediaSession(token) 61 | .setShowActionsInCompactView(0, 1, 2) //Indexes to items added in addAction(). Max 3 items. 62 | .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP)) 63 | .setShowCancelButton(true)) 64 | .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_favicon_round)) 65 | .build() 66 | 67 | private fun buildContentIntent(audioPlayable: AudioPlayable): PendingIntent { 68 | val i = Intent(context, AudioPlayerActivity::class.java) 69 | i.putExtra(MainActivity.AUDIOBOOK_EXTRA, audioPlayable) 70 | val flag = if (hasSnowCone()) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT 71 | return PendingIntent.getActivity(context, 711, i, flag) 72 | } 73 | 74 | private fun buildAction(icon: Int, stringRes: Int, action: Long): NotificationCompat.Action { 75 | return NotificationCompat.Action( 76 | icon, 77 | context.getString(stringRes), 78 | MediaButtonReceiver.buildMediaButtonPendingIntent(context, action)) 79 | } 80 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download.drm 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.C 5 | import com.google.android.exoplayer2.util.Log 6 | import com.google.android.exoplayer2.util.Util 7 | import com.scribd.armadillo.encryption.SecureStorage 8 | import com.scribd.armadillo.error.DrmContentTypeUnsupportedException 9 | import com.scribd.armadillo.extensions.toUri 10 | import com.scribd.armadillo.models.AudioPlayable 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.supervisorScope 14 | import kotlinx.coroutines.withContext 15 | import javax.inject.Inject 16 | import javax.inject.Singleton 17 | 18 | /** 19 | * Manager class responsible for handling DRM downloading/persistence 20 | */ 21 | @Singleton 22 | internal class OfflineDrmManager @Inject constructor( 23 | private val context: Context, 24 | private val secureStorage: SecureStorage, 25 | private val dashDrmLicenseDownloader: DashDrmLicenseDownloader, 26 | ) { 27 | companion object { 28 | private const val TAG = "OfflineDrmManager" 29 | } 30 | 31 | suspend fun downloadDrmLicenseForOffline(id: String, request: AudioPlayable.MediaRequest) { 32 | withContext(Dispatchers.IO) { 33 | request.drmInfo?.let { drmInfo -> 34 | val drmResult = when (@C.ContentType val type = Util.inferContentType(request.url.toUri(), null)) { 35 | C.TYPE_DASH -> dashDrmLicenseDownloader 36 | else -> throw DrmContentTypeUnsupportedException(type) 37 | }.downloadDrmLicense( 38 | requestUrl = request.url, 39 | customRequestHeaders = request.headers, 40 | drmInfo = drmInfo, 41 | ) 42 | 43 | // Persist DRM result, which includes the key ID that can be used to retrieve the offline license 44 | secureStorage.saveDrmDownload(context, id, drmResult) 45 | Log.i(TAG, "DRM license ready for offline usage") 46 | } 47 | } 48 | } 49 | 50 | suspend fun removeDownloadedDrmLicense(id: String, request: AudioPlayable.MediaRequest) { 51 | withContext(Dispatchers.IO) { 52 | request.drmInfo?.let { drmInfo -> 53 | secureStorage.getDrmDownload(context = context, id = id, drmType = drmInfo.drmType)?.let { drmDownload -> 54 | // Remove the persisted download info immediately so audio playback would stop using the offline license 55 | secureStorage.removeDrmDownload(context = context, id = id, drmType = drmInfo.drmType) 56 | 57 | // Release the DRM license 58 | when (val type = drmDownload.audioType) { 59 | C.TYPE_DASH -> dashDrmLicenseDownloader 60 | else -> throw DrmContentTypeUnsupportedException(type) 61 | }.releaseDrmLicense(drmDownload) 62 | } 63 | } 64 | } 65 | } 66 | 67 | suspend fun removeAllDownloadedDrmLicenses() { 68 | withContext(Dispatchers.IO) { 69 | // Make sure that a removal fails, it won't affect the removal of other licenses 70 | supervisorScope { 71 | secureStorage.getAllDrmDownloads(context).forEach { drmDownloadPair -> 72 | launch { 73 | // Remove the persisted download info immediately so audio playback would stop using the offline license 74 | secureStorage.removeDrmDownload(context, drmDownloadPair.key) 75 | 76 | // Release the DRM license 77 | when (val type = drmDownloadPair.value.audioType) { 78 | C.TYPE_DASH -> dashDrmLicenseDownloader 79 | else -> throw DrmContentTypeUnsupportedException(type) 80 | }.releaseDrmLicense(drmDownloadPair.value) 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download.drm 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import android.os.Handler 6 | import com.google.android.exoplayer2.C 7 | import com.google.android.exoplayer2.drm.DrmSessionEventListener 8 | import com.google.android.exoplayer2.drm.OfflineLicenseHelper 9 | import com.google.android.exoplayer2.source.dash.DashUtil 10 | import com.google.android.exoplayer2.upstream.DefaultHttpDataSource 11 | import com.google.android.exoplayer2.util.Log 12 | import com.scribd.armadillo.Constants 13 | import com.scribd.armadillo.StateStore 14 | import com.scribd.armadillo.download.drm.events.WidevineSessionEventListener 15 | import com.scribd.armadillo.error.DrmDownloadException 16 | import com.scribd.armadillo.models.DrmDownload 17 | import com.scribd.armadillo.models.DrmInfo 18 | import com.scribd.armadillo.models.DrmType 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | 22 | @Singleton 23 | internal class DashDrmLicenseDownloader @Inject constructor(context: Context, private val stateStore: StateStore.Modifier) 24 | : DrmLicenseDownloader { 25 | 26 | private val drmDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) 27 | private val audioDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) 28 | private val drmEventDispatcher = DrmSessionEventListener.EventDispatcher() 29 | private val drmHandler = Handler(context.mainLooper) 30 | 31 | private var offlineHelper: OfflineLicenseHelper? = null 32 | 33 | override suspend fun downloadDrmLicense( 34 | requestUrl: String, 35 | customRequestHeaders: Map, 36 | drmInfo: DrmInfo, 37 | ): DrmDownload { 38 | // Update data source for DRM license to add any DRM-specific request headers 39 | drmDataSourceFactory.setDefaultRequestProperties(drmInfo.drmHeaders) 40 | // Update data source for audio to add custom headers 41 | audioDataSourceFactory.setDefaultRequestProperties(customRequestHeaders) 42 | 43 | // Create helper to download DRM license 44 | offlineHelper = findOfflineHelper(drmInfo.drmType, drmInfo.licenseServer) 45 | return try { 46 | val audioDataSource = audioDataSourceFactory.createDataSource() 47 | val manifest = DashUtil.loadManifest(audioDataSource, Uri.parse(requestUrl)) 48 | val format = DashUtil.loadFormatWithDrmInitData(audioDataSource, manifest.getPeriod(0)) 49 | format?.let { 50 | val keyId = offlineHelper!!.downloadLicense(it) 51 | DrmDownload( 52 | drmKeyId = keyId, 53 | drmType = drmInfo.drmType, 54 | licenseServer = drmInfo.licenseServer, 55 | audioType = C.CONTENT_TYPE_DASH, 56 | ) 57 | } ?: throw IllegalStateException("No media format retrieved for audio request") 58 | } catch (e: Exception) { 59 | Log.e(DrmLicenseDownloader.TAG, "Failure to download DRM license for offline usage", e) 60 | throw DrmDownloadException(e) 61 | } 62 | } 63 | 64 | private fun findOfflineHelper(type: DrmType, licenseServerUrl: String): OfflineLicenseHelper = 65 | when (type) { 66 | DrmType.WIDEVINE -> { 67 | OfflineLicenseHelper.newWidevineInstance( 68 | licenseServerUrl, 69 | drmDataSourceFactory, 70 | drmEventDispatcher 71 | ).also { 72 | //streaming equivalent is in DashMediaSourceGenerator 73 | drmEventDispatcher.addEventListener( 74 | drmHandler, 75 | WidevineSessionEventListener() 76 | ) 77 | } 78 | } 79 | } 80 | 81 | override suspend fun releaseDrmLicense(drmDownload: DrmDownload) { 82 | val offlineHelper = offlineHelper ?: findOfflineHelper(drmDownload.drmType, drmDownload.licenseServer) 83 | try { 84 | offlineHelper.releaseLicense(drmDownload.drmKeyId) 85 | } catch (e: Exception) { 86 | Log.e(DrmLicenseDownloader.TAG, "Failure to release downloaded DRM license", e) 87 | throw DrmDownloadException(e) 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /Armadillo/src/main/res/layout/arm_audio_engine_debug_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 17 | 18 | 25 | 26 | 32 | 33 | 34 | 40 | 41 | 47 | 48 | 54 | 55 | 60 | 61 | 66 | 67 | 71 | 72 | 79 | 80 | 88 | 89 | 90 | 95 | 96 | 104 | 105 | 113 | 114 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/playback/ExoPlaybackExceptionExt.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.playback 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.ExoPlaybackException 5 | import com.google.android.exoplayer2.ExoPlaybackException.TYPE_RENDERER 6 | import com.google.android.exoplayer2.ExoPlaybackException.TYPE_SOURCE 7 | import com.google.android.exoplayer2.ParserException 8 | import com.google.android.exoplayer2.audio.AudioSink 9 | import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException 10 | import com.google.android.exoplayer2.drm.MediaDrmCallbackException 11 | import com.google.android.exoplayer2.upstream.DataSpec 12 | import com.google.android.exoplayer2.upstream.HttpDataSource 13 | import com.scribd.armadillo.error.ArmadilloException 14 | import com.scribd.armadillo.error.ArmadilloIOException 15 | import com.scribd.armadillo.error.ConnectivityException 16 | import com.scribd.armadillo.error.DrmPlaybackException 17 | import com.scribd.armadillo.error.HttpResponseCodeException 18 | import com.scribd.armadillo.error.ParsingException 19 | import com.scribd.armadillo.error.RendererConfigurationException 20 | import com.scribd.armadillo.error.RendererInitializationException 21 | import com.scribd.armadillo.error.RendererWriteException 22 | import com.scribd.armadillo.error.UnexpectedException 23 | import com.scribd.armadillo.error.UnknownRendererException 24 | import com.scribd.armadillo.isInternetAvailable 25 | import java.net.SocketTimeoutException 26 | import java.net.UnknownHostException 27 | 28 | internal fun ExoPlaybackException.toArmadilloException(context: Context): ArmadilloException { 29 | return if (TYPE_SOURCE == type) { 30 | return this.sourceException.let { source -> 31 | when (source) { 32 | is HttpDataSource.InvalidResponseCodeException -> 33 | HttpResponseCodeException(source.responseCode, source.dataSpec.uri.toString(), source, source.dataSpec.toAnalyticsMap 34 | (context)) 35 | 36 | is HttpDataSource.HttpDataSourceException -> 37 | HttpResponseCodeException(source.reason, source.dataSpec.uri.toString(), source, source.dataSpec.toAnalyticsMap(context)) 38 | 39 | is MediaDrmCallbackException -> { 40 | val httpCause = source.cause as? HttpDataSource.InvalidResponseCodeException 41 | HttpResponseCodeException(httpCause?.responseCode 42 | ?: 0, httpCause?.dataSpec?.uri.toString(), source, source.dataSpec.toAnalyticsMap(context)) 43 | } 44 | is DrmSessionException -> { 45 | DrmPlaybackException(cause = this) 46 | } 47 | 48 | is UnknownHostException, 49 | is SocketTimeoutException -> ConnectivityException(source) 50 | 51 | else -> { 52 | var cause: Throwable? = source 53 | while (source.cause != null && cause !is ParserException) { 54 | cause = source.cause 55 | } 56 | when (cause) { 57 | is ParserException -> ParsingException(cause = this) 58 | else -> ArmadilloIOException(cause = this, actionThatFailedMessage = "Exoplayer error.") 59 | } 60 | } 61 | } 62 | } 63 | } else if (TYPE_RENDERER == type) { 64 | return this.cause.let { source -> 65 | when (source) { 66 | is AudioSink.ConfigurationException -> RendererConfigurationException(this) 67 | is AudioSink.InitializationException -> RendererInitializationException(this) 68 | is AudioSink.WriteException -> RendererWriteException(this) 69 | else -> UnknownRendererException(this) 70 | } 71 | } 72 | } else { 73 | UnexpectedException(cause = this, actionThatFailedMessage = "Exoplayer error") 74 | } 75 | } 76 | 77 | private fun DataSpec.toAnalyticsMap(context: Context): Map { 78 | return mapOf( 79 | "uri" to uri.toString(), 80 | "uriPositionOffset" to uriPositionOffset.toString(), 81 | "httpMethod" to httpMethod.toString(), 82 | "position" to position.toString(), 83 | "length" to length.toString(), 84 | "key" to key.toString(), 85 | "flags" to flags.toString(), 86 | "customData" to customData.toString(), 87 | "isInternetConnected" to isInternetAvailable(context).toString(), 88 | ) 89 | } -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/mediaitems/ArmadilloMediaBrowse.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.mediaitems 2 | 3 | import android.os.Bundle 4 | import android.support.v4.media.MediaBrowserCompat 5 | import androidx.media.MediaBrowserServiceCompat 6 | import com.scribd.armadillo.ArmadilloPlayer 7 | import com.scribd.armadillo.error.BadMediaHierarchyException 8 | 9 | /** 10 | * Provide Media Content to External Clients outside this app, using the Media Browse Framework built into Android. 11 | * This interface defines the relationship between several classes. 12 | * 13 | * Armadillo's user - the app that has Armadillo built into it - can be considered its "internal client." This user provides media 14 | * content so that other apps, "external clients", can browse the content and play them through Armadillo. External clients can be Android 15 | * Auto, Android Wear, or other third parties. An authenticator interface is provided to separate desired clients and nondesired ones. 16 | * 17 | * MediaItems exist in a hierarchy with a root item that leads to one or more children. 18 | * 19 | * There are two types of MediaItems, playable content and browsable content. 20 | * "Browsable" content works as a type of "folder" that can hold more browsable items and playable items. 21 | * Playable elements represent audio content. Multiple playable contents under a single browsable parent can be considered a sort 22 | * of "playlist" and may be presented as such on an external client designed to play music. Playable items should always be leaves. 23 | * 24 | * Use https://github.com/googlesamples/android-media-controller to test Media Sessions from external clients. 25 | */ 26 | interface ArmadilloMediaBrowse { 27 | 28 | /** Representation of an external client and possible fields it may have to identify itself with. */ 29 | data class ExternalClient(val clientUid: Int?, val clientPackageName: String?) 30 | 31 | /* 32 | * [PlaybackService] : MediaBrowserServiceCompat -> This Android Framework has the following relevant methods: 33 | * 34 | * //first method called by an external client. Performs authorization and returns the root media item if authorization is valid. 35 | * fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? 36 | * 37 | * //Method called by external clients to browse content provided by Armadillo, starting at a point in the hierarchy defined by parent. 38 | * fun onLoadChildren(parentId: String, result: Result>) 39 | */ 40 | 41 | /** Hierarchy Browser of the MediaItem Hierarchy. To be used by [PlaybackService] to browse media content and serve it to external 42 | * clients. */ 43 | interface Browser { 44 | val mediaRootId: String 45 | val isBrowsingEnabled: Boolean 46 | var externalServiceListener: ExternalServiceListener? 47 | 48 | fun determineBrowserRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): MediaBrowserServiceCompat.BrowserRoot? 49 | 50 | /** throws [BadMediaHierarchyException] if the item does not exist in the hierarchy. */ 51 | fun loadChildrenOf(parentId: String, result: MediaBrowserServiceCompat.Result>) 52 | 53 | fun prepareMedia(mediaId: String, playImmediately: Boolean = true): MediaBrowserCompat.MediaItem? 54 | 55 | fun searchForMedia(query: String?, playImmediately: Boolean = true) 56 | 57 | fun checkAuthorization(): AuthorizationStatus 58 | 59 | sealed class AuthorizationStatus { 60 | object Authorized : AuthorizationStatus() 61 | data class Unauthorized(val errorMessage: String) : AuthorizationStatus() 62 | } 63 | } 64 | 65 | /** 66 | * Creator of the MediaItem Hierarchy, to be used by [ArmadilloPlayer] and accessible by the user. 67 | * These fields should be given to Armadillo after initialization, and they shall persist over the life of multiple documents. 68 | * Changes in this class may not be immediately detectable by an external client, not until they browse for content again. */ 69 | interface ContentSharer { 70 | var isBrowsingEnabled: Boolean 71 | var browseController: ArmadilloPlayer.MediaBrowseController? 72 | 73 | /**Tells the external client to reload the content for a given rootId because its children have changed. */ 74 | fun notifyContentChanged(rootId: String) 75 | } 76 | 77 | /** Give to the Playback Service so that it can hear requests to refresh content data for the given rootId.*/ 78 | interface ExternalServiceListener { 79 | fun onContentChangedAndRefreshNeeded(rootId: String) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/download/ExoplayerNotificationUtil.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.download 2 | 3 | /* 4 | * Copyright (C) 2018 The Android Open Source Project 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | 20 | import android.app.Notification 21 | import android.app.PendingIntent 22 | import android.content.Context 23 | import androidx.annotation.DrawableRes 24 | import androidx.annotation.StringRes 25 | import androidx.core.app.NotificationCompat 26 | import com.scribd.armadillo.R 27 | import com.scribd.armadillo.models.DownloadProgressInfo 28 | import com.scribd.armadillo.models.DownloadState 29 | 30 | /** Helper for creating download notifications. From ExoPlayer UI library */ 31 | object ExoplayerNotificationUtil { 32 | 33 | @StringRes 34 | private val NULL_STRING_ID = 0 35 | 36 | /** 37 | * Returns a progress notification for the given download states. 38 | * 39 | * @param context A context for accessing resources. 40 | * @param smallIcon A small icon for the notification. 41 | * @param channelId The id of the notification channel to use. Only required for API level 26 and 42 | * above. 43 | * @param contentIntent An optional content intent to send when the notification is clicked. 44 | * @param message An optional message to display on the notification. 45 | * @param downloadStates The download states 46 | * @return The notification. 47 | */ 48 | fun buildProgressNotification( 49 | context: Context, 50 | @DrawableRes smallIcon: Int, 51 | channelId: String, 52 | contentIntent: PendingIntent?, 53 | message: String?, 54 | downloadStates: Array): Notification { 55 | var totalPercentage = 0 56 | var downloadTaskCount = 0 57 | var allDownloadPercentagesUnknown = true 58 | var hasDownloadedAnyBytes = false 59 | 60 | downloadStates.filter { it.downloadState is DownloadState.STARTED }.forEach { downloadInfo -> 61 | val downloadState: DownloadState.STARTED = downloadInfo.downloadState as DownloadState.STARTED 62 | if (downloadState.percentComplete != DownloadProgressInfo.PROGRESS_UNSET) { 63 | allDownloadPercentagesUnknown = false 64 | totalPercentage += downloadState.percentComplete 65 | } 66 | hasDownloadedAnyBytes = hasDownloadedAnyBytes or (downloadState.downloadedBytes > 0) 67 | downloadTaskCount++ 68 | } 69 | 70 | val haveDownloadTasks = downloadTaskCount > 0 71 | val notificationBuilder = newNotificationBuilder( 72 | context, smallIcon, channelId, contentIntent, message, R.string.arm_downloading) 73 | 74 | val progress = if (haveDownloadTasks) (totalPercentage / downloadTaskCount) else 0 75 | val indeterminate = !haveDownloadTasks || allDownloadPercentagesUnknown && hasDownloadedAnyBytes 76 | notificationBuilder.setProgress(100, progress, indeterminate) 77 | notificationBuilder.setOngoing(true) 78 | notificationBuilder.setShowWhen(false) 79 | return notificationBuilder.build() 80 | } 81 | 82 | private fun newNotificationBuilder( 83 | context: Context, 84 | @DrawableRes smallIcon: Int, 85 | channelId: String, 86 | contentIntent: PendingIntent?, 87 | message: String?, 88 | @StringRes titleStringId: Int): NotificationCompat.Builder { 89 | val notificationBuilder = NotificationCompat.Builder(context, channelId).setSmallIcon(smallIcon) 90 | if (titleStringId != NULL_STRING_ID) { 91 | notificationBuilder.setContentTitle(context.resources.getString(titleStringId)) 92 | } 93 | if (contentIntent != null) { 94 | notificationBuilder.setContentIntent(contentIntent) 95 | } 96 | if (message != null) { 97 | notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) 98 | } 99 | return notificationBuilder 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Armadillo/src/main/java/com/scribd/armadillo/mediaitems/MediaContentSharer.kt: -------------------------------------------------------------------------------- 1 | package com.scribd.armadillo.mediaitems 2 | 3 | import android.os.Bundle 4 | import android.support.v4.media.MediaBrowserCompat 5 | import androidx.media.MediaBrowserServiceCompat 6 | import com.scribd.armadillo.ArmadilloPlayer 7 | import com.scribd.armadillo.StateStore 8 | import com.scribd.armadillo.extensions.getCurrentlyPlayingId 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | internal class MediaContentSharer @Inject constructor(private val playerState: StateStore.Provider) 14 | : ArmadilloMediaBrowse.Browser, ArmadilloMediaBrowse.ContentSharer { 15 | 16 | companion object { 17 | private const val EMPTY_MEDIA_ROOT_ID = "empty_root_id" 18 | } 19 | 20 | //root id will change depending on whether or not the content is authorized to be shared. 21 | override val mediaRootId: String 22 | get() = if (isBrowsingEnabled) { 23 | browseController?.root?.mediaId ?: EMPTY_MEDIA_ROOT_ID 24 | } else { 25 | EMPTY_MEDIA_ROOT_ID 26 | } 27 | 28 | override var browseController: ArmadilloPlayer.MediaBrowseController? = null 29 | override var externalServiceListener: ArmadilloMediaBrowse.ExternalServiceListener? = null 30 | override var isBrowsingEnabled: Boolean = false 31 | 32 | override fun determineBrowserRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?) 33 | : MediaBrowserServiceCompat.BrowserRoot? { 34 | 35 | val authorized = 36 | browseController?.authorizeExternalClient(ArmadilloMediaBrowse.ExternalClient(clientUid, clientPackageName)) ?: false 37 | if (authorized) { 38 | return MediaBrowserServiceCompat.BrowserRoot(mediaRootId, null) 39 | } 40 | return null 41 | } 42 | 43 | override fun loadChildrenOf(parentId: String, result: MediaBrowserServiceCompat.Result>) { 44 | val resultList: MutableList = mutableListOf() 45 | //Empty users not allowed to use this service, / from google instruction guide 46 | if (parentId != EMPTY_MEDIA_ROOT_ID) { 47 | val searchId = getRealMediaId(parentId) 48 | 49 | result.detach() // Wait - expect the user to go to another thread. the external client is told to hold on. 50 | if (isCategoryBrowsable(searchId)) { 51 | val items = browseController?.onChildrenOfCategoryRequested(searchId) ?: emptyList() 52 | resultList.addAll(items) 53 | } else { 54 | //we are being asked to play one specific content item 55 | val currentItem = itemAtId(searchId) 56 | if (currentItem != null) { 57 | resultList.add(currentItem) 58 | } 59 | } 60 | } 61 | 62 | if (resultList.isNotEmpty()) { 63 | result.sendResult(resultList) 64 | } else { 65 | result.sendResult(null) 66 | } 67 | } 68 | 69 | override fun prepareMedia(mediaId: String, playImmediately: Boolean): MediaBrowserCompat.MediaItem? = 70 | playContent(mediaId, playImmediately) 71 | 72 | override fun notifyContentChanged(rootId: String) { 73 | externalServiceListener?.onContentChangedAndRefreshNeeded(rootId) 74 | } 75 | 76 | override fun searchForMedia(query: String?, playImmediately: Boolean) { 77 | searchForContent(query, playImmediately)?.let { 78 | playContent(it.mediaId!!, playImmediately) 79 | } 80 | } 81 | 82 | override fun checkAuthorization(): ArmadilloMediaBrowse.Browser.AuthorizationStatus = browseController?.authorizationStatus() ?: 83 | ArmadilloMediaBrowse.Browser.AuthorizationStatus.Authorized 84 | 85 | private fun playContent(mediaId: String, playImmediately: Boolean): MediaBrowserCompat.MediaItem? = 86 | browseController?.onContentToPlaySelected(mediaId, playImmediately) 87 | 88 | private fun searchForContent(query: String?, playImmediately: Boolean): MediaBrowserCompat.MediaItem? = 89 | browseController?.onContentSearchToPlaySelected(query, playImmediately) 90 | 91 | private fun itemAtId(mediaId: String): MediaBrowserCompat.MediaItem? = 92 | browseController?.getItemFromId(mediaId) 93 | 94 | private fun isCategoryBrowsable(parentId: String) = browseController?.isItemPlayable(parentId) == false 95 | 96 | private fun getRealMediaId(oldMediaId: String): String = 97 | if (oldMediaId == 0.toString()) { //sometimes external clients will send a zero when they want to examine their 'current' object. 98 | (playerState.getCurrentlyPlayingId() ?: 0).toString() 99 | } else { 100 | oldMediaId 101 | } 102 | } --------------------------------------------------------------------------------