diff --git a/androidJournalsApp/src/main/AndroidManifest.xml b/androidJournalsApp/src/main/AndroidManifest.xml index 8c4576c9..5b52cc40 100644 --- a/androidJournalsApp/src/main/AndroidManifest.xml +++ b/androidJournalsApp/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + + 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/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 new file mode 100644 index 00000000..f55ed2c6 --- /dev/null +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.android.kt @@ -0,0 +1,61 @@ +package at.techbee.spectacled.screens.core + +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 + +@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) + + // 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 + } + + return remember(context) { + object : PermissionRequester { + override fun request(permission: AppPermission) { + val manifestPermission = permission.manifestPermission() + if (manifestPermission == null) { + currentOnResult(permission, PermissionStatus.NOT_APPLICABLE) + return + } + requested = permission + launcher.launch(manifestPermission) + } + } + } +} 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/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index b64b1e05..020af540 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 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 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..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 @@ -60,6 +60,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 @@ -69,18 +70,25 @@ 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.PermissionChecker +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 +104,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 @@ -433,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("") } @@ -441,6 +454,8 @@ fun AddAccountScreen( val passwordState = rememberTextFieldState() 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 { @@ -452,7 +467,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 { @@ -472,7 +487,17 @@ fun AddAccountScreen( onCredentialsUpdated(credentials) } + 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 + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier @@ -529,17 +554,62 @@ 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?:"")) + } + + AnimatedVisibility(isInsecure) { + Text( + text = stringResource(Res.string.insecure_connection_warning), + color = MaterialTheme.colorScheme.error + ) + } + + AnimatedVisibility(!isServerTextFieldFocused + && !(isUsernameTextFieldFocused && trimmedServer.isEmpty()) // prevent message while user is typing and server is inferred + && isPrivateNetwork + && localNetworkPermissionStatus != PermissionStatus.NOT_APPLICABLE + ) { + + 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) + ) + } - if(isInsecure) Text( - text = stringResource(Res.string.insecure_connection_warning), - color = MaterialTheme.colorScheme.error + 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. + if (localNetworkPermissionStatus == PermissionStatus.DENIED) + permissionRequester.request(AppPermission.LOCAL_NETWORK) + else + permissionChecker.openAppSettings() + }) { + Text(stringResource(Res.string.local_network_permission_manage)) + } + } } } }, @@ -616,7 +686,9 @@ fun AddAccountScreen( autoCorrectEnabled = false //imeAction = ImeAction.Done ), - modifier = Modifier.width(400.dp) + modifier = Modifier + .width(400.dp) + .onFocusChanged { isServerTextFieldFocused = it.isFocused} ) OutlinedTextField( @@ -631,7 +703,7 @@ fun AddAccountScreen( autoCorrectEnabled = false //imeAction = ImeAction.Done ), - modifier = Modifier.width(400.dp) + modifier = Modifier.width(400.dp).onFocusChanged { isUsernameTextFieldFocused = it.isFocused} ) OutlinedSecureTextField( @@ -732,6 +804,7 @@ fun ChooseProviderScreen( } } + @Composable private fun CalDavProviderChip( calDavProvider: CalDavProvider, @@ -855,6 +928,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/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/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 new file mode 100644 index 00000000..a3af400c --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.kt @@ -0,0 +1,27 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable + +/** + * Asks the user for a permission. + * + * 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. + */ +fun interface PermissionRequester { + + /** + * 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) +} + +@Composable +expect fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester 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 } 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..4886877e --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/LocalNetworkAddressTest.kt @@ -0,0 +1,91 @@ +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( + "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. + ).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/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()) + } +} 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 new file mode 100644 index 00000000..2a2a85f1 --- /dev/null +++ b/shared/src/iosMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.ios.kt @@ -0,0 +1,12 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** 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) { + 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 new file mode 100644 index 00000000..dd71d98c --- /dev/null +++ b/shared/src/jvmMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.jvm.kt @@ -0,0 +1,11 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester = remember(onResult) { + 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 new file mode 100644 index 00000000..dd71d98c --- /dev/null +++ b/shared/src/webMain/kotlin/at/techbee/spectacled/screens/core/PermissionRequester.web.kt @@ -0,0 +1,11 @@ +package at.techbee.spectacled.screens.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberPermissionRequester( + onResult: (AppPermission, PermissionStatus) -> Unit +): PermissionRequester = remember(onResult) { + PermissionRequester { permission -> onResult(permission, PermissionStatus.NOT_APPLICABLE) } +}