Migrate Owncloud login UI to compose

This commit is contained in:
MM20 2025-04-20 17:45:00 +02:00
parent 27503d34f2
commit b12ce46509
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
6 changed files with 329 additions and 80 deletions

File diff suppressed because one or more lines are too long

View File

@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -53,9 +54,9 @@ class LoginActivity : AppCompatActivity() {
MaterialTheme( MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) nextcloudDark else nextcloudLight colorScheme = if (isSystemInDarkTheme()) nextcloudDark else nextcloudLight
) { ) {
var nextcloudUrl by remember { mutableStateOf("") } var nextcloudUrl by rememberSaveable { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) } var error by rememberSaveable { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) } var loading by rememberSaveable { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()

View File

@ -1,6 +1,8 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.kotlin.plugin.compose)
} }
android { android {
@ -32,7 +34,7 @@ android {
} }
buildFeatures { buildFeatures {
viewBinding = true compose = true
} }
namespace = "de.mm20.launcher2.owncloud" namespace = "de.mm20.launcher2.owncloud"
} }
@ -40,10 +42,10 @@ android {
dependencies { dependencies {
implementation(libs.bundles.kotlin) implementation(libs.bundles.kotlin)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.activitycompose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.materialcomponents.core)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.constraintlayout.views)
implementation(libs.androidx.securitycrypto) implementation(libs.androidx.securitycrypto)
implementation(libs.bundles.androidx.lifecycle) implementation(libs.bundles.androidx.lifecycle)

View File

@ -3,14 +3,10 @@
<application> <application>
<activity <activity
android:name=".LoginActivity" android:name=".LoginActivity"
android:label="@string/preference_nextcloud" android:enableOnBackInvokedCallback="true"
android:taskAffinity="de.mm20.launcher2.nextcloud" android:label="@string/preference_owncloud"
android:parentActivityName="de.mm20.launcher2.ui.settings.SettingsActivity" android:launchMode="singleTop"
android:theme="@style/OwncloudLoginTheme" > android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" />
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="de.mm20.launcher2.ui.settings.SettingsActivity" />
</activity>
</application> </application>
</manifest> </manifest>

View File

@ -1,13 +1,51 @@
package de.mm20.launcher2.owncloud package de.mm20.launcher2.owncloud
import android.app.Activity
import android.os.Bundle 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.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 androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.owncloud.databinding.ActivityOwncloudLoginBinding import kotlinx.coroutines.CancellationException
import de.mm20.launcher2.owncloud.databinding.ActivityOwncloudLoginUsernamePasswordBinding import kotlinx.coroutines.launch
import kotlinx.coroutines.*
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
@ -15,51 +53,244 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityOwncloudLoginBinding.inflate(LayoutInflater.from(this))
setContentView(binding.root) setContent {
binding.nextButton.setOnClickListener { MaterialTheme(
binding.serverUrlInputLayout.error = null colorScheme = if (isSystemInDarkTheme()) owncloudDark else owncloudLight
lifecycleScope.launch { ) {
var url = binding.serverUrlInput.text.toString() var owncloudUrl by rememberSaveable { mutableStateOf("") }
if (!(url.startsWith("http://") || url.startsWith("https://"))) { var serverUrlConfirmed by rememberSaveable { mutableStateOf(false) }
url = "https://$url" var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var error by rememberSaveable { mutableStateOf<String?>(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) LaunchedEffect(dark) {
return@launch 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) Column(
} else { modifier = Modifier
binding.serverUrlInputLayout.error = getString(R.string.owncloud_server_invalid_url) .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)) private val owncloudLight: ColorScheme
setContentView(binding.root) get() = lightColorScheme(
binding.loginButton.setOnClickListener { primary = Color(0xFF4A5E87),
val username = binding.username.text.toString() onPrimary = Color(0xFFFFFFFF),
val password = binding.password.text.toString() primaryContainer = Color(0xFFD7E2FF),
if (username.isEmpty()) { onPrimaryContainer = Color(0xFF011A3F),
binding.usernameInputLayout.error = getString(R.string.owncloud_username_empty) inversePrimary = Color(0xFFB2C7F5),
} secondary = Color(0xFF565E71),
if (password.isEmpty()) { onSecondary = Color(0xFFFFFFFF),
binding.passwordInputLayout.error = getString(R.string.owncloud_password_empty) secondaryContainer = Color(0xFFDAE2F9),
} onSecondaryContainer = Color(0xFF131B2C),
if(username.isEmpty() || password.isEmpty()) { tertiary = Color(0xFF705574),
return@setOnClickListener onTertiary = Color(0xFFFFFFFF),
} tertiaryContainer = Color(0xFFFAD7FD),
lifecycleScope.launch { onTertiaryContainer = Color(0xFF29132E),
if (owncloudClient.tryLogin(url, username, password)) { surface = Color(0xFFFBF8FC),
setResult(Activity.RESULT_OK) surfaceBright = Color(0xFFFBF8FC),
finish() surfaceDim = Color(0xFFDCD9DD),
} else { surfaceContainer = Color(0xFFF0EDF1),
binding.passwordInputLayout.error = getString(R.string.owncloud_login_failed) 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),
)
} }

View File

@ -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 var url = url
if (!url.startsWith("http://") && !url.startsWith("https://")) { if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url" url = "https://$url"
@ -82,6 +82,30 @@ class OwncloudClient(val context: Context) {
return response.code == 200 || response.code == 401 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? { suspend fun getLoggedInUser(): OcUser? {
val server = getServer() val server = getServer()
val username = getUserName() val username = getUserName()
@ -150,10 +174,6 @@ class OwncloudClient(val context: Context) {
return preferences.getString("username", null) return preferences.getString("username", null)
} }
fun getUserDisplayName(): String? {
return preferences.getString("displayname", getUserName())
}
private fun getToken(): String? { private fun getToken(): String? {
return preferences.getString("token", null) 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 { val files by lazy {
FilesApi() FilesApi()
} }