Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 28 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,40 @@
},
"ios": {
"bundleIdentifier": "com.example.myapp",
"supportsTablet": true
"supportsTablet": true,
"privacyManifests": {
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": ["E174.1"]
}
],
"NSPrivacyTracking": false,
"NSPrivacyTrackingDomains": [],
"NSPrivacyCollectedDataTypes": []
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.example.myapp"
"package": "com.example.myapp",
"permissions": [
"READ_MEDIA_VISUAL_USER_SELECTED"
]
},
"plugins": [
"expo-router",
Expand Down
58 changes: 58 additions & 0 deletions docs/PRIVACY_MANIFEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# iOS Privacy Manifest & Android Photo Picker

## iOS — `NSPrivacyAccessed…`

Apple requires every iOS 17+ app that links a "required reason API" to declare an approved reason in its privacy manifest. Submission is rejected at the App Store layer if the manifest is missing or contains an unapproved code.

`app.json` ships with reasons for the four most common APIs you'll hit in a fresh Expo + expo-secure-store app:

| API category | Why we declared it | Reason code |
|---|---|---|
| `NSPrivacyAccessedAPICategoryUserDefaults` | `expo-secure-store` falls back to `UserDefaults` when the keychain is unavailable. | `CA92.1` — read/write data the app itself wrote. |
| `NSPrivacyAccessedAPICategoryFileTimestamp` | `expo-router`, `expo-asset`, and React Native's bundler stat their cache files. | `C617.1` — display info to the user inside the app. |
| `NSPrivacyAccessedAPICategorySystemBootTime` | React Native uses `mach_absolute_time` for performance telemetry. | `35F9.1` — measure how long an in-app operation took. |
| `NSPrivacyAccessedAPICategoryDiskSpace` | RN warns at low disk; some Expo modules pre-flight downloads. | `E174.1` — display info to the user inside the app. |

### When to add more reasons

If you add **any** module that uses one of the categories not listed above (`ActiveKeyboards`, `FileTimestamp` for non-display purposes, `UserDefaults` for cross-app sharing, etc.), open Apple's table at <https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api> and add the matching `NSPrivacyAccessedAPIType` block.

### Tracking

The starter declares:

```json
"NSPrivacyTracking": false,
"NSPrivacyTrackingDomains": [],
"NSPrivacyCollectedDataTypes": []
```

Flip `NSPrivacyTracking` to `true` and populate `NSPrivacyTrackingDomains` the moment you add an analytics or ads SDK. Otherwise the App Store will reject.

## Android — Photo Picker (Android 14, API 34+)

Android 14 introduced the **partial photo access** model: instead of the historical `READ_MEDIA_IMAGES` (all photos) you can declare `READ_MEDIA_VISUAL_USER_SELECTED` to request access only to images the user explicitly picks.

The starter declares the partial permission. If your app actually needs the full library:

```json
"android": {
"permissions": [
"READ_MEDIA_VISUAL_USER_SELECTED",
"READ_MEDIA_IMAGES",
"READ_MEDIA_VIDEO"
]
}
```

Always include `READ_MEDIA_VISUAL_USER_SELECTED` even if you also declare `READ_MEDIA_IMAGES` — it's the fallback when the user grants partial-only access on Android 14+.

## EAS Build

These declarations are picked up automatically by the Expo prebuild on EAS Build. No native code changes are required as long as the module versions match Expo SDK 53+.

## References

- [Apple — Describing data use in privacy manifests](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests)
- [Apple — Required reason API](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api)
- [Android 14 — Photo picker permission](https://developer.android.com/training/data-storage/shared/photopicker)
25 changes: 25 additions & 0 deletions tests/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@ describe('Project structure', () => {
expect(appJson.expo.plugins).toContain('expo-secure-store');
expect(appJson.expo.scheme).toBeTruthy();
});

test('app.json declares iOS privacy manifest (App Store gate)', () => {
const appJson = JSON.parse(fs.readFileSync(path.join(root, 'app.json'), 'utf8'));
const pm = appJson.expo.ios.privacyManifests;
expect(pm).toBeDefined();
expect(Array.isArray(pm.NSPrivacyAccessedAPITypes)).toBe(true);
expect(pm.NSPrivacyAccessedAPITypes.length).toBeGreaterThan(0);
// Tracking should default to false; flipping it requires populating
// NSPrivacyTrackingDomains and a deliberate compliance review.
expect(pm.NSPrivacyTracking).toBe(false);
// Each declared API must carry at least one approved reason code.
for (const api of pm.NSPrivacyAccessedAPITypes) {
expect(typeof api.NSPrivacyAccessedAPIType).toBe('string');
expect(Array.isArray(api.NSPrivacyAccessedAPITypeReasons)).toBe(true);
expect(api.NSPrivacyAccessedAPITypeReasons.length).toBeGreaterThan(0);
}
});

test('app.json declares Android 14 partial photo permission', () => {
const appJson = JSON.parse(fs.readFileSync(path.join(root, 'app.json'), 'utf8'));
const perms = appJson.expo.android.permissions || [];
// READ_MEDIA_VISUAL_USER_SELECTED is the fallback granted when a user
// picks "Selected photos" on Android 14+. Always include it.
expect(perms).toContain('READ_MEDIA_VISUAL_USER_SELECTED');
});
});

describe('Version bumper', () => {
Expand Down
Loading