Skip to content

Commit 9b94a7d

Browse files
committed
feat: implement iOS Declared Age Range API and docs
- Add required entitlements and configuration - Update documentation with iOS specifics and error codes - Enhance example app result display and fix lint issues
1 parent b1013ba commit 9b94a7d

5 files changed

Lines changed: 155 additions & 27 deletions

File tree

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ yarn add react-native-store-age-signals-native-modules
2121
```sh
2222
cd ios && pod install
2323
```
24-
2. **Requirements**: This library uses the `DeclaredAgeRange` framework which is available on **iOS 18.0+**. On older iOS versions, the API will return a fallback response.
24+
3. **Entitlements**: You must enable the "Declared Age Range" capability for your app.
25+
- Open your project in Xcode.
26+
- Select your App target -> "Signing & Capabilities".
27+
- Click "+ Capability" and add **Declared Age Range**.
28+
- This adds the `com.apple.developer.declared-age-range` entitlement key to your `.entitlements` file.
29+
*(Without this, the API will fail with "Error 0")*
30+
> **Note**: This capability may not be available for "Personal Team" (free) provisioning profiles. You likely need a paid Apple Developer Program membership to sign apps with this entitlement.
2531
2632
### Android Setup
2733

@@ -126,9 +132,8 @@ Returns `Promise<DeclaredAgeRangeResult>`:
126132
- `upperBound`: `number | null`
127133

128134

129-
### Error Codes
130-
131-
If `errorCode` is present, it corresponds to one of the following Play Age Signals API error codes:
135+
### Android Error Codes
136+
*(Returned in `errorCode` for `getAndroidPlayAgeRangeStatus`)*
132137

133138
| Code | Error | Description | Retryable |
134139
|---|---|---|---|
@@ -143,6 +148,13 @@ If `errorCode` is present, it corresponds to one of the following Play Age Signa
143148
| -9 | APP_NOT_OWNED | App not installed by Google Play. | No |
144149
| -100 | INTERNAL_ERROR | Unknown internal error. | No |
145150

151+
### iOS Error Codes
152+
*(Returned in promise rejection for `requestIOSDeclaredAgeRange`)*
153+
154+
| Code | Error | Description |
155+
|---|---|---|
156+
| 0 | IOS_ENTITLEMENT_ERROR | Missing Entitlement OR Feature unavailable on Simulator. Test on real device. |
157+
146158
## Contributing
147159

148160
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.

example/ios/StoreAgeSignalsNativeModulesExample.xcodeproj/project.pbxproj

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
5DCACB8F33CDC322A6C60F78 /* libPods-StoreAgeSignalsNativeModulesExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-StoreAgeSignalsNativeModulesExample.a"; sourceTree = BUILT_PRODUCTS_DIR; };
2525
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = StoreAgeSignalsNativeModulesExample/AppDelegate.swift; sourceTree = "<group>"; };
2626
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = StoreAgeSignalsNativeModulesExample/LaunchScreen.storyboard; sourceTree = "<group>"; };
27+
B51E33A92EE4F25D00DEA8CA /* StoreAgeSignalsNativeModulesExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = StoreAgeSignalsNativeModulesExample.entitlements; path = StoreAgeSignalsNativeModulesExample/StoreAgeSignalsNativeModulesExample.entitlements; sourceTree = "<group>"; };
2728
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
2829
/* End PBXFileReference section */
2930

