diff --git a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt index 8f273a8d9..744c4e903 100644 --- a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt +++ b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt @@ -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", diff --git a/data/currencies/build.gradle.kts b/data/currencies/build.gradle.kts index a040309a9..a8ea492ff 100644 --- a/data/currencies/build.gradle.kts +++ b/data/currencies/build.gradle.kts @@ -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")) diff --git a/data/currencies/src/main/java/de/mm20/launcher2/currencies/ExchangeRateWorker.kt b/data/currencies/src/main/java/de/mm20/launcher2/currencies/ExchangeRateWorker.kt index 65b2bbd66..d23f20363 100644 --- a/data/currencies/src/main/java/de/mm20/launcher2/currencies/ExchangeRateWorker.kt +++ b/data/currencies/src/main/java/de/mm20/launcher2/currencies/ExchangeRateWorker.kt @@ -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>() var timestamp = System.currentTimeMillis() @@ -41,9 +43,9 @@ class ExchangeRateWorker(val context: Context, params: WorkerParameters) : Worke } val currencies = values.map { Currency( - symbol = it.first, - value = it.second, - lastUpdate = timestamp + symbol = it.first, + value = it.second, + lastUpdate = timestamp ).toDatabaseEntity() } AppDatabase.getInstance(context).currencyDao().insertAll(currencies) diff --git a/data/search-actions/build.gradle.kts b/data/search-actions/build.gradle.kts index 36fc3d700..9bd1db64c 100644 --- a/data/search-actions/build.gradle.kts +++ b/data/search-actions/build.gradle.kts @@ -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")) diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/KnownWebsearchEngines.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/KnownWebsearchEngines.kt index f65cfd412..2bb8d8b4b 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/KnownWebsearchEngines.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/KnownWebsearchEngines.kt @@ -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 } } diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt index 4406ba6e7..ec7b824c8 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt @@ -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 diff --git a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt index 94772f91e..860df0ffe 100644 --- a/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt +++ b/data/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt @@ -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 diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt index 2c72d817d..05879b97b 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt @@ -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? { + private suspend fun getWeatherData( + lat: Double, + lon: Double, + locationName: String + ): List? { val lastUpdate = weatherSettings.lastUpdate.first() try { val forecasts = mutableListOf() @@ -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 } diff --git a/data/websites/build.gradle.kts b/data/websites/build.gradle.kts index d5f473fbc..e798ce75a 100644 --- a/data/websites/build.gradle.kts +++ b/data/websites/build.gradle.kts @@ -46,7 +46,7 @@ dependencies { implementation(libs.bundles.androidx.lifecycle) - implementation(libs.okhttp) + implementation(libs.bundles.ktor) implementation(libs.jsoup) implementation(libs.koin.android) diff --git a/data/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt b/data/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt index c749a9301..2d0816132 100644 --- a/data/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt +++ b/data/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt @@ -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 { - 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> { 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) { "" } diff --git a/docs/docs/developer-guide/project-structure/libraries.md b/docs/docs/developer-guide/project-structure/libraries.md index 90b171f9c..6345c8339 100644 --- a/docs/docs/developer-guide/project-structure/libraries.md +++ b/docs/docs/developer-guide/project-structure/libraries.md @@ -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, …) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68b078185..d2d228e51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/libs/nextcloud/build.gradle.kts b/libs/nextcloud/build.gradle.kts index 5e340532c..f21cdfe8b 100644 --- a/libs/nextcloud/build.gradle.kts +++ b/libs/nextcloud/build.gradle.kts @@ -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")) diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NcUser.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NcUser.kt index 67ada948e..3e35f8278 100644 --- a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NcUser.kt +++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NcUser.kt @@ -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 ) + diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt index 3003788af..71dacbb81 100644 --- a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt +++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt @@ -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(response.body!!.byteStream()) + response.body() } 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(response.body!!.byteStream()) + response.body() } 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() + } 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) } diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/Ocs.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/Ocs.kt new file mode 100644 index 000000000..fd69f35a4 --- /dev/null +++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/Ocs.kt @@ -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?, +) \ No newline at end of file diff --git a/libs/owncloud/build.gradle.kts b/libs/owncloud/build.gradle.kts index 11795a707..0eb9b970e 100644 --- a/libs/owncloud/build.gradle.kts +++ b/libs/owncloud/build.gradle.kts @@ -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")) } \ No newline at end of file diff --git a/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/Ocs.kt b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/Ocs.kt new file mode 100644 index 000000000..91e009971 --- /dev/null +++ b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/Ocs.kt @@ -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?, +) \ No newline at end of file diff --git a/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/OwncloudClient.kt b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/OwncloudClient.kt index ff40dc814..faef81048 100644 --- a/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/OwncloudClient.kt +++ b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/OwncloudClient.kt @@ -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() + } 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? { diff --git a/libs/webdav/build.gradle.kts b/libs/webdav/build.gradle.kts index 1e81d2f67..2a300e295 100644 --- a/libs/webdav/build.gradle.kts +++ b/libs/webdav/build.gradle.kts @@ -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")) diff --git a/libs/webdav/src/main/java/de/mm20/launcher2/webdav/WebDavApi.kt b/libs/webdav/src/main/java/de/mm20/launcher2/webdav/WebDavApi.kt index 08fe6b581..0b06048a1 100644 --- a/libs/webdav/src/main/java/de/mm20/launcher2/webdav/WebDavApi.kt +++ b/libs/webdav/src/main/java/de/mm20/launcher2/webdav/WebDavApi.kt @@ -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 { + suspend fun search( + webDavUrl: String, + username: String, + query: String, + client: HttpClient + ): List { val requestBody = """ @@ -52,54 +57,55 @@ object WebDavApi { """.trimIndent() - val request = Request.Builder() - .url(webDavUrl) - .method("SEARCH", requestBody.toRequestBody("text/xml".toMediaType())) - .build() return withContext(Dispatchers.IO) { val results = mutableListOf() try { - val response = client.newCall(request).execute() - val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream() - ?: return@withContext emptyList()) + 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 { + suspend fun searchReport( + webDavUrl: String, + username: String, + query: String, + client: HttpClient + ): List { val requestBody = getSearchRequestBody(query) - val request = Request.Builder() - .url("${webDavUrl}files/$username") - .method("REPORT", requestBody.toRequestBody()) - .build() return withContext(Dispatchers.IO) { val results = mutableListOf() try { - val response = client.newCall(request).execute() - val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream() - ?: return@withContext emptyList()) + 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) { diff --git a/services/search/build.gradle.kts b/services/search/build.gradle.kts index 403d170b8..a6e769d87 100644 --- a/services/search/build.gradle.kts +++ b/services/search/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { implementation(libs.koin.android) implementation(libs.jsoup) - implementation(libs.okhttp) implementation(libs.coil.core) implementation(project(":data:calculator"))