diff --git a/src/musikdroid/app/build.gradle b/src/musikdroid/app/build.gradle index a53d16871..1cd753676 100644 --- a/src/musikdroid/app/build.gradle +++ b/src/musikdroid/app/build.gradle @@ -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" diff --git a/src/musikdroid/app/libs/videocache-2.8.0-clangen-3.aar b/src/musikdroid/app/libs/videocache-2.8.0-clangen-3.aar new file mode 100644 index 000000000..9d86c4ee6 Binary files /dev/null and b/src/musikdroid/app/libs/videocache-2.8.0-clangen-3.aar differ diff --git a/src/musikdroid/app/libs/videocache-2.8.0-pre.aar b/src/musikdroid/app/libs/videocache-2.8.0-pre.aar deleted file mode 100644 index 953b437d2..000000000 Binary files a/src/musikdroid/app/libs/videocache-2.8.0-pre.aar and /dev/null differ diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/Application.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/Application.kt index 3a88dbc8a..7c945a600 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/Application.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/Application.kt @@ -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 { diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/AppComponent.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/AppComponent.kt index 0c8cd4143..35c90232d 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/AppComponent.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/AppComponent.kt @@ -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 */ diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/ServiceComponent.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/ServiceComponent.kt index 82b700d39..fc57a14ae 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/ServiceComponent.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/ServiceComponent.kt @@ -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) } \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/GaplessHeaderService.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/GaplessHeaderService.kt index f8a5c91d1..e91e7ccf8 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/GaplessHeaderService.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/GaplessHeaderService.kt @@ -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 } } \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDao.java b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDao.java index 9c29b6ea4..636c266c3 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDao.java +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDao.java @@ -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 queryByState(int state); + + @Query("SELECT * FROM GaplessTrack WHERE url=:url") + List queryByUrl(String url); } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDb.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDb.kt index 850b48199..f789a0863 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDb.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessDb.kt @@ -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({ _ -> }, { }) + } } \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessTrack.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessTrack.kt index de775598a..ccf343e12 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessTrack.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/gapless/db/GaplessTrack.kt @@ -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) \ No newline at end of file +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 + } +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/PlayerWrapper.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/PlayerWrapper.kt index 1ffef58f7..1ddbc5f15 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/PlayerWrapper.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/PlayerWrapper.kt @@ -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()) diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamProxy.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamProxy.kt index fe8b69a60..adadc722a 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamProxy.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamProxy.kt @@ -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> -> + /* 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 = mutableMapOf( diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/system/SystemService.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/system/SystemService.kt index 3ebcd388e..19efbb931 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/system/SystemService.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/system/SystemService.kt @@ -259,7 +259,6 @@ class SystemService : Service() { this debouncer ensures we wait at least half a second between updates */ private val mediaSessionDebouncer = object: Debouncer(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 ?: "-") diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/util/NetworkUtil.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/util/NetworkUtil.kt index 7045529e8..0493465d8 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/util/NetworkUtil.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/util/NetworkUtil.kt @@ -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(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) + } }