diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index fa414edf..781e7ba7 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -20,9 +20,12 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import io.heckel.ntfy.service.isConnectionRefused +import io.heckel.ntfy.service.NotAuthorizedException +import io.heckel.ntfy.service.WebSocketNotSupportedException +import io.heckel.ntfy.service.hasCause import kotlinx.coroutines.flow.Flow import java.lang.reflect.Type +import java.net.ConnectException @Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)]) data class Subscription( @@ -105,7 +108,15 @@ data class ConnectionDetails( } fun isConnectionRefused(): Boolean { - return error?.let { isConnectionRefused(it) } ?: false + return error?.hasCause() ?: false + } + + fun isWebSocketNotSupported(): Boolean { + return error?.hasCause() ?: false + } + + fun isNotAuthorized(): Boolean { + return error?.hasCause() ?: false } } diff --git a/app/src/main/java/io/heckel/ntfy/service/Connection.kt b/app/src/main/java/io/heckel/ntfy/service/Connection.kt index 855f00b7..81103446 100644 --- a/app/src/main/java/io/heckel/ntfy/service/Connection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/Connection.kt @@ -2,7 +2,6 @@ package io.heckel.ntfy.service import okhttp3.internal.http2.StreamResetException import java.io.EOFException -import java.net.ConnectException interface Connection { fun start() @@ -10,6 +9,26 @@ interface Connection { fun since(): String? } +/** + * Exception thrown when the server does not support WebSocket connections. + * This typically happens when the server returns a non-101 response during the WebSocket upgrade. + */ +class WebSocketNotSupportedException( + responseCode: Int, + responseMessage: String?, + cause: Throwable? = null +) : Exception("WebSocket upgrade failed with HTTP $responseCode: $responseMessage", cause) + + +/** + * Exception thrown when the server responds with HTTP 401/403 + */ +class NotAuthorizedException( + responseCode: Int, + responseMessage: String?, + cause: Throwable? = null +) : Exception("User not authorized, HTTP $responseCode: $responseMessage", cause) + /** * Represents a unique connection identifier that changes every time a * connection needs to be re-established. @@ -25,16 +44,9 @@ data class ConnectionId( val reconnectVersion: Long // Incremented to force reconnection for this baseUrl ) -/** - * Checks if the throwable or any of its causes is of the specified type. - */ -inline fun Throwable.hasCause(): Boolean { - var current: Throwable? = this - while (current != null) { - if (current is T) return true - current = current.cause - } - return false +fun isResponseCode(response: okhttp3.Response?, vararg codes: Int): Boolean { + val responseCode = response?.code ?: return false + return responseCode in codes } /** @@ -46,9 +58,13 @@ fun isConnectionBrokenException(t: Throwable): Boolean { } /** - * Returns true if the exception indicates the connection was refused - * (e.g., server is down or address is incorrect). + * Checks if the throwable or any of its causes is of the specified type. */ -fun isConnectionRefused(t: Throwable): Boolean { - return t.hasCause() -} +inline fun Throwable.hasCause(): Boolean { + var current: Throwable? = this + while (current != null) { + if (current is T) return true + current = current.cause + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 8cf2268e..e296aa3b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -80,6 +80,7 @@ class JsonConnection( retryMillis = nextRetryMillis(retryMillis, startTime) val nextRetryTime = System.currentTimeMillis() + retryMillis val error = if (isConnectionBrokenException(e)) null else e + // FIXME add NotAuthorizedException connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTING, error, nextRetryTime) Log.w(TAG, "[$url] Retrying connection in ${retryMillis / 1000}s ...") delay(retryMillis) diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index 0443c96c..2bc3e704 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -185,7 +185,17 @@ class WsConnection( errorCount++ val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last() val nextRetryTime = System.currentTimeMillis() + (retrySeconds * 1000L) - val error = if (isConnectionBrokenException(t)) null else t + + // Special cases: + // - Ignore broken connections in the UI, we don't want to show warning icons + // - Handle authentication errors + // - Handle servers that do not support WebSockets + val error = when { + isConnectionBrokenException(t) -> null + isResponseCode(response, 401, 403) -> NotAuthorizedException(response!!.code, response.message, t) + isResponseCode(response, 101) -> WebSocketNotSupportedException(response!!.code, response.message, t) + else -> t + } connectionDetailsListener(subscriptionIds, ConnectionState.CONNECTING, error, nextRetryTime) scheduleReconnect(retrySeconds) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt index a3e1350a..ce7f7bd0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt @@ -197,6 +197,8 @@ class ConnectionErrorFragment : DialogFragment() { if (details != null && details.hasError()) { errorTextView.text = when { details.isConnectionRefused() -> getString(R.string.connection_error_dialog_connection_refused) + details.isWebSocketNotSupported() -> getString(R.string.connection_error_dialog_websocket_not_supported) + details.isNotAuthorized() -> getString(R.string.connection_error_dialog_not_authorized) else -> getErrorDisplayText(details.error) } val stackTrace = details.getStackTraceString() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed8f9b7c..345e8d5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,8 @@ Connection error There was a problem connecting to %1$s. The app will keep trying to reconnect in the background. Connection refused. The server may be down or the address may be incorrect. + WebSocket not supported. The server may not support WebSocket connections, or the address may be incorrect. + Not authorized. The server returned a HTTP 401/403 response. Please check if your username and password are correct. Copy Retry now Retrying in %1$ds…