Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[APT-10393] Support Rotating Content URLs #46

Merged
merged 4 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 28 additions & 22 deletions Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.scribd.armadillo.exoplayerExternalDirectory
import dagger.Module
import dagger.Provides
import java.io.File
import java.security.KeyStore
import javax.inject.Named
import javax.inject.Qualifier
import javax.inject.Singleton
Expand Down Expand Up @@ -122,35 +123,40 @@ internal class DownloadModule {
@Provides
@Named(Constants.DI.STANDARD_SECURE_STORAGE)
fun standardSecureStorage(context: Context): SharedPreferences {
val keys = MasterKeys.getOrCreate(
KeyGenParameterSpec.Builder("armadilloStandard", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
return EncryptedSharedPreferences.create(
"armadillo.standard.secure",
keys,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
val keystoreAlias = "armadilloStandard"
val fileName = "armadillo.standard.secure"
return createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias)
}

@Singleton
@Provides
@Named(Constants.DI.DRM_SECURE_STORAGE)
fun drmSecureStorage(context: Context): SharedPreferences {
val keys = MasterKeys.getOrCreate(
KeyGenParameterSpec.Builder("armadillo", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
val keystoreAlias = "armadillo"
val fileName = "armadillo.download.secure"
return createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias)
}

private fun createEncryptedSharedPrefsKeyStore(context: Context, fileName: String, keystoreAlias: String)
: SharedPreferences {
val keySpec = KeyGenParameterSpec.Builder(keystoreAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()

val keys = try {
MasterKeys.getOrCreate(keySpec)
} catch (ex: Exception) {
//clear corrupted store, contents will be lost
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(keystoreAlias)
context.getSharedPreferences(fileName, Context.MODE_PRIVATE).edit().clear().apply()
MasterKeys.getOrCreate(keySpec)
}
return EncryptedSharedPreferences.create(
"armadillo.download.secure",
fileName,
keys,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal interface DownloadEngine {
fun removeDownload(audioPlayable: AudioPlayable)
fun removeAllDownloads()
fun updateProgress()
fun redownloadDrmLicense(request: AudioPlayable.MediaRequest)
fun redownloadDrmLicense(id: String, request: AudioPlayable.MediaRequest)
}

/**
Expand Down Expand Up @@ -70,15 +70,20 @@ internal class ExoplayerDownloadEngine @Inject constructor(
scope.launch(errorHandler) {
launch {
// Download DRM license for offline use
offlineDrmManager.downloadDrmLicenseForOffline(audioPlayable.request)
offlineDrmManager.downloadDrmLicenseForOffline(id = audioPlayable.id.toString(), request = audioPlayable.request)
}

launch {
val downloadHelper = downloadHelper(context, audioPlayable.request)
val downloadHelper = downloadHelper(
id = audioPlayable.id.toString(),
context = context,
mediaRequest = audioPlayable.request
)

downloadHelper.prepare(object : DownloadHelper.Callback {
override fun onPrepared(helper: DownloadHelper) {
val request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray())
var request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray())
request = request.copyWithId(audioPlayable.id.toString())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we changing from the byte array to the string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's string everywhere else, and the audioplayable.id is not what's being set as the ID there, its being set as the URL, and then i have to override it back to being the audioplayable.id instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope: I don't think we use the audioPlayable.id.encodeInByteArray() data anywhere, it's a bit of a red herring. I think we can pass null

Also, the var threw me off for a moment thinking there was reason to reassign it.

Any reason not to do:

val request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray()).copyWithId(audioPlayable.id.toString())?

try {
startDownload(context, request)
} catch (e: Exception) {
Expand All @@ -99,8 +104,8 @@ internal class ExoplayerDownloadEngine @Inject constructor(

override fun removeDownload(audioPlayable: AudioPlayable) {
scope.launch(errorHandler) {
launch { downloadManager.removeDownload(audioPlayable.request.url) }
launch { offlineDrmManager.removeDownloadedDrmLicense(audioPlayable.request) }
launch { downloadManager.removeDownload(audioPlayable.id.toString()) }
launch { offlineDrmManager.removeDownloadedDrmLicense(id = audioPlayable.id.toString(), request = audioPlayable.request) }
}
}

Expand All @@ -113,10 +118,10 @@ internal class ExoplayerDownloadEngine @Inject constructor(

override fun updateProgress() = downloadTracker.updateProgress()

override fun redownloadDrmLicense(request: AudioPlayable.MediaRequest) {
override fun redownloadDrmLicense(id: String, request: AudioPlayable.MediaRequest) {
scope.launch(errorHandler) {
try {
offlineDrmManager.downloadDrmLicenseForOffline(request)
offlineDrmManager.downloadDrmLicenseForOffline(id = id, request = request)
} catch (ex: DrmDownloadException){
//continue to try and use old license - a playback error appears elsewhere
}
Expand All @@ -126,7 +131,7 @@ internal class ExoplayerDownloadEngine @Inject constructor(
private fun startDownload(context: Context, downloadRequest: DownloadRequest) =
DownloadService.sendAddDownload(context, downloadService, downloadRequest, true)

private fun downloadHelper(context: Context, mediaRequest: AudioPlayable.MediaRequest): DownloadHelper {
private fun downloadHelper(id: String, context: Context, mediaRequest: AudioPlayable.MediaRequest): DownloadHelper {
val uri = mediaRequest.url.toUri()
val renderersFactory = createRenderersFactory(context)
val dataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context))
Expand All @@ -139,6 +144,7 @@ internal class ExoplayerDownloadEngine @Inject constructor(
}
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaId(id)
.build()
return when (@C.ContentType val type = Util.inferContentType(uri)) {
C.TYPE_HLS,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.scribd.armadillo.download

import android.net.Uri
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.google.android.exoplayer2.offline.Download
Expand All @@ -14,7 +13,6 @@ import com.scribd.armadillo.actions.StopTrackingDownloadAction
import com.scribd.armadillo.actions.UpdateDownloadAction
import com.scribd.armadillo.error.DownloadFailed
import com.scribd.armadillo.error.UnableToLoadDownloadInfo
import com.scribd.armadillo.extensions.toUri
import com.scribd.armadillo.models.DownloadProgressInfo
import com.scribd.armadillo.models.DownloadState
import kotlinx.coroutines.CoroutineScope
Expand All @@ -35,8 +33,8 @@ import javax.inject.Singleton
*/
internal interface DownloadTracker {
fun init()
fun trackDownload(download: ExoplayerDownload)
fun getDownload(uri: Uri): ExoplayerDownload?
fun trackDownload(id: String, download: ExoplayerDownload)
fun getDownload(id: String, uri: String): ExoplayerDownload?
fun updateProgress()
suspend fun loadDownloads()
}
Expand All @@ -58,7 +56,7 @@ internal class ExoplayerDownloadTracker @Inject constructor(
private const val TAG = "DownloadTracker"
}

private val downloads = HashMap<Uri, ExoplayerDownload>()
private val downloads = HashMap<String, ExoplayerDownload>()
private val downloadIndex = downloadManager.downloadIndex

private var isInitialized = false
Expand Down Expand Up @@ -89,8 +87,8 @@ internal class ExoplayerDownloadTracker @Inject constructor(
.use { loadedDownloads ->
while (loadedDownloads.moveToNext()) {
val download = loadedDownloads.download
downloads[download.request.uri] = download
// If we want to resume downloads we should make a call here to the download service to begin download for this uri
downloads[download.request.id] = download
//If we want to resume downloads we should make a call here to the download service to begin download for this id
}
}
} catch (e: IOException) {
Expand All @@ -102,18 +100,22 @@ internal class ExoplayerDownloadTracker @Inject constructor(
}
}

override fun trackDownload(download: ExoplayerDownload) {
if (downloads.containsKey(download.request.uri)) {
override fun trackDownload(id: String, download: ExoplayerDownload) {
if (downloads.containsKey(id) || downloads.containsKey(download.request.uri.toString())) {
return
}
downloads[download.request.uri] = download
downloads[id] = download
}

override fun getDownload(uri: Uri): ExoplayerDownload? = downloads[uri]
override fun getDownload(id: String, uri: String): ExoplayerDownload? = downloads[id] ?: downloads[uri] //older usage

override fun updateProgress() {
downloadManager.currentDownloads.forEach { download ->
downloads[download.request.uri] = download
if(downloads.containsKey(download.request.uri.toString())){
downloads[download.request.uri.toString()] = download //older usage
} else {
downloads[download.request.id] = download
}
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand All @@ -127,7 +129,11 @@ internal class ExoplayerDownloadTracker @Inject constructor(

override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) {
Log.v(TAG, "onDownloadChanged")
downloads[download.request.uri] = download
if(downloads.containsKey(download.request.uri.toString())){
downloads[download.request.uri.toString()] = download //older usage
} else {
downloads[download.request.id] = download
}
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand All @@ -138,7 +144,8 @@ internal class ExoplayerDownloadTracker @Inject constructor(
*/
override fun onDownloadRemoved(downloadManager: DownloadManager, download: ExoplayerDownload) {
Log.v(TAG, "onDownloadRemoved")
downloads.remove(download.request.uri)
downloads.remove(download.request.id)
downloads.remove(download.request.uri.toString()) //older usage
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand Down Expand Up @@ -175,6 +182,7 @@ internal class ExoplayerDownloadTracker @Inject constructor(
}

private fun stopTracking(downloadInfo: DownloadProgressInfo) {
downloads.remove(downloadInfo.url.toUri())
downloads.remove(downloadInfo.id.toString())
downloads.remove(downloadInfo.url) //older usage
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class OfflineDrmManager @Inject constructor(
private const val TAG = "OfflineDrmManager"
}

suspend fun downloadDrmLicenseForOffline(request: AudioPlayable.MediaRequest) {
suspend fun downloadDrmLicenseForOffline(id: String, request: AudioPlayable.MediaRequest) {
withContext(Dispatchers.IO) {
request.drmInfo?.let { drmInfo ->
val drmResult = when (@C.ContentType val type = Util.inferContentType(request.url.toUri(), null)) {
Expand All @@ -41,18 +41,18 @@ internal class OfflineDrmManager @Inject constructor(
)

// Persist DRM result, which includes the key ID that can be used to retrieve the offline license
secureStorage.saveDrmDownload(context, request.url, drmResult)
secureStorage.saveDrmDownload(context, id, drmResult)
Log.i(TAG, "DRM license ready for offline usage")
}
}
}

suspend fun removeDownloadedDrmLicense(request: AudioPlayable.MediaRequest) {
suspend fun removeDownloadedDrmLicense(id: String, request: AudioPlayable.MediaRequest) {
withContext(Dispatchers.IO) {
request.drmInfo?.let { drmInfo ->
secureStorage.getDrmDownload(context, request.url, drmInfo.drmType)?.let { drmDownload ->
secureStorage.getDrmDownload(context = context, id = id, drmType = drmInfo.drmType)?.let { drmDownload ->
// Remove the persisted download info immediately so audio playback would stop using the offline license
secureStorage.removeDrmDownload(context, request.url, drmInfo.drmType)
secureStorage.removeDrmDownload(context = context, id = id, drmType = drmInfo.drmType)

// Release the DRM license
when (val type = drmDownload.audioType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import com.scribd.armadillo.StateStore
import com.scribd.armadillo.actions.LicenseAcquiredAction
import com.scribd.armadillo.actions.LicenseReleasedAction
import com.scribd.armadillo.di.Injector
import com.scribd.armadillo.encryption.SecureStorage
import com.scribd.armadillo.models.DrmType
import javax.inject.Inject

Expand All @@ -17,9 +16,6 @@ internal class WidevineSessionEventListener
@Inject
internal lateinit var stateStore: StateStore.Modifier

@Inject
internal lateinit var secureStorage: SecureStorage

@Inject
internal lateinit var context: Context

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import javax.inject.Singleton

internal interface SecureStorage {
fun downloadSecretKey(context: Context): ByteArray
fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload)
fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload?
fun saveDrmDownload(context: Context, id: String, drmDownload: DrmDownload)
fun getDrmDownload(context: Context, id: String, drmType: DrmType): DrmDownload?
fun getAllDrmDownloads(context: Context): Map<String, DrmDownload>
fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType)
fun removeDrmDownload(context: Context, id: String, drmType: DrmType)
fun removeDrmDownload(context: Context, key: String)
}

Expand All @@ -42,14 +42,14 @@ internal class ArmadilloSecureStorage @Inject constructor(

override fun downloadSecretKey(context: Context): ByteArray {
return if (secureStandardStorage.contains(DOWNLOAD_KEY)) {
val storedKey = secureDrmStorage.getString(DOWNLOAD_KEY, DEFAULT)!!
val storedKey = secureDrmStorage.getString(DOWNLOAD_KEY, DEFAULT) ?: DEFAULT
if (storedKey == DEFAULT) {
Log.e(TAG, "Storage Is Out of Alignment")
}
storedKey.toSecretByteArray
} else if(legacyStandardStorage.contains(DOWNLOAD_KEY)) {
//migrate to secured version
val storedKey = legacyStandardStorage.getString(DOWNLOAD_KEY, DEFAULT)!!
val storedKey = legacyStandardStorage.getString(DOWNLOAD_KEY, DEFAULT) ?: DEFAULT
if (storedKey == DEFAULT) {
Log.e(TAG, "Storage Is Out of Alignment")
}
Expand All @@ -70,14 +70,14 @@ internal class ArmadilloSecureStorage @Inject constructor(
.joinToString("")
}

override fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) {
val alias = getDrmDownloadAlias(audioUrl, drmDownload.drmType)
override fun saveDrmDownload(context: Context, id: String, drmDownload: DrmDownload) {
val alias = getDrmDownloadAlias(id, drmDownload.drmType)
val value = Base64.encodeToString(Json.encodeToString(drmDownload).toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
secureDrmStorage.edit().putString(alias, value).apply()
}

override fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload? {
val alias = getDrmDownloadAlias(audioUrl, drmType)
override fun getDrmDownload(context: Context, id: String, drmType: DrmType): DrmDownload? {
val alias = getDrmDownloadAlias(id, drmType)
var download = secureDrmStorage.getString(alias, null)?.decodeToDrmDownload()
if (download == null && legacyDrmStorage.contains(alias)) {
//migrate old storage to secure storage
Expand All @@ -101,11 +101,11 @@ internal class ArmadilloSecureStorage @Inject constructor(
}
}.toMap()

return drmDownloads.plus(legacyDownloads)
return legacyDownloads.plus(drmDownloads)
}

override fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType) {
val alias = getDrmDownloadAlias(audioUrl, drmType)
override fun removeDrmDownload(context: Context, id: String, drmType: DrmType) {
val alias = getDrmDownloadAlias(id, drmType)
legacyDrmStorage.edit().remove(alias).apply()
secureDrmStorage.edit().remove(alias).apply()
}
Expand All @@ -124,8 +124,8 @@ internal class ArmadilloSecureStorage @Inject constructor(
return keyBytes
}

private fun getDrmDownloadAlias(audioUrl: String, drmType: DrmType) =
Base64.encodeToString(audioUrl.toSecretByteArray + drmType.name.toSecretByteArray, Base64.NO_WRAP)
private fun getDrmDownloadAlias(id: String, drmType: DrmType) =
Base64.encodeToString(id.toSecretByteArray + drmType.name.toSecretByteArray, Base64.NO_WRAP)

private fun String.decodeToDrmDownload(): DrmDownload =
Base64.decode(this, Base64.NO_WRAP).let { resultByteArray ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ internal class ExoplayerPlaybackEngine(private var audioPlayable: AudioPlayable)
exoPlayer = createExoplayerInstance(context, audioAttributes.exoPlayerAttrs, loadControl)

try {
val mediaSource = mediaSourceRetriever.generateMediaSource(audioPlayable.request, context)
val mediaSource = mediaSourceRetriever.generateMediaSource(
mediaId = audioPlayable.id.toString(),
request = audioPlayable.request,
context = context)
exoPlayer.setMediaSource(mediaSource)
exoPlayer.prepare()

Expand Down
Loading
Loading