diff --git a/libs/nextcloud/build.gradle.kts b/libs/nextcloud/build.gradle.kts index 025b9493..30e8ff2d 100644 --- a/libs/nextcloud/build.gradle.kts +++ b/libs/nextcloud/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.kotlin.plugin.compose) } @@ -53,5 +54,6 @@ dependencies { api(project(":libs:webdav")) implementation(project(":core:i18n")) + implementation(project(":core:base")) } \ No newline at end of file diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginActivity.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginActivity.kt index 5830cbd8..37b23bf5 100644 --- a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginActivity.kt +++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginActivity.kt @@ -1,14 +1,12 @@ package de.mm20.launcher2.nextcloud -import android.app.Activity import android.os.Bundle -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient +import android.util.Log import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -40,6 +38,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch @@ -118,8 +117,9 @@ class LoginActivity : AppCompatActivity() { if (!(url.startsWith("http://") || url.startsWith("https://"))) { url = "https://$url" } - if (nextcloudClient.checkNextcloudInstallation(url)) { - openLoginPage(url) + val flow = nextcloudClient.startLoginFlow(url) + if (flow != null) { + openLoginPage(flow) } else { error = getString(R.string.nextcloud_server_invalid_url) } @@ -135,49 +135,24 @@ class LoginActivity : AppCompatActivity() { } } - private fun openLoginPage(url: String) { - val webView = WebView(this) - webView.settings.userAgentString = getString(R.string.app_name) - webView.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - if (request?.url?.scheme == "nc") { - val path = request.url?.path?.trim('/') ?: run { - setResult(0) - finish() - return false - } - val segments = path.split('&') - var username: String? = null - var token: String? = null - var server: String? = null - for (segment in segments) { - when { - segment.startsWith("server") -> server = segment.substringAfter(":") - segment.startsWith("user") -> username = segment.substringAfter(":") - segment.startsWith("password") -> token = segment.substringAfter(":") - } - } - if (username != null && server != null && token != null) { - nextcloudClient.setServer(server, username, token) - } - setResult(Activity.RESULT_OK) - finish() - return true - } - webView.loadUrl(request?.url?.toString() ?: "") - return false + private var currentLoginFlow: LoginFlowResponse? = null + private fun openLoginPage(flow: LoginFlowResponse) { + currentLoginFlow = flow + val customTabIntent = CustomTabsIntent.Builder().build() + customTabIntent.launchUrl(this, flow.login.toUri()) + } + + override fun onResume() { + super.onResume() + val flow = currentLoginFlow ?: return + lifecycleScope.launch { + val result = nextcloudClient.pollLoginFlow(flow) + if (result != null) { + nextcloudClient.setServer(result.server, result.loginName, result.appPassword) + currentLoginFlow = null + finish() } } - webView.settings.javaScriptEnabled = true - setContentView(webView) - val headers = mapOf( - "OCS-APIREQUEST" to "true" - ) - webView.loadUrl("$url/index.php/login/flow", headers) - } private val nextcloudLight: ColorScheme diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginFlow.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginFlow.kt new file mode 100644 index 00000000..c231c871 --- /dev/null +++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/LoginFlow.kt @@ -0,0 +1,22 @@ +package de.mm20.launcher2.nextcloud + +import kotlinx.serialization.Serializable + +@Serializable +internal data class LoginFlowResponse( + val poll: LoginFlowResponsePoll, + val login: String, +) + +@Serializable +internal data class LoginFlowResponsePoll( + val token: String, + val endpoint: String, +) + +@Serializable +internal data class LoginPollResponse( + val server: String, + val loginName: String, + val appPassword: String, +) \ No newline at end of file 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 75c9068c..27d3c3f2 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 @@ -4,14 +4,25 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.util.Log import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +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 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 @@ -65,20 +76,61 @@ class NextcloudApiHelper(val context: Context) { } - suspend fun checkNextcloudInstallation(url: String): Boolean { - var url = url - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "https://$url" - } + /** + * Perform a POST request to the /index.php/login/v2 endpoint of the given Nextcloud server + * and return the response. Returns null if an error occurs. + */ + internal suspend fun startLoginFlow(serverUrl: String): LoginFlowResponse? { val request = Request.Builder() - .url("$url/remote.php/dav") + .url("$serverUrl/index.php/login/v2") + .method("POST", EMPTY_REQUEST) + .header("user-agent", context.getString(R.string.app_name)) .build() - val response = runCatching { + val response = try { withContext(Dispatchers.IO) { httpClient.newCall(request).execute() } - }.getOrNull() ?: return false - return response.code == 200 || response.code == 401 + } 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}") + return null + } + + return try { + Json.Lenient.decodeFromStream(response.body!!.byteStream()) + } catch (e: SerializationException) { + Log.e("NextcloudApiHelper", "Invalid response body", e) + null + } + } + + 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() + } + } 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}") + return null + } + + return try { + Json.Lenient.decodeFromStream(response.body!!.byteStream()) + } catch (e: SerializationException) { + Log.e("NextcloudApiHelper", "Invalid response body", e) + null + } } suspend fun getLoggedInUser(): NcUser? {