diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt index 11c28a75d..f97f7e268 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -5,11 +5,13 @@ package at.bitfire.davdroid.network import androidx.annotation.VisibleForTesting +import at.bitfire.dav4jvm.ktor.exception.HttpException import at.bitfire.davdroid.settings.Credentials import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString import at.bitfire.davdroid.util.withTrailingSlash import at.bitfire.vcard4android.GroupMethod +import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -18,10 +20,12 @@ import io.ktor.http.URLBuilder import io.ktor.http.Url import io.ktor.http.appendPathSegments import io.ktor.http.contentType +import io.ktor.http.isSuccess import io.ktor.http.path import kotlinx.serialization.Serializable import java.net.URI import javax.inject.Inject +import javax.inject.Provider /** * Implements Nextcloud Login Flow v2. @@ -29,11 +33,9 @@ import javax.inject.Inject * See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 */ class NextcloudLoginFlow @Inject constructor( - httpClientBuilder: HttpClientBuilder + private val httpClientBuilder: Provider ) { - private val httpClient = httpClientBuilder.buildKtor() - // Login flow state var pollUrl: Url? = null var token: String? = null @@ -44,6 +46,8 @@ class NextcloudLoginFlow @Inject constructor( * @param baseUrl Nextcloud login flow or base URL * * @return URL that should be opened in the browser (login screen) + * + * @throws HttpException on non-successful HTTP status */ suspend fun start(baseUrl: Url): Url { // reset fields in case something goes wrong @@ -51,14 +55,18 @@ class NextcloudLoginFlow @Inject constructor( token = null // POST to login flow URL in order to receive endpoint data - val result = httpClient.post(loginFlowUrl(baseUrl)) - val endpointData: EndpointData = result.body() + createClient().use { client -> + val result = client.post(loginFlowUrl(baseUrl)) + if (!result.status.isSuccess()) + throw HttpException.fromResponse(result) - // save endpoint data for polling - pollUrl = Url(endpointData.poll.endpoint) - token = endpointData.poll.token + // save endpoint data for polling + val endpointData: EndpointData = result.body() + pollUrl = Url(endpointData.poll.endpoint) + token = endpointData.poll.token - return Url(endpointData.login) + return Url(endpointData.login) + } } @VisibleForTesting @@ -87,31 +95,45 @@ class NextcloudLoginFlow @Inject constructor( /** * Retrieves login info from the polling endpoint using [pollUrl]/[token]. + * + * @throws HttpException on non-successful HTTP status */ suspend fun fetchLoginInfo(): LoginInfo { val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl") val token = token ?: throw IllegalArgumentException("Missing token") // send HTTP request to request server, login name and app password - val result = httpClient.post(pollUrl) { - contentType(ContentType.Application.FormUrlEncoded) - setBody("token=$token") + createClient().use { client -> + val result = client.post(pollUrl) { + contentType(ContentType.Application.FormUrlEncoded) + setBody("token=$token") + } + if (!result.status.isSuccess()) + throw HttpException.fromResponse(result) + + // make sure server URL ends with a slash so that DAV_PATH can be appended + val loginData: LoginData = result.body() + val serverUrl = loginData.server.withTrailingSlash() + + return LoginInfo( + baseUri = URI(serverUrl).resolve(DAV_PATH), + credentials = Credentials( + username = loginData.loginName, + password = loginData.appPassword.toSensitiveString() + ), + suggestedGroupMethod = GroupMethod.CATEGORIES + ) } - val loginData: LoginData = result.body() - - // make sure server URL ends with a slash so that DAV_PATH can be appended - val serverUrl = loginData.server.withTrailingSlash() - - return LoginInfo( - baseUri = URI(serverUrl).resolve(DAV_PATH), - credentials = Credentials( - username = loginData.loginName, - password = loginData.appPassword.toSensitiveString() - ), - suggestedGroupMethod = GroupMethod.CATEGORIES - ) } + /** + * Creates a Ktor HTTP client that follows redirects. + */ + private fun createClient(): HttpClient = + httpClientBuilder.get() + .followRedirects(true) + .buildKtor() + /** * Represents the JSON response that is returned on the first call to `/login/v2`. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt index 8d0034b21..783dd3f32 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt @@ -124,7 +124,7 @@ class NextcloudLoginModel @AssistedInject constructor( uiState = uiState.copy( inProgress = false, - error = e.toString() + error = e.localizedMessage ?: e.javaClass.simpleName ) } } @@ -149,7 +149,7 @@ class NextcloudLoginModel @AssistedInject constructor( logger.log(Level.WARNING, "Fetching login info failed", e) uiState = uiState.copy( inProgress = false, - error = e.toString() + error = e.localizedMessage ?: e.javaClass.simpleName ) return@launch } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 532450ccb..afb8c0803 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "42d883e958" -bitfire-dav4jvm = "de16b12343" +bitfire-dav4jvm = "57321c95ad" bitfire-synctools = "de78892b5c" compose-accompanist = "0.37.3" compose-bom = "2025.12.00"