Permission model¶
Aagya represents permissions as a sealed hierarchy. v0.1 ships only the Location family but the shape is designed for a dozen more.
The sealed hierarchy¶
sealed interface AppPermission {
val key: String
sealed interface Location : AppPermission {
data object Fine : Location
data object Coarse : Location
}
// Reserved for future minor releases:
// sealed interface Camera
// sealed interface Microphone
// sealed interface Notifications
// sealed interface Photos { Read; ReadWrite }
// sealed interface Calendar
// sealed interface Contacts
}
Each leaf is a data object so equality, hashing, and toString are free. The key field is the stable string identifier that storage adapters and telemetry use; it is part of the public contract and must not change.
Why a sealed hierarchy¶
Two reasons:
- Exhaustive
whenfor callers. Code that explicitly handles every permission gets compiler-checked completeness. - Closed mapping for the library. Every leaf has exactly one definition for "what does this mean on Android" and "what does this mean on iOS". Aagya can guarantee correct platform mappings because it owns the universe of values.
Adding a new permission family¶
Adding a family in v0.x is a non-breaking change because consumers pattern-match against specific families (e.g. is AppPermission.Location), not the closed universe of all permissions.
sealed interface Camera : AppPermission {
data object Default : Camera {
override val key: String = "camera.default"
}
}
Then in the platform layer:
// Android
internal fun AppPermission.toAndroidPermissions(): List<String> = when (this) {
AppPermission.Location.Fine -> listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
AppPermission.Location.Coarse -> listOf(Manifest.permission.ACCESS_COARSE_LOCATION)
AppPermission.Camera.Default -> listOf(Manifest.permission.CAMERA)
}
iOS gets a parallel mapping in IosPermissionMapping.kt.
See Extending to new permissions for the full checklist.
Status vs Result¶
Aagya distinguishes between reading the permission state and acting on it.
| Question | Type | Returned by |
|---|---|---|
| What state is the permission in right now? | PermissionStatus | checkPermissionStatus(...) |
| What did the user just do? | PermissionResult | requestPermission(...) |
sealed interface PermissionStatus {
data object NotDetermined
data object Granted
data class Denied(val canAskAgain: Boolean)
data object Restricted // iOS parental controls etc.
}
sealed interface PermissionResult {
data object Granted
data class Denied(val canAskAgain: Boolean, val reason: DenialReason)
data object Cancelled
data object PolicyExhausted
}
The canAskAgain field on Denied is the most important value in the library. It is the only signal your UI needs to decide between showing "Allow" (which will prompt again) and "Open Settings" (which will route the user to manual grant).
The DenialReason field¶
PermissionResult.Denied carries a reason for diagnostics. It is not intended to drive UX; canAskAgain is. Reasons:
UserDenied, the user tapped "Don't Allow".SystemSuppressed, the OS short-circuited the dialog because the permission is already permanently denied.PresentationUnavailable, Aagya could not present the dialog (noActivity, noUIViewController).PlatformError, an unexpected platform exception. Inspect logs.
If you find yourself wanting to branch UX on reason, that is a smell, open an issue so we can talk about extending canAskAgain instead.