Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions automotive/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ android {
applicationId = "com.chamika.dashtune"
minSdk = 28
targetSdk = 36
versionCode = 27
versionName = "1.3.1"
versionCode = 28
versionName = "1.3.2"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -74,6 +74,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.jellyfin.core)
implementation(libs.slf4j.android)
implementation(libs.okhttp)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.chamika.dashtune.tls

import android.net.Uri
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.chamika.dashtune.di.DashTuneModule
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import javax.net.ssl.SSLHandshakeException

/**
* Exercises the media data source — the path playback, buffering and prefetch use — against a
* server whose certificate Android rejects.
*
* Builds the client with the real production provider and wraps it exactly as
* DashTuneMusicService does, so this proves the wiring rather than a copy of it.
*/
@RunWith(AndroidJUnit4::class)
class PinnedMediaPathTest {

private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val store = TrustedCertificateStore(context)

// Unauthenticated Jellyfin endpoint, so this needs no credentials.
private val url = "https://diotify.dedyn.io:4433/System/Info/Public"

@Before
fun clearPins() = store.clear()

@After
fun tearDown() = store.clear()

private fun openMediaDataSource(): Long {
val client = DashTuneModule().provideOkHttpClient(store)
val dataSource = OkHttpDataSource.Factory(client).createDataSource()
return dataSource.open(DataSpec(Uri.parse(url)))
}

@Test
fun untrustedCertificateFailsTheMediaPath() {
try {
openMediaDataSource()
fail("Expected the media data source to reject an untrusted certificate")
} catch (e: Exception) {
assertTrue(
"Expected a TLS failure but got: $e",
generateSequence(e as Throwable) { it.cause }.any { it is SSLHandshakeException }
)
}
}

@Test
fun pinnedCertificateAllowsTheMediaPath() {
val certificate = runBlocking { CertificateInspector().inspect(url) }
assertNotNull("Could not read the server certificate", certificate)
store.pin(certificate!!.host, certificate.certificate)

// Throws if the handshake fails; returning means playback/prefetch can stream from here.
openMediaDataSource()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import com.chamika.dashtune.Constants.LOG_TAG
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
Expand All @@ -20,10 +24,27 @@ import java.util.concurrent.TimeUnit

class AlbumArtContentProvider : ContentProvider() {

private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
/**
* Hilt can't inject a ContentProvider (providers are created before the Application is fully
* initialised), so the shared client is pulled from the entry point instead. It's resolved
* lazily on the first artwork request — long after startup — and reuses the app-wide TLS
* configuration so pinned certificates apply to album art too.
*/
private val client: OkHttpClient by lazy {
EntryPointAccessors
.fromApplication(context!!.applicationContext, AlbumArtEntryPoint::class.java)
.okHttpClient()
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface AlbumArtEntryPoint {
fun okHttpClient(): OkHttpClient
}

companion object {
// Written from the media session/browse threads and read from binder threads,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.NoOpCacheEvictor
Expand Down Expand Up @@ -160,6 +160,9 @@ class DashTuneMusicService : MediaLibraryService() {
@Inject
lateinit var mediaCacheDao: MediaCacheDao

@Inject
lateinit var okHttpClient: okhttp3.OkHttpClient

private lateinit var accountManager: com.chamika.dashtune.auth.JellyfinAccountManager
private lateinit var jellyfinApi: ApiClient
private lateinit var mediaSourceFactory: DefaultMediaSourceFactory
Expand Down Expand Up @@ -195,7 +198,7 @@ class DashTuneMusicService : MediaLibraryService() {
private lateinit var downloadCache: SimpleCache
private lateinit var downloadManager: DownloadManager
private lateinit var cacheDataSourceFactory: CacheDataSource.Factory
private lateinit var httpDataSourceFactory: DefaultHttpDataSource.Factory
private lateinit var httpDataSourceFactory: OkHttpDataSource.Factory

override fun onCreate() {
super.onCreate()
Expand Down Expand Up @@ -230,7 +233,10 @@ class DashTuneMusicService : MediaLibraryService() {
databaseProvider
)

httpDataSourceFactory = DefaultHttpDataSource.Factory()
// OkHttp rather than the default HttpURLConnection stack so streaming, buffering and
// prefetch go through the same TLS configuration as the API calls — including any
// certificate the user approved at sign-in.
httpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(downloadCache)
.setUpstreamDataSourceFactory(httpDataSourceFactory)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,59 @@ import android.accounts.AccountManager
import android.content.Context
import com.chamika.dashtune.R
import com.chamika.dashtune.auth.JellyfinAccountManager
import com.chamika.dashtune.tls.PinnedHostnameVerifier
import com.chamika.dashtune.tls.PinnedTrustManager
import com.chamika.dashtune.tls.TrustedCertificateStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import javax.inject.Singleton
import javax.net.ssl.SSLContext

@Module
@InstallIn(SingletonComponent::class)
class DashTuneModule {

/**
* The single OkHttp client every network path shares — Jellyfin API calls, album art, playback
* and prefetch. Sharing it is what makes a certificate the user approves at sign-in apply to
* streaming and buffering too, rather than only to the login request.
*/
@Provides
fun provideJellyfin(@ApplicationContext appContext: Context): Jellyfin {
@Singleton
fun provideOkHttpClient(store: TrustedCertificateStore): OkHttpClient {
val trustManager = PinnedTrustManager(PinnedTrustManager.platformTrustManager(), store)
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}

return OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.hostnameVerifier(PinnedHostnameVerifier(store))
.build()
}

@Provides
fun provideJellyfin(
@ApplicationContext appContext: Context,
okHttpClient: OkHttpClient,
): Jellyfin {
val version =
appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName

return createJellyfin {
clientInfo = ClientInfo(appContext.getString(R.string.app_name), version ?: "unknown")
deviceInfo = androidDevice(appContext)
context = appContext
apiClientFactory = OkHttpFactory(base = okHttpClient)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@ import com.chamika.dashtune.DashTuneMusicService
import com.chamika.dashtune.DashTuneSessionCallback.Companion.SYNC_COMMAND
import com.chamika.dashtune.R
import com.chamika.dashtune.signin.SignInActivity
import com.chamika.dashtune.tls.TrustedCertificateStore
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject

@AndroidEntryPoint
class SettingsFragment : PreferenceFragmentCompat() {

@Inject
lateinit var trustedCertificateStore: TrustedCertificateStore

private lateinit var viewModel: SettingsViewModel
private lateinit var controllerFuture: ListenableFuture<MediaController>

Expand Down Expand Up @@ -93,6 +98,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}

val trustedCertsPref = findPreference<Preference>("trusted_certificates")
trustedCertsPref?.let { pref ->
refreshTrustedCertificates(pref)
pref.setOnPreferenceClickListener {
showTrustedCertificates(pref)
true
}
}

findPreference<Preference>("force_exit")?.setOnPreferenceClickListener {
AlertDialog.Builder(requireContext())
.setMessage(R.string.force_exit_confirmation)
Expand Down Expand Up @@ -136,6 +150,47 @@ class SettingsFragment : PreferenceFragmentCompat() {
super.onStop()
}

private fun refreshTrustedCertificates(pref: Preference) {
val count = trustedCertificateStore.pinnedCertificates().size
pref.summary = if (count == 0) {
getString(R.string.trusted_certificates_none)
} else {
getString(R.string.trusted_certificates_count, count)
}
pref.isEnabled = count > 0
}

/** Lists approved certificates so a user can withdraw trust without signing out. */
private fun showTrustedCertificates(pref: Preference) {
val pinned = trustedCertificateStore.pinnedCertificates().toList()
if (pinned.isEmpty()) return

val labels = pinned.map { (host, fingerprints) ->
"$host\n${fingerprints.joinToString("\n")}"
}.toTypedArray()

AlertDialog.Builder(requireContext())
.setTitle(R.string.trusted_certificates)
.setItems(labels) { _, index ->
val host = pinned[index].first
AlertDialog.Builder(requireContext())
.setMessage(getString(R.string.trusted_certificate_remove_confirmation, host))
.setPositiveButton(R.string.trusted_certificate_remove_confirm) { _, _ ->
trustedCertificateStore.remove(host)
refreshTrustedCertificates(pref)
Toast.makeText(
requireContext(),
R.string.trusted_certificate_removed,
Toast.LENGTH_SHORT
).show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}

private fun lastSyncSummary(): String {
val lastSync = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getLong("last_sync_timestamp", 0L)
Expand Down
Loading
Loading