From b12ce465090f663ff757cb21ce336229a9d464a4 Mon Sep 17 00:00:00 2001
From: MM20 <15646950+MM2-0@users.noreply.github.com>
Date: Sun, 20 Apr 2025 17:45:00 +0200
Subject: [PATCH] Migrate Owncloud login UI to compose
---
.../main/res/drawable/ic_badge_owncloud.xml | 19 +-
.../mm20/launcher2/nextcloud/LoginActivity.kt | 7 +-
libs/owncloud/build.gradle.kts | 8 +-
libs/owncloud/src/main/AndroidManifest.xml | 12 +-
.../mm20/launcher2/owncloud/LoginActivity.kt | 321 +++++++++++++++---
.../mm20/launcher2/owncloud/OwncloudClient.kt | 42 ++-
6 files changed, 329 insertions(+), 80 deletions(-)
diff --git a/core/base/src/main/res/drawable/ic_badge_owncloud.xml b/core/base/src/main/res/drawable/ic_badge_owncloud.xml
index 2be8c5f7..e4738afe 100644
--- a/core/base/src/main/res/drawable/ic_badge_owncloud.xml
+++ b/core/base/src/main/res/drawable/ic_badge_owncloud.xml
@@ -3,8 +3,19 @@
android:height="12dp"
android:viewportWidth="12"
android:viewportHeight="12">
-
+
+
+
+
+
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 37b23bf5..bbe02930 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
@@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,9 +54,9 @@ class LoginActivity : AppCompatActivity() {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) nextcloudDark else nextcloudLight
) {
- var nextcloudUrl by remember { mutableStateOf("") }
- var error by remember { mutableStateOf(null) }
- var loading by remember { mutableStateOf(false) }
+ var nextcloudUrl by rememberSaveable { mutableStateOf("") }
+ var error by rememberSaveable { mutableStateOf(null) }
+ var loading by rememberSaveable { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
diff --git a/libs/owncloud/build.gradle.kts b/libs/owncloud/build.gradle.kts
index 2d6f5689..364f01e1 100644
--- a/libs/owncloud/build.gradle.kts
+++ b/libs/owncloud/build.gradle.kts
@@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.plugin.serialization)
+ alias(libs.plugins.kotlin.plugin.compose)
}
android {
@@ -32,7 +34,7 @@ android {
}
buildFeatures {
- viewBinding = true
+ compose = true
}
namespace = "de.mm20.launcher2.owncloud"
}
@@ -40,10 +42,10 @@ android {
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
+ implementation(libs.androidx.activitycompose)
+ implementation(libs.androidx.compose.material3)
implementation(libs.androidx.appcompat)
- implementation(libs.materialcomponents.core)
implementation(libs.androidx.browser)
- implementation(libs.androidx.constraintlayout.views)
implementation(libs.androidx.securitycrypto)
implementation(libs.bundles.androidx.lifecycle)
diff --git a/libs/owncloud/src/main/AndroidManifest.xml b/libs/owncloud/src/main/AndroidManifest.xml
index 2d4b7d8d..850947ea 100644
--- a/libs/owncloud/src/main/AndroidManifest.xml
+++ b/libs/owncloud/src/main/AndroidManifest.xml
@@ -3,14 +3,10 @@
-
-
+ android:enableOnBackInvokedCallback="true"
+ android:label="@string/preference_owncloud"
+ android:launchMode="singleTop"
+ android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" />
\ No newline at end of file
diff --git a/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/LoginActivity.kt b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/LoginActivity.kt
index db327d1f..4c495af4 100644
--- a/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/LoginActivity.kt
+++ b/libs/owncloud/src/main/java/de/mm20/launcher2/owncloud/LoginActivity.kt
@@ -1,13 +1,51 @@
package de.mm20.launcher2.owncloud
-import android.app.Activity
import android.os.Bundle
-import android.view.LayoutInflater
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
-import de.mm20.launcher2.owncloud.databinding.ActivityOwncloudLoginBinding
-import de.mm20.launcher2.owncloud.databinding.ActivityOwncloudLoginUsernamePasswordBinding
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
class LoginActivity : AppCompatActivity() {
@@ -15,51 +53,244 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val binding = ActivityOwncloudLoginBinding.inflate(LayoutInflater.from(this))
- setContentView(binding.root)
- binding.nextButton.setOnClickListener {
- binding.serverUrlInputLayout.error = null
- lifecycleScope.launch {
- var url = binding.serverUrlInput.text.toString()
- if (!(url.startsWith("http://") || url.startsWith("https://"))) {
- url = "https://$url"
+
+ setContent {
+ MaterialTheme(
+ colorScheme = if (isSystemInDarkTheme()) owncloudDark else owncloudLight
+ ) {
+ var owncloudUrl by rememberSaveable { mutableStateOf("") }
+ var serverUrlConfirmed by rememberSaveable { mutableStateOf(false) }
+ var username by rememberSaveable { mutableStateOf("") }
+ var password by rememberSaveable { mutableStateOf("") }
+ var error by rememberSaveable { mutableStateOf(null) }
+ var loading by rememberSaveable { mutableStateOf(false) }
+
+ val dark = isSystemInDarkTheme()
+
+ if (serverUrlConfirmed) {
+ BackHandler {
+ serverUrlConfirmed = false
+ error = null
+ }
}
- if (url.isBlank()) {
- binding.serverUrlInputLayout.error = getString(R.string.nextcloud_server_url_empty)
- return@launch
+
+ LaunchedEffect(dark) {
+ enableEdgeToEdge(
+ statusBarStyle = if (dark) SystemBarStyle.dark(0) else SystemBarStyle.light(
+ 0,
+ 0x33000000.toInt()
+ ),
+ navigationBarStyle = if (dark) SystemBarStyle.dark(0) else SystemBarStyle.light(
+ 0,
+ 0x33000000.toInt()
+ ),
+ )
}
- if (owncloudClient.checkOwncloudInstallation(url)) {
- openLoginPage(url)
- } else {
- binding.serverUrlInputLayout.error = getString(R.string.owncloud_server_invalid_url)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(32.dp)
+ .systemBarsPadding()
+ .imePadding(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painterResource(R.drawable.ic_owncloud_logo),
+ contentDescription = "Owncloud Logo",
+ colorFilter = ColorFilter.tint(
+ MaterialTheme.colorScheme.primary,
+ )
+ )
+ AnimatedContent(!serverUrlConfirmed) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (it) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 32.dp),
+ label = {
+ Text(stringResource(R.string.owncloud_server_url))
+ },
+ value = owncloudUrl,
+ onValueChange = { owncloudUrl = it },
+ enabled = !loading,
+ isError = error != null,
+ supportingText = error?.let { { Text(it) } },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ )
+ )
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ enabled = owncloudUrl.isNotBlank() && !loading && !serverUrlConfirmed,
+ onClick = {
+ lifecycleScope.launch {
+ loading = true
+ error = null
+ var url = owncloudUrl
+ if (!(url.startsWith("http://") || url.startsWith("https://"))) {
+ url = "https://$url"
+ }
+ if (owncloudClient.checkOwncloudInstallation(url)) {
+ owncloudUrl = url
+ serverUrlConfirmed = true
+ } else {
+ error =
+ getString(R.string.owncloud_server_invalid_url)
+ }
+ loading = false
+ }
+ }
+ ) {
+ Text(stringResource(R.string.login_flow_continue))
+ }
+ } else {
+ Text(
+ owncloudUrl,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 24.dp),
+ style = MaterialTheme.typography.labelSmall,
+ textAlign = TextAlign.Center,
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ label = {
+ Text(stringResource(R.string.owncloud_username))
+ },
+ value = username,
+ onValueChange = { username = it },
+ enabled = !loading,
+ isError = error != null,
+ singleLine = true,
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 32.dp),
+ label = {
+ Text(stringResource(R.string.owncloud_password))
+ },
+ value = password,
+ onValueChange = { password = it },
+ enabled = !loading,
+ isError = error != null,
+ supportingText = {
+ Text(error ?: stringResource(R.string.owncloud_login_2fa_hint))
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ ),
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ enabled = username.isNotBlank() && password.isNotBlank() && !loading,
+ onClick = {
+ lifecycleScope.launch {
+ loading = true
+ val valid = owncloudClient.checkOwncloudCredentials(
+ server = owncloudUrl,
+ username = username,
+ password = password
+ )
+ loading = false
+ if (valid) {
+ owncloudClient.setServer(owncloudUrl, username, password)
+ finish()
+ } else {
+ error = getString(R.string.owncloud_login_failed)
+ }
+ }
+ }
+ ) {
+ Text(stringResource(R.string.login_flow_login))
+ }
+ }
+ }
+ }
}
}
}
}
- private fun openLoginPage(url: String) {
- val binding = ActivityOwncloudLoginUsernamePasswordBinding.inflate(LayoutInflater.from(this))
- setContentView(binding.root)
- binding.loginButton.setOnClickListener {
- val username = binding.username.text.toString()
- val password = binding.password.text.toString()
- if (username.isEmpty()) {
- binding.usernameInputLayout.error = getString(R.string.owncloud_username_empty)
- }
- if (password.isEmpty()) {
- binding.passwordInputLayout.error = getString(R.string.owncloud_password_empty)
- }
- if(username.isEmpty() || password.isEmpty()) {
- return@setOnClickListener
- }
- lifecycleScope.launch {
- if (owncloudClient.tryLogin(url, username, password)) {
- setResult(Activity.RESULT_OK)
- finish()
- } else {
- binding.passwordInputLayout.error = getString(R.string.owncloud_login_failed)
- }
- }
- }
- }
+
+ private val owncloudLight: ColorScheme
+ get() = lightColorScheme(
+ primary = Color(0xFF4A5E87),
+ onPrimary = Color(0xFFFFFFFF),
+ primaryContainer = Color(0xFFD7E2FF),
+ onPrimaryContainer = Color(0xFF011A3F),
+ inversePrimary = Color(0xFFB2C7F5),
+ secondary = Color(0xFF565E71),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFDAE2F9),
+ onSecondaryContainer = Color(0xFF131B2C),
+ tertiary = Color(0xFF705574),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFFAD7FD),
+ onTertiaryContainer = Color(0xFF29132E),
+ surface = Color(0xFFFBF8FC),
+ surfaceBright = Color(0xFFFBF8FC),
+ surfaceDim = Color(0xFFDCD9DD),
+ surfaceContainer = Color(0xFFF0EDF1),
+ surfaceContainerHighest = Color(0xFFE4E1E5),
+ surfaceContainerHigh = Color(0xFFEAE7EB),
+ surfaceContainerLow = Color(0xFFF5F3F7),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ onSurface = Color(0xFF1B1B1E),
+ onSurfaceVariant = Color(0xFF43474F),
+ inverseSurface = Color(0xFF303033),
+ inverseOnSurface = Color(0xFFF3F0F4),
+ error = Color(0xFFBA1A1A),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFFFDAD5),
+ onErrorContainer = Color(0xFF410002),
+ outline = Color(0xFF747780),
+ outlineVariant = Color(0xFFC4C6D0),
+ scrim = Color(0xFF000000),
+ )
+
+ private val owncloudDark: ColorScheme
+ get() = darkColorScheme(
+ primary = Color(0xFFB2C7F5),
+ onPrimary = Color(0xFF1A3055),
+ primaryContainer = Color(0xFF32476D),
+ onPrimaryContainer = Color(0xFFD7E2FF),
+ inversePrimary = Color(0xFF4A5E87),
+ secondary = Color(0xFFBEC6DC),
+ onSecondary = Color(0xFF283042),
+ secondaryContainer = Color(0xFF3F4759),
+ onSecondaryContainer = Color(0xFFDAE2F9),
+ tertiary = Color(0xFFDDBCE0),
+ onTertiary = Color(0xFF3F2844),
+ tertiaryContainer = Color(0xFF573E5C),
+ onTertiaryContainer = Color(0xFFFAD7FD),
+ surface = Color(0xFF1B1B1E),
+ surfaceBright = Color(0xFF39393C),
+ surfaceDim = Color(0xFF131316),
+ surfaceContainer = Color(0xFF1F1F22),
+ surfaceContainerHighest = Color(0xFF353438),
+ surfaceContainerHigh = Color(0xFF2A2A2D),
+ surfaceContainerLow = Color(0xFF1B1B1E),
+ surfaceContainerLowest = Color(0xFF0E0E11),
+ onSurface = Color(0xFFE4E1E5),
+ onSurfaceVariant = Color(0xFFC4C6D0),
+ inverseSurface = Color(0xFFE4E1E5),
+ inverseOnSurface = Color(0xFF303033),
+ error = Color(0xFFFFB4AB),
+ onError = Color(0xFF690004),
+ errorContainer = Color(0xFF930009),
+ onErrorContainer = Color(0xFFFFB4AB),
+ outline = Color(0xFF8E909A),
+ outlineVariant = Color(0xFF43474F),
+ scrim = Color(0xFF000000),
+ )
}
\ 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 d134d7a6..ff40dc81 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
@@ -66,7 +66,7 @@ class OwncloudClient(val context: Context) {
}
- suspend fun checkOwncloudInstallation(url: String): Boolean {
+ internal suspend fun checkOwncloudInstallation(url: String): Boolean {
var url = url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
@@ -82,6 +82,30 @@ class OwncloudClient(val context: Context) {
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()
+ }
+ } catch (e: IOException) {
+ Log.e("OwncloudClient", "HTTP error", e)
+ return false
+ }
+
+ if (response.code != 200) {
+ Log.e("OwncloudClient", "HTTP error: ${response.code}")
+ return false
+ }
+
+ return true
+ }
+
+
suspend fun getLoggedInUser(): OcUser? {
val server = getServer()
val username = getUserName()
@@ -150,10 +174,6 @@ class OwncloudClient(val context: Context) {
return preferences.getString("username", null)
}
- fun getUserDisplayName(): String? {
- return preferences.getString("displayname", getUserName())
- }
-
private fun getToken(): String? {
return preferences.getString("token", null)
}
@@ -175,18 +195,6 @@ class OwncloudClient(val context: Context) {
}
}
- suspend fun tryLogin(url: String, username: String, pw: String): Boolean {
-
- setServer(url, username, pw)
-
- val displayName = getDisplayName()
- preferences.edit {
- putString("displayname", displayName)
- }
-
- return displayName != null
- }
-
val files by lazy {
FilesApi()
}