Migrate OkHttp to Ktor

This commit is contained in:
MM20
2025-09-13 17:26:40 +02:00
parent f9d5d2a36c
commit 2953667875
22 changed files with 369 additions and 289 deletions

View File

@@ -54,7 +54,7 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.okhttp)
implementation(libs.bundles.ktor)
api(project(":libs:webdav"))
implementation(project(":core:i18n"))

View File

@@ -1,6 +1,10 @@
package de.mm20.launcher2.nextcloud
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class NcUser(
val displayName: String,
val username: String
val displayName: String,
val username: String
)

View File

@@ -11,19 +11,27 @@ import androidx.security.crypto.MasterKey
import de.mm20.launcher2.serialization.Json
import de.mm20.launcher2.webdav.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.basicAuth
import io.ktor.client.request.delete
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.parameters
import io.ktor.http.path
import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONObject
import java.io.File
import java.io.IOException
@@ -31,18 +39,18 @@ class NextcloudApiHelper(val context: Context) {
private val httpClient by lazy {
OkHttpClient.Builder()
.authenticator(object : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.priorResponse?.priorResponse != null) return null
return response.request
.newBuilder()
.addHeader("Authorization", getAuthorization() ?: return null)
.build()
HttpClient {
install(ContentNegotiation) {
json(Json.Lenient)
}
defaultRequest {
val user = getUserName()
val token = getToken()
if (user != null && token != null) {
basicAuth(user, token)
}
})
.build()
}
}
}
private val preferences by lazy {
@@ -81,26 +89,28 @@ class NextcloudApiHelper(val context: Context) {
* and return the response. Returns null if an error occurs.
*/
internal suspend fun startLoginFlow(serverUrl: String): LoginFlowResponse? {
val request = Request.Builder()
.url("$serverUrl/index.php/login/v2")
.method("POST", EMPTY_REQUEST)
.header("user-agent", context.getString(R.string.app_name))
.build()
val response = try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
httpClient.post {
url {
takeFrom(serverUrl)
path("index.php", "login", "v2")
}
header(HttpHeaders.UserAgent, context.getString(R.string.app_name))
}
} catch (e: IOException) {
Log.e("NextcloudApiHelper", "HTTP error", e)
null
}
if (response?.code != 200 || response.body == null) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}")
if (response?.status != HttpStatusCode.OK) {
Log.e(
"NextcloudApiHelper",
"Invalid response: ${response?.status} ${response?.bodyAsText()}"
)
return null
}
return try {
Json.Lenient.decodeFromStream<LoginFlowResponse>(response.body!!.byteStream())
response.body<LoginFlowResponse>()
} catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e)
null
@@ -108,25 +118,27 @@ class NextcloudApiHelper(val context: Context) {
}
internal suspend fun pollLoginFlow(loginFlow: LoginFlowResponse): LoginPollResponse? {
val request = Request.Builder()
.url(loginFlow.poll.endpoint)
.method("POST", FormBody.Builder().add("token", loginFlow.poll.token).build())
.build()
val response = try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
}
httpClient.submitForm(
url = loginFlow.poll.endpoint,
formParameters = parameters {
append("token", loginFlow.poll.token)
}
)
} catch (e: IOException) {
Log.e("NextcloudApiHelper", "HTTP error", e)
null
}
if (response?.code != 200 || response.body == null) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}")
if (response?.status != HttpStatusCode.OK) {
Log.e(
"NextcloudApiHelper",
"Invalid response: ${response?.status} ${response?.bodyAsText()}"
)
return null
}
return try {
Json.Lenient.decodeFromStream<LoginPollResponse>(response.body!!.byteStream())
response.body<LoginPollResponse>()
} catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e)
null
@@ -164,28 +176,33 @@ class NextcloudApiHelper(val context: Context) {
val server = getServer() ?: return null
val request = Request.Builder()
.addHeader("OCS-APIRequest", "true")
.url("$server/ocs/v1.php/cloud/user?format=json")
.build()
val response = runCatching {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
val response = try {
httpClient.get {
url {
takeFrom(server)
path("ocs", "v1.php", "cloud", "user")
parameter("format", "json")
}
header("OCS-APIRequest", "true")
}
}.getOrNull() ?: return getUserName()
} catch (e: Exception) {
Log.e("NextcloudApiHelper", "HTTP error", e)
return getUserName()
}
if (response.code != 200) {
if (response.status != HttpStatusCode.OK) {
logout()
return null
}
val body = response.body ?: return getUserName()
val body = try {
response.body<UserReponse>()
} catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e)
return getUserName()
}
return withContext(Dispatchers.IO) {
val json = JSONObject(body.string())
val name = json.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("display-name")
val name = body.ocs.data.displayName
preferences.edit {
putString("displayname", name)
@@ -196,10 +213,6 @@ class NextcloudApiHelper(val context: Context) {
}
}
private fun getAuthorization(): String? {
return Credentials.basic(getUserName() ?: return null, getToken() ?: return null)
}
fun getServer(): String? {
return preferences.getString("server", null)
}
@@ -225,15 +238,15 @@ class NextcloudApiHelper(val context: Context) {
val username = getUserName()
val token = getToken()
if (server == null || username == null || token == null) return
val request = Request.Builder()
.addHeader("OCS-APIREQUEST", "true")
.delete()
.url("$server/ocs/v2.php/core/apppassword")
.build()
withContext(Dispatchers.IO) {
try {
val response = httpClient.newCall(request).execute()
response
httpClient.delete {
url {
takeFrom(server)
path("ocs", "v2.php", "core", "apppassword")
}
header("OCS-APIREQUEST", "true")
}
} catch (e: IOException) {
Log.e("NextcloudApiHelper", "Error during Nextcloud logout", e)
}

View File

@@ -0,0 +1,20 @@
package de.mm20.launcher2.nextcloud
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class UserReponse(
val ocs: UserReponseOcs
)
@Serializable
internal data class UserReponseOcs(
val data: UserReponseOcsData
)
@Serializable
internal data class UserReponseOcsData(
@SerialName("display-name") val displayName: String?,
)