@milkinteractive/react-native-age-range 1.0.4 โ†’ 1.0.6

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/README.md CHANGED
@@ -191,6 +191,86 @@ async function checkEligibility() {
191
191
  }
192
192
  ```
193
193
 
194
+ ## ๐Ÿ›ก๏ธ PermissionKit Integration (iOS 26+)
195
+
196
+ This package also supports Apple's **PermissionKit** framework for apps that need parental consent flows. Use these APIs when the `parentalControls` flags from `requestIOSDeclaredAgeRange()` indicate they're needed.
197
+
198
+ ### When to Use PermissionKit
199
+
200
+ After calling `requestIOSDeclaredAgeRange()`, check the `parentalControls` object:
201
+
202
+ ```typescript
203
+ const ageResult = await requestIOSDeclaredAgeRange(13, 17, 21);
204
+
205
+ if (ageResult.parentalControls?.significantAppChangeApprovalRequired) {
206
+ // App updates require parental approval โ†’ use requestIOSSignificantChangeApproval()
207
+ }
208
+
209
+ if (ageResult.parentalControls?.communicationLimits) {
210
+ // Communication with unknown contacts requires approval โ†’ use requestIOSCommunicationPermission()
211
+ }
212
+ ```
213
+
214
+ ### Significant Change Approval
215
+
216
+ When your app has significant updates that require parental consent:
217
+
218
+ ```typescript
219
+ import { requestIOSSignificantChangeApproval } from '@milkinteractive/react-native-age-range';
220
+
221
+ const result = await requestIOSSignificantChangeApproval();
222
+
223
+ if (result.status === 'pending') {
224
+ // Request sent to parent/guardian via Messages
225
+ // Show "waiting for approval" UI
226
+ } else if (result.error) {
227
+ console.error('Significant change request failed:', result.error);
228
+ }
229
+ ```
230
+
231
+ ### Communication Permissions
232
+
233
+ For apps with chat/messaging features, request permission to communicate with unknown contacts:
234
+
235
+ ```typescript
236
+ import {
237
+ getIOSKnownCommunicationHandles,
238
+ requestIOSCommunicationPermission,
239
+ } from '@milkinteractive/react-native-age-range';
240
+
241
+ // 1. Check which contacts are already known (approved)
242
+ const knownResult = await getIOSKnownCommunicationHandles([
243
+ { handle: 'user@example.com', handleKind: 'email' },
244
+ { handle: 'gamer123', handleKind: 'custom' },
245
+ ]);
246
+
247
+ // 2. Request permission for unknown contacts
248
+ const unknownContacts = contacts.filter(
249
+ c => !knownResult.knownHandles.includes(c.handle)
250
+ );
251
+
252
+ if (unknownContacts.length > 0) {
253
+ const permResult = await requestIOSCommunicationPermission(
254
+ unknownContacts.map(c => ({
255
+ handle: c.handle,
256
+ handleKind: 'custom',
257
+ displayName: c.displayName, // Shown to parent in approval request
258
+ })),
259
+ ['message', 'call'] // Requested communication actions
260
+ );
261
+ }
262
+ ```
263
+
264
+ ### PermissionKit Requirements
265
+
266
+ - **iOS 26.0+** for Significant Change API
267
+ - **iOS 26.2+** for Communication Limits API
268
+ - **Family Sharing** must be enabled on the device
269
+ - **Communication Limits** must be enabled by parent/guardian
270
+ - No additional entitlements required (uses system permissions)
271
+
272
+ > **Note**: PermissionKit features require a real device with Family Sharing configured. Simulator testing has limited functionality.
273
+
194
274
  ## ๐Ÿงช Developer Mock Mode
195
275
 
196
276
  Testing store APIs usually requires signed production builds. This library includes a powerful **Mock Mode** for development.
@@ -271,6 +351,49 @@ Check if the current Android user is subject to age verification requirements.
271
351
 
272
352
  **Note**: Makes a lightweight API call to determine eligibility.
273
353
 
354
+ ### `requestIOSSignificantChangeApproval()`
355
+ Request parental approval for significant app changes (iOS PermissionKit).
356
+
357
+ **Requirements**: iOS 26.0+
358
+
359
+ **Returns**: `Promise<SignificantChangeResult>`
360
+ - `status`: `'approved' | 'denied' | 'pending' | null` - Current approval status
361
+ - `error`: `string | null` - Error message if request failed
362
+
363
+ **Usage**: Call when `parentalControls.significantAppChangeApprovalRequired` is `true`.
364
+
365
+ ### `requestIOSCommunicationPermission(contacts, actions?)`
366
+ Request permission for a child to communicate with specified contacts (iOS PermissionKit).
367
+
368
+ **Requirements**: iOS 26.2+
369
+
370
+ | Parameter | Type | Description |
371
+ |---|---|---|
372
+ | `contacts` | `CommunicationContact[]` | Contacts to request permission for |
373
+ | `actions` | `CommunicationAction[]` | Optional: `['message']`, `['call']`, `['video']` (default: `['message']`) |
374
+
375
+ **CommunicationContact**:
376
+ - `handle`: `string` - Unique identifier (phone, email, username)
377
+ - `handleKind`: `'phoneNumber' | 'email' | 'custom'`
378
+ - `displayName`: `string` (optional) - Shown to parent in approval request
379
+
380
+ **Returns**: `Promise<CommunicationPermissionResult>`
381
+ - `granted`: `boolean` - Whether the permission dialog was shown successfully
382
+ - `error`: `string | null` - Error message if request failed
383
+
384
+ ### `getIOSKnownCommunicationHandles(handles)`
385
+ Check which handles are recognized by the system (known contacts).
386
+
387
+ **Requirements**: iOS 26.2+
388
+
389
+ | Parameter | Type | Description |
390
+ |---|---|---|
391
+ | `handles` | `CommunicationContact[]` | Handles to check |
392
+
393
+ **Returns**: `Promise<KnownHandlesResult>`
394
+ - `knownHandles`: `string[]` - Array of handle values that are known/approved
395
+ - `error`: `string | null` - Error message if check failed
396
+
274
397
  ## ๐Ÿšจ Troubleshooting
275
398
 
276
399
  ### iOS Errors
@@ -303,6 +426,8 @@ Check if the current Android user is subject to age verification requirements.
303
426
  **Apple iOS:**
304
427
  - [Declared Age Range Framework](https://developer.apple.com/documentation/declaredagerange) - Official Apple Developer Documentation
305
428
  - [WWDC25: Deliver age-appropriate experiences in your app](https://developer.apple.com/videos/play/wwdc2025/299/) - Session video explaining implementation
429
+ - [PermissionKit Framework](https://developer.apple.com/documentation/PermissionKit) - Official PermissionKit Documentation
430
+ - [WWDC25: Enhance child safety with PermissionKit](https://developer.apple.com/videos/play/wwdc2025/293/) - PermissionKit session video
306
431
 
307
432
  **Google Android:**
308
433
  - [Play Age Signals Overview](https://developer.android.com/google/play/age-signals/overview) - Introduction and concepts
@@ -39,17 +39,17 @@ RCT_EXPORT_METHOD(getAndroidPlayAgeRangeStatus : (RCTPromiseResolveBlock)
39
39
  resolve(result);
40
40
  }
41
41
 
42
- RCT_EXPORT_METHOD(requestIOSDeclaredAgeRange : (
43
- double)firstThresholdAge secondThresholdAge : (double)
44
- secondThresholdAge thirdThresholdAge : (double)
45
- thirdThresholdAge resolve : (RCTPromiseResolveBlock)
46
- resolve reject : (RCTPromiseRejectBlock)reject) {
47
- // Assuming _swiftImpl has a method named
48
- // requestIOSDeclaredAgeRangeWithFirstThresholdAge that matches the signature.
42
+ RCT_EXPORT_METHOD(requestIOSDeclaredAgeRange : (nonnull NSNumber *)firstThresholdAge
43
+ secondThresholdAge : (NSNumber *)secondThresholdAge
44
+ thirdThresholdAge : (NSNumber *)thirdThresholdAge
45
+ resolve : (RCTPromiseResolveBlock)resolve
46
+ reject : (RCTPromiseRejectBlock)reject) {
47
+ // Convert NSNumber to NSNumber (handling nil for optional params)
48
+ // Swift side expects NSNumber which can represent nil via NSNull
49
49
  [_swiftImpl
50
- requestIOSDeclaredAgeRangeWithFirstThresholdAge:@(firstThresholdAge)
51
- secondThresholdAge:@(secondThresholdAge)
52
- thirdThresholdAge:@(thirdThresholdAge)
50
+ requestIOSDeclaredAgeRangeWithFirstThresholdAge:firstThresholdAge
51
+ secondThresholdAge:secondThresholdAge
52
+ thirdThresholdAge:thirdThresholdAge
53
53
  resolve:resolve
54
54
  reject:reject];
55
55
  }
@@ -59,6 +59,33 @@ RCT_EXPORT_METHOD(isEligibleForAgeFeatures : (RCTPromiseResolveBlock)
59
59
  [_swiftImpl isEligibleForAgeFeaturesWithResolve:resolve reject:reject];
60
60
  }
61
61
 
62
+ // MARK: - PermissionKit: Significant Change API
63
+
64
+ RCT_EXPORT_METHOD(requestSignificantChangeApproval : (RCTPromiseResolveBlock)
65
+ resolve reject : (RCTPromiseRejectBlock)reject) {
66
+ [_swiftImpl requestSignificantChangeApprovalWithResolve:resolve reject:reject];
67
+ }
68
+
69
+ // MARK: - PermissionKit: Communication Limits API
70
+
71
+ RCT_EXPORT_METHOD(requestCommunicationPermission : (NSArray *)contacts
72
+ actions : (NSArray *)actions
73
+ resolve : (RCTPromiseResolveBlock)resolve
74
+ reject : (RCTPromiseRejectBlock)reject) {
75
+ [_swiftImpl requestCommunicationPermissionWithContacts:contacts
76
+ actions:actions
77
+ resolve:resolve
78
+ reject:reject];
79
+ }
80
+
81
+ RCT_EXPORT_METHOD(getKnownCommunicationHandles : (NSArray *)handles
82
+ resolve : (RCTPromiseResolveBlock)resolve
83
+ reject : (RCTPromiseRejectBlock)reject) {
84
+ [_swiftImpl getKnownCommunicationHandlesWithHandles:handles
85
+ resolve:resolve
86
+ reject:reject];
87
+ }
88
+
62
89
  // Don't compile this code when we build for the old architecture.
63
90
  // Don't compile this code when we build for the old architecture.
64
91
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -5,6 +5,11 @@ import StoreKit
5
5
  #if canImport(DeclaredAgeRange)
6
6
  import DeclaredAgeRange
7
7
  #endif
8
+
9
+ #if canImport(PermissionKit)
10
+ import PermissionKit
11
+ #endif
12
+
8
13
  import UIKit
9
14
 
10
15
  // Check if DeclaredAgeRange type exists (iOS 18+) or handled via availability checks
@@ -21,8 +26,8 @@ public class StoreAgeSignalsNativeModulesSwift: NSObject {
21
26
  @objc
22
27
  public func requestIOSDeclaredAgeRange(
23
28
  firstThresholdAge: NSNumber,
24
- secondThresholdAge: NSNumber,
25
- thirdThresholdAge: NSNumber,
29
+ secondThresholdAge: NSNumber?,
30
+ thirdThresholdAge: NSNumber?,
26
31
  resolve: @escaping RCTPromiseResolveBlock,
27
32
  reject: @escaping RCTPromiseRejectBlock
28
33
  ) {
@@ -36,16 +41,21 @@ public class StoreAgeSignalsNativeModulesSwift: NSObject {
36
41
  reject("VIEW_CONTROLLER_ERROR", "Could not find top view controller", nil)
37
42
  return
38
43
  }
39
-
44
+
45
+ // Convert NSNumber to Int, handling optional parameters
40
46
  let t1 = Int(truncating: firstThresholdAge)
41
- let t2 = Int(truncating: secondThresholdAge)
42
- let t3 = Int(truncating: thirdThresholdAge)
43
-
44
- // Use AgeRangeService as per reference
45
- let response = try await AgeRangeService.shared.requestAgeRange(
46
- ageGates: t1, t2, t3,
47
- in: viewController
48
- )
47
+ let t2 = secondThresholdAge.map { Int(truncating: $0) }
48
+ let t3 = thirdThresholdAge.map { Int(truncating: $0) }
49
+
50
+ // Call API with provided thresholds (variadic params require separate calls)
51
+ let response: AgeRangeService.Response
52
+ if let t2, let t3 {
53
+ response = try await AgeRangeService.shared.requestAgeRange(ageGates: t1, t2, t3, in: viewController)
54
+ } else if let t2 {
55
+ response = try await AgeRangeService.shared.requestAgeRange(ageGates: t1, t2, in: viewController)
56
+ } else {
57
+ response = try await AgeRangeService.shared.requestAgeRange(ageGates: t1, in: viewController)
58
+ }
49
59
 
50
60
  var statusString = "declined"
51
61
  var lowerBound: NSNumber? = nil
@@ -160,6 +170,222 @@ public class StoreAgeSignalsNativeModulesSwift: NSObject {
160
170
  #endif
161
171
  }
162
172
 
173
+ // MARK: - PermissionKit: Significant Change API
174
+
175
+ @objc
176
+ public func requestSignificantChangeApproval(
177
+ resolve: @escaping RCTPromiseResolveBlock,
178
+ reject: @escaping RCTPromiseRejectBlock
179
+ ) {
180
+ #if compiler(>=6.0) && canImport(PermissionKit)
181
+ if #available(iOS 26.0, *) {
182
+ Task { @MainActor in
183
+ do {
184
+ guard let viewController = self.topViewController() else {
185
+ reject("VIEW_CONTROLLER_ERROR", "Could not find top view controller", nil)
186
+ return
187
+ }
188
+
189
+ var topic = SignificantAppUpdateTopic()
190
+ var question = PermissionQuestion(significantAppUpdateTopic: topic)
191
+
192
+ try await AskCenter.current.ask(question: question, in: viewController)
193
+
194
+ // If we get here without error, the request was shown successfully
195
+ // The actual approval status is delivered asynchronously via updates
196
+ let resultMap: [String: Any?] = [
197
+ "status": "pending",
198
+ "error": nil
199
+ ]
200
+ resolve(resultMap)
201
+
202
+ } catch {
203
+ var errorMsg = error.localizedDescription
204
+ if errorMsg.contains("region") {
205
+ errorMsg += ". (Hint: User may be in a region that does not support this feature.)"
206
+ }
207
+ reject("ERR_IOS_SIGNIFICANT_CHANGE", errorMsg, error)
208
+ }
209
+ }
210
+ } else {
211
+ resolve(["status": nil, "error": "Requires iOS 26.0+"])
212
+ }
213
+ #else
214
+ resolve(["status": nil, "error": "PermissionKit SDK not available"])
215
+ #endif
216
+ }
217
+
218
+ // MARK: - PermissionKit: Communication Limits API
219
+
220
+ @objc
221
+ public func requestCommunicationPermission(
222
+ contacts: NSArray,
223
+ actions: NSArray,
224
+ resolve: @escaping RCTPromiseResolveBlock,
225
+ reject: @escaping RCTPromiseRejectBlock
226
+ ) {
227
+ #if compiler(>=6.0) && canImport(PermissionKit)
228
+ if #available(iOS 26.2, *) {
229
+ Task { @MainActor in
230
+ do {
231
+ guard let viewController = self.topViewController() else {
232
+ reject("VIEW_CONTROLLER_ERROR", "Could not find top view controller", nil)
233
+ return
234
+ }
235
+
236
+ // Parse contacts from JS
237
+ var personInfoList: [PersonInformation] = []
238
+ for contact in contacts {
239
+ guard let contactDict = contact as? [String: Any],
240
+ let handleValue = contactDict["handle"] as? String,
241
+ let handleKindStr = contactDict["handleKind"] as? String else {
242
+ continue
243
+ }
244
+
245
+ let handleKind: CommunicationHandle.Kind
246
+ switch handleKindStr {
247
+ case "phoneNumber":
248
+ handleKind = .phoneNumber
249
+ case "email":
250
+ handleKind = .email
251
+ default:
252
+ handleKind = .custom
253
+ }
254
+
255
+ let handle = CommunicationHandle(value: handleValue, kind: handleKind)
256
+
257
+ // Optional display name
258
+ var nameComponents: PersonNameComponents? = nil
259
+ if let displayName = contactDict["displayName"] as? String {
260
+ var components = PersonNameComponents()
261
+ components.nickname = displayName
262
+ nameComponents = components
263
+ }
264
+
265
+ let personInfo = PersonInformation(handle: handle, nameComponents: nameComponents)
266
+ personInfoList.append(personInfo)
267
+ }
268
+
269
+ guard !personInfoList.isEmpty else {
270
+ reject("ERR_IOS_COMM_PERMISSION", "No valid contacts provided", nil)
271
+ return
272
+ }
273
+
274
+ // Parse actions
275
+ var communicationActions: Set<CommunicationTopic.Action> = []
276
+ for action in actions {
277
+ if let actionStr = action as? String {
278
+ switch actionStr {
279
+ case "message":
280
+ communicationActions.insert(.message)
281
+ case "call":
282
+ communicationActions.insert(.call)
283
+ case "video":
284
+ communicationActions.insert(.video)
285
+ default:
286
+ break
287
+ }
288
+ }
289
+ }
290
+
291
+ // Default to message if no actions specified
292
+ if communicationActions.isEmpty {
293
+ communicationActions.insert(.message)
294
+ }
295
+
296
+ var topic = CommunicationTopic(personInformation: personInfoList)
297
+ topic.actions = communicationActions
298
+
299
+ var question = PermissionQuestion(communicationTopic: topic)
300
+
301
+ try await CommunicationLimits.current.ask(question, in: viewController)
302
+
303
+ // If we get here without error, the request was shown successfully
304
+ let resultMap: [String: Any?] = [
305
+ "granted": true,
306
+ "error": nil
307
+ ]
308
+ resolve(resultMap)
309
+
310
+ } catch {
311
+ var errorMsg = error.localizedDescription
312
+ if errorMsg.contains("region") {
313
+ errorMsg += ". (Hint: User may be in a region that does not support this feature.)"
314
+ } else if errorMsg.contains("Family Sharing") || errorMsg.contains("Communication Limits") {
315
+ errorMsg += ". (Hint: Family Sharing and Communication Limits must be enabled.)"
316
+ }
317
+ reject("ERR_IOS_COMM_PERMISSION", errorMsg, error)
318
+ }
319
+ }
320
+ } else {
321
+ resolve(["granted": false, "error": "Requires iOS 26.2+"])
322
+ }
323
+ #else
324
+ resolve(["granted": false, "error": "PermissionKit SDK not available"])
325
+ #endif
326
+ }
327
+
328
+ @objc
329
+ public func getKnownCommunicationHandles(
330
+ handles: NSArray,
331
+ resolve: @escaping RCTPromiseResolveBlock,
332
+ reject: @escaping RCTPromiseRejectBlock
333
+ ) {
334
+ #if compiler(>=6.0) && canImport(PermissionKit)
335
+ if #available(iOS 26.2, *) {
336
+ Task {
337
+ do {
338
+ // Parse handles from JS
339
+ var communicationHandles: Set<CommunicationHandle> = []
340
+ for handle in handles {
341
+ guard let handleDict = handle as? [String: Any],
342
+ let handleValue = handleDict["handle"] as? String,
343
+ let handleKindStr = handleDict["handleKind"] as? String else {
344
+ continue
345
+ }
346
+
347
+ let handleKind: CommunicationHandle.Kind
348
+ switch handleKindStr {
349
+ case "phoneNumber":
350
+ handleKind = .phoneNumber
351
+ case "email":
352
+ handleKind = .email
353
+ default:
354
+ handleKind = .custom
355
+ }
356
+
357
+ let commHandle = CommunicationHandle(value: handleValue, kind: handleKind)
358
+ communicationHandles.insert(commHandle)
359
+ }
360
+
361
+ guard !communicationHandles.isEmpty else {
362
+ reject("ERR_IOS_KNOWN_HANDLES", "No valid handles provided", nil)
363
+ return
364
+ }
365
+
366
+ let knownHandles = await CommunicationLimits.current.knownHandles(in: communicationHandles)
367
+
368
+ // Convert back to string array for JS
369
+ let knownHandleValues = knownHandles.map { $0.value }
370
+
371
+ let resultMap: [String: Any?] = [
372
+ "knownHandles": knownHandleValues,
373
+ "error": nil
374
+ ]
375
+ resolve(resultMap)
376
+
377
+ } catch {
378
+ reject("ERR_IOS_KNOWN_HANDLES", error.localizedDescription, error)
379
+ }
380
+ }
381
+ } else {
382
+ resolve(["knownHandles": [], "error": "Requires iOS 26.2+"])
383
+ }
384
+ #else
385
+ resolve(["knownHandles": [], "error": "PermissionKit SDK not available"])
386
+ #endif
387
+ }
388
+
163
389
  // Helper to get top view controller
164
390
  private func topViewController() -> UIViewController? {
165
391
  guard let windowScene = UIApplication.shared.connectedScenes