diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42416a3024..854dd65f5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,8 +7,8 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) - alias(libs.plugins.mikepenz.aboutLibraries.android) } @@ -190,8 +190,10 @@ dependencies { implementation(libs.conscrypt) implementation(libs.dnsjava) implementation(libs.guava) + implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.mikepenz.aboutLibraries.m3) implementation(libs.okhttp.base) implementation(libs.okhttp.brotli) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt index c01b612814..7530a38642 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt @@ -29,8 +29,7 @@ class ConscryptIntegration { if (initialized) return - val alreadyInstalled = conscryptInstalled() - if (!alreadyInstalled) { + if (Conscrypt.isAvailable() && !conscryptInstalled()) { // install Conscrypt as most preferred provider Security.insertProviderAt(Conscrypt.newProvider(), 1) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index e233683a9d..dcbe2bf80c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed import dagger.hilt.android.qualifiers.ApplicationContext import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState @@ -391,6 +393,11 @@ class HttpClientBuilder @Inject constructor( val client = HttpClient(OkHttp) { // Ktor-level configuration here + // automatically convert JSON from/into data classes (if requested in respective code) + install(ContentNegotiation) { + json() + } + engine { // okhttp engine configuration here 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 7251ec7958..11c28a75d4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -4,25 +4,22 @@ package at.bitfire.davdroid.network -import at.bitfire.dav4jvm.okhttp.exception.DavException -import at.bitfire.dav4jvm.okhttp.exception.HttpException +import androidx.annotation.VisibleForTesting 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject -import java.net.HttpURLConnection +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.appendPathSegments +import io.ktor.http.contentType +import io.ktor.http.path +import kotlinx.serialization.Serializable import java.net.URI import javax.inject.Inject @@ -35,100 +32,119 @@ class NextcloudLoginFlow @Inject constructor( httpClientBuilder: HttpClientBuilder ) { - companion object { - const val FLOW_V1_PATH = "index.php/login/flow" - const val FLOW_V2_PATH = "index.php/login/v2" - - /** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */ - const val DAV_PATH = "remote.php/dav" - } - - val httpClient = httpClientBuilder.build() - + private val httpClient = httpClientBuilder.buildKtor() // Login flow state - var loginUrl: HttpUrl? = null - var pollUrl: HttpUrl? = null + var pollUrl: Url? = null var token: String? = null - - suspend fun initiate(baseUrl: HttpUrl): HttpUrl? { - loginUrl = null + /** + * Starts Nextcloud Login Flow v2. + * + * @param baseUrl Nextcloud login flow or base URL + * + * @return URL that should be opened in the browser (login screen) + */ + suspend fun start(baseUrl: Url): Url { + // reset fields in case something goes wrong pollUrl = null token = null - val json = postForJson(initiateUrl(baseUrl), "".toRequestBody()) + // POST to login flow URL in order to receive endpoint data + val result = httpClient.post(loginFlowUrl(baseUrl)) + val endpointData: EndpointData = result.body() - loginUrl = json.getString("login").toHttpUrlOrNull() - json.getJSONObject("poll").let { poll -> - pollUrl = poll.getString("endpoint").toHttpUrl() - token = poll.getString("token") - } + // save endpoint data for polling + pollUrl = Url(endpointData.poll.endpoint) + token = endpointData.poll.token - return loginUrl + return Url(endpointData.login) } - fun initiateUrl(baseUrl: HttpUrl): HttpUrl { - val path = baseUrl.encodedPath - - if (path.endsWith(FLOW_V2_PATH)) + @VisibleForTesting + internal fun loginFlowUrl(baseUrl: Url): Url { + return when { // already a Login Flow v2 URL - return baseUrl + baseUrl.encodedPath.endsWith(FLOW_V2_PATH) -> + baseUrl - if (path.endsWith(FLOW_V1_PATH)) // Login Flow v1 URL, rewrite to v2 - return baseUrl.newBuilder() - .encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH)) - .build() - - // other URL, make it a Login Flow v2 URL - return baseUrl.newBuilder() - .addPathSegments(FLOW_V2_PATH) - .build() + baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> { + // drop "[index.php/login]/flow" from the end and append "/v2" + val v2Segments = baseUrl.segments.dropLast(1) + "v2" + val builder = URLBuilder(baseUrl) + builder.path(*v2Segments.toTypedArray()) + builder.build() + } + + // other URL, make it a Login Flow v2 URL + else -> + URLBuilder(baseUrl) + .appendPathSegments(FLOW_V2_PATH.split('/')) + .build() + } } - + /** + * Retrieves login info from the polling endpoint using [pollUrl]/[token]. + */ 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 json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) + val result = httpClient.post(pollUrl) { + contentType(ContentType.Application.FormUrlEncoded) + setBody("token=$token") + } + val loginData: LoginData = result.body() // make sure server URL ends with a slash so that DAV_PATH can be appended - val serverUrl = json.getString("server").withTrailingSlash() + val serverUrl = loginData.server.withTrailingSlash() return LoginInfo( baseUri = URI(serverUrl).resolve(DAV_PATH), credentials = Credentials( - username = json.getString("loginName"), - password = json.getString("appPassword").toSensitiveString() + username = loginData.loginName, + password = loginData.appPassword.toSensitiveString() ), suggestedGroupMethod = GroupMethod.CATEGORIES ) } - private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) { - val postRq = Request.Builder() - .url(url) - .post(requestBody) - .build() - val response = runInterruptible { - httpClient.newCall(postRq).execute() - } + /** + * Represents the JSON response that is returned on the first call to `/login/v2`. + */ + @Serializable + private data class EndpointData( + val poll: Poll, + val login: String + ) { + @Serializable + data class Poll( + val token: String, + val endpoint: String + ) + } - if (response.code != HttpURLConnection.HTTP_OK) - throw HttpException(response) + /** + * Represents the JSON response that is returned by the polling endpoint. + */ + @Serializable + private data class LoginData( + val server: String, + val loginName: String, + val appPassword: String + ) - response.body.use { body -> - val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type") - if (mimeType.type != "application" || mimeType.subtype != "json") - throw DavException("Invalid Login Flow response (not JSON)") - // decode JSON - return@withContext JSONObject(body.string()) - } + companion object { + const val FLOW_V1_PATH = "index.php/login/flow" + const val FLOW_V2_PATH = "index.php/login/v2" + + /** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */ + const val DAV_PATH = "remote.php/dav" } } \ No newline at end of file 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 62c940aaad..8d0034b214 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 @@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.bitfire.dav4jvm.ktor.toUrlOrNull import at.bitfire.davdroid.network.NextcloudLoginFlow import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.http.Url import kotlinx.coroutines.launch -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.util.logging.Level import java.util.logging.Logger @@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor( val error: String? = null, /** URL to open in the browser (set during Login Flow) */ - val loginUrl: HttpUrl? = null, + val loginUrl: Url? = null, /** login info (set after successful login) */ val result: LoginInfo? = null ) { - - val baseHttpUrl: HttpUrl? = run { - val baseUrlWithPrefix = - if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) - baseUrl - else - "https://$baseUrl" - - baseUrlWithPrefix.toHttpUrlOrNull() - } - - val canContinue = !inProgress && baseHttpUrl != null - + val baseUrlWithPrefix = + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) + baseUrl + else + "https://$baseUrl" + val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull() + + val canContinue = !inProgress && baseKtorUrl != null } var uiState by mutableStateOf(UiState()) @@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor( * Starts the Login Flow. */ fun startLoginFlow() { - val baseUrl = uiState.baseHttpUrl + val baseUrl = uiState.baseKtorUrl if (uiState.inProgress || baseUrl == null) return @@ -118,13 +113,12 @@ class NextcloudLoginModel @AssistedInject constructor( viewModelScope.launch { try { - val loginUrl = loginFlow.initiate(baseUrl) + val loginUrl = loginFlow.start(baseUrl) uiState = uiState.copy( loginUrl = loginUrl, inProgress = false ) - } catch (e: Exception) { logger.log(Level.WARNING, "Initiating Login Flow failed", e) diff --git a/app/src/test/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlowTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlowTest.kt new file mode 100644 index 0000000000..24607dd191 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlowTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import io.ktor.http.Url +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +class NextcloudLoginFlowTest { + + private val flow = NextcloudLoginFlow(mockk(relaxed = true)) + + @Test + fun `loginFlowUrl accepts v2 URL`() { + assertEquals( + Url("http://example.com/index.php/login/v2"), + flow.loginFlowUrl(Url("http://example.com/index.php/login/v2")) + ) + } + + @Test + fun `loginFlowUrl rewrites root URL to v2 URL`() { + assertEquals( + Url("http://example.com/index.php/login/v2"), + flow.loginFlowUrl(Url("http://example.com/")) + ) + } + + @Test + fun `loginFlowUrl rewrites v1 URL to v2 URL`() { + assertEquals( + Url("http://example.com/index.php/login/v2"), + flow.loginFlowUrl(Url("http://example.com/index.php/login/flow")) + ) + } + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd28ce518d..9f8d41bd97 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 = "acd9bca096" +bitfire-dav4jvm = "de16b12343" bitfire-synctools = "5fb54ec88c" compose-accompanist = "0.37.3" compose-bom = "2025.11.01" @@ -95,8 +95,10 @@ junit = { module = "junit:junit", version = "4.13.2" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -119,5 +121,6 @@ android-application = { id = "com.android.application", version.ref = "android-a compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }