Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.lostf1sh.pixelplayeross.data.database.SongEntity
import com.lostf1sh.pixelplayeross.data.database.SourceType
import com.lostf1sh.pixelplayeross.data.database.toEntity
import com.lostf1sh.pixelplayeross.data.database.toSong
import com.lostf1sh.pixelplayeross.data.network.ConnectionErrors
import com.lostf1sh.pixelplayeross.data.jellyfin.model.JellyfinCredentials
import com.lostf1sh.pixelplayeross.data.jellyfin.model.JellyfinSong
import com.lostf1sh.pixelplayeross.data.model.Song
Expand Down Expand Up @@ -159,7 +160,9 @@ class JellyfinRepository @Inject constructor(
if (authResult.isFailure) {
api.clearCredentials()
return@withContext Result.failure(
authResult.exceptionOrNull() ?: Exception("Authentication failed")
ConnectionErrors.humanize(
authResult.exceptionOrNull() ?: Exception("Authentication failed")
)
)
}

Expand All @@ -182,7 +185,7 @@ class JellyfinRepository @Inject constructor(
Timber.e(e, "$TAG: Login failed")
api.clearCredentials()
_isLoggedInFlow.value = false
Result.failure(e)
Result.failure(ConnectionErrors.humanize(e))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.lostf1sh.pixelplayeross.data.database.toEntity
import com.lostf1sh.pixelplayeross.data.database.SongArtistCrossRef
import com.lostf1sh.pixelplayeross.data.database.SongEntity
import com.lostf1sh.pixelplayeross.data.database.SourceType
import com.lostf1sh.pixelplayeross.data.network.ConnectionErrors
import com.lostf1sh.pixelplayeross.data.database.toSong
import com.lostf1sh.pixelplayeross.data.model.Song
import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeCredentials
Expand Down Expand Up @@ -199,7 +200,9 @@ class NavidromeRepository @Inject constructor(
if (pingResult.isFailure) {
api.clearCredentials()
return@withContext Result.failure(
pingResult.exceptionOrNull() ?: Exception("Connection failed")
ConnectionErrors.humanize(
pingResult.exceptionOrNull() ?: Exception("Connection failed")
)
)
}

Expand All @@ -217,7 +220,7 @@ class NavidromeRepository @Inject constructor(
Timber.e(e, "$TAG: Login failed")
api.clearCredentials()
_isLoggedInFlow.value = false
Result.failure(e)
Result.failure(ConnectionErrors.humanize(e))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.lostf1sh.pixelplayeross.data.network

import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException

/**
* Maps low-level connection failures to messages a user can act on.
*
* Self-hosted Navidrome/Jellyfin servers commonly run behind self-signed
* certificates. The app trusts user-installed CAs (see
* res/xml/network_security_config.xml), but until the user imports their CA
* the TLS handshake fails with a raw Java exception message ("Trust anchor
* for certification path not found") that gives no hint about the fix.
*/
object ConnectionErrors {

private const val UNTRUSTED_CERT_MESSAGE =
"The server's certificate is not trusted by this device. " +
"If your server uses a self-signed certificate, install its CA certificate " +
"in Android Settings (Security > Encryption & credentials > Install a certificate " +
"> CA certificate). PixelPlayer trusts user-installed CA certificates."

private const val HOSTNAME_MISMATCH_MESSAGE =
"The server's certificate does not match its hostname. " +
"Make sure the certificate includes the host or IP address you are connecting to " +
"in its Subject Alternative Names."

/**
* Returns a throwable whose message explains a certificate trust failure in
* actionable terms, or the original throwable unchanged for everything else.
* The original exception is preserved as the cause for logging.
*/
fun humanize(error: Throwable): Throwable {
if (isHostnameMismatch(error)) return Exception(HOSTNAME_MISMATCH_MESSAGE, error)
if (isCertificateTrustFailure(error)) return Exception(UNTRUSTED_CERT_MESSAGE, error)
return error
}

private fun isHostnameMismatch(error: Throwable): Boolean =
causeChain(error).any { it is SSLPeerUnverifiedException }

private fun isCertificateTrustFailure(error: Throwable): Boolean =
causeChain(error).any {
it is SSLHandshakeException ||
it is CertificateException ||
it is CertPathValidatorException
}

private fun causeChain(error: Throwable): Sequence<Throwable> =
generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.lostf1sh.pixelplayeross.data.network

import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException

class ConnectionErrorsTest {

@Test
fun `ssl handshake failure gets an actionable CA install message`() {
val raw = SSLHandshakeException("Trust anchor for certification path not found.")
val humanized = ConnectionErrors.humanize(raw)
assertTrue(humanized.message!!.contains("CA certificate"), humanized.message)
assertSame(raw, humanized.cause)
}

@Test
fun `trust failure buried in the cause chain is still detected`() {
// OkHttp typically wraps CertPathValidatorException inside SSLHandshakeException,
// and repositories may wrap that again.
val raw = IOException(
SSLHandshakeException("handshake failed").apply {
initCause(CertPathValidatorException("Trust anchor not found"))
}
)
val humanized = ConnectionErrors.humanize(raw)
assertTrue(humanized.message!!.contains("CA certificate"), humanized.message)
}

@Test
fun `bare certificate exception is detected`() {
val humanized = ConnectionErrors.humanize(CertificateException("bad cert"))
assertTrue(humanized.message!!.contains("CA certificate"), humanized.message)
}

@Test
fun `hostname mismatch gets a SAN hint instead of the CA message`() {
val raw = SSLPeerUnverifiedException("Hostname 192.168.1.10 not verified")
val humanized = ConnectionErrors.humanize(raw)
assertTrue(humanized.message!!.contains("Subject Alternative Names"), humanized.message)
}

@Test
fun `unrelated errors pass through unchanged`() {
val raw = IOException("Failed to connect to /192.168.1.10:8096")
assertSame(raw, ConnectionErrors.humanize(raw))
}
}