diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts index 2ea2630..ec8517b 100644 --- a/ncal/app/build.gradle.kts +++ b/ncal/app/build.gradle.kts @@ -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")) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/LoginFlowV2Client.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/LoginFlowV2Client.kt new file mode 100644 index 0000000..b2d9c9d --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/LoginFlowV2Client.kt @@ -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") + ) + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt index 8c992fa..9af84b3 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt @@ -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) + ) } } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt index 922d940..827716b 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt @@ -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 = _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" diff --git a/ncal/app/src/main/res/xml/next_tasks_widget_info.xml b/ncal/app/src/main/res/xml/next_tasks_widget_info.xml index 79dc4aa..6f20912 100644 --- a/ncal/app/src/main/res/xml/next_tasks_widget_info.xml +++ b/ncal/app/src/main/res/xml/next_tasks_widget_info.xml @@ -1,7 +1,7 @@