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:
RomanNum3ral 2026-07-18 13:01:46 -04:00
parent d31894f980
commit 33d6469bbe
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
5 changed files with 247 additions and 33 deletions

View File

@ -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"))

View File

@ -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")
)
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -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"

View File

@ -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"