diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42416a302..854dd65f5 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 c01b61281..7530a3864 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 e233683a9..dcbe2bf80 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 7251ec795..11c28a75d 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,6 +32,113 @@ class NextcloudLoginFlow @Inject constructor( httpClientBuilder: HttpClientBuilder ) { + private val httpClient = httpClientBuilder.buildKtor() + + // Login flow state + var pollUrl: Url? = null + var token: String? = 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 + + // POST to login flow URL in order to receive endpoint data + val result = httpClient.post(loginFlowUrl(baseUrl)) + val endpointData: EndpointData = result.body() + + // save endpoint data for polling + pollUrl = Url(endpointData.poll.endpoint) + token = endpointData.poll.token + + return Url(endpointData.login) + } + + @VisibleForTesting + internal fun loginFlowUrl(baseUrl: Url): Url { + return when { + // already a Login Flow v2 URL + baseUrl.encodedPath.endsWith(FLOW_V2_PATH) -> + baseUrl + + // Login Flow v1 URL, rewrite to v2 + 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 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 = loginData.server.withTrailingSlash() + + return LoginInfo( + baseUri = URI(serverUrl).resolve(DAV_PATH), + credentials = Credentials( + username = loginData.loginName, + password = loginData.appPassword.toSensitiveString() + ), + suggestedGroupMethod = GroupMethod.CATEGORIES + ) + } + + + /** + * 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 + ) + } + + /** + * 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 + ) + + companion object { const val FLOW_V1_PATH = "index.php/login/flow" const val FLOW_V2_PATH = "index.php/login/v2" @@ -43,92 +147,4 @@ class NextcloudLoginFlow @Inject constructor( const val DAV_PATH = "remote.php/dav" } - val httpClient = httpClientBuilder.build() - - - // Login flow state - var loginUrl: HttpUrl? = null - var pollUrl: HttpUrl? = null - var token: String? = null - - - suspend fun initiate(baseUrl: HttpUrl): HttpUrl? { - loginUrl = null - pollUrl = null - token = null - - val json = postForJson(initiateUrl(baseUrl), "".toRequestBody()) - - loginUrl = json.getString("login").toHttpUrlOrNull() - json.getJSONObject("poll").let { poll -> - pollUrl = poll.getString("endpoint").toHttpUrl() - token = poll.getString("token") - } - - return loginUrl - } - - fun initiateUrl(baseUrl: HttpUrl): HttpUrl { - val path = baseUrl.encodedPath - - if (path.endsWith(FLOW_V2_PATH)) - // already a Login Flow v2 URL - return 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() - } - - - 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())) - - // make sure server URL ends with a slash so that DAV_PATH can be appended - val serverUrl = json.getString("server").withTrailingSlash() - - return LoginInfo( - baseUri = URI(serverUrl).resolve(DAV_PATH), - credentials = Credentials( - username = json.getString("loginName"), - password = json.getString("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() - } - - if (response.code != HttpURLConnection.HTTP_OK) - throw HttpException(response) - - 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()) - } - } - } \ 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 62c940aaa..8d0034b21 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 baseUrlWithPrefix = + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) + baseUrl + else + "https://$baseUrl" + val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull() - 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 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 000000000..24607dd19 --- /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 cd28ce518..9f8d41bd9 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" }