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?,
)

View File

@@ -54,11 +54,12 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.okhttp)
implementation(libs.bundles.ktor)
api(project(":libs:webdav"))
implementation(project(":core:crashreporter"))
implementation(project(":core:ktx"))
implementation(project(":core:i18n"))
implementation(project(":core:base"))
}

View File

@@ -0,0 +1,20 @@
package de.mm20.launcher2.owncloud
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?,
)

View File

@@ -8,13 +8,24 @@ import android.util.Log
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import androidx.security.crypto.MasterKeys
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.serialization.Json
import de.mm20.launcher2.webdav.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.*
import org.json.JSONObject
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.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import io.ktor.http.appendPathSegments
import io.ktor.http.path
import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerializationException
import java.io.File
import java.io.IOException
@@ -22,18 +33,18 @@ class OwncloudClient(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()
}
})
.build()
HttpClient {
install(ContentNegotiation) {
json(Json.Lenient)
}
defaultRequest {
val user = getUserName()
val token = getToken()
if (user != null && token != null) {
basicAuth(user, token)
}
}
}
}
private val preferences by lazy {
@@ -42,7 +53,8 @@ class OwncloudClient(val context: Context) {
private fun createPreferences(catchErrors: Boolean = true): SharedPreferences {
try {
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val masterKey =
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
context,
"owncloud",
@@ -71,34 +83,42 @@ class OwncloudClient(val context: Context) {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
val request = Request.Builder()
.url("$url/remote.php/webdav")
.build()
val response = runCatching {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
}
}.getOrNull() ?: return false
return response.code == 200 || response.code == 401
}
internal suspend fun checkOwncloudCredentials(server: String, username: String, password: String): Boolean {
val request = Request.Builder()
.addHeader("authorization", Credentials.basic(username, password))
.url("$server/ocs/v1.php/cloud/user?format=json")
.build()
val response = try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
httpClient.get {
url {
takeFrom(url)
appendPathSegments("remote.php", "webdav")
}
}
} catch (e: Exception) {
return false
}
return response.status == HttpStatusCode.OK || response.status == HttpStatusCode.Unauthorized
}
internal suspend fun checkOwncloudCredentials(
server: String,
username: String,
password: String
): Boolean {
val response = try {
httpClient.get {
url {
takeFrom(server)
path("ocs", "v1.php", "cloud", "user")
parameter("format", "json")
}
basicAuth(username, password)
}
} catch (e: IOException) {
Log.e("OwncloudClient", "HTTP error", e)
return false
}
if (response.code != 200) {
Log.e("OwncloudClient", "HTTP error: ${response.code}")
if (response.status != HttpStatusCode.OK) {
Log.e("OwncloudClient", "HTTP error: ${response.status}")
return false
}
@@ -118,8 +138,8 @@ class OwncloudClient(val context: Context) {
val displayName = getDisplayName() ?: return null
return OcUser(
displayName,
username
displayName,
username
)
}
@@ -134,36 +154,31 @@ class OwncloudClient(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) {
CrashReporter.logException(e)
return getUserName()
}
if (response.code != 200) {
if (response.status != HttpStatusCode.OK) {
logout()
return null
}
val body = response.body ?: return getUserName()
return withContext(Dispatchers.IO) {
val json = JSONObject(body.string())
return@withContext json.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("display-name")
?: getUserName()
val body = try {
response.body<UserReponse>()
} catch (e: SerializationException) {
CrashReporter.logException(e)
return getUserName()
}
}
private fun getAuthorization(): String? {
return Credentials.basic(getUserName() ?: return null, getToken() ?: return null)
return body.ocs.data.displayName ?: getUserName()
}
fun getServer(): String? {

View File

@@ -42,7 +42,7 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.okhttp)
implementation(libs.bundles.ktor)
implementation(project(":core:crashreporter"))
implementation(project(":core:ktx"))

View File

@@ -1,25 +1,30 @@
package de.mm20.launcher2.webdav
import com.balsikandar.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.castToOrNull
import de.mm20.launcher2.ktx.decodeUrl
import io.ktor.client.HttpClient
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.appendPathSegments
import io.ktor.http.contentType
import io.ktor.http.takeFrom
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.w3c.dom.Element
import org.xml.sax.SAXException
import java.io.IOException
import java.io.InputStream
import java.lang.Exception
import java.net.URLDecoder
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.parsers.ParserConfigurationException
object WebDavApi {
suspend fun search(webDavUrl: String, username: String, query: String, client: OkHttpClient): List<WebDavFile> {
suspend fun search(
webDavUrl: String,
username: String,
query: String,
client: HttpClient
): List<WebDavFile> {
val requestBody = """
<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
@@ -52,54 +57,55 @@ object WebDavApi {
</d:basicsearch>
</d:searchrequest>
""".trimIndent()
val request = Request.Builder()
.url(webDavUrl)
.method("SEARCH", requestBody.toRequestBody("text/xml".toMediaType()))
.build()
return withContext(Dispatchers.IO) {
val results = mutableListOf<WebDavFile>()
try {
val response = client.newCall(request).execute()
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream()
?: return@withContext emptyList<WebDavFile>())
val response = client.request {
method = HttpMethod("SEARCH")
url(webDavUrl)
setBody(requestBody)
contentType(ContentType.Text.Xml)
}
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(response.bodyAsChannel().toInputStream())
val responses = document.getElementsByTagName("d:response")
for (i in 0 until responses.length) {
val res = responses.item(i) as? Element ?: continue
val url = res.getElementsByTagName("d:href")
.takeIf { it.length > 0 }?.item(0)
?.textContent?.takeIf { it.isNotEmpty() } ?: continue
.takeIf { it.length > 0 }?.item(0)
?.textContent?.takeIf { it.isNotEmpty() } ?: continue
val fileId = res.getElementsByTagName("oc:fileid")
.takeIf { it.length > 0 }?.item(0)
?.textContent?.toLongOrNull() ?: continue
.takeIf { it.length > 0 }?.item(0)
?.textContent?.toLongOrNull() ?: continue
val displayName = res.getElementsByTagName("d:displayname")
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
?: url.trimEnd('/').substringAfterLast("/").decodeUrl("utf8")
?: continue
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
?: url.trimEnd('/').substringAfterLast("/").decodeUrl("utf8")
?: continue
val isDirectory = res.getElementsByTagName("d:resourcetype")
.takeIf { it.length > 0 }
?.item(0)?.childNodes?.length == 1
.takeIf { it.length > 0 }
?.item(0)?.childNodes?.length == 1
val mimeType = res.getElementsByTagName("d:getcontenttype")
.takeIf { it.length > 0 }?.item(0)?.textContent?.takeIf { it.isNotEmpty() }
?: if (isDirectory) "inode/directory" else "application/octet-stream"
.takeIf { it.length > 0 }?.item(0)?.textContent?.takeIf { it.isNotEmpty() }
?: if (isDirectory) "inode/directory" else "application/octet-stream"
val size = res.getElementsByTagName("oc:size")
.takeIf { it.length > 0 }?.item(0)?.textContent?.toLongOrNull()
?: 0L
.takeIf { it.length > 0 }?.item(0)?.textContent?.toLongOrNull()
?: 0L
val owner = res.getElementsByTagName("oc:owner-display-name")
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
results += WebDavFile(
name = displayName,
id = fileId,
isDirectory = isDirectory,
mimeType = mimeType,
size = size,
owner = owner,
url = url
name = displayName,
id = fileId,
isDirectory = isDirectory,
mimeType = mimeType,
size = size,
owner = owner,
url = url
)
}
} catch (e: Exception) {
@@ -131,56 +137,65 @@ object WebDavApi {
""".trimIndent()
}
suspend fun searchReport(webDavUrl: String, username: String, query: String, client: OkHttpClient): List<WebDavFile> {
suspend fun searchReport(
webDavUrl: String,
username: String,
query: String,
client: HttpClient
): List<WebDavFile> {
val requestBody = getSearchRequestBody(query)
val request = Request.Builder()
.url("${webDavUrl}files/$username")
.method("REPORT", requestBody.toRequestBody())
.build()
return withContext(Dispatchers.IO) {
val results = mutableListOf<WebDavFile>()
try {
val response = client.newCall(request).execute()
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream()
?: return@withContext emptyList<WebDavFile>())
val response = client.request {
method = HttpMethod("REPORT")
url {
takeFrom(webDavUrl)
appendPathSegments("files", username)
}
setBody(requestBody)
contentType(ContentType.Text.Xml)
}
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(response.bodyAsChannel().toInputStream())
val responses = document.getElementsByTagName("d:response")
for (i in 0 until responses.length) {
val res = responses.item(i) as? Element ?: continue
val url = res.getElementsByTagName("d:href")
.takeIf { it.length > 0 }?.item(0)
?.textContent?.takeIf { it.isNotEmpty() } ?: continue
.takeIf { it.length > 0 }?.item(0)
?.textContent?.takeIf { it.isNotEmpty() } ?: continue
val fileId = res.getElementsByTagName("oc:fileid")
.takeIf { it.length > 0 }?.item(0)
?.textContent?.toLongOrNull() ?: continue
.takeIf { it.length > 0 }?.item(0)
?.textContent?.toLongOrNull() ?: continue
val displayName = res.getElementsByTagName("d:displayname")
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
?: url.trimEnd('/').substringAfterLast("/").decodeUrl("utf8")
?: continue
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
?: url.trimEnd('/').substringAfterLast("/").decodeUrl("utf8")
?: continue
val isDirectory = res.getElementsByTagName("d:resourcetype")
.takeIf { it.length > 0 }
?.item(0)?.childNodes?.length == 1
.takeIf { it.length > 0 }
?.item(0)?.childNodes?.length == 1
val mimeType = res.getElementsByTagName("d:getcontenttype")
.takeIf { it.length > 0 }?.item(0)?.textContent?.takeIf { it.isNotEmpty() }
?: if (isDirectory) "inode/directory" else "application/octet-stream"
.takeIf { it.length > 0 }?.item(0)?.textContent?.takeIf { it.isNotEmpty() }
?: if (isDirectory) "inode/directory" else "application/octet-stream"
val size = res.getElementsByTagName("oc:size")
.takeIf { it.length > 0 }?.item(0)?.textContent?.toLongOrNull()
?: 0L
.takeIf { it.length > 0 }?.item(0)?.textContent?.toLongOrNull()
?: 0L
val owner = res.getElementsByTagName("oc:owner-display-name")
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
.takeIf { it.length > 0 }?.item(0)?.textContent
?.takeIf { it.isNotEmpty() }
results += WebDavFile(
name = displayName,
id = fileId,
isDirectory = isDirectory,
mimeType = mimeType,
size = size,
owner = owner,
url = url
name = displayName,
id = fileId,
isDirectory = isDirectory,
mimeType = mimeType,
size = size,
owner = owner,
url = url
)
}
} catch (e: Exception) {