@sigx/lynx-permissions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/android/com/sigx/permissions/MediaCapture.kt +203 -0
- package/android/com/sigx/permissions/PermissionHelper.kt +251 -0
- package/android/com/sigx/permissions/PermissionsActivityHook.kt +47 -0
- package/package.json +19 -0
- package/sigx-module.json +13 -0
- package/src/index.ts +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Andreas Ekdahl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @sigx/lynx-permissions
|
|
2
|
+
|
|
3
|
+
**Android-only infrastructure module.** Provides the shared `PermissionHelper` + `MediaCapture` classes that `@sigx/lynx-camera`, `@sigx/lynx-image-picker`, `@sigx/lynx-location`, and `@sigx/lynx-notifications` all dispatch through to show OS permission dialogs and receive Activity Result callbacks. iOS doesn't need this — `UIImagePickerController`/`CLLocationManager`/etc. handle their own prompts.
|
|
4
|
+
|
|
5
|
+
You typically don't import this package directly — pull it in transitively by listing it after the modules that need it. Most apps that use Camera/ImagePicker/Location/Notifications will end up declaring it explicitly because the autolinker doesn't currently dedup peer requirements.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @sigx/lynx-permissions
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// sigx.lynx.config.ts
|
|
15
|
+
export default defineLynxConfig({
|
|
16
|
+
modules: [
|
|
17
|
+
'@sigx/lynx-permissions',
|
|
18
|
+
'@sigx/lynx-camera',
|
|
19
|
+
'@sigx/lynx-image-picker',
|
|
20
|
+
// ...
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`sigx prebuild` copies the Kotlin sources (`PermissionHelper.kt` + `MediaCapture.kt`) into your Android source tree.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
The app template's `MainActivity.kt` reflectively wires this module on lifecycle hooks:
|
|
30
|
+
|
|
31
|
+
- `onResume` → `PermissionHelper.setActivity(this)`
|
|
32
|
+
- `onPause` → `PermissionHelper.clearActivity()`
|
|
33
|
+
- `onCreate` → `MediaCapture.register(this)` (Activity Result API launchers — must be wired before `STARTED` state)
|
|
34
|
+
- `onRequestPermissionsResult` → `PermissionHelper.onRequestPermissionsResult(...)`
|
|
35
|
+
|
|
36
|
+
The `try { Class.forName(...).getDeclaredField("INSTANCE").get(null) } catch { /* not present */ }` pattern means the wiring silently no-ops in apps that don't have this module on the classpath — so adding/removing it is a one-line change in `sigx.lynx.config.ts`.
|
|
37
|
+
|
|
38
|
+
## Public API
|
|
39
|
+
|
|
40
|
+
The Kotlin classes are consumed by other native modules, not by JS. There's no `index.ts` export here — JS callers go through `@sigx/lynx-camera.requestPermission()` etc.
|
|
41
|
+
|
|
42
|
+
If you're authoring a new native module that needs runtime permissions on Android, reach into:
|
|
43
|
+
|
|
44
|
+
```kotlin
|
|
45
|
+
import com.sigx.permissions.PermissionHelper
|
|
46
|
+
|
|
47
|
+
// In a coroutine-friendly context:
|
|
48
|
+
val granted = PermissionHelper.request(arrayOf(Manifest.permission.CAMERA))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
…and for `ActivityResultContracts`:
|
|
52
|
+
|
|
53
|
+
```kotlin
|
|
54
|
+
import com.sigx.permissions.MediaCapture
|
|
55
|
+
|
|
56
|
+
// MediaCapture.takePicture(uri) etc. — wraps the system intents through
|
|
57
|
+
// pre-registered launchers so the call site doesn't need to deal with
|
|
58
|
+
// Activity Result lifecycle.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Why a separate module
|
|
62
|
+
|
|
63
|
+
Camera + ImagePicker + Location + Notifications all need the same `requestPermissions()` plumbing on Android. Without a shared layer, each module would re-implement Activity Result wiring, hold its own static `Activity` reference, and fight over `onRequestPermissionsResult` request codes. Centralizing it here keeps each consumer module trivially small and avoids overlapping request-code namespaces.
|
|
64
|
+
|
|
65
|
+
iOS has no equivalent because the OS-level pickers (`UIImagePickerController`, `PHPickerViewController`, `CLLocationManager`) all handle their own permission flows internally.
|
|
66
|
+
|
|
67
|
+
## Reference
|
|
68
|
+
|
|
69
|
+
The wiring in `packages/lynx-cli/templates/android/app/src/main/kotlin/__package__/MainActivity.kt` is the canonical example of how an `Activity` integrates with this module via reflection.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package com.sigx.permissions
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import androidx.activity.ComponentActivity
|
|
7
|
+
import androidx.activity.result.ActivityResultLauncher
|
|
8
|
+
import androidx.activity.result.PickVisualMediaRequest
|
|
9
|
+
import androidx.activity.result.contract.ActivityResultContracts
|
|
10
|
+
import androidx.core.content.FileProvider
|
|
11
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
12
|
+
import java.io.File
|
|
13
|
+
import java.lang.ref.WeakReference
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Module-level registry for Activity Result launchers. Camera + ImagePicker
|
|
17
|
+
* (and any future module needing Activity-result flows) delegate here so a
|
|
18
|
+
* single MainActivity hook covers them all.
|
|
19
|
+
*
|
|
20
|
+
* Design constraint: ActivityResultLaunchers MUST be registered BEFORE the
|
|
21
|
+
* Activity reaches STARTED state. So `register(activity)` has to be called
|
|
22
|
+
* from `onCreate()`, not lazily on first use. The MainActivity template
|
|
23
|
+
* wires this via the same reflective `callPermissionHelper(...)` path used
|
|
24
|
+
* for setActivity / clearActivity.
|
|
25
|
+
*
|
|
26
|
+
* Callbacks resolve once the user finishes the system UI (or cancels). We
|
|
27
|
+
* stash the pending callback on the singleton because the Activity-result
|
|
28
|
+
* launcher's own callback runs on the main thread but doesn't carry context;
|
|
29
|
+
* the module-side caller registered its callback via `takePicture(callback)`
|
|
30
|
+
* just before launching, and we route the result back to that callback.
|
|
31
|
+
*
|
|
32
|
+
* Concurrency: `pendingTakePictureCallback` and `pendingPickMediaCallback`
|
|
33
|
+
* are guarded by their nullness — only one camera launch or one picker
|
|
34
|
+
* launch at a time. Re-entrant calls overwrite the previous pending callback;
|
|
35
|
+
* the pre-empted callback is invoked with `error="cancelled"`.
|
|
36
|
+
*/
|
|
37
|
+
object MediaCapture {
|
|
38
|
+
|
|
39
|
+
private const val TAG = "MediaCapture"
|
|
40
|
+
|
|
41
|
+
private var activityRef: WeakReference<ComponentActivity>? = null
|
|
42
|
+
private var takePictureLauncher: ActivityResultLauncher<Uri>? = null
|
|
43
|
+
private var pickMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
|
|
44
|
+
|
|
45
|
+
private var pendingPhotoUri: Uri? = null
|
|
46
|
+
private var pendingTakePictureCallback: ((JavaOnlyMap) -> Unit)? = null
|
|
47
|
+
private var pendingPickMediaCallback: ((JavaOnlyMap) -> Unit)? = null
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wire this Activity into the registry. Call from MainActivity.onCreate
|
|
51
|
+
* BEFORE super.onCreate() returns OR at the very top of onCreate (after
|
|
52
|
+
* super) — registerForActivityResult requires the host to be in INITIALIZED
|
|
53
|
+
* or CREATED state, throws afterwards.
|
|
54
|
+
*/
|
|
55
|
+
fun register(activity: ComponentActivity) {
|
|
56
|
+
activityRef = WeakReference(activity)
|
|
57
|
+
|
|
58
|
+
takePictureLauncher = activity.registerForActivityResult(
|
|
59
|
+
ActivityResultContracts.TakePicture()
|
|
60
|
+
) { success ->
|
|
61
|
+
val uri = pendingPhotoUri
|
|
62
|
+
val cb = pendingTakePictureCallback
|
|
63
|
+
pendingPhotoUri = null
|
|
64
|
+
pendingTakePictureCallback = null
|
|
65
|
+
val result = JavaOnlyMap()
|
|
66
|
+
if (success && uri != null) {
|
|
67
|
+
result.putString("uri", uri.toString())
|
|
68
|
+
result.putBoolean("canceled", false)
|
|
69
|
+
} else {
|
|
70
|
+
result.putString("error", "cancelled")
|
|
71
|
+
result.putBoolean("canceled", true)
|
|
72
|
+
}
|
|
73
|
+
cb?.invoke(result)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pickMediaLauncher = activity.registerForActivityResult(
|
|
77
|
+
ActivityResultContracts.PickVisualMedia()
|
|
78
|
+
) { uri ->
|
|
79
|
+
val cb = pendingPickMediaCallback
|
|
80
|
+
pendingPickMediaCallback = null
|
|
81
|
+
val result = JavaOnlyMap()
|
|
82
|
+
if (uri != null) {
|
|
83
|
+
val assets = com.lynx.react.bridge.JavaOnlyArray()
|
|
84
|
+
val asset = JavaOnlyMap().apply {
|
|
85
|
+
putString("uri", uri.toString())
|
|
86
|
+
putString("type", "image")
|
|
87
|
+
}
|
|
88
|
+
assets.pushMap(asset)
|
|
89
|
+
result.putArray("assets", assets)
|
|
90
|
+
result.putBoolean("canceled", false)
|
|
91
|
+
} else {
|
|
92
|
+
result.putBoolean("canceled", true)
|
|
93
|
+
}
|
|
94
|
+
cb?.invoke(result)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Log.i(TAG, "registered launchers on ${activity.javaClass.simpleName}")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear the Activity reference. Call from MainActivity.onDestroy.
|
|
102
|
+
*/
|
|
103
|
+
fun unregister() {
|
|
104
|
+
activityRef = null
|
|
105
|
+
takePictureLauncher = null
|
|
106
|
+
pickMediaLauncher = null
|
|
107
|
+
// Resolve any pending callbacks with cancel so JS Promises don't hang.
|
|
108
|
+
pendingTakePictureCallback?.invoke(JavaOnlyMap().apply {
|
|
109
|
+
putString("error", "activity destroyed")
|
|
110
|
+
putBoolean("canceled", true)
|
|
111
|
+
})
|
|
112
|
+
pendingPickMediaCallback?.invoke(JavaOnlyMap().apply {
|
|
113
|
+
putString("error", "activity destroyed")
|
|
114
|
+
putBoolean("canceled", true)
|
|
115
|
+
})
|
|
116
|
+
pendingTakePictureCallback = null
|
|
117
|
+
pendingPickMediaCallback = null
|
|
118
|
+
pendingPhotoUri = null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Launch the system camera. Requires CAMERA permission + a configured
|
|
123
|
+
* FileProvider authority "${packageName}.fileprovider" (the MainActivity
|
|
124
|
+
* AndroidManifest template seeds this). The captured photo lands at
|
|
125
|
+
* cacheDir/sigx-camera-<timestamp>.jpg; the JS callback receives a
|
|
126
|
+
* content:// URI pointing at it.
|
|
127
|
+
*/
|
|
128
|
+
fun takePicture(context: Context, callback: (JavaOnlyMap) -> Unit) {
|
|
129
|
+
val activity = activityRef?.get()
|
|
130
|
+
val launcher = takePictureLauncher
|
|
131
|
+
if (activity == null || launcher == null) {
|
|
132
|
+
callback(JavaOnlyMap().apply {
|
|
133
|
+
putString("error", "MediaCapture not registered — wire MediaCapture.register(this) into MainActivity.onCreate")
|
|
134
|
+
putBoolean("canceled", true)
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Pre-empt any prior pending call.
|
|
139
|
+
pendingTakePictureCallback?.invoke(JavaOnlyMap().apply {
|
|
140
|
+
putString("error", "cancelled by new takePicture")
|
|
141
|
+
putBoolean("canceled", true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
val photoFile = File(context.cacheDir, "sigx-camera-${System.currentTimeMillis()}.jpg")
|
|
145
|
+
val authority = "${context.packageName}.fileprovider"
|
|
146
|
+
val photoUri = try {
|
|
147
|
+
FileProvider.getUriForFile(context, authority, photoFile)
|
|
148
|
+
} catch (e: Throwable) {
|
|
149
|
+
callback(JavaOnlyMap().apply {
|
|
150
|
+
putString("error", "FileProvider authority '$authority' not configured: ${e.message}")
|
|
151
|
+
putBoolean("canceled", true)
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pendingPhotoUri = photoUri
|
|
157
|
+
pendingTakePictureCallback = callback
|
|
158
|
+
try {
|
|
159
|
+
launcher.launch(photoUri)
|
|
160
|
+
} catch (e: Throwable) {
|
|
161
|
+
pendingPhotoUri = null
|
|
162
|
+
pendingTakePictureCallback = null
|
|
163
|
+
callback(JavaOnlyMap().apply {
|
|
164
|
+
putString("error", e.message ?: "launcher.launch failed")
|
|
165
|
+
putBoolean("canceled", true)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Launch the system photo picker. No CAMERA / READ_MEDIA_IMAGES permission
|
|
172
|
+
* needed — Android 13+'s photo picker grants per-pick access on the fly.
|
|
173
|
+
* Returns a content:// URI for the picked image (or canceled=true).
|
|
174
|
+
*/
|
|
175
|
+
fun pickImage(callback: (JavaOnlyMap) -> Unit) {
|
|
176
|
+
val launcher = pickMediaLauncher
|
|
177
|
+
if (launcher == null) {
|
|
178
|
+
callback(JavaOnlyMap().apply {
|
|
179
|
+
putString("error", "MediaCapture not registered — wire MediaCapture.register(this) into MainActivity.onCreate")
|
|
180
|
+
putBoolean("canceled", true)
|
|
181
|
+
})
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
pendingPickMediaCallback?.invoke(JavaOnlyMap().apply {
|
|
185
|
+
putString("error", "cancelled by new pickImage")
|
|
186
|
+
putBoolean("canceled", true)
|
|
187
|
+
})
|
|
188
|
+
pendingPickMediaCallback = callback
|
|
189
|
+
try {
|
|
190
|
+
launcher.launch(
|
|
191
|
+
PickVisualMediaRequest.Builder()
|
|
192
|
+
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
|
193
|
+
.build()
|
|
194
|
+
)
|
|
195
|
+
} catch (e: Throwable) {
|
|
196
|
+
pendingPickMediaCallback = null
|
|
197
|
+
callback(JavaOnlyMap().apply {
|
|
198
|
+
putString("error", e.message ?: "launcher.launch failed")
|
|
199
|
+
putBoolean("canceled", true)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
package com.sigx.permissions
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.pm.PackageManager
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.provider.Settings
|
|
11
|
+
import android.util.Log
|
|
12
|
+
import androidx.core.app.ActivityCompat
|
|
13
|
+
import androidx.core.content.ContextCompat
|
|
14
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
15
|
+
import java.lang.ref.WeakReference
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared permission utility for sigx-lynx-go native modules.
|
|
19
|
+
*
|
|
20
|
+
* NOT a LynxModule — a Kotlin utility class that other modules (CameraModule,
|
|
21
|
+
* LocationModule, etc.) call internally to check and request permissions.
|
|
22
|
+
*
|
|
23
|
+
* Requires [setActivity] to be called from MainActivity.onResume() so that
|
|
24
|
+
* runtime permission requests can show the OS dialog.
|
|
25
|
+
*/
|
|
26
|
+
object PermissionHelper {
|
|
27
|
+
|
|
28
|
+
private const val TAG = "PermissionHelper"
|
|
29
|
+
|
|
30
|
+
private var activityRef: WeakReference<Activity>? = null
|
|
31
|
+
private var requestCode = 1000
|
|
32
|
+
private val pendingCallbacks = mutableMapOf<Int, (JavaOnlyMap) -> Unit>()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set the current Activity reference. Call from MainActivity.onResume().
|
|
36
|
+
*/
|
|
37
|
+
fun setActivity(activity: Activity) {
|
|
38
|
+
activityRef = WeakReference(activity)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clear the Activity reference. Call from MainActivity.onPause().
|
|
43
|
+
*/
|
|
44
|
+
fun clearActivity() {
|
|
45
|
+
activityRef = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map a sigx permission key to Android permission string(s).
|
|
50
|
+
* Handles API-level branching (e.g., READ_MEDIA_IMAGES on API 33+).
|
|
51
|
+
*/
|
|
52
|
+
fun resolveAndroidPermissions(permission: String): List<String> {
|
|
53
|
+
return when (permission) {
|
|
54
|
+
"camera" -> listOf(Manifest.permission.CAMERA)
|
|
55
|
+
"microphone" -> listOf(Manifest.permission.RECORD_AUDIO)
|
|
56
|
+
"location_when_in_use", "location" -> listOf(
|
|
57
|
+
Manifest.permission.ACCESS_FINE_LOCATION,
|
|
58
|
+
Manifest.permission.ACCESS_COARSE_LOCATION
|
|
59
|
+
)
|
|
60
|
+
"location_always" -> listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
|
61
|
+
"photo_library" -> {
|
|
62
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
63
|
+
listOf(
|
|
64
|
+
Manifest.permission.READ_MEDIA_IMAGES,
|
|
65
|
+
Manifest.permission.READ_MEDIA_VIDEO
|
|
66
|
+
)
|
|
67
|
+
} else {
|
|
68
|
+
@Suppress("DEPRECATION")
|
|
69
|
+
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
"notifications" -> {
|
|
73
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
74
|
+
listOf(Manifest.permission.POST_NOTIFICATIONS)
|
|
75
|
+
} else {
|
|
76
|
+
emptyList() // No runtime permission needed pre-API 33
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
"contacts" -> listOf(Manifest.permission.READ_CONTACTS)
|
|
80
|
+
"calendar" -> listOf(Manifest.permission.READ_CALENDAR)
|
|
81
|
+
"bluetooth" -> {
|
|
82
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
83
|
+
listOf(
|
|
84
|
+
Manifest.permission.BLUETOOTH_SCAN,
|
|
85
|
+
Manifest.permission.BLUETOOTH_CONNECT
|
|
86
|
+
)
|
|
87
|
+
} else {
|
|
88
|
+
@Suppress("DEPRECATION")
|
|
89
|
+
listOf(Manifest.permission.BLUETOOTH)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else -> {
|
|
93
|
+
// Allow raw Android permission strings (e.g., "android.permission.CAMERA")
|
|
94
|
+
if (permission.startsWith("android.permission.")) {
|
|
95
|
+
listOf(permission)
|
|
96
|
+
} else {
|
|
97
|
+
Log.w(TAG, "Unknown permission key: $permission")
|
|
98
|
+
emptyList()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check permission status without prompting.
|
|
106
|
+
* Returns a map with { status, canAskAgain }.
|
|
107
|
+
*/
|
|
108
|
+
fun checkPermission(context: Context, permission: String): JavaOnlyMap {
|
|
109
|
+
val androidPerms = resolveAndroidPermissions(permission)
|
|
110
|
+
val result = JavaOnlyMap()
|
|
111
|
+
|
|
112
|
+
if (androidPerms.isEmpty()) {
|
|
113
|
+
// No runtime permission required (e.g., notifications pre-API 33)
|
|
114
|
+
result.putString("status", "granted")
|
|
115
|
+
result.putBoolean("canAskAgain", false)
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
val allGranted = androidPerms.all { perm ->
|
|
120
|
+
ContextCompat.checkSelfPermission(context, perm) == PackageManager.PERMISSION_GRANTED
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (allGranted) {
|
|
124
|
+
result.putString("status", "granted")
|
|
125
|
+
result.putBoolean("canAskAgain", false)
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check if we can ask again (only meaningful if we have an Activity)
|
|
130
|
+
val activity = activityRef?.get()
|
|
131
|
+
val canAskAgain = if (activity != null) {
|
|
132
|
+
androidPerms.any { perm ->
|
|
133
|
+
ActivityCompat.shouldShowRequestPermissionRationale(activity, perm)
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
true // Assume we can ask if no Activity to check
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Determine if blocked (denied + can't ask again) vs undetermined (never asked)
|
|
140
|
+
val anyDeniedPermanently = activity != null && androidPerms.any { perm ->
|
|
141
|
+
ContextCompat.checkSelfPermission(context, perm) != PackageManager.PERMISSION_GRANTED &&
|
|
142
|
+
!ActivityCompat.shouldShowRequestPermissionRationale(activity, perm)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If permission was never requested, shouldShowRationale returns false too,
|
|
146
|
+
// so we use SharedPreferences to track if we've asked before
|
|
147
|
+
val prefs = context.getSharedPreferences("sigx_permissions", Context.MODE_PRIVATE)
|
|
148
|
+
val hasAskedBefore = prefs.getBoolean("asked_$permission", false)
|
|
149
|
+
|
|
150
|
+
if (!hasAskedBefore) {
|
|
151
|
+
result.putString("status", "undetermined")
|
|
152
|
+
result.putBoolean("canAskAgain", true)
|
|
153
|
+
} else if (anyDeniedPermanently && !canAskAgain) {
|
|
154
|
+
result.putString("status", "blocked")
|
|
155
|
+
result.putBoolean("canAskAgain", false)
|
|
156
|
+
} else {
|
|
157
|
+
result.putString("status", "denied")
|
|
158
|
+
result.putBoolean("canAskAgain", canAskAgain)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Request a permission, showing the OS dialog if possible.
|
|
166
|
+
* Calls back with { status, canAskAgain }.
|
|
167
|
+
*/
|
|
168
|
+
fun requestPermission(context: Context, permission: String, callback: (JavaOnlyMap) -> Unit) {
|
|
169
|
+
val androidPerms = resolveAndroidPermissions(permission)
|
|
170
|
+
|
|
171
|
+
if (androidPerms.isEmpty()) {
|
|
172
|
+
val result = JavaOnlyMap()
|
|
173
|
+
result.putString("status", "granted")
|
|
174
|
+
result.putBoolean("canAskAgain", false)
|
|
175
|
+
callback(result)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check if already granted
|
|
180
|
+
val allGranted = androidPerms.all { perm ->
|
|
181
|
+
ContextCompat.checkSelfPermission(context, perm) == PackageManager.PERMISSION_GRANTED
|
|
182
|
+
}
|
|
183
|
+
if (allGranted) {
|
|
184
|
+
val result = JavaOnlyMap()
|
|
185
|
+
result.putString("status", "granted")
|
|
186
|
+
result.putBoolean("canAskAgain", false)
|
|
187
|
+
callback(result)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
val activity = activityRef?.get()
|
|
192
|
+
if (activity == null) {
|
|
193
|
+
Log.w(TAG, "No Activity available to request permission: $permission")
|
|
194
|
+
val result = JavaOnlyMap()
|
|
195
|
+
result.putString("status", "denied")
|
|
196
|
+
result.putBoolean("canAskAgain", true)
|
|
197
|
+
callback(result)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Mark that we've asked for this permission
|
|
202
|
+
val prefs = context.getSharedPreferences("sigx_permissions", Context.MODE_PRIVATE)
|
|
203
|
+
prefs.edit().putBoolean("asked_$permission", true).apply()
|
|
204
|
+
|
|
205
|
+
// Request the permission
|
|
206
|
+
val code = requestCode++
|
|
207
|
+
pendingCallbacks[code] = callback
|
|
208
|
+
ActivityCompat.requestPermissions(activity, androidPerms.toTypedArray(), code)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Called from MainActivity.onRequestPermissionsResult() to resolve pending callbacks.
|
|
213
|
+
*/
|
|
214
|
+
fun onRequestPermissionsResult(
|
|
215
|
+
requestCode: Int,
|
|
216
|
+
permissions: Array<out String>,
|
|
217
|
+
grantResults: IntArray
|
|
218
|
+
) {
|
|
219
|
+
val callback = pendingCallbacks.remove(requestCode) ?: return
|
|
220
|
+
|
|
221
|
+
val allGranted = grantResults.isNotEmpty() && grantResults.all {
|
|
222
|
+
it == PackageManager.PERMISSION_GRANTED
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
val result = JavaOnlyMap()
|
|
226
|
+
if (allGranted) {
|
|
227
|
+
result.putString("status", "granted")
|
|
228
|
+
result.putBoolean("canAskAgain", false)
|
|
229
|
+
} else {
|
|
230
|
+
val activity = activityRef?.get()
|
|
231
|
+
val canAskAgain = activity != null && permissions.any { perm ->
|
|
232
|
+
ActivityCompat.shouldShowRequestPermissionRationale(activity, perm)
|
|
233
|
+
}
|
|
234
|
+
result.putString("status", if (canAskAgain) "denied" else "blocked")
|
|
235
|
+
result.putBoolean("canAskAgain", canAskAgain)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
callback(result)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Open the app's system settings page (for when permissions are blocked).
|
|
243
|
+
*/
|
|
244
|
+
fun openSettings(context: Context) {
|
|
245
|
+
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
246
|
+
data = Uri.fromParts("package", context.packageName, null)
|
|
247
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
248
|
+
}
|
|
249
|
+
context.startActivity(intent)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package com.sigx.permissions
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import androidx.activity.ComponentActivity
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Activity-lifecycle hook for `@sigx/lynx-permissions`.
|
|
9
|
+
*
|
|
10
|
+
* Discovered by the auto-linker via `sigx-module.json`'s
|
|
11
|
+
* `android.activityHook`. Wires the host Activity into [PermissionHelper]
|
|
12
|
+
* (so runtime permission prompts can show their OS dialog) and registers
|
|
13
|
+
* Activity-result launchers used by Camera + ImagePicker.
|
|
14
|
+
*
|
|
15
|
+
* `MediaCapture.register` MUST run before the Activity reaches STARTED — the
|
|
16
|
+
* Android Activity-result API enforces this — so we call it from `onCreate`
|
|
17
|
+
* rather than `onResume`.
|
|
18
|
+
*/
|
|
19
|
+
object PermissionsActivityHook {
|
|
20
|
+
|
|
21
|
+
@JvmStatic
|
|
22
|
+
fun onCreate(activity: Activity, savedInstanceState: Bundle?) {
|
|
23
|
+
if (activity is ComponentActivity) {
|
|
24
|
+
MediaCapture.register(activity)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@JvmStatic
|
|
29
|
+
fun onResume(activity: Activity) {
|
|
30
|
+
PermissionHelper.setActivity(activity)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@JvmStatic
|
|
34
|
+
fun onPause(activity: Activity) {
|
|
35
|
+
PermissionHelper.clearActivity()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@JvmStatic
|
|
39
|
+
fun onRequestPermissionsResult(
|
|
40
|
+
activity: Activity,
|
|
41
|
+
requestCode: Int,
|
|
42
|
+
permissions: Array<String>,
|
|
43
|
+
grantResults: IntArray,
|
|
44
|
+
) {
|
|
45
|
+
PermissionHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigx/lynx-permissions",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared Android permission helper for sigx-lynx native modules",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./sigx-module.json": "./sigx-module.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"android",
|
|
15
|
+
"sigx-module.json"
|
|
16
|
+
],
|
|
17
|
+
"author": "Andreas Ekdahl",
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|
package/sigx-module.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Permissions",
|
|
3
|
+
"package": "@sigx/lynx-permissions",
|
|
4
|
+
"description": "Shared Android permission helper (peer dep for camera/location/etc.)",
|
|
5
|
+
"platforms": ["android"],
|
|
6
|
+
"android": {
|
|
7
|
+
"activityHook": {
|
|
8
|
+
"class": "com.sigx.permissions.PermissionsActivityHook",
|
|
9
|
+
"methods": ["onCreate", "onResume", "onPause", "onRequestPermissionsResult"]
|
|
10
|
+
},
|
|
11
|
+
"sourceDir": "android"
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sigx/lynx-permissions — shared Android permission helper.
|
|
3
|
+
*
|
|
4
|
+
* This package ships only Android Kotlin source (PermissionHelper.kt). It is
|
|
5
|
+
* a peer dependency of camera/location/imagepicker/notifications/filesystem
|
|
6
|
+
* modules that need to check or request runtime permissions. The host's
|
|
7
|
+
* Activity must call:
|
|
8
|
+
*
|
|
9
|
+
* - `PermissionHelper.setActivity(this)` in `onResume()`
|
|
10
|
+
* - `PermissionHelper.clearActivity()` in `onPause()`
|
|
11
|
+
* - `PermissionHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)`
|
|
12
|
+
* in `onRequestPermissionsResult(...)`
|
|
13
|
+
*
|
|
14
|
+
* No JS surface today — modules call the Kotlin helper internally. If a
|
|
15
|
+
* cross-platform JS-side `permissions.request('camera')` API ever lands, it
|
|
16
|
+
* goes here.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|