diff --git a/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt index ec997eef..6deca027 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt @@ -38,6 +38,9 @@ interface SourcesDao { @Upsert suspend fun upsertSubsonicSource(subsonic: SubsonicSource) + @Query("UPDATE subsonic_sources SET useTokenAuth = 0 WHERE sourceId = :sourceId") + suspend fun disableTokenAuth(sourceId: Long) + @Query("DELETE FROM sources WHERE id = :id") suspend fun deleteSourceRow(id: Long) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 8d0c631f..b9fffda3 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -25,6 +25,7 @@ class SourceRepository( private val db: SubtracksDatabase, private val http: OkHttpClient, private val prefs: UserPreferences, + private val showMessage: (String) -> Unit = {}, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -126,22 +127,38 @@ class SourceRepository( ): SubsonicMusicSource = SubsonicMusicSource( id = id, - client = client(address, username, password, useTokenAuth), + client = + client( + address = address, + username = username, + password = password, + useTokenAuth = useTokenAuth, + onTokenAuthUnsupported = { disableTokenAuth(id) }, + ), maxBitrate = maxBitrate, streamFormat = streamFormat, ) + private fun disableTokenAuth(sourceId: Long) { + scope.launch { + db.sourcesDao().disableTokenAuth(sourceId) + showMessage("Server does not support token auth; using the password instead") + } + } + private fun client( address: String, username: String, password: String, useTokenAuth: Boolean, + onTokenAuthUnsupported: (() -> Unit)? = null, ) = SubsonicClient( baseUrl = normalizeAddress(address).toHttpUrl(), username = username, password = password, useTokenAuth = useTokenAuth, http = http, + onTokenAuthUnsupported = onTokenAuthUnsupported, ) private fun normalizeAddress(address: String): String { diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt index ed034c77..e298c2b7 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt @@ -15,10 +15,14 @@ class SubsonicClient( private val baseUrl: HttpUrl, private val username: String, private val password: String, - private val useTokenAuth: Boolean, + useTokenAuth: Boolean, private val http: OkHttpClient, private val userAgent: String = "subtracks/android", + private val onTokenAuthUnsupported: (() -> Unit)? = null, ) { + @Volatile + private var useTokenAuth: Boolean = useTokenAuth + fun uri( method: String, params: Map = emptyMap(), @@ -49,6 +53,19 @@ class SubsonicClient( method: String, params: Map = emptyMap(), body: (InputStream) -> T, + ): T = + try { + execute(method, params, body) + } catch (failure: SubsonicException) { + if (failure.code != TOKEN_AUTH_UNSUPPORTED || !useTokenAuth) throw failure + disableTokenAuth() + execute(method, params, body) + } + + private fun execute( + method: String, + params: Map, + body: (InputStream) -> T, ): T { val request = Request @@ -64,9 +81,17 @@ class SubsonicClient( } } + @Synchronized + private fun disableTokenAuth() { + if (!useTokenAuth) return + useTokenAuth = false + onTokenAuthUnsupported?.invoke() + } + companion object { const val API_VERSION = "1.13.0" const val CLIENT = "subtracks" + const val TOKEN_AUTH_UNSUPPORTED = 41 private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 01982ed9..4e7358f2 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -1,6 +1,9 @@ package com.subtracks.di import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.prefs.createUserPreferences import com.subtracks.data.repo.ArtworkSeedRepository @@ -28,10 +31,20 @@ fun appModule( context: Context, http: OkHttpClient, ) = module { + val mainHandler = Handler(Looper.getMainLooper()) single { createAndroidDatabase(context) } single { http } single { createUserPreferences(context) } - single { SourceRepository(get(), get(), get()) } + single { + SourceRepository( + get(), + get(), + get(), + showMessage = { message -> + mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + }, + ) + } single { LibraryRepository(get(), get()) } single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get()) } diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt index 2fe4c353..e8f41dbe 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt @@ -25,14 +25,22 @@ class SubsonicClientTest { server.shutdown() } - private fun client(tokenAuth: Boolean) = - SubsonicClient( - baseUrl = server.url("/"), - username = "guest", - password = "secret", - useTokenAuth = tokenAuth, - http = OkHttpClient(), - ) + private fun client( + tokenAuth: Boolean, + onTokenAuthUnsupported: (() -> Unit)? = null, + ) = SubsonicClient( + baseUrl = server.url("/"), + username = "guest", + password = "secret", + useTokenAuth = tokenAuth, + http = OkHttpClient(), + onTokenAuthUnsupported = onTokenAuthUnsupported, + ) + + private fun failed( + code: Int, + message: String, + ) = "" @Test fun tokenAuthSendsSaltAndHash() { @@ -90,4 +98,51 @@ class SubsonicClientTest { assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") } } + + @Test + fun tokenAuthUnsupportedRetriesWithPassword() { + server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users."))) + server.enqueue(MockResponse().setBody("")) + var notified = 0 + + client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping") + + val first = server.takeRequest().requestUrl!! + assertNotNull(first.queryParameter("t")) + assertNull(first.queryParameter("p")) + val second = server.takeRequest().requestUrl!! + assertNull(second.queryParameter("t")) + assertEquals("secret", second.queryParameter("p")) + assertEquals(1, notified) + } + + @Test + fun tokenAuthUnsupportedIsNotifiedOnceAndNotRetriedAgain() { + server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users."))) + server.enqueue(MockResponse().setBody("")) + server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users."))) + var notified = 0 + val client = client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }) + + client.check("ping") + assertThrows(SubsonicException::class.java) { client.check("ping") } + + assertEquals(1, notified) + assertEquals(3, server.requestCount) + } + + @Test + fun otherErrorsAreNotRetried() { + server.enqueue(MockResponse().setBody(failed(40, "Wrong username"))) + var notified = 0 + + val error = + assertThrows(SubsonicException::class.java) { + client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping") + } + + assertEquals(40, error.code) + assertEquals(0, notified) + assertEquals(1, server.requestCount) + } }