From dc233d534d3b1b9eae8364a0804d13c64ca3f06d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 19:33:05 +0000 Subject: [PATCH 01/18] Request local network permission for private CalDAV hosts Android 17 (API 37) gates local network access behind the android.permission.ACCESS_LOCAL_NETWORK runtime permission; below that it came for free with INTERNET. Since targetSdk is 37, adding a CalDAV account on a LAN address silently failed: denied TCP connects do not fail fast, they time out, so the app looked broken rather than blocked. Reproduced against Radicale on a phone where targetSdk 36 connects and 37 times out. Introduce the repo's first runtime-permission API, kept generic so further permissions only need a new AppPermission constant and a branch in the Android actual: - PermissionRequester (expect/actual) with status/request/openAppSettings, following the rememberX() shape used by ImagePicker. Android implements it for real; iOS can only open settings, since its own prompt is raised implicitly on first connection and cannot be queried; Desktop and Web report NOT_APPLICABLE. - isPrivateNetworkHost() classifies RFC1918, link-local, ULA, loopback, .local and single-label hosts, so people syncing with a hosted provider never see a "nearby devices" prompt. In the add-account sheet, both routes to OnAddPrincipal now funnel through one submit() that requests the permission first when the host is private, and the server field shows whether access is granted with a button to review it in system settings. Rediscovery and sync are deliberately not gated: they require an account added earlier, so that flow has already been through this. Also declare NSLocalNetworkUsageDescription in the three iOS Info.plists. iOS already prompts and works, but the key supplies the purpose string shown in that prompt and is effectively mandatory on iOS 18+. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../src/main/AndroidManifest.xml | 4 + androidNotesApp/src/main/AndroidManifest.xml | 4 + androidTasksApp/src/main/AndroidManifest.xml | 4 + .../iosJournalsApp/iosJournalsApp/Info.plist | 2 + iosApp/iosNotesApp/iosNotesApp/Info.plist | 2 + iosApp/iosTasksApp/iosTasksApp/Info.plist | 2 + .../core/PermissionRequester.android.kt | 96 +++++++++++ .../composeResources/values/strings.xml | 4 + .../components/AddPrincipalBottomSheet.kt | 156 +++++++++++++++++- .../screens/core/LocalNetworkAddress.kt | 69 ++++++++ .../screens/core/PermissionRequester.kt | 64 +++++++ .../screens/core/LocalNetworkAddressTest.kt | 87 ++++++++++ .../screens/core/PermissionRequester.ios.kt | 42 +++++ .../screens/core/PermissionRequester.jvm.kt | 23 +++ .../screens/core/PermissionRequester.web.kt | 26 +++ 15 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddress.kt create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt create mode 100644 shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt create mode 100644 shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt create mode 100644 shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt diff --git a/androidJournalsApp/src/main/AndroidManifest.xml b/androidJournalsApp/src/main/AndroidManifest.xml index 8c4576c9..27f705a6 100644 --- a/androidJournalsApp/src/main/AndroidManifest.xml +++ b/androidJournalsApp/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + + + + NSLocalNetworkUsageDescription + Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home. ITSAppUsesNonExemptEncryption BGTaskSchedulerPermittedIdentifiers diff --git a/iosApp/iosNotesApp/iosNotesApp/Info.plist b/iosApp/iosNotesApp/iosNotesApp/Info.plist index df8a08ee..0d75da29 100644 --- a/iosApp/iosNotesApp/iosNotesApp/Info.plist +++ b/iosApp/iosNotesApp/iosNotesApp/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home. ITSAppUsesNonExemptEncryption BGTaskSchedulerPermittedIdentifiers diff --git a/iosApp/iosTasksApp/iosTasksApp/Info.plist b/iosApp/iosTasksApp/iosTasksApp/Info.plist index 5c801fae..2cda0477 100644 --- a/iosApp/iosTasksApp/iosTasksApp/Info.plist +++ b/iosApp/iosTasksApp/iosTasksApp/Info.plist @@ -2,6 +2,8 @@ + NSLocalNetworkUsageDescription + Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home. ITSAppUsesNonExemptEncryption BGTaskSchedulerPermittedIdentifiers diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt new file mode 100644 index 00000000..3668dd1e --- /dev/null +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt @@ -0,0 +1,96 @@ +package at.techbee.spectacled.screens.core + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager + +/** + * The permission string rather than `Manifest.permission.ACCESS_LOCAL_NETWORK`, so the shared + * module keeps compiling if the compileSdk is rolled back below 37. + */ +private const val ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK" + +/** + * First OS version that enforces the local network permission (Android 17). + * + * A literal, not a `Build.VERSION_CODES` constant: Google's own documentation sample names the + * wrong one here, and a misnamed constant would silently compile into a check that never fires. + */ +private const val SDK_LOCAL_NETWORK_ENFORCED = 37 + +private fun AppPermission.manifestPermission(): String? = when (this) { + AppPermission.LOCAL_NETWORK -> + ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } +} + +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester { + val context = LocalContext.current + + // The returned object is remembered across recompositions, so it must not capture the callback + // it was first built with - by the time a result arrives, the caller's lambda has been recreated. + val currentOnResult by rememberUpdatedState(onResult) + + // Which permission the in-flight launcher is for: the contract only reports a boolean back. + var requested by remember { mutableStateOf(null) } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + requested?.let { permission -> + currentOnResult(permission, if (granted) PermissionStatus.GRANTED else PermissionStatus.DENIED) + } + requested = null + } + + return remember(context) { + object : PermissionRequester { + + override fun status(permission: AppPermission): PermissionStatus { + val manifestPermission = permission.manifestPermission() + ?: return PermissionStatus.NOT_APPLICABLE + + return if (ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) + PermissionStatus.GRANTED + else + PermissionStatus.DENIED + } + + override fun request(permission: AppPermission) { + val manifestPermission = permission.manifestPermission() + if (manifestPermission == null) { + currentOnResult(permission, PermissionStatus.NOT_APPLICABLE) + return + } + requested = permission + launcher.launch(manifestPermission) + } + + override fun openAppSettings() { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ).apply { + // The Context here may be the Activity, but callers can also reach this from a + // non-Activity Context, where a new task is required. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index b64b1e05..2e3aa326 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -50,6 +50,10 @@ Connect to "%1$s" without encryption? Your password and everything you sync will be sent in plain text - visible to anyone else on this network. Only continue if you trust the network this server is on, for example your home Wi-Fi. Connect anyway + Local network access granted + Local network access not granted - this server is on your own network, so syncing will time out until you allow it. + This server is on your own network. Your device may ask for permission the first time it connects. + Manage permission Create folder Update folder Edit folders diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index e2f3f8ce..118316fb 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.ChevronLeft import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff @@ -69,18 +70,24 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.screens.account.presentation.AccountListAction import at.techbee.spectacled.screens.account.presentation.ProcessingState import at.techbee.spectacled.screens.account.presentation.components.datastructures.CalDavProvider import at.techbee.spectacled.screens.account.presentation.components.datastructures.CalDavProviderCategory import at.techbee.spectacled.screens.account.presentation.components.settings.ProxyServerSetup +import at.techbee.spectacled.screens.core.AppPermission +import at.techbee.spectacled.screens.core.PermissionStatus import at.techbee.spectacled.screens.core.Platforms import at.techbee.spectacled.screens.core.data.Credentials import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore import at.techbee.spectacled.screens.core.getPlatform +import at.techbee.spectacled.screens.core.isPrivateNetworkHost import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu import at.techbee.spectacled.screens.core.presentation.components.SplashScreen +import at.techbee.spectacled.screens.core.rememberPermissionRequester import at.techbee.spectacled.theme.AppTheme import io.ktor.http.Url import kotlinx.coroutines.launch @@ -96,15 +103,19 @@ import spectacled.shared.generated.resources.add_account_option2_recommendation_ import spectacled.shared.generated.resources.add_account_option2_recommended_providers import spectacled.shared.generated.resources.add_account_option2_text import spectacled.shared.generated.resources.add_account_option_x +import spectacled.shared.generated.resources.add_account_provider_tasks_only_warning import spectacled.shared.generated.resources.add_account_proxy_change import spectacled.shared.generated.resources.add_account_proxy_ready import spectacled.shared.generated.resources.add_account_proxy_required_info import spectacled.shared.generated.resources.add_account_proxy_required_title -import spectacled.shared.generated.resources.add_account_provider_tasks_only_warning import spectacled.shared.generated.resources.add_account_spectacled_is_provider_independent import spectacled.shared.generated.resources.back import spectacled.shared.generated.resources.cancel import spectacled.shared.generated.resources.insecure_connection_warning +import spectacled.shared.generated.resources.local_network_permission_granted +import spectacled.shared.generated.resources.local_network_permission_manage +import spectacled.shared.generated.resources.local_network_permission_not_granted +import spectacled.shared.generated.resources.local_network_permission_unknown import spectacled.shared.generated.resources.open_in_browser import spectacled.shared.generated.resources.password import spectacled.shared.generated.resources.server_inferred @@ -134,6 +145,45 @@ fun AddPrincipalBottomSheet( var showInsecureConnectionAlert by rememberSaveable { mutableStateOf(false) } var credentials by rememberSaveable { mutableStateOf(null) } + // Credentials held back while the OS permission dialog is up, dispatched from the result below. + var credentialsAwaitingPermission by remember { mutableStateOf(null) } + var localNetworkStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } + + val permissionRequester = rememberPermissionRequester { permission, status -> + if (permission != AppPermission.LOCAL_NETWORK) return@rememberPermissionRequester + + localNetworkStatus = status + // Dispatch either way: a refusal still ends in the timeout, but the indicator now sits + // above the button saying why, which is the point of showing it. + credentialsAwaitingPermission?.let { onAction(AccountListAction.OnAddPrincipal(it)) } + credentialsAwaitingPermission = null + } + + // Re-read on resume so returning from the settings page (see onManageLocalNetworkPermission) + // shows the new state rather than the one captured when the sheet opened. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) + } + LaunchedEffect(credentials?.server?.host) { + localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) + } + + /** + * The single way credentials reach the ViewModel, so the permission step cannot be skipped by + * whichever of the two routes (direct, or via the insecure-connection dialog) got here. + */ + fun submit(newCredentials: Credentials) { + val needsLocalNetwork = isPrivateNetworkHost(newCredentials.server.host) && + localNetworkStatus == PermissionStatus.DENIED + + if (needsLocalNetwork) { + credentialsAwaitingPermission = newCredentials + permissionRequester.request(AppPermission.LOCAL_NETWORK) + } else { + onAction(AccountListAction.OnAddPrincipal(newCredentials)) + } + } + LaunchedEffect(selectedPage) { if (selectedPage == AddPrincipalBottomSheetPage.SELECTION) scope.launch { pagerState.animateScrollToPage(0) } @@ -146,7 +196,7 @@ fun AddPrincipalBottomSheet( server = credentials?.server?.toString()?:"", onDismiss = { showInsecureConnectionAlert = false }, onConfirm = { - credentials?.let { onAction(AccountListAction.OnAddPrincipal(it)) } + credentials?.let { submit(it) } showInsecureConnectionAlert = false } ) @@ -202,7 +252,7 @@ fun AddPrincipalBottomSheet( if(credentials?.server?.toString()?.startsWith("http://") == true) showInsecureConnectionAlert = true else - credentials?.let { onAction(AccountListAction.OnAddPrincipal(it)) } + credentials?.let { submit(it) } }, enabled = credentials != null && processingState !is ProcessingState.Processing ) { @@ -230,6 +280,8 @@ fun AddPrincipalBottomSheet( processingState = processingState, //onAction = onAction, onCredentialsUpdated = { credentials = it }, + localNetworkStatus = localNetworkStatus, + onManageLocalNetworkPermission = { permissionRequester.openAppSettings() }, modifier = Modifier.padding(8.dp).fillMaxSize().verticalScroll(rememberScrollState()) ) } else if (selectedPage == AddPrincipalBottomSheetPage.SELECT_FROM_LIST) { // SELECT FROM LIST @@ -433,6 +485,8 @@ fun AddAccountScreen( processingState: ProcessingState, //onAction: (AccountListAction.OnAddPrincipal) -> Unit, onCredentialsUpdated: (Credentials?) -> Unit, + localNetworkStatus: PermissionStatus = PermissionStatus.NOT_APPLICABLE, + onManageLocalNetworkPermission: () -> Unit = {}, modifier: Modifier = Modifier ) { @@ -472,6 +526,31 @@ fun AddAccountScreen( onCredentialsUpdated(credentials) } + // The host as typed, resolved the same way [credentials] resolves it, so the permission notice + // appears while the form is still incomplete rather than only once it validates. + val typedHost by remember { + derivedStateOf { + val trimmedServer = server.trim() + val trimmedUsername = username.trim() + val effectiveServer = when { + trimmedServer.isNotBlank() -> trimmedServer + trimmedUsername.contains("@") -> trimmedUsername.substringAfter("@") + else -> null + }?.takeIf { it.isNotBlank() } ?: return@derivedStateOf null + + val urlString = if (!effectiveServer.startsWith("http://") && !effectiveServer.startsWith("https://")) + "https://$effectiveServer" + else + effectiveServer + + try { + Url(urlString).host.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + } + } + Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -619,6 +698,13 @@ fun AddAccountScreen( modifier = Modifier.width(400.dp) ) + LocalNetworkPermissionNotice( + host = typedHost, + status = localNetworkStatus, + onManage = onManageLocalNetworkPermission, + modifier = Modifier.width(400.dp) + ) + OutlinedTextField( value = username, onValueChange = { username = it }, @@ -732,6 +818,70 @@ fun ChooseProviderScreen( } } +/** + * Says whether the OS lets us reach [host], for the servers where that is in question. + * + * Shown only for a host on the user's own network: someone adding a hosted provider has no local + * network permission to think about, and a "nearby devices" notice there would only confuse. The + * manage button is offered in every state it does show, granted included, so the decision stays + * reviewable rather than only appearing once something is broken. + */ +@Composable +private fun LocalNetworkPermissionNotice( + host: String?, + status: PermissionStatus, + onManage: () -> Unit, + modifier: Modifier = Modifier +) { + val relevant = host != null && + isPrivateNetworkHost(host) && + status != PermissionStatus.NOT_APPLICABLE + + AnimatedVisibility(relevant, modifier = modifier) { + val (icon, tint, message) = when (status) { + PermissionStatus.GRANTED -> Triple( + Icons.Outlined.Check, + MaterialTheme.colorScheme.primary, + stringResource(Res.string.local_network_permission_granted) + ) + PermissionStatus.DENIED -> Triple( + Icons.Outlined.Warning, + MaterialTheme.colorScheme.error, + stringResource(Res.string.local_network_permission_not_granted) + ) + // iOS cannot be asked, and raises its own prompt on the first connection. + else -> Triple( + Icons.Outlined.Info, + MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(Res.string.local_network_permission_unknown) + ) + } + + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp) + ) + Text( + text = message, + color = tint, + style = MaterialTheme.typography.labelSmall + ) + } + + TextButton(onClick = onManage) { + Text(stringResource(Res.string.local_network_permission_manage)) + } + } + } +} + @Composable private fun CalDavProviderChip( calDavProvider: CalDavProvider, diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddress.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddress.kt new file mode 100644 index 00000000..6ebd3542 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddress.kt @@ -0,0 +1,69 @@ +package at.techbee.spectacled.screens.core + +/** + * Whether [host] names a machine on the user's own network rather than somewhere on the internet. + * + * Used to decide whether a permission that only governs local network access is worth mentioning + * at all - someone syncing with a hosted CalDAV provider should never see a "nearby devices" + * prompt, and someone syncing with a box in their hallway should. + * + * Matches on the literal host, so a DNS name that happens to resolve into a private range (a + * hostname pointed at 192.168.x.y, say) is not recognised. Resolving it would mean a DNS lookup, + * which is unavailable in commonMain and would have to happen before the UI could render; the + * cost of the gap is a missed hint, not a broken connection. + */ +fun isPrivateNetworkHost(host: String): Boolean { + // Ktor hands IPv6 hosts over bracketed in some code paths and bare in others. + val bare = host.trim().removeSurrounding("[", "]").substringBefore('%').lowercase() + if (bare.isEmpty()) return false + + parseIpv4(bare)?.let { return it.isPrivateIpv4() } + if (bare.contains(':')) return bare.isPrivateIpv6() + + // A trailing dot marks a fully qualified name; ".local" is mDNS, and a name with no dot at + // all is a short LAN hostname ("nas", "raspberrypi") that only a local resolver can answer. + val name = bare.trimEnd('.') + return name.endsWith(".local") || name == "local" || !name.contains('.') +} + +/** The four octets of [host], or null if it is not a dotted-quad IPv4 literal. */ +private fun parseIpv4(host: String): List? { + val parts = host.split('.') + if (parts.size != 4) return null + + return parts.map { part -> + // Reject "01", "+1" and the like: only a plain decimal octet is an IPv4 literal. + if (part.isEmpty() || part.length > 3 || !part.all { it.isDigit() }) return null + if (part.length > 1 && part[0] == '0') return null + part.toInt().also { if (it > 255) return null } + } +} + +private fun List.isPrivateIpv4(): Boolean { + val (a, b) = this + return when { + a == 10 -> true // 10.0.0.0/8 + a == 172 && b in 16..31 -> true // 172.16.0.0/12 + a == 192 && b == 168 -> true // 192.168.0.0/16 + a == 169 && b == 254 -> true // 169.254.0.0/16 link-local + a == 127 -> true // 127.0.0.0/8 loopback + else -> false + } +} + +private fun String.isPrivateIpv6(): Boolean { + val address = this + if (address == "::1") return true + + // An IPv4-mapped address ("::ffff:192.168.1.21") is really the IPv4 address it carries. + address.substringAfterLast(':').let { tail -> + parseIpv4(tail)?.let { return it.isPrivateIpv4() } + } + + return when { + address.startsWith("fe8") || address.startsWith("fe9") || + address.startsWith("fea") || address.startsWith("feb") -> true // fe80::/10 link-local + address.startsWith("fc") || address.startsWith("fd") -> true // fc00::/7 unique local + else -> false + } +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt new file mode 100644 index 00000000..028bbc85 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt @@ -0,0 +1,64 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable + +/** + * A permission the app may have to ask the operating system for. + * + * Deliberately an enum of app-level concepts rather than platform permission strings: the same + * entry maps to a different mechanism per target (a runtime permission on Android, an implicit + * consent prompt on iOS, nothing at all on Desktop and Web). Adding a permission means adding a + * constant here and a branch in the Android actual. + */ +enum class AppPermission { + /** + * Reaching hosts on the user's own network - a self-hosted CalDAV server, or one of the + * OpenAI-compatible AI endpoints. + * + * Android 17 (API 37) gates this behind `android.permission.ACCESS_LOCAL_NETWORK`; before + * that it came for free with `INTERNET`. Denied TCP connects do not fail fast, they time + * out, so an app that never asks looks broken rather than blocked. + */ + LOCAL_NETWORK +} + +enum class PermissionStatus { + GRANTED, + + /** Refused, or never asked for - either way the app cannot act until [PermissionRequester.request]. */ + DENIED, + + /** + * The platform gates access but offers no way to read the current state (iOS). Distinct from + * [DENIED] so the UI can say "we cannot tell" instead of claiming a refusal that may not exist. + */ + UNKNOWN, + + /** Nothing to ask for on this platform or OS version. Callers show no permission UI at all. */ + NOT_APPLICABLE +} + +interface PermissionRequester { + + fun status(permission: AppPermission): PermissionStatus + + /** + * Asks the user, if this platform has a way to. The outcome arrives through the + * `onResult` callback passed to [rememberPermissionRequester] - always, including on the + * platforms where this call does nothing, so callers can treat it as a single code path. + */ + fun request(permission: AppPermission) + + /** + * Opens the OS page where the user can review or revoke what they granted. + * + * Takes no [AppPermission]: Android and iOS both only expose a per-app settings page, so a + * parameter here would promise a precision neither platform delivers. + */ + fun openAppSettings() +} + +@Composable +expect fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt new file mode 100644 index 00000000..65c9fb48 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt @@ -0,0 +1,87 @@ +package at.techbee.spectacled.screens.core + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LocalNetworkAddressTest { + + @Test + fun privateIpv4Ranges() { + listOf( + "10.0.0.1", "10.255.255.254", + "172.16.0.1", "172.31.255.254", + "192.168.1.21", // the Radicale host this was reported against + "169.254.10.5", + "127.0.0.1", + "10.0.2.2" // the Android emulator's host alias + ).forEach { assertTrue(isPrivateNetworkHost(it), "$it should be private") } + } + + @Test + fun publicIpv4AddressesJustOutsideThePrivateRanges() { + listOf( + "11.0.0.1", // just past 10/8 + "9.255.255.255", + "172.15.0.1", "172.32.0.1", // either side of 172.16/12 + "192.169.1.1", "192.167.1.1", + "169.253.0.1", "169.255.0.1", + "126.0.0.1", "128.0.0.1", + "8.8.8.8" + ).forEach { assertFalse(isPrivateNetworkHost(it), "$it should be public") } + } + + @Test + fun privateIpv6Addresses() { + listOf( + "::1", + "[::1]", + "fe80::1", "FE80::1", "feb0::1", + "fc00::1", "fd12:3456::1", + "fe80::1%en0", // zone identifier + "::ffff:192.168.1.21" // IPv4-mapped + ).forEach { assertTrue(isPrivateNetworkHost(it), "$it should be private") } + } + + @Test + fun publicIpv6Addresses() { + listOf( + "2001:4860:4860::8888", + "[2606:4700:4700::1111]", + "fec0::1", // site-local, outside fe80::/10 and fc00::/7 + "::ffff:8.8.8.8" + ).forEach { assertFalse(isPrivateNetworkHost(it), "$it should be public") } + } + + @Test + fun mdnsAndSingleLabelHostnames() { + listOf("raspberrypi.local", "NAS.LOCAL", "nas", "radicale", "server.local.").forEach { + assertTrue(isPrivateNetworkHost(it), "$it should be private") + } + } + + @Test + fun publicHostnames() { + listOf( + "baikal.techbee.at", + "spectacled.techbee.at", + "caldav.fastmail.com", + "example.com" + ).forEach { assertFalse(isPrivateNetworkHost(it), "$it should be public") } + } + + @Test + fun malformedHostsAreNotTreatedAsIpLiterals() { + // Each has four dot-separated parts but is not a valid dotted quad, so it falls through to + // the hostname rules - and as a multi-label name, it is treated as public. + listOf("192.168.1.256", "010.0.0.1", "192.168.1.x", "1.2.3.4.5").forEach { + assertFalse(isPrivateNetworkHost(it), "$it should not be private") + } + } + + @Test + fun blankHostIsNotPrivate() { + assertFalse(isPrivateNetworkHost("")) + assertFalse(isPrivateNetworkHost(" ")) + } +} diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt new file mode 100644 index 00000000..7868ae24 --- /dev/null +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt @@ -0,0 +1,42 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +/** + * iOS gates local network access from iOS 14 on, but on its own terms: the consent prompt is + * raised implicitly by the first connection to a LAN address, and there is no public API to read + * the current state or to ask ahead of time. So [PermissionRequester.status] reports + * [PermissionStatus.UNKNOWN] and [PermissionRequester.request] does nothing - what makes the + * prompt legible to the user is the `NSLocalNetworkUsageDescription` string in each app's + * Info.plist. + * + * [PermissionRequester.openAppSettings] is real, and is the only way the user can revisit the + * decision once made. + */ +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester = remember(onResult) { + object : PermissionRequester { + + override fun status(permission: AppPermission): PermissionStatus = when (permission) { + AppPermission.LOCAL_NETWORK -> PermissionStatus.UNKNOWN + } + + override fun request(permission: AppPermission) { + // Nothing to ask for - report back so callers keep a single code path. + onResult(permission, status(permission)) + } + + override fun openAppSettings() { + val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) ?: return + // openURL:options:completionHandler: rather than the plain openURL:, which has been + // deprecated since iOS 10. + UIApplication.sharedApplication.openURL(url, options = emptyMap(), completionHandler = null) + } + } +} diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt new file mode 100644 index 00000000..a3db1419 --- /dev/null +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt @@ -0,0 +1,23 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** Desktop has no permission model for any of [AppPermission], so every call is inert. */ +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester = remember(onResult) { + object : PermissionRequester { + + override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE + + override fun request(permission: AppPermission) { + onResult(permission, PermissionStatus.NOT_APPLICABLE) + } + + override fun openAppSettings() { + // No per-app permission page to open. + } + } +} diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt new file mode 100644 index 00000000..f2ed8718 --- /dev/null +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt @@ -0,0 +1,26 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * The browser grants nothing and asks nothing here: the web build reaches CalDAV servers through + * the CORS proxy (see `HttpClientFactory`), so it never opens a local network socket itself. + */ +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester = remember(onResult) { + object : PermissionRequester { + + override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE + + override fun request(permission: AppPermission) { + onResult(permission, PermissionStatus.NOT_APPLICABLE) + } + + override fun openAppSettings() { + // No per-app permission page to open. + } + } +} From 9872b5d7230f0349ef472b94948427e2c557a7f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:12:57 +0000 Subject: [PATCH 02/18] Do not resume the add after the permission prompt Requesting the local network permission now ends the tap: the account is not added behind the OS dialog, the user taps Add account again once they have answered it. Resuming for them meant holding the credentials in a second piece of state and deciding what to do on a refusal - and the people who reach this are pointing the app at a server on their own network, so tapping again is no burden. The permission callback only updates the indicator now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../components/AddPrincipalBottomSheet.kt | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index 118316fb..b2f1b8ea 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -145,18 +145,11 @@ fun AddPrincipalBottomSheet( var showInsecureConnectionAlert by rememberSaveable { mutableStateOf(false) } var credentials by rememberSaveable { mutableStateOf(null) } - // Credentials held back while the OS permission dialog is up, dispatched from the result below. - var credentialsAwaitingPermission by remember { mutableStateOf(null) } var localNetworkStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } val permissionRequester = rememberPermissionRequester { permission, status -> - if (permission != AppPermission.LOCAL_NETWORK) return@rememberPermissionRequester - - localNetworkStatus = status - // Dispatch either way: a refusal still ends in the timeout, but the indicator now sits - // above the button saying why, which is the point of showing it. - credentialsAwaitingPermission?.let { onAction(AccountListAction.OnAddPrincipal(it)) } - credentialsAwaitingPermission = null + if (permission == AppPermission.LOCAL_NETWORK) + localNetworkStatus = status } // Re-read on resume so returning from the settings page (see onManageLocalNetworkPermission) @@ -171,17 +164,19 @@ fun AddPrincipalBottomSheet( /** * The single way credentials reach the ViewModel, so the permission step cannot be skipped by * whichever of the two routes (direct, or via the insecure-connection dialog) got here. + * + * Asking for the permission ends the tap: the account is not added behind the prompt, the user + * taps again once they have answered it. Resuming the add for them would mean guessing what a + * refusal meant, and the indicator above the button already shows where things stand. */ fun submit(newCredentials: Credentials) { val needsLocalNetwork = isPrivateNetworkHost(newCredentials.server.host) && localNetworkStatus == PermissionStatus.DENIED - if (needsLocalNetwork) { - credentialsAwaitingPermission = newCredentials + if (needsLocalNetwork) permissionRequester.request(AppPermission.LOCAL_NETWORK) - } else { + else onAction(AccountListAction.OnAddPrincipal(newCredentials)) - } } LaunchedEffect(selectedPage) { From 39754561e8d6b3a7e4b2e497aaf1697fa631ecef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:37:18 +0000 Subject: [PATCH 03/18] Drop the permission gate, let the manage button do the asking Adding an account now always runs discovery, permission or not, so both paths to OnAddPrincipal go back to exactly what they are on main and the sheet's behaviour change is just the notice and its button. Someone pointing the app at a LAN server can read the timeout and act on the notice sitting right above the button. The manage button now picks its action from the status: DENIED is the one state the OS may still prompt for, so it asks there; GRANTED can only be revoked in settings, and UNKNOWN is iOS, which has nothing to ask through, so both open settings. Android cannot tell "never asked" from "refused" - both read DENIED - so this prompts on the first-run case and does nothing visible after a permanent denial. Accepted to keep the sheet at one piece of permission state and no gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../components/AddPrincipalBottomSheet.kt | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index b2f1b8ea..beb9fc1d 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -161,24 +161,6 @@ fun AddPrincipalBottomSheet( localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) } - /** - * The single way credentials reach the ViewModel, so the permission step cannot be skipped by - * whichever of the two routes (direct, or via the insecure-connection dialog) got here. - * - * Asking for the permission ends the tap: the account is not added behind the prompt, the user - * taps again once they have answered it. Resuming the add for them would mean guessing what a - * refusal meant, and the indicator above the button already shows where things stand. - */ - fun submit(newCredentials: Credentials) { - val needsLocalNetwork = isPrivateNetworkHost(newCredentials.server.host) && - localNetworkStatus == PermissionStatus.DENIED - - if (needsLocalNetwork) - permissionRequester.request(AppPermission.LOCAL_NETWORK) - else - onAction(AccountListAction.OnAddPrincipal(newCredentials)) - } - LaunchedEffect(selectedPage) { if (selectedPage == AddPrincipalBottomSheetPage.SELECTION) scope.launch { pagerState.animateScrollToPage(0) } @@ -191,7 +173,7 @@ fun AddPrincipalBottomSheet( server = credentials?.server?.toString()?:"", onDismiss = { showInsecureConnectionAlert = false }, onConfirm = { - credentials?.let { submit(it) } + credentials?.let { onAction(AccountListAction.OnAddPrincipal(it)) } showInsecureConnectionAlert = false } ) @@ -247,7 +229,7 @@ fun AddPrincipalBottomSheet( if(credentials?.server?.toString()?.startsWith("http://") == true) showInsecureConnectionAlert = true else - credentials?.let { submit(it) } + credentials?.let { onAction(AccountListAction.OnAddPrincipal(it)) } }, enabled = credentials != null && processingState !is ProcessingState.Processing ) { @@ -276,7 +258,15 @@ fun AddPrincipalBottomSheet( //onAction = onAction, onCredentialsUpdated = { credentials = it }, localNetworkStatus = localNetworkStatus, - onManageLocalNetworkPermission = { permissionRequester.openAppSettings() }, + onManageLocalNetworkPermission = { + // DENIED is the one state the OS may still be willing to prompt for, so + // ask there and send everyone else to settings: GRANTED can only be + // revoked there, and UNKNOWN is iOS, which has nothing to ask through. + if (localNetworkStatus == PermissionStatus.DENIED) + permissionRequester.request(AppPermission.LOCAL_NETWORK) + else + permissionRequester.openAppSettings() + }, modifier = Modifier.padding(8.dp).fillMaxSize().verticalScroll(rememberScrollState()) ) } else if (selectedPage == AddPrincipalBottomSheetPage.SELECT_FROM_LIST) { // SELECT FROM LIST From 772d84282a6571899429f553e03381919281258f Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:04:01 +0200 Subject: [PATCH 04/18] Removed comments for better readability --- androidJournalsApp/src/main/AndroidManifest.xml | 3 --- androidNotesApp/src/main/AndroidManifest.xml | 3 --- androidTasksApp/src/main/AndroidManifest.xml | 3 --- 3 files changed, 9 deletions(-) diff --git a/androidJournalsApp/src/main/AndroidManifest.xml b/androidJournalsApp/src/main/AndroidManifest.xml index 27f705a6..5b52cc40 100644 --- a/androidJournalsApp/src/main/AndroidManifest.xml +++ b/androidJournalsApp/src/main/AndroidManifest.xml @@ -2,9 +2,6 @@ - - - Date: Sat, 12 Sep 2026 10:06:10 +0200 Subject: [PATCH 05/18] Updated string wording to be more generic --- shared/src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 2e3aa326..020af540 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -51,7 +51,7 @@ Your password and everything you sync will be sent in plain text - visible to anyone else on this network. Only continue if you trust the network this server is on, for example your home Wi-Fi. Connect anyway Local network access granted - Local network access not granted - this server is on your own network, so syncing will time out until you allow it. + Local network access not granted - this server is on your own network, so requests will time out until you allow it. This server is on your own network. Your device may ask for permission the first time it connects. Manage permission Create folder From a6c2b6d6c721fdfaafda864e99fe67169ab439d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:13:28 +0000 Subject: [PATCH 06/18] Move the permission state into AddAccountScreen It was only ever read there. With it moved, AddPrincipalBottomSheet is byte-identical to main again and the whole feature lives in the one composable that draws it. Dropped the LaunchedEffect(host) re-read along the way: the grant is app-wide, not per-host, so changing the server field cannot change it. The ON_RESUME observer is synced up to the current lifecycle state when it is added, so it covers both the first read and the return from the settings page on its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../components/AddPrincipalBottomSheet.kt | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index beb9fc1d..eee85497 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -145,22 +145,6 @@ fun AddPrincipalBottomSheet( var showInsecureConnectionAlert by rememberSaveable { mutableStateOf(false) } var credentials by rememberSaveable { mutableStateOf(null) } - var localNetworkStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } - - val permissionRequester = rememberPermissionRequester { permission, status -> - if (permission == AppPermission.LOCAL_NETWORK) - localNetworkStatus = status - } - - // Re-read on resume so returning from the settings page (see onManageLocalNetworkPermission) - // shows the new state rather than the one captured when the sheet opened. - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) - } - LaunchedEffect(credentials?.server?.host) { - localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) - } - LaunchedEffect(selectedPage) { if (selectedPage == AddPrincipalBottomSheetPage.SELECTION) scope.launch { pagerState.animateScrollToPage(0) } @@ -257,16 +241,6 @@ fun AddPrincipalBottomSheet( processingState = processingState, //onAction = onAction, onCredentialsUpdated = { credentials = it }, - localNetworkStatus = localNetworkStatus, - onManageLocalNetworkPermission = { - // DENIED is the one state the OS may still be willing to prompt for, so - // ask there and send everyone else to settings: GRANTED can only be - // revoked there, and UNKNOWN is iOS, which has nothing to ask through. - if (localNetworkStatus == PermissionStatus.DENIED) - permissionRequester.request(AppPermission.LOCAL_NETWORK) - else - permissionRequester.openAppSettings() - }, modifier = Modifier.padding(8.dp).fillMaxSize().verticalScroll(rememberScrollState()) ) } else if (selectedPage == AddPrincipalBottomSheetPage.SELECT_FROM_LIST) { // SELECT FROM LIST @@ -470,8 +444,6 @@ fun AddAccountScreen( processingState: ProcessingState, //onAction: (AccountListAction.OnAddPrincipal) -> Unit, onCredentialsUpdated: (Credentials?) -> Unit, - localNetworkStatus: PermissionStatus = PermissionStatus.NOT_APPLICABLE, - onManageLocalNetworkPermission: () -> Unit = {}, modifier: Modifier = Modifier ) { @@ -536,6 +508,20 @@ fun AddAccountScreen( } } + var localNetworkStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } + + val permissionRequester = rememberPermissionRequester { permission, status -> + if (permission == AppPermission.LOCAL_NETWORK) + localNetworkStatus = status + } + + // Covers both reads: the observer is synced up to the current lifecycle state when it is added, + // so this fires once on first composition, and again whenever the user comes back from the + // settings page having changed the grant there. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) + } + Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -686,7 +672,15 @@ fun AddAccountScreen( LocalNetworkPermissionNotice( host = typedHost, status = localNetworkStatus, - onManage = onManageLocalNetworkPermission, + onManage = { + // DENIED is the one state the OS may still be willing to prompt for, so ask + // there and send everyone else to settings: GRANTED can only be revoked there, + // and UNKNOWN is iOS, which has nothing to ask through. + if (localNetworkStatus == PermissionStatus.DENIED) + permissionRequester.request(AppPermission.LOCAL_NETWORK) + else + permissionRequester.openAppSettings() + }, modifier = Modifier.width(400.dp) ) From 5701f62d0cd0ddf17db924fb5f93007005c59356 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:40:10 +0200 Subject: [PATCH 07/18] Code style and variable naming updates (personal preference) --- .../screens/core/PermissionRequester.android.kt | 15 ++++----------- .../components/AddPrincipalBottomSheet.kt | 10 +++++----- .../screens/core/PermissionRequester.ios.kt | 10 ++++------ .../screens/core/PermissionRequester.jvm.kt | 11 ++--------- .../screens/core/PermissionRequester.web.kt | 11 ++--------- 5 files changed, 17 insertions(+), 40 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt index 3668dd1e..23fcfdd7 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt @@ -1,6 +1,7 @@ package at.techbee.spectacled.screens.core import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.Settings @@ -14,7 +15,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat -import android.content.pm.PackageManager /** * The permission string rather than `Manifest.permission.ACCESS_LOCAL_NETWORK`, so the shared @@ -22,17 +22,11 @@ import android.content.pm.PackageManager */ private const val ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK" -/** - * First OS version that enforces the local network permission (Android 17). - * - * A literal, not a `Build.VERSION_CODES` constant: Google's own documentation sample names the - * wrong one here, and a misnamed constant would silently compile into a check that never fires. - */ +/** First OS version that enforces the local network permission (Android 17). */ private const val SDK_LOCAL_NETWORK_ENFORCED = 37 private fun AppPermission.manifestPermission(): String? = when (this) { - AppPermission.LOCAL_NETWORK -> - ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } + AppPermission.LOCAL_NETWORK -> ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } } @Composable @@ -61,8 +55,7 @@ actual fun rememberPermissionRequester( object : PermissionRequester { override fun status(permission: AppPermission): PermissionStatus { - val manifestPermission = permission.manifestPermission() - ?: return PermissionStatus.NOT_APPLICABLE + val manifestPermission = permission.manifestPermission() ?: return PermissionStatus.NOT_APPLICABLE return if (ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) PermissionStatus.GRANTED diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index eee85497..23c47cbf 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -508,18 +508,18 @@ fun AddAccountScreen( } } - var localNetworkStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } + var localNetworkPermissionStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } val permissionRequester = rememberPermissionRequester { permission, status -> if (permission == AppPermission.LOCAL_NETWORK) - localNetworkStatus = status + localNetworkPermissionStatus = status } // Covers both reads: the observer is synced up to the current lifecycle state when it is added, // so this fires once on first composition, and again whenever the user comes back from the // settings page having changed the grant there. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - localNetworkStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) + localNetworkPermissionStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) } @@ -671,12 +671,12 @@ fun AddAccountScreen( LocalNetworkPermissionNotice( host = typedHost, - status = localNetworkStatus, + status = localNetworkPermissionStatus, onManage = { // DENIED is the one state the OS may still be willing to prompt for, so ask // there and send everyone else to settings: GRANTED can only be revoked there, // and UNKNOWN is iOS, which has nothing to ask through. - if (localNetworkStatus == PermissionStatus.DENIED) + if (localNetworkPermissionStatus == PermissionStatus.DENIED) permissionRequester.request(AppPermission.LOCAL_NETWORK) else permissionRequester.openAppSettings() diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt index 7868ae24..4e8a2684 100644 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt @@ -28,15 +28,13 @@ actual fun rememberPermissionRequester( } override fun request(permission: AppPermission) { - // Nothing to ask for - report back so callers keep a single code path. - onResult(permission, status(permission)) + onResult(permission, status(permission)) // Nothing to ask for - report back so callers keep a single code path. } override fun openAppSettings() { - val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) ?: return - // openURL:options:completionHandler: rather than the plain openURL:, which has been - // deprecated since iOS 10. - UIApplication.sharedApplication.openURL(url, options = emptyMap(), completionHandler = null) + NSURL.URLWithString(UIApplicationOpenSettingsURLString)?.let { url -> + UIApplication.sharedApplication.openURL(url, options = emptyMap(), completionHandler = null) + } } } } diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt index a3db1419..03361e3f 100644 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt @@ -9,15 +9,8 @@ actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit ): PermissionRequester = remember(onResult) { object : PermissionRequester { - override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE - - override fun request(permission: AppPermission) { - onResult(permission, PermissionStatus.NOT_APPLICABLE) - } - - override fun openAppSettings() { - // No per-app permission page to open. - } + override fun request(permission: AppPermission) { onResult(permission, PermissionStatus.NOT_APPLICABLE) } + override fun openAppSettings() {/* No per-app permission page to open. */ } } } diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt index f2ed8718..7c8530e9 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt @@ -12,15 +12,8 @@ actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit ): PermissionRequester = remember(onResult) { object : PermissionRequester { - override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE - - override fun request(permission: AppPermission) { - onResult(permission, PermissionStatus.NOT_APPLICABLE) - } - - override fun openAppSettings() { - // No per-app permission page to open. - } + override fun request(permission: AppPermission) { onResult(permission, PermissionStatus.NOT_APPLICABLE) } + override fun openAppSettings() { /* No per-app permission page to open. */ } } } From 8ea9d3b4ca27dcd6c2fe366bc0d4c9af7530cd5b Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:51:05 +0200 Subject: [PATCH 08/18] taking server from credentials where it is already derived. --- .../components/AddPrincipalBottomSheet.kt | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index 23c47cbf..2ac2a773 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -483,31 +483,6 @@ fun AddAccountScreen( onCredentialsUpdated(credentials) } - // The host as typed, resolved the same way [credentials] resolves it, so the permission notice - // appears while the form is still incomplete rather than only once it validates. - val typedHost by remember { - derivedStateOf { - val trimmedServer = server.trim() - val trimmedUsername = username.trim() - val effectiveServer = when { - trimmedServer.isNotBlank() -> trimmedServer - trimmedUsername.contains("@") -> trimmedUsername.substringAfter("@") - else -> null - }?.takeIf { it.isNotBlank() } ?: return@derivedStateOf null - - val urlString = if (!effectiveServer.startsWith("http://") && !effectiveServer.startsWith("https://")) - "https://$effectiveServer" - else - effectiveServer - - try { - Url(urlString).host.takeIf { it.isNotBlank() } - } catch (_: Exception) { - null - } - } - } - var localNetworkPermissionStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } val permissionRequester = rememberPermissionRequester { permission, status -> @@ -670,7 +645,7 @@ fun AddAccountScreen( ) LocalNetworkPermissionNotice( - host = typedHost, + host = credentials?.server?.host?.takeIf { it.isNotBlank() }, status = localNetworkPermissionStatus, onManage = { // DENIED is the one state the OS may still be willing to prompt for, so ask From bcf2a1b6e9ef60cb58d64c14a46b08f3c3c19eaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:54:58 +0000 Subject: [PATCH 09/18] Pin localhost as a private network host It already passes, via the no-dot rule rather than a case of its own, which makes it easy to drop by accident: tightening that rule later would silently disagree with 127.0.0.1 and ::1, which are private explicitly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../spectacled/screens/core/LocalNetworkAddressTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt index 65c9fb48..d50f1047 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt @@ -55,7 +55,12 @@ class LocalNetworkAddressTest { @Test fun mdnsAndSingleLabelHostnames() { - listOf("raspberrypi.local", "NAS.LOCAL", "nas", "radicale", "server.local.").forEach { + listOf( + "raspberrypi.local", "NAS.LOCAL", "nas", "radicale", "server.local.", + // Caught by the no-dot rule rather than a case of its own, so pin it: it has to stay + // in step with 127.0.0.1 and ::1, which are private explicitly. + "localhost" + ).forEach { assertTrue(isPrivateNetworkHost(it), "$it should be private") } } From 96d0025383115d7c56f85cf6a409990fb10e1020 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:18:32 +0200 Subject: [PATCH 10/18] Rearranged code --- .../techbee/spectacled/screens/core/LocalNetworkAddressTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt index d50f1047..4886877e 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt @@ -56,10 +56,9 @@ class LocalNetworkAddressTest { @Test fun mdnsAndSingleLabelHostnames() { listOf( - "raspberrypi.local", "NAS.LOCAL", "nas", "radicale", "server.local.", + "localhost", "raspberrypi.local", "NAS.LOCAL", "nas", "radicale", "server.local." // Caught by the no-dot rule rather than a case of its own, so pin it: it has to stay // in step with 127.0.0.1 and ::1, which are private explicitly. - "localhost" ).forEach { assertTrue(isPrivateNetworkHost(it), "$it should be private") } From 635b1c1c5b6b525bbcb7bd93a00bbf9b11e66cdb Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:23:45 +0200 Subject: [PATCH 11/18] Show local host info only when there's a local host but the field is not focused anymore. Also allow credentials to have no username and password to allow server communication without authentication. --- .../components/AddPrincipalBottomSheet.kt | 149 +++++++++--------- .../screens/core/data/CredentialsStore.kt | 4 +- .../webdav/RemoteDataSourceCalendarCRUD.kt | 10 +- .../RemoteDataSourceCalendarDiscovery.kt | 10 +- .../data/webdav/RemoteDataSourceIcalEntry.kt | 20 ++- 5 files changed, 98 insertions(+), 95 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index 2ac2a773..aef49bfc 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -61,6 +61,7 @@ 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.focus.onFocusChanged import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -452,6 +453,7 @@ fun AddAccountScreen( val passwordState = rememberTextFieldState() var isPasswordVisible by rememberSaveable { mutableStateOf(false) } var serverDropdownMenuExpanded by remember { mutableStateOf(false) } + var isServerTextFieldFocused by remember { mutableStateOf(false) } val credentials by remember { derivedStateOf { @@ -463,7 +465,7 @@ fun AddAccountScreen( else -> null } - if (!effectiveServer.isNullOrBlank() && trimmedUsername.isNotBlank() && passwordState.text.isNotBlank()) { + if (!effectiveServer.isNullOrBlank()) { val urlString = if (!effectiveServer.startsWith("http://") && !effectiveServer.startsWith("https://")) { "https://$effectiveServer" } else { @@ -554,17 +556,36 @@ fun AddAccountScreen( val domain = username.substringAfter("@").trim() if (domain.isNotEmpty()) "https://$domain" else null } else null + val isPrivateNetwork = credentials?.server?.host?.let { isPrivateNetworkHost(it) } ?: false - AnimatedVisibility(inferred?.isNotBlank() == true || isInsecure) { - Column { - if(inferred?.isNotBlank() == true) - Text(stringResource(Res.string.server_inferred, inferred)) + Column { + AnimatedVisibility(inferred?.isNotBlank() == true) { + Text(stringResource(Res.string.server_inferred, inferred?:"")) + } - if(isInsecure) - Text( - text = stringResource(Res.string.insecure_connection_warning), - color = MaterialTheme.colorScheme.error - ) + AnimatedVisibility(isInsecure) { + Text( + text = stringResource(Res.string.insecure_connection_warning), + color = MaterialTheme.colorScheme.error + ) + } + + AnimatedVisibility(!isServerTextFieldFocused + && isPrivateNetwork + && localNetworkPermissionStatus != PermissionStatus.NOT_APPLICABLE + ) { + LocalNetworkPermissionNotice( + status = localNetworkPermissionStatus, + onManagePermission = { + // DENIED is the one state the OS may still be willing to prompt for, so ask + // there and send everyone else to settings: GRANTED can only be revoked there, + // and UNKNOWN is iOS, which has nothing to ask through. + if (localNetworkPermissionStatus == PermissionStatus.DENIED) + permissionRequester.request(AppPermission.LOCAL_NETWORK) + else + permissionRequester.openAppSettings() + } + ) } } }, @@ -641,22 +662,9 @@ fun AddAccountScreen( autoCorrectEnabled = false //imeAction = ImeAction.Done ), - modifier = Modifier.width(400.dp) - ) - - LocalNetworkPermissionNotice( - host = credentials?.server?.host?.takeIf { it.isNotBlank() }, - status = localNetworkPermissionStatus, - onManage = { - // DENIED is the one state the OS may still be willing to prompt for, so ask - // there and send everyone else to settings: GRANTED can only be revoked there, - // and UNKNOWN is iOS, which has nothing to ask through. - if (localNetworkPermissionStatus == PermissionStatus.DENIED) - permissionRequester.request(AppPermission.LOCAL_NETWORK) - else - permissionRequester.openAppSettings() - }, - modifier = Modifier.width(400.dp) + modifier = Modifier + .width(400.dp) + .onFocusChanged { isServerTextFieldFocused = it.isFocused} ) OutlinedTextField( @@ -772,66 +780,53 @@ fun ChooseProviderScreen( } } -/** - * Says whether the OS lets us reach [host], for the servers where that is in question. - * - * Shown only for a host on the user's own network: someone adding a hosted provider has no local - * network permission to think about, and a "nearby devices" notice there would only confuse. The - * manage button is offered in every state it does show, granted included, so the decision stays - * reviewable rather than only appearing once something is broken. - */ +/** Note if the local network access is blocked in permissions with a button to open permissions */ @Composable private fun LocalNetworkPermissionNotice( - host: String?, status: PermissionStatus, - onManage: () -> Unit, + onManagePermission: () -> Unit, modifier: Modifier = Modifier ) { - val relevant = host != null && - isPrivateNetworkHost(host) && - status != PermissionStatus.NOT_APPLICABLE - - AnimatedVisibility(relevant, modifier = modifier) { - val (icon, tint, message) = when (status) { - PermissionStatus.GRANTED -> Triple( - Icons.Outlined.Check, - MaterialTheme.colorScheme.primary, - stringResource(Res.string.local_network_permission_granted) - ) - PermissionStatus.DENIED -> Triple( - Icons.Outlined.Warning, - MaterialTheme.colorScheme.error, - stringResource(Res.string.local_network_permission_not_granted) + + val (icon, tint, message) = when (status) { + PermissionStatus.GRANTED -> Triple( + Icons.Outlined.Check, + MaterialTheme.colorScheme.primary, + stringResource(Res.string.local_network_permission_granted) + ) + PermissionStatus.DENIED -> Triple( + Icons.Outlined.Warning, + MaterialTheme.colorScheme.error, + stringResource(Res.string.local_network_permission_not_granted) + ) + // iOS cannot be asked, and raises its own prompt on the first connection. + else -> Triple( + Icons.Outlined.Info, + MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(Res.string.local_network_permission_unknown) + ) + } + + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp) ) - // iOS cannot be asked, and raises its own prompt on the first connection. - else -> Triple( - Icons.Outlined.Info, - MaterialTheme.colorScheme.onSurfaceVariant, - stringResource(Res.string.local_network_permission_unknown) + Text( + text = message, + color = tint, + style = MaterialTheme.typography.labelSmall ) } - Column { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(16.dp) - ) - Text( - text = message, - color = tint, - style = MaterialTheme.typography.labelSmall - ) - } - - TextButton(onClick = onManage) { - Text(stringResource(Res.string.local_network_permission_manage)) - } + TextButton(onClick = onManagePermission) { + Text(stringResource(Res.string.local_network_permission_manage)) } } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.kt index 8b735438..a5c0ae90 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/CredentialsStore.kt @@ -11,7 +11,9 @@ data class Credentials( val server: Url, val username: String, val password: String -) +) { + fun hasUsernameAndPassword() = username.isNotBlank() && password.isNotBlank() +} interface CredentialStore { suspend fun save(credentials: Credentials) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarCRUD.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarCRUD.kt index 4476b9e3..5e8b59e2 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarCRUD.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarCRUD.kt @@ -76,7 +76,7 @@ suspend fun createCalendarMultiplatform( val xmlString = calDavXml.encodeToString(mkColRequest) client.request(newCalendar.url.toString().trimEnd('/')+"/") { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } method = HttpMethod.parse("MKCOL") @@ -118,7 +118,7 @@ suspend fun createCalendarMultiplatform( val xmlString2 = calDavXml.encodeToString(propfindRequest) client.request(newCalendar.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "0") @@ -203,7 +203,7 @@ suspend fun updateCalDavCalendarMultiplatform( val xmlString = calDavXml.encodeToString(propertyupdateRequest) client.request(calendar.url.toString().trimEnd('/')+"/") { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } method = HttpMethod.parse("PROPPATCH") @@ -239,7 +239,7 @@ suspend fun updateCalDavCalendarMultiplatform( val xmlString2 = calDavXml.encodeToString(propfindRequest) client.request(calendar.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "0") @@ -291,7 +291,7 @@ suspend fun deleteCalendarMultiplatform( ): DeleteCalendarResult { client.request(calendar.url.toString().trimEnd('/')+"/") { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } method = HttpMethod.Delete diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt index 42ba7bea..616dda2b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceCalendarDiscovery.kt @@ -82,7 +82,9 @@ suspend fun discoverPrincipalsMultiplatform( // We follow these redirects using a GET request to find the effective discovery URL. val discoveryUrl = try { val response = client.get(wellKnownUrl) { - if (credentials != null) basicAuth(credentials.username, credentials.password) + if (credentials?.hasUsernameAndPassword() == true) { + basicAuth(credentials.username, credentials.password) + } } if (response.status.value in 300..399) { @@ -133,7 +135,7 @@ private suspend fun discoverPrincipalsInternal( val xmlString = calDavXml.encodeToString(propfindRequest) client.request(location) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "0") @@ -224,7 +226,7 @@ suspend fun discoverHomeCollectionsMultiplatform( val xmlString = calDavXml.encodeToString(propfindRequest) client.request(principal.principalUrl) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "0") @@ -317,7 +319,7 @@ suspend fun discoverCalendarsMultiplatform( val xmlString = calDavXml.encodeToString(propfindRequest) client.request(homeCollection.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "1") diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceIcalEntry.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceIcalEntry.kt index 8dce97ab..912a1d9e 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceIcalEntry.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/webdav/RemoteDataSourceIcalEntry.kt @@ -55,7 +55,7 @@ suspend fun multigetResourceHrefsMultiplatform( val xmlString = calDavXml.encodeToString(calendarQuery) client.request(calendar.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "1") @@ -105,7 +105,7 @@ suspend fun syncCollectionMultiplatform( val xmlString = calDavXml.encodeToString(syncCollection) client.request(calendar.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.Depth, "1") @@ -165,7 +165,7 @@ suspend fun fetchSingleEntryMultiplatform( val xmlBody = calDavXml.encodeToString(calendarMultigetRequest) client.request(calendar.url) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } method = HttpMethod.parse("REPORT") @@ -217,7 +217,7 @@ suspend fun putResourceMultiplatform( val href = Url(calendar.url.toString().trimEnd('/')+"/"+icalEntry.uid+".ics") client.put(href) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } contentType(ContentType.parse("text/calendar").withCharset(Charsets.UTF_8)) @@ -257,7 +257,7 @@ suspend fun deleteResourceMultiplatform( val href = Url(calendar.url.toString().trimEnd('/')+"/"+icalEntry.uid+".ics") client.delete(href) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } contentType(ContentType.parse("text/calendar").withCharset(Charsets.UTF_8)) @@ -285,7 +285,7 @@ suspend fun getResourceMultiplatform( val href = Url(calendar.url.toString().trimEnd('/')+"/"+icalEntry.uid+".ics") client.get(href) { - if (credentials != null) { + if (credentials?.hasUsernameAndPassword() == true) { basicAuth(credentials.username, credentials.password) } headers.append(HttpHeaders.IfNoneMatch, icalEntry.etag?:"*") @@ -317,7 +317,9 @@ suspend fun uploadFileMultiplatform( credentials: Credentials? ): HttpStatusCode { val response = client.put(targetUrl) { - credentials?.let { basicAuth(it.username, it.password) } + if (credentials?.hasUsernameAndPassword() == true) { + basicAuth(credentials.username, credentials.password) + } contentType(mimeType?.let { ContentType.parse(it) } ?: ContentType.Application.OctetStream) setBody(bytes) } @@ -330,7 +332,9 @@ suspend fun downloadFileMultiplatform( credentials: Credentials? ): ByteArray? { val response = client.get(sourceUrl) { - credentials?.let { basicAuth(it.username, it.password) } + if (credentials?.hasUsernameAndPassword() == true) { + basicAuth(credentials.username, credentials.password) + } } return if (response.status.isSuccess()) response.body() else null // TODO: respond with an actual HttpStatusCode } From 219305a38d8a9944253336c5ed5e2ffa2e54b0cf Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:51:15 +0200 Subject: [PATCH 12/18] Simplified and streamlined message for local server --- .../components/AddPrincipalBottomSheet.kt | 84 +++++++------------ 1 file changed, 29 insertions(+), 55 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index aef49bfc..e5ea6e1b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.ChevronLeft import androidx.compose.material.icons.outlined.ChevronRight -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff @@ -574,9 +573,32 @@ fun AddAccountScreen( && isPrivateNetwork && localNetworkPermissionStatus != PermissionStatus.NOT_APPLICABLE ) { - LocalNetworkPermissionNotice( - status = localNetworkPermissionStatus, - onManagePermission = { + + Column { + + val (tint, message) = when (localNetworkPermissionStatus) { + PermissionStatus.GRANTED -> Pair( + MaterialTheme.colorScheme.primary, + stringResource(Res.string.local_network_permission_granted) + ) + PermissionStatus.DENIED -> Pair( + MaterialTheme.colorScheme.error, + stringResource(Res.string.local_network_permission_not_granted) + ) + // iOS cannot be asked, and raises its own prompt on the first connection. + else -> Pair( + MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(Res.string.local_network_permission_unknown) + ) + } + + Text( + text = message, + color = tint, + style = MaterialTheme.typography.labelSmall + ) + + TextButton(onClick = { // DENIED is the one state the OS may still be willing to prompt for, so ask // there and send everyone else to settings: GRANTED can only be revoked there, // and UNKNOWN is iOS, which has nothing to ask through. @@ -584,8 +606,10 @@ fun AddAccountScreen( permissionRequester.request(AppPermission.LOCAL_NETWORK) else permissionRequester.openAppSettings() + }) { + Text(stringResource(Res.string.local_network_permission_manage)) } - ) + } } } }, @@ -780,56 +804,6 @@ fun ChooseProviderScreen( } } -/** Note if the local network access is blocked in permissions with a button to open permissions */ -@Composable -private fun LocalNetworkPermissionNotice( - status: PermissionStatus, - onManagePermission: () -> Unit, - modifier: Modifier = Modifier -) { - - val (icon, tint, message) = when (status) { - PermissionStatus.GRANTED -> Triple( - Icons.Outlined.Check, - MaterialTheme.colorScheme.primary, - stringResource(Res.string.local_network_permission_granted) - ) - PermissionStatus.DENIED -> Triple( - Icons.Outlined.Warning, - MaterialTheme.colorScheme.error, - stringResource(Res.string.local_network_permission_not_granted) - ) - // iOS cannot be asked, and raises its own prompt on the first connection. - else -> Triple( - Icons.Outlined.Info, - MaterialTheme.colorScheme.onSurfaceVariant, - stringResource(Res.string.local_network_permission_unknown) - ) - } - - Column(modifier = modifier) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(16.dp) - ) - Text( - text = message, - color = tint, - style = MaterialTheme.typography.labelSmall - ) - } - - TextButton(onClick = onManagePermission) { - Text(stringResource(Res.string.local_network_permission_manage)) - } - } -} @Composable private fun CalDavProviderChip( From b591b958df547509f9f9b9d2218874affe1aba3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 10:54:48 +0000 Subject: [PATCH 13/18] Fall back to app settings when Android will not prompt Android stops offering the permission dialog once the user has refused twice. From then on launch() returns denied immediately without showing anything - logcat says "No requestable permission in the request." - so the manage button looked dead in exactly the state where the user most wants it to work. A rationale the system will no longer show is how that state announces itself, so after a denied result with shouldShowRequestPermissionRationale false, open the settings page instead, where the grant can still be changed. Where the Activity cannot be resolved nothing changes. One deliberate imprecision: dismissing the dialog with Back rather than answering it looks the same from here, so that also lands in settings. Telling the two apart needs timing guesswork or a persisted "already asked" flag, and neither is worth it for a button whose whole job is to get the user to where they can change the grant. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../core/PermissionRequester.android.kt | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt index 23fcfdd7..c243a4fa 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt @@ -1,5 +1,8 @@ package at.techbee.spectacled.screens.core +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -29,6 +32,28 @@ private fun AppPermission.manifestPermission(): String? = when (this) { AppPermission.LOCAL_NETWORK -> ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } } +/** The Activity this Context is hosted by, unwrapping the wrappers Compose may hand over. */ +private fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} + +private fun Context.openAppSettings() { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ).apply { + // The Context here may be the Activity, but callers can also reach this from a + // non-Activity Context, where a new task is required. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) +} + @Composable actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit @@ -47,6 +72,19 @@ actual fun rememberPermissionRequester( ) { granted -> requested?.let { permission -> currentOnResult(permission, if (granted) PermissionStatus.GRANTED else PermissionStatus.DENIED) + + // Android stops offering the dialog once the user has refused twice, and from then on + // launch() returns denied immediately without showing anything ("No requestable + // permission in the request." in logcat), which leaves the button looking dead. A + // rationale the system will no longer show is how that state announces itself, so fall + // back to the settings page, where the grant can still be changed. + val manifestPermission = permission.manifestPermission() + val activity = context.findActivity() + if (!granted && manifestPermission != null && + activity?.shouldShowRequestPermissionRationale(manifestPermission) == false + ) { + context.openAppSettings() + } } requested = null } @@ -73,17 +111,7 @@ actual fun rememberPermissionRequester( launcher.launch(manifestPermission) } - override fun openAppSettings() { - val intent = Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null) - ).apply { - // The Context here may be the Activity, but callers can also reach this from a - // non-Activity Context, where a new task is required. - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } + override fun openAppSettings() = context.openAppSettings() } } } From d822648d5f9bdb73474d49757e377f18939b2c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 11:10:45 +0000 Subject: [PATCH 14/18] Split the permission API into an injected checker and a composable requester Reading permission state and opening the settings page both work off an application context, so PlatformPermissionChecker now joins the other platform services in Koin and reads like them. What stays composable is only what has to be: on Android the prompt goes through an ActivityResultLauncher, which must be registered against the Activity before it reaches STARTED and dies with it, so a singleton holding the Application can never own one. With the rest moved out, that constraint is what the remaining file is about, which is easier to see than it was when one type mixed both. AddAccountScreen takes the checker as a defaulted parameter, matching how AddPrincipalBottomSheet takes its variant, so the preview can supply a stub instead of needing a Koin graph. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../screens/core/PermissionChecker.android.kt | 60 ++++++++++++++++++ .../core/PermissionRequester.android.kt | 56 ----------------- .../screens/core/koin/Modules.android.kt | 3 + .../components/AddPrincipalBottomSheet.kt | 13 +++- .../screens/core/PermissionChecker.kt | 61 +++++++++++++++++++ .../screens/core/PermissionRequester.kt | 57 +++-------------- .../screens/core/PermissionChecker.ios.kt | 25 ++++++++ .../screens/core/PermissionRequester.ios.kt | 32 +--------- .../screens/core/koin/Modules.ios.kt | 3 + .../screens/core/koin/Modules.js.kt | 3 + .../screens/core/PermissionChecker.jvm.kt | 7 +++ .../screens/core/PermissionRequester.jvm.kt | 7 +-- .../screens/core/koin/Modules.jvm.kt | 3 + .../screens/core/koin/Modules.wasmJs.kt | 3 + .../screens/core/PermissionChecker.web.kt | 10 +++ .../screens/core/PermissionRequester.web.kt | 10 +-- 16 files changed, 202 insertions(+), 151 deletions(-) create mode 100644 shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.android.kt create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.kt create mode 100644 shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.ios.kt create mode 100644 shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.jvm.kt create mode 100644 shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.web.kt diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.android.kt new file mode 100644 index 00000000..bb1e0842 --- /dev/null +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.android.kt @@ -0,0 +1,60 @@ +package at.techbee.spectacled.screens.core + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.ContextCompat + +/** + * The permission string rather than `Manifest.permission.ACCESS_LOCAL_NETWORK`, so the shared + * module keeps compiling if the compileSdk is rolled back below 37. + */ +private const val ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK" + +/** First OS version that enforces the local network permission (Android 17). */ +private const val SDK_LOCAL_NETWORK_ENFORCED = 37 + +/** The Android permission behind this one, or null where this OS version does not gate it. */ +internal fun AppPermission.manifestPermission(): String? = when (this) { + AppPermission.LOCAL_NETWORK -> ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } +} + +/** The Activity this Context is hosted by, unwrapping the wrappers Compose may hand over. */ +internal fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} + +internal fun Context.openAppSettings() { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ).apply { + // The injected Context is the Application, which needs its own task to start an Activity. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) +} + +actual class PlatformPermissionChecker(private val context: Context) : PermissionChecker { + + actual override fun status(permission: AppPermission): PermissionStatus { + val manifestPermission = permission.manifestPermission() ?: return PermissionStatus.NOT_APPLICABLE + + return if (ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) + PermissionStatus.GRANTED + else + PermissionStatus.DENIED + } + + actual override fun openAppSettings() = context.openAppSettings() +} diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt index c243a4fa..f55ed2c6 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt @@ -1,13 +1,5 @@ package at.techbee.spectacled.screens.core -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -17,42 +9,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat - -/** - * The permission string rather than `Manifest.permission.ACCESS_LOCAL_NETWORK`, so the shared - * module keeps compiling if the compileSdk is rolled back below 37. - */ -private const val ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK" - -/** First OS version that enforces the local network permission (Android 17). */ -private const val SDK_LOCAL_NETWORK_ENFORCED = 37 - -private fun AppPermission.manifestPermission(): String? = when (this) { - AppPermission.LOCAL_NETWORK -> ACCESS_LOCAL_NETWORK.takeIf { Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_ENFORCED } -} - -/** The Activity this Context is hosted by, unwrapping the wrappers Compose may hand over. */ -private fun Context.findActivity(): Activity? { - var context = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - return null -} - -private fun Context.openAppSettings() { - val intent = Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", packageName, null) - ).apply { - // The Context here may be the Activity, but callers can also reach this from a - // non-Activity Context, where a new task is required. - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) -} @Composable actual fun rememberPermissionRequester( @@ -91,16 +47,6 @@ actual fun rememberPermissionRequester( return remember(context) { object : PermissionRequester { - - override fun status(permission: AppPermission): PermissionStatus { - val manifestPermission = permission.manifestPermission() ?: return PermissionStatus.NOT_APPLICABLE - - return if (ContextCompat.checkSelfPermission(context, manifestPermission) == PackageManager.PERMISSION_GRANTED) - PermissionStatus.GRANTED - else - PermissionStatus.DENIED - } - override fun request(permission: AppPermission) { val manifestPermission = permission.manifestPermission() if (manifestPermission == null) { @@ -110,8 +56,6 @@ actual fun rememberPermissionRequester( requested = permission launcher.launch(manifestPermission) } - - override fun openAppSettings() = context.openAppSettings() } } } diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.android.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.android.kt index 26d3518e..596024e7 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.android.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.android.kt @@ -3,8 +3,10 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.core.DatabaseDriverFactory import at.techbee.spectacled.screens.core.FileLauncher import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PlatformFileLauncher import at.techbee.spectacled.screens.core.PlatformFileManager +import at.techbee.spectacled.screens.core.PlatformPermissionChecker import at.techbee.spectacled.screens.core.PlatformShareManager import at.techbee.spectacled.screens.core.PlatformSyncTrigger import at.techbee.spectacled.screens.core.ShareManager @@ -25,4 +27,5 @@ actual val platformModule = module { single { PlatformShareManager(androidContext()) }.bind() single { PlatformFileManager(androidContext()) }.bind() single { PlatformFileLauncher(androidContext()) }.bind() + single { PlatformPermissionChecker(androidContext()) }.bind() } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index e5ea6e1b..4d3e750f 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -79,6 +79,7 @@ import at.techbee.spectacled.screens.account.presentation.components.datastructu import at.techbee.spectacled.screens.account.presentation.components.datastructures.CalDavProviderCategory import at.techbee.spectacled.screens.account.presentation.components.settings.ProxyServerSetup import at.techbee.spectacled.screens.core.AppPermission +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PermissionStatus import at.techbee.spectacled.screens.core.Platforms import at.techbee.spectacled.screens.core.data.Credentials @@ -444,7 +445,8 @@ fun AddAccountScreen( processingState: ProcessingState, //onAction: (AccountListAction.OnAddPrincipal) -> Unit, onCredentialsUpdated: (Credentials?) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + permissionChecker: PermissionChecker = koinInject() ) { var server by rememberSaveable { mutableStateOf("") } @@ -495,7 +497,7 @@ fun AddAccountScreen( // so this fires once on first composition, and again whenever the user comes back from the // settings page having changed the grant there. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - localNetworkPermissionStatus = permissionRequester.status(AppPermission.LOCAL_NETWORK) + localNetworkPermissionStatus = permissionChecker.status(AppPermission.LOCAL_NETWORK) } @@ -605,7 +607,7 @@ fun AddAccountScreen( if (localNetworkPermissionStatus == PermissionStatus.DENIED) permissionRequester.request(AppPermission.LOCAL_NETWORK) else - permissionRequester.openAppSettings() + permissionChecker.openAppSettings() }) { Text(stringResource(Res.string.local_network_permission_manage)) } @@ -928,6 +930,11 @@ private fun AddAccountScreen_Preview_Error() { processingState = ProcessingState.Error("This is an error"), onCredentialsUpdated = {}, //onAction = {} + // Supplied explicitly: a preview has no Koin graph to resolve it from. + permissionChecker = object : PermissionChecker { + override fun status(permission: AppPermission) = PermissionStatus.NOT_APPLICABLE + override fun openAppSettings() {} + } ) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.kt new file mode 100644 index 00000000..947cedbb --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.kt @@ -0,0 +1,61 @@ +package at.techbee.spectacled.screens.core + +/** + * A permission the app may have to ask the operating system for. + * + * Deliberately an enum of app-level concepts rather than platform permission strings: the same + * entry maps to a different mechanism per target (a runtime permission on Android, an implicit + * consent prompt on iOS, nothing at all on Desktop and Web). Adding a permission means adding a + * constant here and a branch in the Android actuals. + */ +enum class AppPermission { + /** + * Reaching hosts on the user's own network - a self-hosted CalDAV server, or one of the + * OpenAI-compatible AI endpoints. + * + * Android 17 (API 37) gates this behind `android.permission.ACCESS_LOCAL_NETWORK`; before + * that it came for free with `INTERNET`. Denied TCP connects do not fail fast, they time + * out, so an app that never asks looks broken rather than blocked. + */ + LOCAL_NETWORK +} + +enum class PermissionStatus { + GRANTED, + + /** Refused, or never asked for - either way the app cannot act until [PermissionRequester.request]. */ + DENIED, + + /** + * The platform gates access but offers no way to read the current state (iOS). Distinct from + * [DENIED] so the UI can say "we cannot tell" instead of claiming a refusal that may not exist. + */ + UNKNOWN, + + /** Nothing to ask for on this platform or OS version. Callers show no permission UI at all. */ + NOT_APPLICABLE +} + +/** + * Reads permission state and sends the user to the OS page where they can change it. + * + * Everything here works off an application context, so this is injected through Koin like the + * other platform services. Asking the user is the part that cannot be - see [PermissionRequester]. + */ +interface PermissionChecker { + + fun status(permission: AppPermission): PermissionStatus + + /** + * Opens the OS page where the user can review or revoke what they granted. + * + * Takes no [AppPermission]: Android and iOS both only expose a per-app settings page, so a + * parameter here would promise a precision neither platform delivers. + */ + fun openAppSettings() +} + +expect class PlatformPermissionChecker : PermissionChecker { + override fun status(permission: AppPermission): PermissionStatus + override fun openAppSettings() +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt index 028bbc85..a3af400c 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt @@ -3,59 +3,22 @@ package at.techbee.spectacled.screens.core import androidx.compose.runtime.Composable /** - * A permission the app may have to ask the operating system for. + * Asks the user for a permission. * - * Deliberately an enum of app-level concepts rather than platform permission strings: the same - * entry maps to a different mechanism per target (a runtime permission on Android, an implicit - * consent prompt on iOS, nothing at all on Desktop and Web). Adding a permission means adding a - * constant here and a branch in the Android actual. + * This one is not injected, unlike [PermissionChecker]: on Android the prompt goes through an + * `ActivityResultLauncher`, which has to be registered against the Activity before it reaches + * STARTED and is torn down with it. A Koin singleton holds the Application and so can never own + * one - hence the `remember`, the same shape `rememberImagePicker` and `rememberFilePicker` use + * for the same reason. */ -enum class AppPermission { - /** - * Reaching hosts on the user's own network - a self-hosted CalDAV server, or one of the - * OpenAI-compatible AI endpoints. - * - * Android 17 (API 37) gates this behind `android.permission.ACCESS_LOCAL_NETWORK`; before - * that it came for free with `INTERNET`. Denied TCP connects do not fail fast, they time - * out, so an app that never asks looks broken rather than blocked. - */ - LOCAL_NETWORK -} - -enum class PermissionStatus { - GRANTED, - - /** Refused, or never asked for - either way the app cannot act until [PermissionRequester.request]. */ - DENIED, +fun interface PermissionRequester { /** - * The platform gates access but offers no way to read the current state (iOS). Distinct from - * [DENIED] so the UI can say "we cannot tell" instead of claiming a refusal that may not exist. - */ - UNKNOWN, - - /** Nothing to ask for on this platform or OS version. Callers show no permission UI at all. */ - NOT_APPLICABLE -} - -interface PermissionRequester { - - fun status(permission: AppPermission): PermissionStatus - - /** - * Asks the user, if this platform has a way to. The outcome arrives through the - * `onResult` callback passed to [rememberPermissionRequester] - always, including on the - * platforms where this call does nothing, so callers can treat it as a single code path. + * Asks the user, if this platform has a way to. The outcome arrives through the `onResult` + * callback passed to [rememberPermissionRequester] - always, including on the platforms where + * this call does nothing, so callers can treat it as a single code path. */ fun request(permission: AppPermission) - - /** - * Opens the OS page where the user can review or revoke what they granted. - * - * Takes no [AppPermission]: Android and iOS both only expose a per-app settings page, so a - * parameter here would promise a precision neither platform delivers. - */ - fun openAppSettings() } @Composable diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.ios.kt new file mode 100644 index 00000000..abafe46b --- /dev/null +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.ios.kt @@ -0,0 +1,25 @@ +package at.techbee.spectacled.screens.core + +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +/** + * iOS gates local network access from iOS 14 on, but on its own terms: the consent prompt is + * raised implicitly by the first connection to a LAN address, and there is no public API to read + * the current state or to ask ahead of time. So [status] reports [PermissionStatus.UNKNOWN] and + * the prompt is made legible instead by the NSLocalNetworkUsageDescription string in each app's + * Info.plist. [openAppSettings] is the only way the user can revisit the decision once made. + */ +actual class PlatformPermissionChecker : PermissionChecker { + + actual override fun status(permission: AppPermission): PermissionStatus = when (permission) { + AppPermission.LOCAL_NETWORK -> PermissionStatus.UNKNOWN + } + + actual override fun openAppSettings() { + NSURL.URLWithString(UIApplicationOpenSettingsURLString)?.let { url -> + UIApplication.sharedApplication.openURL(url, options = emptyMap(), completionHandler = null) + } + } +} diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt index 4e8a2684..2a2a85f1 100644 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt @@ -2,39 +2,11 @@ package at.techbee.spectacled.screens.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import platform.Foundation.NSURL -import platform.UIKit.UIApplication -import platform.UIKit.UIApplicationOpenSettingsURLString -/** - * iOS gates local network access from iOS 14 on, but on its own terms: the consent prompt is - * raised implicitly by the first connection to a LAN address, and there is no public API to read - * the current state or to ask ahead of time. So [PermissionRequester.status] reports - * [PermissionStatus.UNKNOWN] and [PermissionRequester.request] does nothing - what makes the - * prompt legible to the user is the `NSLocalNetworkUsageDescription` string in each app's - * Info.plist. - * - * [PermissionRequester.openAppSettings] is real, and is the only way the user can revisit the - * decision once made. - */ +/** Nothing to ask through on iOS - report back so callers keep a single code path. */ @Composable actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit ): PermissionRequester = remember(onResult) { - object : PermissionRequester { - - override fun status(permission: AppPermission): PermissionStatus = when (permission) { - AppPermission.LOCAL_NETWORK -> PermissionStatus.UNKNOWN - } - - override fun request(permission: AppPermission) { - onResult(permission, status(permission)) // Nothing to ask for - report back so callers keep a single code path. - } - - override fun openAppSettings() { - NSURL.URLWithString(UIApplicationOpenSettingsURLString)?.let { url -> - UIApplication.sharedApplication.openURL(url, options = emptyMap(), completionHandler = null) - } - } - } + PermissionRequester { permission -> onResult(permission, PermissionStatus.UNKNOWN) } } diff --git a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.ios.kt b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.ios.kt index 3776ec66..91753375 100644 --- a/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.ios.kt +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.ios.kt @@ -3,8 +3,10 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.core.DatabaseDriverFactory import at.techbee.spectacled.screens.core.FileLauncher import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PlatformFileLauncher import at.techbee.spectacled.screens.core.PlatformFileManager +import at.techbee.spectacled.screens.core.PlatformPermissionChecker import at.techbee.spectacled.screens.core.PlatformShareManager import at.techbee.spectacled.screens.core.PlatformSyncTrigger import at.techbee.spectacled.screens.core.ShareManager @@ -25,4 +27,5 @@ actual val platformModule = module { singleOf(::PlatformShareManager) { bind() } singleOf(::PlatformFileManager) { bind() } singleOf(::PlatformFileLauncher) { bind() } + singleOf(::PlatformPermissionChecker) { bind() } } diff --git a/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.js.kt b/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.js.kt index 3776ec66..91753375 100644 --- a/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.js.kt +++ b/shared/src/jsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.js.kt @@ -3,8 +3,10 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.core.DatabaseDriverFactory import at.techbee.spectacled.screens.core.FileLauncher import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PlatformFileLauncher import at.techbee.spectacled.screens.core.PlatformFileManager +import at.techbee.spectacled.screens.core.PlatformPermissionChecker import at.techbee.spectacled.screens.core.PlatformShareManager import at.techbee.spectacled.screens.core.PlatformSyncTrigger import at.techbee.spectacled.screens.core.ShareManager @@ -25,4 +27,5 @@ actual val platformModule = module { singleOf(::PlatformShareManager) { bind() } singleOf(::PlatformFileManager) { bind() } singleOf(::PlatformFileLauncher) { bind() } + singleOf(::PlatformPermissionChecker) { bind() } } diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.jvm.kt new file mode 100644 index 00000000..b56be095 --- /dev/null +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.jvm.kt @@ -0,0 +1,7 @@ +package at.techbee.spectacled.screens.core + +/** Desktop has no permission model for any of [AppPermission], so every call is inert. */ +actual class PlatformPermissionChecker : PermissionChecker { + actual override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE + actual override fun openAppSettings() {/* No per-app permission page to open. */ } +} diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt index 03361e3f..dd71d98c 100644 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt @@ -3,14 +3,9 @@ package at.techbee.spectacled.screens.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -/** Desktop has no permission model for any of [AppPermission], so every call is inert. */ @Composable actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit ): PermissionRequester = remember(onResult) { - object : PermissionRequester { - override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE - override fun request(permission: AppPermission) { onResult(permission, PermissionStatus.NOT_APPLICABLE) } - override fun openAppSettings() {/* No per-app permission page to open. */ } - } + PermissionRequester { permission -> onResult(permission, PermissionStatus.NOT_APPLICABLE) } } diff --git a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.jvm.kt b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.jvm.kt index 3776ec66..91753375 100644 --- a/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.jvm.kt +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.jvm.kt @@ -3,8 +3,10 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.core.DatabaseDriverFactory import at.techbee.spectacled.screens.core.FileLauncher import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PlatformFileLauncher import at.techbee.spectacled.screens.core.PlatformFileManager +import at.techbee.spectacled.screens.core.PlatformPermissionChecker import at.techbee.spectacled.screens.core.PlatformShareManager import at.techbee.spectacled.screens.core.PlatformSyncTrigger import at.techbee.spectacled.screens.core.ShareManager @@ -25,4 +27,5 @@ actual val platformModule = module { singleOf(::PlatformShareManager) { bind() } singleOf(::PlatformFileManager) { bind() } singleOf(::PlatformFileLauncher) { bind() } + singleOf(::PlatformPermissionChecker) { bind() } } diff --git a/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.wasmJs.kt b/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.wasmJs.kt index 3776ec66..91753375 100644 --- a/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.wasmJs.kt +++ b/shared/src/wasmJsMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.wasmJs.kt @@ -3,8 +3,10 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.core.DatabaseDriverFactory import at.techbee.spectacled.screens.core.FileLauncher import at.techbee.spectacled.screens.core.FileManager +import at.techbee.spectacled.screens.core.PermissionChecker import at.techbee.spectacled.screens.core.PlatformFileLauncher import at.techbee.spectacled.screens.core.PlatformFileManager +import at.techbee.spectacled.screens.core.PlatformPermissionChecker import at.techbee.spectacled.screens.core.PlatformShareManager import at.techbee.spectacled.screens.core.PlatformSyncTrigger import at.techbee.spectacled.screens.core.ShareManager @@ -25,4 +27,5 @@ actual val platformModule = module { singleOf(::PlatformShareManager) { bind() } singleOf(::PlatformFileManager) { bind() } singleOf(::PlatformFileLauncher) { bind() } + singleOf(::PlatformPermissionChecker) { bind() } } diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.web.kt new file mode 100644 index 00000000..93c2c45d --- /dev/null +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionChecker.web.kt @@ -0,0 +1,10 @@ +package at.techbee.spectacled.screens.core + +/** + * The browser grants nothing and asks nothing here: the web build reaches CalDAV servers through + * the CORS proxy (see `HttpClientFactory`), so it never opens a local network socket itself. + */ +actual class PlatformPermissionChecker : PermissionChecker { + actual override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE + actual override fun openAppSettings() {/* No per-app permission page to open. */ } +} diff --git a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt index 7c8530e9..dd71d98c 100644 --- a/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt @@ -3,17 +3,9 @@ package at.techbee.spectacled.screens.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -/** - * The browser grants nothing and asks nothing here: the web build reaches CalDAV servers through - * the CORS proxy (see `HttpClientFactory`), so it never opens a local network socket itself. - */ @Composable actual fun rememberPermissionRequester( onResult: (AppPermission, PermissionStatus) -> Unit ): PermissionRequester = remember(onResult) { - object : PermissionRequester { - override fun status(permission: AppPermission): PermissionStatus = PermissionStatus.NOT_APPLICABLE - override fun request(permission: AppPermission) { onResult(permission, PermissionStatus.NOT_APPLICABLE) } - override fun openAppSettings() { /* No per-app permission page to open. */ } - } + PermissionRequester { permission -> onResult(permission, PermissionStatus.NOT_APPLICABLE) } } From 36540595b739e7627b2e8e5827880866363b3506 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:33:11 +0200 Subject: [PATCH 15/18] Prevent showing message about local server when user is typing in the username and server is inferred. --- .../presentation/components/AddPrincipalBottomSheet.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index 4d3e750f..a496831d 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -455,6 +455,7 @@ fun AddAccountScreen( var isPasswordVisible by rememberSaveable { mutableStateOf(false) } var serverDropdownMenuExpanded by remember { mutableStateOf(false) } var isServerTextFieldFocused by remember { mutableStateOf(false) } + var isUsernameTextFieldFocused by remember { mutableStateOf(false) } val credentials by remember { derivedStateOf { @@ -572,6 +573,7 @@ fun AddAccountScreen( } AnimatedVisibility(!isServerTextFieldFocused + && !(isUsernameTextFieldFocused && trimmedServer.isEmpty()) // prevent message while user is typing and server is inferred && isPrivateNetwork && localNetworkPermissionStatus != PermissionStatus.NOT_APPLICABLE ) { @@ -705,7 +707,7 @@ fun AddAccountScreen( autoCorrectEnabled = false //imeAction = ImeAction.Done ), - modifier = Modifier.width(400.dp) + modifier = Modifier.width(400.dp).onFocusChanged { isUsernameTextFieldFocused = it.isFocused} ) OutlinedSecureTextField( From 08b8b4ce7efb8b73bec0dc3df927f19d909cb6c4 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:35:14 +0200 Subject: [PATCH 16/18] Code rearrangement --- .../components/AddPrincipalBottomSheet.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index a496831d..4adffe16 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -488,18 +488,17 @@ fun AddAccountScreen( } var localNetworkPermissionStatus by remember { mutableStateOf(PermissionStatus.NOT_APPLICABLE) } + // This fires once on first composition, and again whenever the user comes back from the settings. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + localNetworkPermissionStatus = permissionChecker.status(AppPermission.LOCAL_NETWORK) + } val permissionRequester = rememberPermissionRequester { permission, status -> if (permission == AppPermission.LOCAL_NETWORK) localNetworkPermissionStatus = status } - // Covers both reads: the observer is synced up to the current lifecycle state when it is added, - // so this fires once on first composition, and again whenever the user comes back from the - // settings page having changed the grant there. - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - localNetworkPermissionStatus = permissionChecker.status(AppPermission.LOCAL_NETWORK) - } + Column( From 46d2a95429065a1149b3c255749bb590ae0fb698 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:42:26 +0200 Subject: [PATCH 17/18] removed unnecessary blanks --- .../presentation/components/AddPrincipalBottomSheet.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index 4adffe16..7edf9924 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -497,10 +497,7 @@ fun AddAccountScreen( if (permission == AppPermission.LOCAL_NETWORK) localNetworkPermissionStatus = status } - - - - + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier From 94d9b323bbf0647700f03a5e92c61697af802ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 11:43:46 +0000 Subject: [PATCH 18/18] Add tests for Credentials.hasUsernameAndPassword It is the single guard every WebDAV request checks before attaching a basicAuth header, so both directions matter: too eager and the app sends Basic base64(":") to servers that would have answered anonymously, too shy and it drops credentials the user entered. Covers both present, neither, either one alone, whitespace-only treated as absent (isNotBlank, not isNotEmpty), and a password with surrounding spaces still counting as real. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019HgjhLvQ2D1St6oxS6nx8T --- .../screens/core/data/CredentialsTest.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/CredentialsTest.kt diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/CredentialsTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/CredentialsTest.kt new file mode 100644 index 00000000..9ca8c97d --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/data/CredentialsTest.kt @@ -0,0 +1,51 @@ +package at.techbee.spectacled.screens.core.data + +import io.ktor.http.Url +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [Credentials.hasUsernameAndPassword] is what every WebDAV request checks before sending a + * basicAuth header. Getting it wrong in either direction is costly: too eager and the app sends + * `Basic base64(":")` to servers that would have served the request anonymously, too shy and it + * drops the credentials the user actually entered. + */ +class CredentialsTest { + + private fun credentials(username: String, password: String) = + Credentials(Url("https://caldav.example.com"), username, password) + + @Test + fun bothPresent() { + assertTrue(credentials("user", "secret").hasUsernameAndPassword()) + } + + @Test + fun neitherPresent() { + // An anonymous account - a read-only collection on a server that does not authenticate. + assertFalse(credentials("", "").hasUsernameAndPassword()) + } + + @Test + fun onlyOneOfThemPresent() { + // Half a credential is not worth sending: a server that wants auth rejects it anyway, and + // one that does not would have answered without it. + assertFalse(credentials("user", "").hasUsernameAndPassword()) + assertFalse(credentials("", "secret").hasUsernameAndPassword()) + } + + @Test + fun whitespaceOnlyCountsAsAbsent() { + // isNotBlank rather than isNotEmpty, so a field holding only spaces is treated as empty. + assertFalse(credentials(" ", "secret").hasUsernameAndPassword()) + assertFalse(credentials("user", " ").hasUsernameAndPassword()) + assertFalse(credentials(" ", "\t").hasUsernameAndPassword()) + } + + @Test + fun surroundingWhitespaceDoesNotMakeAValueAbsent() { + // Only entirely blank counts: a password that happens to start or end with a space is real. + assertTrue(credentials("user", " secret ").hasUsernameAndPassword()) + } +}