Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dc233d5
Request local network permission for private CalDAV hosts
claude Sep 11, 2026
9872b5d
Do not resume the add after the permission prompt
claude Sep 11, 2026
3975456
Drop the permission gate, let the manage button do the asking
claude Sep 11, 2026
772d842
Removed comments for better readability
patrickunterwegs Sep 12, 2026
feedf14
Updated string wording to be more generic
patrickunterwegs Sep 12, 2026
a6c2b6d
Move the permission state into AddAccountScreen
claude Sep 12, 2026
5701f62
Code style and variable naming updates (personal preference)
patrickunterwegs Sep 12, 2026
8ea9d3b
taking server from credentials where it is already derived.
patrickunterwegs Sep 12, 2026
bcf2a1b
Pin localhost as a private network host
claude Sep 12, 2026
96d0025
Rearranged code
patrickunterwegs Sep 12, 2026
635b1c1
Show local host info only when there's a local host but the field is …
patrickunterwegs Sep 12, 2026
219305a
Simplified and streamlined message for local server
patrickunterwegs Sep 12, 2026
b591b95
Fall back to app settings when Android will not prompt
claude Sep 12, 2026
d822648
Split the permission API into an injected checker and a composable re…
claude Sep 12, 2026
3654059
Prevent showing message about local server when user is typing in the…
patrickunterwegs Sep 12, 2026
08b8b4c
Code rearrangement
patrickunterwegs Sep 12, 2026
46d2a95
removed unnecessary blanks
patrickunterwegs Sep 12, 2026
94d9b32
Add tests for Credentials.hasUsernameAndPassword
claude Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions androidJournalsApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".JournalsApplication"
Expand Down
1 change: 1 addition & 0 deletions androidNotesApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".NotesApplication"
Expand Down
1 change: 1 addition & 0 deletions androidTasksApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".TasksApplication"
Expand Down
2 changes: 2 additions & 0 deletions iosApp/iosJournalsApp/iosJournalsApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
Expand Down
2 changes: 2 additions & 0 deletions iosApp/iosNotesApp/iosNotesApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
Expand Down
2 changes: 2 additions & 0 deletions iosApp/iosTasksApp/iosTasksApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Allows syncing with a CalDAV server on your own network, such as a self-hosted one at home.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<AppPermission?>(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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,4 +27,5 @@ actual val platformModule = module {
single<PlatformShareManager> { PlatformShareManager(androidContext()) }.bind<ShareManager>()
single<PlatformFileManager> { PlatformFileManager(androidContext()) }.bind<FileManager>()
single<PlatformFileLauncher> { PlatformFileLauncher(androidContext()) }.bind<FileLauncher>()
single<PlatformPermissionChecker> { PlatformPermissionChecker(androidContext()) }.bind<PermissionChecker>()
}
4 changes: 4 additions & 0 deletions shared/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
<string name="insecure_connection_title">Connect to "%1$s" without encryption?</string>
<string name="insecure_connection_message">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.</string>
<string name="insecure_connection_connect_anyway">Connect anyway</string>
<string name="local_network_permission_granted">Local network access granted</string>
<string name="local_network_permission_not_granted">Local network access not granted - this server is on your own network, so requests will time out until you allow it.</string>
<string name="local_network_permission_unknown">This server is on your own network. Your device may ask for permission the first time it connects.</string>
<string name="local_network_permission_manage">Manage permission</string>
<string name="create_folder">Create folder</string>
<string name="update_folder">Update folder</string>
<string name="edit_folders">Edit folders</string>
Expand Down
Loading
Loading