├── 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 | }
--------------------------------------------------------------------------------