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/" url = "https://square.github.io/okhttp/"
), ),
OpenSourceLibrary( OpenSourceLibrary(
name = "Retrofit", name = "Ktor Client",
description = "A type-safe HTTP client for Android and Java", description = "A multiplatform asynchronous HTTP client",
licenseName = R.string.apache_license_name, licenseName = R.string.apache_license_name,
licenseText = R.raw.license_apache_2, licenseText = R.raw.license_apache_2,
copyrightNote = "Copyright 2013 Square, Inc.", url = "https://ktor.io/"
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/"
), ),
OpenSourceLibrary( OpenSourceLibrary(
name = "commons-suncalc", name = "commons-suncalc",

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.okhttp) implementation(libs.bundles.ktor)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(project(":core:base")) 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}") "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}") "duckduckgo.com" -> CustomWebsearchActionBuilder(label = "DuckDuckGo", urlTemplate = "https://duckduckgo.com/?q=\${1}")
"yahoo.com" -> CustomWebsearchActionBuilder(label = "Yahoo", urlTemplate = "https://search.yahoo.com/search?p=\${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 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.AppDatabase
import de.mm20.launcher2.database.entities.SearchActionEntity import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf 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.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder 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.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.CustomWebsearchActionBuilder 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -24,8 +29,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
@@ -138,16 +141,14 @@ internal class SearchActionServiceImpl(
iconSize: Int iconSize: Int
): CustomWebsearchActionBuilder? { ): CustomWebsearchActionBuilder? {
try { try {
val httpClient = OkHttpClient() val httpClient = HttpClient()
val request = Request.Builder() val response = httpClient.get {
.url(openSearchHref) url(openSearchHref)
.build() }
val response = httpClient.newCall(request).execute() val inputStream = response.bodyAsChannel().toInputStream()
val inputStream = response.body?.byteStream() ?: return null
var label: String? = null var label: String? = null
var urlTemplate: String? = null var urlTemplate: String? = null
var icon: String? = null
var largestIconSize: Int = 0 var largestIconSize: Int = 0
var largestIcon: String? = null var largestIcon: String? = null

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,6 @@ accompanist = "0.36.0"
haze = "1.6.10" haze = "1.6.10"
coil = "2.7.0" coil = "2.7.0"
koin = "4.1.1" koin = "4.1.1"
retrofit = "2.11.0"
ktor = "3.2.3" ktor = "3.2.3"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" 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" } 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" } 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-core = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", 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] [bundles]
kotlin = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android", "kotlinx-collections-immutable", "kotlinx-serialization-json"] 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"] 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"] ktor = ["ktor-client-core", "ktor-client-okhttp", "ktor-serialization-kotlinx-json", "ktor-client-content-negotiation"]
tests = ["junit"] tests = ["junit"]

View File

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

View File

@@ -1,6 +1,10 @@
package de.mm20.launcher2.nextcloud package de.mm20.launcher2.nextcloud
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class NcUser( data class NcUser(
val displayName: String, val displayName: String,
val username: 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.serialization.Json
import de.mm20.launcher2.webdav.WebDavApi import de.mm20.launcher2.webdav.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException 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.File
import java.io.IOException import java.io.IOException
@@ -31,18 +39,18 @@ class NextcloudApiHelper(val context: Context) {
private val httpClient by lazy { private val httpClient by lazy {
OkHttpClient.Builder() HttpClient {
.authenticator(object : Authenticator { install(ContentNegotiation) {
override fun authenticate(route: Route?, response: Response): Request? { json(Json.Lenient)
if (response.priorResponse?.priorResponse != null) return null }
return response.request defaultRequest {
.newBuilder() val user = getUserName()
.addHeader("Authorization", getAuthorization() ?: return null) val token = getToken()
.build() if (user != null && token != null) {
basicAuth(user, token)
} }
}
}) }
.build()
} }
private val preferences by lazy { private val preferences by lazy {
@@ -81,26 +89,28 @@ class NextcloudApiHelper(val context: Context) {
* and return the response. Returns null if an error occurs. * and return the response. Returns null if an error occurs.
*/ */
internal suspend fun startLoginFlow(serverUrl: String): LoginFlowResponse? { 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 { val response = try {
withContext(Dispatchers.IO) { httpClient.post {
httpClient.newCall(request).execute() url {
takeFrom(serverUrl)
path("index.php", "login", "v2")
}
header(HttpHeaders.UserAgent, context.getString(R.string.app_name))
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e("NextcloudApiHelper", "HTTP error", e) Log.e("NextcloudApiHelper", "HTTP error", e)
null null
} }
if (response?.code != 200 || response.body == null) { if (response?.status != HttpStatusCode.OK) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}") Log.e(
"NextcloudApiHelper",
"Invalid response: ${response?.status} ${response?.bodyAsText()}"
)
return null return null
} }
return try { return try {
Json.Lenient.decodeFromStream<LoginFlowResponse>(response.body!!.byteStream()) response.body<LoginFlowResponse>()
} catch (e: SerializationException) { } catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e) Log.e("NextcloudApiHelper", "Invalid response body", e)
null null
@@ -108,25 +118,27 @@ class NextcloudApiHelper(val context: Context) {
} }
internal suspend fun pollLoginFlow(loginFlow: LoginFlowResponse): LoginPollResponse? { 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 { val response = try {
withContext(Dispatchers.IO) { httpClient.submitForm(
httpClient.newCall(request).execute() url = loginFlow.poll.endpoint,
} formParameters = parameters {
append("token", loginFlow.poll.token)
}
)
} catch (e: IOException) { } catch (e: IOException) {
Log.e("NextcloudApiHelper", "HTTP error", e) Log.e("NextcloudApiHelper", "HTTP error", e)
null null
} }
if (response?.code != 200 || response.body == null) { if (response?.status != HttpStatusCode.OK) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}") Log.e(
"NextcloudApiHelper",
"Invalid response: ${response?.status} ${response?.bodyAsText()}"
)
return null return null
} }
return try { return try {
Json.Lenient.decodeFromStream<LoginPollResponse>(response.body!!.byteStream()) response.body<LoginPollResponse>()
} catch (e: SerializationException) { } catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e) Log.e("NextcloudApiHelper", "Invalid response body", e)
null null
@@ -164,28 +176,33 @@ class NextcloudApiHelper(val context: Context) {
val server = getServer() ?: return null val server = getServer() ?: return null
val request = Request.Builder() val response = try {
.addHeader("OCS-APIRequest", "true") httpClient.get {
.url("$server/ocs/v1.php/cloud/user?format=json") url {
.build() takeFrom(server)
path("ocs", "v1.php", "cloud", "user")
val response = runCatching { parameter("format", "json")
withContext(Dispatchers.IO) { }
httpClient.newCall(request).execute() 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() logout()
return null 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) { return withContext(Dispatchers.IO) {
val json = JSONObject(body.string()) val name = body.ocs.data.displayName
val name = json.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("display-name")
preferences.edit { preferences.edit {
putString("displayname", name) 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? { fun getServer(): String? {
return preferences.getString("server", null) return preferences.getString("server", null)
} }
@@ -225,15 +238,15 @@ class NextcloudApiHelper(val context: Context) {
val username = getUserName() val username = getUserName()
val token = getToken() val token = getToken()
if (server == null || username == null || token == null) return 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) { withContext(Dispatchers.IO) {
try { try {
val response = httpClient.newCall(request).execute() httpClient.delete {
response url {
takeFrom(server)
path("ocs", "v2.php", "core", "apppassword")
}
header("OCS-APIREQUEST", "true")
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e("NextcloudApiHelper", "Error during Nextcloud logout", e) 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.bundles.androidx.lifecycle)
implementation(libs.okhttp) implementation(libs.bundles.ktor)
api(project(":libs:webdav")) api(project(":libs:webdav"))
implementation(project(":core:crashreporter")) implementation(project(":core:crashreporter"))
implementation(project(":core:ktx")) implementation(project(":core:ktx"))
implementation(project(":core:i18n")) 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.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey 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.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile import de.mm20.launcher2.webdav.WebDavFile
import kotlinx.coroutines.Dispatchers import io.ktor.client.HttpClient
import kotlinx.coroutines.withContext import io.ktor.client.call.body
import okhttp3.* import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import org.json.JSONObject 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.File
import java.io.IOException import java.io.IOException
@@ -22,18 +33,18 @@ class OwncloudClient(val context: Context) {
private val httpClient by lazy { private val httpClient by lazy {
OkHttpClient.Builder() HttpClient {
.authenticator(object : Authenticator { install(ContentNegotiation) {
override fun authenticate(route: Route?, response: Response): Request? { json(Json.Lenient)
if (response.priorResponse?.priorResponse != null) return null }
return response.request defaultRequest {
.newBuilder() val user = getUserName()
.addHeader("Authorization", getAuthorization() ?: return null) val token = getToken()
.build() if (user != null && token != null) {
} basicAuth(user, token)
}
}) }
.build() }
} }
private val preferences by lazy { private val preferences by lazy {
@@ -42,7 +53,8 @@ class OwncloudClient(val context: Context) {
private fun createPreferences(catchErrors: Boolean = true): SharedPreferences { private fun createPreferences(catchErrors: Boolean = true): SharedPreferences {
try { 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( return EncryptedSharedPreferences.create(
context, context,
"owncloud", "owncloud",
@@ -71,34 +83,42 @@ class OwncloudClient(val context: Context) {
if (!url.startsWith("http://") && !url.startsWith("https://")) { if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url" 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 { val response = try {
withContext(Dispatchers.IO) { httpClient.get {
httpClient.newCall(request).execute() 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) { } catch (e: IOException) {
Log.e("OwncloudClient", "HTTP error", e) Log.e("OwncloudClient", "HTTP error", e)
return false return false
} }
if (response.code != 200) { if (response.status != HttpStatusCode.OK) {
Log.e("OwncloudClient", "HTTP error: ${response.code}") Log.e("OwncloudClient", "HTTP error: ${response.status}")
return false return false
} }
@@ -118,8 +138,8 @@ class OwncloudClient(val context: Context) {
val displayName = getDisplayName() ?: return null val displayName = getDisplayName() ?: return null
return OcUser( return OcUser(
displayName, displayName,
username username
) )
} }
@@ -134,36 +154,31 @@ class OwncloudClient(val context: Context) {
} }
val server = getServer() ?: return null val server = getServer() ?: return null
val response = try {
val request = Request.Builder() httpClient.get {
.addHeader("OCS-APIRequest", "true") url {
.url("$server/ocs/v1.php/cloud/user?format=json") takeFrom(server)
.build() path("ocs", "v1.php", "cloud", "user")
parameter("format", "json")
val response = runCatching { }
withContext(Dispatchers.IO) { header("OCS-APIRequest", "true")
httpClient.newCall(request).execute()
} }
}.getOrNull() ?: return getUserName() } catch (e: Exception) {
CrashReporter.logException(e)
return getUserName()
}
if (response.code != 200) { if (response.status != HttpStatusCode.OK) {
logout() logout()
return null return null
} }
val body = response.body ?: return getUserName() val body = try {
response.body<UserReponse>()
return withContext(Dispatchers.IO) { } catch (e: SerializationException) {
val json = JSONObject(body.string()) CrashReporter.logException(e)
return getUserName()
return@withContext json.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("display-name")
?: getUserName()
} }
} return body.ocs.data.displayName ?: getUserName()
private fun getAuthorization(): String? {
return Credentials.basic(getUserName() ?: return null, getToken() ?: return null)
} }
fun getServer(): String? { fun getServer(): String? {

View File

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

View File

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

View File

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