Add Authentik/SSO login via Nextcloud Login Flow v2; fix Tasks widget minHeight
Login Flow v2 opens Nextcloud's own browser login page in a Custom Tab (androidx.browser), so any SSO backend configured server-side (Authentik via user_oidc) and WebAuthn hardware-key prompts work without NCal implementing OIDC/OAuth2 itself - the flow ends with a normal app password, stored the same way as manual login. The Tasks widget's minHeight was pinned exactly to its 4-row default size (Android's 70*cells-30 formula), leaving no slack to ever resize shorter. Lowered to 110dp (2 rows) so it can actually be resized to a 2x3 layout, which was verified to render cleanly.
This commit is contained in:
parent
d31894f980
commit
33d6469bbe
|
|
@ -77,6 +77,7 @@ dependencies {
|
|||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
|
||||
// Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.homelab.ncal.data.network
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class LoginFlowV2Session(val loginUrl: String, val pollEndpoint: String, val pollToken: String)
|
||||
data class LoginFlowV2Result(val server: String, val loginName: String, val appPassword: String)
|
||||
|
||||
/**
|
||||
* Nextcloud's browser-based "Login Flow v2" - the same mechanism the official desktop/mobile
|
||||
* apps use for "Log in with browser". The login page itself is rendered by Nextcloud, so it
|
||||
* transparently supports whatever auth backends the server has configured (password, SSO via
|
||||
* user_oidc/Authentik, WebAuthn hardware keys) without NCal needing to speak OIDC/OAuth2
|
||||
* directly - the end result is always a normal Nextcloud app password, stored and used exactly
|
||||
* like one the user typed in manually.
|
||||
*/
|
||||
class LoginFlowV2Client {
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** Starts a flow: returns the URL to open in a browser plus the token to poll for completion. */
|
||||
fun initiate(serverUrl: String): LoginFlowV2Session {
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}/index.php/login/v2")
|
||||
.post(FormBody.Builder().build())
|
||||
.build()
|
||||
http.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
throw CalDavException("HTTP ${resp.code} starting browser login", resp.code)
|
||||
}
|
||||
val json = JSONObject(resp.body?.string().orEmpty())
|
||||
val poll = json.getJSONObject("poll")
|
||||
return LoginFlowV2Session(
|
||||
loginUrl = json.getString("login"),
|
||||
pollEndpoint = poll.getString("endpoint"),
|
||||
pollToken = poll.getString("token")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns null while the user hasn't finished the browser flow yet (server 404s until then). */
|
||||
fun poll(session: LoginFlowV2Session): LoginFlowV2Result? {
|
||||
val body = FormBody.Builder().add("token", session.pollToken).build()
|
||||
val request = Request.Builder()
|
||||
.url(session.pollEndpoint)
|
||||
.post(body)
|
||||
.build()
|
||||
http.newCall(request).execute().use { resp ->
|
||||
if (resp.code == 404) return null
|
||||
if (!resp.isSuccessful) {
|
||||
throw CalDavException("HTTP ${resp.code} polling browser login", resp.code)
|
||||
}
|
||||
val json = JSONObject(resp.body?.string() ?: return null)
|
||||
return LoginFlowV2Result(
|
||||
server = json.getString("server"),
|
||||
loginName = json.getString("loginName"),
|
||||
appPassword = json.getString("appPassword")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
package com.homelab.ncal.ui.login
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -12,17 +15,21 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -34,11 +41,19 @@ fun LoginScreen(
|
|||
onLoggedIn: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onLoggedIn()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.browserLoginUrl) {
|
||||
state.browserLoginUrl?.let { url ->
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||
viewModel.browserLoginUrlConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -70,41 +85,84 @@ fun LoginScreen(
|
|||
placeholder = { Text("nextcloud.yourdomain.com") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
singleLine = true,
|
||||
enabled = !state.awaitingBrowserLogin,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.appPassword,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("App password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
"Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp))
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||
} else {
|
||||
Text("Connect")
|
||||
if (state.awaitingBrowserLogin) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(vertical = 12.dp))
|
||||
Text(
|
||||
"Finish logging in in the browser tab that just opened, including any SSO or " +
|
||||
"security-key prompt \u2014 this screen will continue automatically.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
TextButton(onClick = viewModel::cancelBrowserLogin) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.appPassword,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("App password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
"Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp))
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
|
||||
) {
|
||||
Divider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"or",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Divider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = viewModel::startBrowserLogin,
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Log in with browser")
|
||||
}
|
||||
Text(
|
||||
"For SSO (e.g. Authentik) or hardware security keys.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@ package com.homelab.ncal.ui.login
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.network.LoginFlowV2Client
|
||||
import com.homelab.ncal.data.network.LoginFlowV2Result
|
||||
import com.homelab.ncal.data.network.LoginFlowV2Session
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class LoginUiState(
|
||||
val serverUrl: String = "",
|
||||
|
|
@ -13,7 +22,11 @@ data class LoginUiState(
|
|||
val appPassword: String = "",
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false
|
||||
val success: Boolean = false,
|
||||
/** Set once to tell the UI to open a Custom Tab, then immediately consumed/cleared. */
|
||||
val browserLoginUrl: String? = null,
|
||||
/** True while waiting for the user to finish the browser flow and the poll loop is running. */
|
||||
val awaitingBrowserLogin: Boolean = false
|
||||
)
|
||||
|
||||
class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
|
@ -21,6 +34,9 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
|||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
private val flowClient = LoginFlowV2Client()
|
||||
private var pollJob: Job? = null
|
||||
|
||||
fun onServerUrlChange(v: String) { _state.value = _state.value.copy(serverUrl = v, error = null) }
|
||||
fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v, error = null) }
|
||||
fun onPasswordChange(v: String) { _state.value = _state.value.copy(appPassword = v, error = null) }
|
||||
|
|
@ -51,6 +67,78 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Starts Nextcloud's browser-based "Login Flow v2" - see [LoginFlowV2Client]. */
|
||||
fun startBrowserLogin() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank()) {
|
||||
_state.value = s.copy(error = "Enter your server URL first")
|
||||
return
|
||||
}
|
||||
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
||||
_state.value = s.copy(loading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val session = withContext(Dispatchers.IO) { flowClient.initiate(normalized) }
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
awaitingBrowserLogin = true,
|
||||
browserLoginUrl = session.loginUrl
|
||||
)
|
||||
pollJob = launch {
|
||||
val result = pollUntilDone(session)
|
||||
if (result != null) {
|
||||
finishBrowserLogin(result)
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
awaitingBrowserLogin = false,
|
||||
error = "Browser login timed out — try again"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalLogin", "Failed to start browser login", e)
|
||||
_state.value = _state.value.copy(loading = false, error = "Couldn't start browser login: ${describe(e)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishBrowserLogin(result: LoginFlowV2Result) {
|
||||
try {
|
||||
repo.login(result.server, result.loginName, result.appPassword)
|
||||
repo.fullSync()
|
||||
repo.scheduleBackgroundSync()
|
||||
_state.value = _state.value.copy(awaitingBrowserLogin = false, success = true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalLogin", "Browser login completed but sync failed", e)
|
||||
_state.value = _state.value.copy(awaitingBrowserLogin = false, error = "Couldn't connect: ${describe(e)}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollUntilDone(session: LoginFlowV2Session): LoginFlowV2Result? = withContext(Dispatchers.IO) {
|
||||
// Nextcloud's own flow token expires after ~20 minutes; give up a little before that.
|
||||
val deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(18)
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val result = try {
|
||||
flowClient.poll(session)
|
||||
} catch (e: IOException) {
|
||||
null // transient network hiccup while polling - just keep trying until the deadline
|
||||
}
|
||||
if (result != null) return@withContext result
|
||||
delay(2000)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
/** Called by the UI right after it launches the Custom Tab, so it doesn't relaunch on recomposition. */
|
||||
fun browserLoginUrlConsumed() {
|
||||
_state.value = _state.value.copy(browserLoginUrl = null)
|
||||
}
|
||||
|
||||
fun cancelBrowserLogin() {
|
||||
pollJob?.cancel()
|
||||
_state.value = _state.value.copy(awaitingBrowserLogin = false, browserLoginUrl = null)
|
||||
}
|
||||
|
||||
/** Turns common low-level exceptions (which often have a null .message) into something readable. */
|
||||
private fun describe(e: Exception): String = when (e) {
|
||||
is java.net.UnknownHostException -> "Can't resolve that host \u2014 check the server URL and your network/DNS"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
|
|
|
|||
Loading…
Reference in New Issue