Fleshed out GaplessHeaderService (and DAO/DB friends). Tracks downloaded

via realtime transcoder are now patched up after-the-fact, so subsequent
plays are gapless.  Also added a new, custom version of
AndroidVideoCache that supports receiving response headers.
This commit is contained in:
casey langen 2017-11-26 23:40:37 -08:00
parent 33d3c1544c
commit 19db8456c8
14 changed files with 239 additions and 61 deletions

View File

@ -66,7 +66,7 @@ dependencies {
implementation(name:'android-taskrunner-0.5', ext:'aar')
implementation(name:'videocache-2.8.0-pre', ext:'aar')
implementation(name:'videocache-2.8.0-clangen-3', ext:'aar')
implementation 'org.slf4j:slf4j-android:1.7.21'
implementation "android.arch.persistence.room:runtime:1.0.0"

Binary file not shown.

View File

@ -6,10 +6,13 @@ import io.casey.musikcube.remote.injection.AppComponent
import io.casey.musikcube.remote.injection.AppModule
import io.casey.musikcube.remote.injection.DaggerAppComponent
import io.casey.musikcube.remote.injection.ServiceModule
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
import io.fabric.sdk.android.Fabric
import javax.inject.Inject
class Application : android.app.Application() {
@Inject lateinit var gaplessService: GaplessHeaderService
override fun onCreate() {
instance = this
@ -20,14 +23,16 @@ class Application : android.app.Application() {
.serviceModule(ServiceModule())
.build()
appComponent.inject(this)
gaplessService.schedule()
if (BuildConfig.DEBUG) {
Stetho.initializeWithDefaults(this)
}
else {
Fabric.with(this, Crashlytics())
}
NetworkUtil.init()
}
companion object {

View File

@ -2,6 +2,7 @@ package io.casey.musikcube.remote.injection
import android.content.Context
import dagger.Component
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
@ -13,6 +14,8 @@ import io.casey.musikcube.remote.ui.settings.model.ConnectionsDb
@ApplicationScope
@Component(modules = arrayOf(AppModule::class, DataModule::class, ServiceModule::class))
interface AppComponent {
fun inject(app: Application)
fun webSocketService(): WebSocketService /* via ServiceModule */
fun gaplessHeaderService(): GaplessHeaderService /* via ServiceModule */
fun streamProxy(): StreamProxy /* via ServiceModule */

View File

@ -3,6 +3,7 @@ package io.casey.musikcube.remote.injection
import dagger.Component
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
import io.casey.musikcube.remote.service.playback.impl.remote.RemotePlaybackService
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamingPlaybackService
@ServiceScope
@ -11,4 +12,5 @@ interface ServiceComponent {
fun inject(service: StreamingPlaybackService)
fun inject(service: RemotePlaybackService)
fun inject(service: GaplessHeaderService)
fun inject(service: StreamProxy)
}

View File

@ -1,17 +1,124 @@
package io.casey.musikcube.remote.service.gapless
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.os.Message
import android.util.Base64
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.injection.DaggerServiceComponent
import io.casey.musikcube.remote.injection.DataModule
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
import io.casey.musikcube.remote.ui.settings.constants.Prefs
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.RandomAccessFile
import javax.inject.Inject
/**
* When MP3 files are transcoded on-demand, the required metadata for gapless playback isn't
* available yet. So, once we know the transocded files have been downloaded, we run this
* service to fetch and replace the first 32000 bytes of the file, which should ensure the
* required header is present, and subsequent plays are gapless.
*/
class GaplessHeaderService {
@Inject lateinit var db: GaplessDb
@Inject lateinit var streamProxy: StreamProxy
private val prefs = Application.instance!!.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
private val thread: HandlerThread
private val handler: Handler
private val httpClient: OkHttpClient
init {
DaggerServiceComponent.builder()
.appComponent(Application.appComponent)
.build().inject(this)
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
var request = chain.request()
val userPass = "default:" + prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD)!!
val encoded = Base64.encodeToString(userPass.toByteArray(), Base64.NO_WRAP)
request = request.newBuilder().addHeader("Authorization", "Basic " + encoded).build()
chain.proceed(request)
}
if (prefs.getBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, Prefs.Default.CERT_VALIDATION_DISABLED)) {
NetworkUtil.disableCertificateValidation(builder)
}
httpClient = builder.build()
thread = HandlerThread("GaplessHeaderService")
thread.start()
handler = object : Handler(thread.looper) {
override fun handleMessage(msg: Message?) {
if (msg?.what == MESSAGE_PROCESS) {
db.dao().queryByState(GaplessTrack.DOWNLOADED).forEach { process(it) }
}
}
}
}
fun schedule() {
if (!handler.hasMessages(MESSAGE_PROCESS)) {
handler.sendEmptyMessage(MESSAGE_PROCESS)
}
}
private fun process(gaplessTrack: GaplessTrack) {
val url = gaplessTrack.url
val fn = File(streamProxy.getProxyFilename(gaplessTrack.url))
var newState = -1
if (fn.exists()) {
/* the first 32000 bytes should be more than enough to snag the
LAME header that contains gapless playback metadata. just rewrite
those bytes in the already-downloaded file */
val req = Request.Builder()
.addHeader("Range", "bytes=0-32000")
.url(url)
.build()
var res: Response? = null
try {
res = httpClient.newCall(req).execute()
}
catch (ex: Exception) {
newState = GaplessTrack.DOWNLOADED
}
if (res?.code() == 206) {
val bytes = res.body()?.bytes()
if (bytes?.isNotEmpty() == true) {
RandomAccessFile(fn, "rw").use {
it.seek(0)
it.write(bytes)
}
newState = GaplessTrack.UPDATED
}
}
else {
newState = GaplessTrack.ERROR
}
}
if (newState == -1) {
db.dao().deleteByUrl(gaplessTrack.url)
}
else {
db.dao().update(newState, url)
}
}
companion object {
val MESSAGE_PROCESS = 1
}
}

View File

@ -1,7 +1,31 @@
package io.casey.musikcube.remote.service.gapless.db;
import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Insert;
import android.arch.persistence.room.OnConflictStrategy;
import android.arch.persistence.room.Query;
import java.util.List;
@Dao
public class GaplessDao {
public interface GaplessDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(GaplessTrack... track);
@Query("UPDATE GaplessTrack " +
"SET state=:state " +
"WHERE url=:url ")
void update(int state, String url);
@Query("DELETE FROM GaplessTrack WHERE state=:state")
void deleteByState(int state);
@Query("DELETE FROM GaplessTrack WHERE url=:url")
void deleteByUrl(String url);
@Query("SELECT * FROM GaplessTrack WHERE state=:state")
List<GaplessTrack> queryByState(int state);
@Query("SELECT * FROM GaplessTrack WHERE url=:url")
List<GaplessTrack> queryByUrl(String url);
}

View File

@ -2,8 +2,21 @@ package io.casey.musikcube.remote.service.gapless.db
import android.arch.persistence.room.Database
import android.arch.persistence.room.RoomDatabase
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
@Database(entities = arrayOf(GaplessTrack::class), version = 1)
abstract class GaplessDb: RoomDatabase() {
abstract fun dao(): GaplessDao
fun prune() {
Single.fromCallable {
dao().deleteByState(GaplessTrack.DOWNLOADING)
true
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ _ -> }, { })
}
}

View File

@ -4,4 +4,11 @@ import android.arch.persistence.room.Entity
import android.arch.persistence.room.PrimaryKey
@Entity
data class GaplessTrack(@PrimaryKey val id: Long, val url: String, val state: Int)
data class GaplessTrack(@PrimaryKey val id: Long?, val url: String, val state: Int) {
companion object {
val DOWNLOADING = 1
val DOWNLOADED = 2
val UPDATED = 3
val ERROR = 4
}
}

View File

@ -2,7 +2,10 @@ package io.casey.musikcube.remote.service.playback
import android.content.SharedPreferences
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.injection.*
import io.casey.musikcube.remote.injection.DaggerPlaybackComponent
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
import io.casey.musikcube.remote.service.playback.impl.player.ExoPlayerWrapper
import io.casey.musikcube.remote.service.playback.impl.player.GaplessExoPlayerWrapper
import io.casey.musikcube.remote.service.playback.impl.player.MediaPlayerWrapper
@ -20,7 +23,9 @@ import javax.inject.Inject
abstract class PlayerWrapper {
@Inject lateinit var offlineDb: OfflineDb
@Inject lateinit var gaplessDb: GaplessDb
@Inject lateinit var streamProxy: StreamProxy
@Inject lateinit var gaplessService: GaplessHeaderService
init {
DaggerPlaybackComponent.builder()
@ -89,6 +94,11 @@ abstract class PlayerWrapper {
offlineDb.trackDao().insertTrack(track)
offlineDb.prune()
}
gaplessDb.dao().queryByUrl(uri).forEach {
gaplessDb.dao().update(GaplessTrack.DOWNLOADED, it.url)
gaplessService.schedule()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())

View File

@ -7,17 +7,29 @@ import android.util.Base64
import com.danikula.videocache.CacheListener
import com.danikula.videocache.HttpProxyCacheServer
import com.danikula.videocache.file.Md5FileNameGenerator
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.injection.DaggerServiceComponent
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
import io.casey.musikcube.remote.ui.settings.constants.Prefs
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
import io.casey.musikcube.remote.util.Strings
import java.io.File
import java.util.*
import javax.inject.Inject
class StreamProxy(private val context: Context) {
@Inject lateinit var gaplessDb: GaplessDb
private lateinit var proxy: HttpProxyCacheServer
private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
init {
DaggerServiceComponent.builder()
.appComponent(Application.appComponent)
.build().inject(this)
gaplessDb.prune()
restart()
}
@ -38,7 +50,7 @@ class StreamProxy(private val context: Context) {
}
@Synchronized fun getProxyFilename(url: String): String {
return FILENAME_GENERATOR(url)
return proxy.cacheDirectory.canonicalPath + "/" + FILENAME_GENERATOR(url)
}
@Synchronized fun reload() {
@ -73,6 +85,21 @@ class StreamProxy(private val context: Context) {
headers.put("Authorization", "Basic " + encoded)
headers
}
.headerReceiver { url: String, headers: Map<String, List<String>> ->
/* if we have a 'X-musikcube-Estimated-Content-Length' header in the response, that
means the on-demand transcoder is running, therefore gapless information won't be
available yet. track this download so we can patch up the header later, once the
file finishes transcoding */
val estimated = headers[ESTIMATED_LENGTH]
if (estimated?.firstOrNull() == "true") {
synchronized (proxy) {
val dao = gaplessDb.dao()
if (dao.queryByUrl(url).isEmpty()) {
dao.insert(GaplessTrack(null, url, GaplessTrack.DOWNLOADING))
}
}
}
}
.fileNameGenerator(FILENAME_GENERATOR)
.build()
}
@ -80,6 +107,7 @@ class StreamProxy(private val context: Context) {
companion object {
private val BYTES_PER_MEGABYTE = 1048576L
private val BYTES_PER_GIGABYTE = 1073741824L
private val ESTIMATED_LENGTH = "X-musikcube-Estimated-Content-Length"
val MINIMUM_CACHE_SIZE_BYTES = BYTES_PER_MEGABYTE * 128
val CACHE_SETTING_TO_BYTES: MutableMap<Int, Long> = mutableMapOf(

View File

@ -259,7 +259,6 @@ class SystemService : Service() {
this debouncer ensures we wait at least half a second between updates */
private val mediaSessionDebouncer = object: Debouncer<Unit>(500) {
override fun onDebounced(last: Unit?) {
Log.e(TAG, String.format("updatePlaybackState: %s", sessionData.bitmap))
val track = sessionData.track
mediaSession?.setMetadata(MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track?.artist ?: "-")

View File

@ -6,57 +6,10 @@ import java.security.cert.CertificateException
import javax.net.ssl.*
object NetworkUtil {
private var sslContext: SSLContext? = null
private var insecureSslSocketFactory: SSLSocketFactory? = null
private var originalHttpsUrlConnectionSocketFactory: SSLSocketFactory? = null
private var originalHttpsUrlConnectionHostnameVerifier: HostnameVerifier? = null
@Synchronized fun init() {
if (originalHttpsUrlConnectionHostnameVerifier == null) {
originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
}
if (sslContext == null) {
try {
sslContext = SSLContext.getInstance("TLS")
sslContext?.init(null, trustAllCerts, java.security.SecureRandom())
insecureSslSocketFactory = sslContext?.socketFactory
}
catch (ex: Exception) {
throw RuntimeException(ex)
}
}
}
fun disableCertificateValidation(okHttpClient: OkHttpClient.Builder) {
okHttpClient.sslSocketFactory(insecureSslSocketFactory!!, trustAllCerts[0] as X509TrustManager)
okHttpClient.hostnameVerifier { _, _ -> true }
}
fun disableCertificateValidation(socketFactory: WebSocketFactory) {
socketFactory.sslContext = sslContext
}
fun disableCertificateValidation() {
try {
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory)
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
}
catch (e: Exception) {
throw RuntimeException("should never happen")
}
}
fun enableCertificateValidation() {
try {
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier)
}
catch (e: Exception) {
throw RuntimeException("should never happen")
}
}
private var sslContext: SSLContext
private var insecureSslSocketFactory: SSLSocketFactory
private var originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
private var originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
private val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
@Throws(CertificateException::class)
@ -71,4 +24,31 @@ object NetworkUtil {
return arrayOf()
}
})
init {
originalHttpsUrlConnectionSocketFactory
originalHttpsUrlConnectionHostnameVerifier
sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
insecureSslSocketFactory = sslContext.socketFactory
}
fun disableCertificateValidation(okHttpClient: OkHttpClient.Builder) {
okHttpClient.sslSocketFactory(insecureSslSocketFactory, trustAllCerts[0] as X509TrustManager)
okHttpClient.hostnameVerifier { _, _ -> true }
}
fun disableCertificateValidation(socketFactory: WebSocketFactory) {
socketFactory.sslContext = sslContext
}
fun disableCertificateValidation() {
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory)
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
}
fun enableCertificateValidation() {
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier)
}
}