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

@@ -69,20 +69,11 @@ val OpenSourceLicenses = arrayOf(
url = "https://square.github.io/okhttp/"
),
OpenSourceLibrary(
name = "Retrofit",
description = "A type-safe HTTP client for Android and Java",
name = "Ktor Client",
description = "A multiplatform asynchronous HTTP client",
licenseName = R.string.apache_license_name,
licenseText = R.raw.license_apache_2,
copyrightNote = "Copyright 2013 Square, Inc.",
url = "https://square.github.io/retrofit/"
),
OpenSourceLibrary(
name = "Gson",
description = "Gson is a Java library that can be used to convert Java Objects into their JSON representation.",
licenseName = R.string.apache_license_name,
licenseText = R.raw.license_apache_2,
copyrightNote = "Copyright 2008 Google Inc.",
url = "https://github.com/google/gson/"
url = "https://ktor.io/"
),
OpenSourceLibrary(
name = "commons-suncalc",

View File

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

View File

@@ -2,28 +2,30 @@ package de.mm20.launcher2.currencies
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import okhttp3.OkHttpClient
import okhttp3.Request
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.jvm.javaio.toInputStream
import org.w3c.dom.Element
import java.text.SimpleDateFormat
import javax.xml.parsers.DocumentBuilderFactory
class ExchangeRateWorker(val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
class ExchangeRateWorker(val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Log.d("MM20", "Updating currency exchange rates")
val httpClient = OkHttpClient()
val request = Request.Builder()
.url("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")
.get()
.build()
val httpClient = HttpClient()
try {
val response = httpClient.newCall(request).execute()
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream()
?: return Result.retry())
val response = httpClient.get {
url("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")
}
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(response.bodyAsChannel().toInputStream())
val cubes = document.getElementsByTagName("Cube")
val values = mutableListOf<Pair<String, Double>>()
var timestamp = System.currentTimeMillis()

View File

@@ -43,7 +43,7 @@ dependencies {
implementation(libs.koin.android)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.bundles.ktor)
implementation(libs.coil.core)
implementation(project(":core:base"))

View File

@@ -16,6 +16,7 @@ fun knownWebsearchByHostname(hostname: String): CustomWebsearchActionBuilder? {
"amazon.cn" -> CustomWebsearchActionBuilder(label = "Amazon CN", urlTemplate = "https://www.amazon.cn/s?k=\${1}")
"duckduckgo.com" -> CustomWebsearchActionBuilder(label = "DuckDuckGo", urlTemplate = "https://duckduckgo.com/?q=\${1}")
"yahoo.com" -> CustomWebsearchActionBuilder(label = "Yahoo", urlTemplate = "https://search.yahoo.com/search?p=\${1}")
"ecosia.org" -> CustomWebsearchActionBuilder(label = "Ecosia", urlTemplate = "https://www.ecosia.org/search?q=\${1}")
else -> null
}
}

View File

@@ -6,7 +6,6 @@ import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.actions.ShareAction
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder

View File

@@ -16,6 +16,11 @@ import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.CustomWebsearchActionBuilder
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -24,8 +29,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
@@ -138,16 +141,14 @@ internal class SearchActionServiceImpl(
iconSize: Int
): CustomWebsearchActionBuilder? {
try {
val httpClient = OkHttpClient()
val request = Request.Builder()
.url(openSearchHref)
.build()
val response = httpClient.newCall(request).execute()
val inputStream = response.body?.byteStream() ?: return null
val httpClient = HttpClient()
val response = httpClient.get {
url(openSearchHref)
}
val inputStream = response.bodyAsChannel().toInputStream()
var label: String? = null
var urlTemplate: String? = null
var icon: String? = null
var largestIconSize: Int = 0
var largestIcon: String? = null

View File

@@ -14,12 +14,8 @@ import de.mm20.launcher2.weather.GeocoderWeatherProvider
import de.mm20.launcher2.weather.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONException
import org.json.JSONObject
import kotlinx.serialization.SerializationException
import org.shredzone.commons.suncalc.SunTimes
import java.io.IOException
import java.security.MessageDigest
@@ -34,7 +30,7 @@ import kotlin.math.roundToInt
internal class MetNoProvider(
private val context: Context,
private val weatherSettings: WeatherSettings,
): GeocoderWeatherProvider(context) {
) : GeocoderWeatherProvider(context) {
private val metNoApi = MetNoApi()
@@ -43,6 +39,7 @@ internal class MetNoProvider(
is WeatherLocation.LatLon -> withContext(Dispatchers.IO) {
getWeatherData(location.lat, location.lon, location.name)
}
else -> {
Log.e("MetNoProvider", "Unsupported location type: $location")
null
@@ -58,7 +55,11 @@ internal class MetNoProvider(
}
@WorkerThread
private suspend fun getWeatherData(lat: Double, lon: Double, locationName: String): List<Forecast>? {
private suspend fun getWeatherData(
lat: Double,
lon: Double,
locationName: String
): List<Forecast>? {
val lastUpdate = weatherSettings.lastUpdate.first()
try {
val forecasts = mutableListOf<Forecast>()
@@ -115,7 +116,7 @@ internal class MetNoProvider(
)
}
return forecasts
} catch (e: JSONException) {
} catch (e: SerializationException) {
CrashReporter.logException(e)
} catch (e: IOException) {
CrashReporter.logException(e)
@@ -180,7 +181,6 @@ internal class MetNoProvider(
}
private fun conditionForCode(code: String): String {
return context.getString(
when (code.substringBefore("_")) {
@@ -240,17 +240,22 @@ internal class MetNoProvider(
"rainshowersandthunder", "snowandthunder", "snowshowersandthunder",
"lightssnowshowersandthunder", "lightsleetandthunder",
"lightsnowandthunder" -> Forecast.THUNDERSTORM
"sleetshowers", "sleet", "lightsleetshowers", "heavysleetshowers", "lightsleet",
"heavysleet" -> Forecast.SLEET
"snowshowers", "snow", "lightsnowshowers", "heavysnowshowers", "lightsnow",
"heavysnow" -> Forecast.SNOW
"heavyrain", "heavyrainshowers" -> Forecast.SHOWERS
"heavyrainandthunder", "sleetshowersandthunder", "rainandthunder", "sleetandthunder",
"lightrainshowersandthunder", "heavyrainshowersandthunder",
"lightssleetshowersandthunder", "lightrainandthunder" -> Forecast.THUNDERSTORM_WITH_RAIN
"fog" -> Forecast.FOG
"heavysleetshowersandthunder",
"heavysleetandthunder" -> Forecast.HEAVY_THUNDERSTORM_WITH_RAIN
"heavysnowshowersandthunder", "heavysnowandthunder" -> Forecast.HEAVY_THUNDERSTORM
else -> Forecast.NONE
}

View File

@@ -46,7 +46,7 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.okhttp)
implementation(libs.bundles.ktor)
implementation(libs.jsoup)
implementation(libs.koin.android)

View File

@@ -6,6 +6,13 @@ import androidx.core.graphics.toColorInt
import de.mm20.launcher2.preferences.search.WebsiteSearchSettings
import de.mm20.launcher2.search.SearchableRepository
import de.mm20.launcher2.search.Website
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.Url
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -13,16 +20,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import java.io.IOException
import java.io.UncheckedIOException
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.concurrent.TimeUnit
internal class WebsiteRepository(
@@ -30,20 +33,20 @@ internal class WebsiteRepository(
val settings: WebsiteSearchSettings,
) : SearchableRepository<Website> {
private val httpClient = OkHttpClient
.Builder()
.connectTimeout(200, TimeUnit.MILLISECONDS)
.readTimeout(3000, TimeUnit.MILLISECONDS)
.writeTimeout(1000, TimeUnit.MILLISECONDS)
.build()
private val httpClient by lazy {
HttpClient {
install(HttpTimeout) {
connectTimeoutMillis = 200
requestTimeoutMillis = 3000
socketTimeoutMillis = 1000
}
}
}
override fun search(query: String, allowNetwork: Boolean): Flow<ImmutableList<Website>> {
if (!allowNetwork) return flowOf(persistentListOf())
return settings.enabled.transformLatest { enabled ->
emit(persistentListOf())
withContext(Dispatchers.IO) {
httpClient.dispatcher.cancelAll()
}
if (!enabled || query.isBlank()) return@transformLatest
val website = queryWebsite(query)
@@ -61,14 +64,11 @@ internal class WebsiteRepository(
"$protocol$query"
if (!URLUtil.isValidUrl(url)) return@withContext null
try {
val request = Request.Builder()
.url(URL(url))
.get()
.tag("onlinesearch")
.build()
val response = httpClient.newCall(request).execute()
val response = httpClient.get {
url(url)
}
url = response.request.url.toString()
val body = response.body?.string() ?: return@withContext null
val body = response.bodyAsText()
val doc = Jsoup.parse(body)
var title = doc.select("meta[property=og:title]").attr("content")
if (title.isBlank()) title = doc.title()
@@ -112,9 +112,9 @@ internal class WebsiteRepository(
return result
}
private fun resolveUrl(url: HttpUrl, link: String): String {
private fun resolveUrl(url: Url, link: String): String {
return try {
URL(url.toUrl(), link).toString()
URL(URL(url.toString()), link).toString()
} catch (e: MalformedURLException) {
""
}

View File

@@ -13,5 +13,5 @@ The following libraries are commonly used:
- **KotlinX serialization** for JSON serialization
- **AndroidX Room** to store launcher data in an SQLite database
- **AndroidX Datastore** to store additional user preferences
- **OkHttp and Retrofit** for HTTP requests
- **Ktor** for HTTP requests
- Several other **AndroidX** libraries (Work, Lifecycle, AppCompat, …)

View File

@@ -40,7 +40,6 @@ accompanist = "0.36.0"
haze = "1.6.10"
coil = "2.7.0"
koin = "4.1.1"
retrofit = "2.11.0"
ktor = "3.2.3"
junit = "4.13.2"
junitVersion = "1.2.1"
@@ -111,10 +110,6 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.9.0" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
coil-core = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
@@ -141,7 +136,6 @@ osmopeninghours = { group = "de.westnordost", name = "osm-opening-hours", versio
[bundles]
kotlin = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android", "kotlinx-collections-immutable", "kotlinx-serialization-json"]
androidx-lifecycle = ["androidx-lifecycle-viewmodel", "androidx-lifecycle-common", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodelcompose", "androidx-lifecycle-runtimecompose"]
retrofit = ["retrofit-core", "retrofit-gson"]
ktor = ["ktor-client-core", "ktor-client-okhttp", "ktor-serialization-kotlinx-json", "ktor-client-content-negotiation"]
tests = ["junit"]

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
)

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")
}
} catch (e: Exception) {
Log.e("NextcloudApiHelper", "HTTP error", e)
return getUserName()
}
}.getOrNull() ?: 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()
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 {
@@ -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
}
@@ -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")
}
} catch (e: Exception) {
CrashReporter.logException(e)
return getUserName()
}
}.getOrNull() ?: 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,16 +57,17 @@ 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
@@ -131,18 +137,27 @@ 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

View File

@@ -47,7 +47,6 @@ dependencies {
implementation(libs.koin.android)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.coil.core)
implementation(project(":data:calculator"))