@@ -42,6 +43,7 @@
4243
13B07FAE1A68108700A75B9A /* StoreAgeSignalsNativeModulesExample */ = {
4344
isa = PBXGroup;
4445
children = (
46+
B51E33A92EE4F25D00DEA8CA /* StoreAgeSignalsNativeModulesExample.entitlements */,
4547
13B07FB51A68108700A75B9A /* Images.xcassets */,
4648
761780EC2CA45674006654EE /* AppDelegate.swift */,
4749
13B07FB61A68108700A75B9A /* Info.plist */,
@@ -191,10 +193,14 @@
191193
inputFileListPaths = (
192194
"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-frameworks-${CONFIGURATION}-input-files.xcfilelist",
193195
);
196+
inputPaths = (
197+
);
194198
name = "[CP] Embed Pods Frameworks";
195199
outputFileListPaths = (
196200
"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-frameworks-${CONFIGURATION}-output-files.xcfilelist",
197201
);
202+
outputPaths = (
203+
);
198204
runOnlyForDeploymentPostprocessing = 0;
199205
shellPath = /bin/sh;
200206
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-frameworks.sh\"\n";
@@ -230,10 +236,14 @@
230236
inputFileListPaths = (
231237
"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-resources-${CONFIGURATION}-input-files.xcfilelist",
232238
);
239+
inputPaths = (
240+
);
233241
name = "[CP] Copy Pods Resources";
234242
outputFileListPaths = (
235243
"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-resources-${CONFIGURATION}-output-files.xcfilelist",
236244
);
245+
outputPaths = (
246+
);
237247
runOnlyForDeploymentPostprocessing = 0;
238248
shellPath = /bin/sh;
239249
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-StoreAgeSignalsNativeModulesExample/Pods-StoreAgeSignalsNativeModulesExample-resources.sh\"\n";
@@ -259,7 +269,9 @@
259269
buildSettings = {
260270
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
261271
CLANG_ENABLE_MODULES = YES;
272+
CODE_SIGN_ENTITLEMENTS = StoreAgeSignalsNativeModulesExample/StoreAgeSignalsNativeModulesExample.entitlements;
262273
CURRENT_PROJECT_VERSION = 1;
274+
DEVELOPMENT_TEAM = K8DUGA9V3J;
263275
ENABLE_BITCODE = NO;
264276
INFOPLIST_FILE = StoreAgeSignalsNativeModulesExample/Info.plist;
265277
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -287,7 +299,9 @@
287299
buildSettings = {
288300
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
289301
CLANG_ENABLE_MODULES = YES;
302+
CODE_SIGN_ENTITLEMENTS = StoreAgeSignalsNativeModulesExample/StoreAgeSignalsNativeModulesExample.entitlements;
290303
CURRENT_PROJECT_VERSION = 1;
304+
DEVELOPMENT_TEAM = K8DUGA9V3J;
291305
INFOPLIST_FILE = StoreAgeSignalsNativeModulesExample/Info.plist;
292306
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
293307
LD_RUNPATH_SEARCH_PATHS = (
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.developer.declared-age-range</key>
6+
<true/>
7+
</dict>
8+
</plist>

example/src/App.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import {
22
getAndroidPlayAgeRangeStatus,
33
requestIOSDeclaredAgeRange,
44
} from 'react-native-store-age-signals-native-modules';
5-
import { Text, View, StyleSheet, Button, Platform } from 'react-native';
5+
import {
6+
Text,
7+
View,
8+
StyleSheet,
9+
Button,
10+
Platform,
11+
TextInput,
12+
} from 'react-native';
613
import { useState } from 'react';
714

815
export default function App() {
@@ -37,7 +44,7 @@ export default function App() {
3744
<Text style={styles.sectionTitle}>Real API</Text>
3845
<Button title="Check Live Age Status" onPress={checkAndroidAge} />
3946

40-
<View style={{ height: 20 }} />
47+
<View style={styles.spacer20} />
4148
<Text style={styles.sectionTitle}>Mock Scenarios</Text>
4249

4350
<Button
@@ -52,7 +59,7 @@ export default function App() {
5259
setResult(JSON.stringify(data, null, 2));
5360
}}
5461
/>
55-
<View style={{ height: 10 }} />
62+
<View style={styles.spacer10} />
5663
<Button
5764
title="Mock: Supervised (13-17)"
5865
color="#f1c40f"
@@ -67,7 +74,7 @@ export default function App() {
6774
setResult(JSON.stringify(data, null, 2));
6875
}}
6976
/>
70-
<View style={{ height: 10 }} />
77+
<View style={styles.spacer10} />
7178
<Button
7279
title="Mock: Error (API Unavailable -1)"
7380
color="#e74c3c"
@@ -80,7 +87,7 @@ export default function App() {
8087
setResult(JSON.stringify(data, null, 2));
8188
}}
8289
/>
83-
<View style={{ height: 10 }} />
90+
<View style={styles.spacer10} />
8491
<Button
8592
title="Mock: Unknown"
8693
color="#95a5a6"
@@ -100,9 +107,12 @@ export default function App() {
100107
<Button title="Request iOS Declared Age" onPress={checkIOSAge} />
101108
)}
102109

103-
<Text selectable={true} style={styles.result}>
104-
{result}
105-
</Text>
110+
<TextInput
111+
style={styles.resultInput}
112+
multiline
113+
editable={false}
114+
value={result}
115+
/>
106116
</View>
107117
);
108118
}
@@ -119,10 +129,16 @@ const styles = StyleSheet.create({
119129
marginBottom: 20,
120130
fontWeight: 'bold',
121131
},
122-
result: {
132+
resultInput: {
123133
marginTop: 20,
124134
fontSize: 14,
125135
color: '#333',
136+
width: '100%',
137+
height: 200,
138+
borderColor: '#ccc',
139+
borderWidth: 1,
140+
padding: 10,
141+
textAlignVertical: 'top',
126142
},
127143
actions: {
128144
width: '100%',
@@ -135,4 +151,10 @@ const styles = StyleSheet.create({
135151
marginBottom: 10,
136152
color: '#666',
137153
},
154+
spacer10: {
155+
height: 10,
156+
},
157+
spacer20: {
158+
height: 20,
159+
},
138160
});

ios/StoreAgeSignalsNativeModules.swift

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import StoreKit
55
#if canImport(DeclaredAgeRange)
66
import DeclaredAgeRange
77
#endif
8+
import UIKit
89

910
// Check if DeclaredAgeRange type exists (iOS 18+) or handled via availability checks
1011
// Removing generic canImport because it's part of StoreKit usually
@@ -25,25 +26,96 @@ public class StoreAgeSignalsNativeModulesSwift: NSObject {
2526
resolve: @escaping RCTPromiseResolveBlock,
2627
reject: @escaping RCTPromiseRejectBlock
2728
) {
28-
// NOTE: Temporarily disabled due to API signature instability in current SDKs.
29-
// The framework DeclaredAgeRange exists, but the request types vary by beta version.
30-
// Re-enable when using a verified SDK with known signatures.
31-
#if false
32-
if #available(iOS 18.0, *) {
33-
// ... implementation ...
29+
// NOTE: Using patterns from reference implementation
30+
#if compiler(>=6.0) && canImport(DeclaredAgeRange)
31+
// The SDK strictly requires iOS 26.0+ for AgeRangeService
32+
if #available(iOS 26.0, *) {
3433
Task { @MainActor in
35-
// ...
34+
do {
35+
guard let viewController = self.topViewController() else {
36+
reject("VIEW_CONTROLLER_ERROR", "Could not find top view controller", nil)
37+
return
38+
}
39+
40+
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+
)
49+
50+
var statusString = "declined"
51+
var lowerBound: NSNumber? = nil
52+
var upperBound: NSNumber? = nil
53+
var parentControls: String? = nil
54+
55+
switch response {
56+
case .sharing(let declaration):
57+
if let declStatus = declaration.ageRangeDeclaration {
58+
statusString = String(describing: declStatus)
59+
} else {
60+
statusString = "sharing"
61+
}
62+
63+
if let lower = declaration.lowerBound {
64+
lowerBound = NSNumber(value: lower)
65+
}
66+
if let upper = declaration.upperBound {
67+
upperBound = NSNumber(value: upper)
68+
}
69+
70+
let controlsRaw = declaration.activeParentalControls.rawValue
71+
parentControls = "\(controlsRaw)"
72+
73+
case .declinedSharing:
74+
statusString = "declined"
75+
@unknown default:
76+
statusString = "unknown"
77+
}
78+
79+
let resultMap: [String: Any?] = [
80+
"status": statusString,
81+
"parentControls": parentControls,
82+
"lowerBound": lowerBound,
83+
"upperBound": upperBound
84+
]
85+
resolve(resultMap)
86+
87+
} catch {
88+
// Enhance "Error 0" with a helpful message
89+
var errorMsg = error.localizedDescription
90+
if (error as NSError).code == 0 {
91+
errorMsg += ". (Hint: Missing Entitlement OR Feature is unavailable on Simulator. Verify on real device.)"
92+
}
93+
reject("ERR_IOS_AGE_REQUEST", errorMsg, error)
94+
}
3695
}
3796
} else {
38-
// ...
97+
resolve(["status": nil, "error": "Requires iOS 26.0+"])
3998
}
4099
#else
41-
resolve([
42-
"status": nil,
43-
"parentControls": nil,
44-
"lowerBound": nil,
45-
"upperBound": nil
46-
])
100+
resolve(["status": nil, "error": "SDK not available"])
47101
#endif
48102
}
103+
104+
// Helper to get top view controller
105+
private func topViewController() -> UIViewController? {
106+
guard let windowScene = UIApplication.shared.connectedScenes
107+
.compactMap({ $0 as? UIWindowScene })
108+
.first(where: { $0.activationState == .foregroundActive }),
109+
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
110+
let rootViewController = window.rootViewController else {
111+
return nil
112+
}
113+
114+
var topController = rootViewController
115+
while let presentedViewController = topController.presentedViewController {
116+
topController = presentedViewController
117+
}
118+
119+
return topController
120+
}
49121
}

0 commit comments

Comments
 (0)