diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
index 54436687..153da676 100644
--- a/.github/workflows/audit.yml
+++ b/.github/workflows/audit.yml
@@ -14,7 +14,7 @@ permissions:
jobs:
audit:
- name: npm audit + depcheck
+ name: npm audit + depcheck + license
runs-on: ubuntu-latest
permissions:
contents: read
@@ -42,4 +42,7 @@ jobs:
run: npm run audit
- name: Check for unused dependencies
- run: npm run depcheck
\ No newline at end of file
+ run: npm run depcheck
+
+ - name: Check for disallowed dependency licenses
+ run: npm run license:check
\ No newline at end of file
diff --git a/app/licenses.tsx b/app/licenses.tsx
new file mode 100644
index 00000000..7ef8ad9b
--- /dev/null
+++ b/app/licenses.tsx
@@ -0,0 +1,20 @@
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { SettingsSkeleton } from '@/components/mobile/SettingsSkeleton';
+import { createLazyRoute } from '@/utils/lazyRoute';
+
+const LazyLicenses = createLazyRoute({
+ importFn: () => import('@/components/mobile/LicensesScreen'),
+ LoadingFallback: SettingsSkeleton,
+ boundaryName: 'LicensesRoute',
+});
+
+const LicensesScreen = () => {
+ return (
+
+
+
+ );
+};
+
+export default LicensesScreen;
diff --git a/assets/THIRD_PARTY_NOTICES.json b/assets/THIRD_PARTY_NOTICES.json
new file mode 100644
index 00000000..f40cb6e2
--- /dev/null
+++ b/assets/THIRD_PARTY_NOTICES.json
@@ -0,0 +1,336 @@
+{
+ "generatedAt": "2026-08-27T07:23:45.610Z",
+ "note": "Baseline snapshot from package.json direct production dependencies. Run \"npm run attribution\" (scripts/license-audit.js --generate-attribution) to regenerate the full transitive tree with resolved licenses.",
+ "packages": [
+ {
+ "name": "@expo/config-plugins",
+ "version": "56.0.9",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@expo/vector-icons",
+ "version": "15.0.3",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-native-async-storage/async-storage",
+ "version": "2.2.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-native-community/netinfo",
+ "version": "12.0.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-navigation/bottom-tabs",
+ "version": "7.4.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-navigation/drawer",
+ "version": "7.5.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-navigation/elements",
+ "version": "2.6.3",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-navigation/native",
+ "version": "7.1.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@react-navigation/native-stack",
+ "version": "7.3.16",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "@sentry/react-native",
+ "version": "7.2.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "axios",
+ "version": "1.7.9",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "axios-mock-adapter",
+ "version": "2.1.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "clsx",
+ "version": "2.1.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo",
+ "version": "54.0.33",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-asset",
+ "version": "12.0.12",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-av",
+ "version": "16.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-barcode-scanner",
+ "version": "12.0.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-battery",
+ "version": "55.0.13",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-build-properties",
+ "version": "1.0.10",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-constants",
+ "version": "18.0.13",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-crypto",
+ "version": "14.0.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-local-authentication",
+ "version": "14.0.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-device",
+ "version": "8.0.10",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-document-picker",
+ "version": "14.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-file-system",
+ "version": "19.0.23",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-font",
+ "version": "14.0.11",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-haptics",
+ "version": "15.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-image",
+ "version": "3.0.11",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-image-picker",
+ "version": "17.0.11",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-keep-awake",
+ "version": "15.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-linear-gradient",
+ "version": "15.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-linking",
+ "version": "8.0.11",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-location",
+ "version": "56.0.18",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-network",
+ "version": "8.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-notifications",
+ "version": "0.32.16",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-router",
+ "version": "6.0.23",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-secure-store",
+ "version": "15.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-sensors",
+ "version": "15.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-speech-recognition",
+ "version": "3.1.3",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-splash-screen",
+ "version": "31.0.13",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-status-bar",
+ "version": "3.0.9",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-symbols",
+ "version": "1.0.8",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-updates",
+ "version": "56.0.19",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-video",
+ "version": "3.0.16",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "expo-web-browser",
+ "version": "15.0.10",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "lucide-react-native",
+ "version": "0.562.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "nativewind",
+ "version": "4.2.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "prettier-plugin-tailwindcss",
+ "version": "0.5.14",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react",
+ "version": "19.1.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-dom",
+ "version": "19.1.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-hook-form",
+ "version": "7.80.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native",
+ "version": "0.81.5",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-gesture-handler",
+ "version": "2.28.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-iap",
+ "version": "15.2.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-mmkv",
+ "version": "4.3.2",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-reanimated",
+ "version": "4.1.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-safe-area-context",
+ "version": "5.6.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-screens",
+ "version": "4.16.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-svg",
+ "version": "15.12.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-web",
+ "version": "0.21.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-webview",
+ "version": "14.0.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "react-native-worklets",
+ "version": "0.5.1",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "socket.io-client",
+ "version": "4.8.3",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "web-vitals",
+ "version": "5.3.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "zod",
+ "version": "3.23.0",
+ "license": "See package / regenerate via npm run attribution"
+ },
+ {
+ "name": "zustand",
+ "version": "5.0.10",
+ "license": "See package / regenerate via npm run attribution"
+ }
+ ]
+}
diff --git a/license-allowlist.json b/license-allowlist.json
new file mode 100644
index 00000000..082cd5f8
--- /dev/null
+++ b/license-allowlist.json
@@ -0,0 +1,19 @@
+[
+ "MIT",
+ "ISC",
+ "Apache-2.0",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "BSD-2-Clause-FreeBSD",
+ "BSD-3-Clause-Clear",
+ "0BSD",
+ "Unlicense",
+ "CC0-1.0",
+ "CC-BY-4.0",
+ "MPL-2.0",
+ "Python-2.0",
+ "Zlib",
+ "WTFPL",
+ "BlueOak-1.0.0",
+ "MIT-0"
+]
diff --git a/license-exceptions.json b/license-exceptions.json
new file mode 100644
index 00000000..c96faa50
--- /dev/null
+++ b/license-exceptions.json
@@ -0,0 +1,4 @@
+{
+ "_comment": "Per-package license exceptions. Each key is an exact production dependency name mapped to a short approval reason. Add an entry here ONLY after review by a maintainer; disallowed licenses require explicit sign-off and must be deleted again once the package is replaced or relicensed. See scripts/README.md -> License policy.",
+ "packages": {}
+}
diff --git a/package.json b/package.json
index 74f21794..ab5c9afa 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,8 @@
"fonts:analyze": "node ./scripts/analyze-fonts.js",
"audit": "node scripts/security-audit.js",
"depcheck": "depcheck",
+ "license:check": "node scripts/license-audit.js",
+ "attribution": "node scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json",
"architecture:check": "depcruise src app components --config dependency-cruiser.config.js",
"audit:performance": "tsx src/audit/cli.ts",
"audit:performance:html": "tsx src/audit/cli.ts --format html --output ./reports/audit-report",
@@ -158,6 +160,7 @@
"js-yaml": "^4.2.0",
"lint-staged": "^16.4.0",
"lz-string": "^1.5.0",
+ "license-checker": "^25.0.1",
"metro": "^0.83.7",
"postcss": "^8.5.12",
"prettier": "^3.8.3",
diff --git a/scripts/README.md b/scripts/README.md
index 8ff1bcc6..6f13dc00 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -240,6 +240,49 @@ npm run ci:monitor:report
node scripts/testCacheInvalidation.js
```
+## Dependency Licensing
+
+Production dependencies are license-scanned on every PR. The scan covers the
+**transitive production tree** (`npm install --production` scope) and fails the
+build on any package whose license is not on the allowlist, unless that package
+has an approved exception.
+
+```bash
+# Validate that every production dependency license is allowed (fails build)
+npm run license:check
+
+# Regenerate the committed attribution file used by the in-app Licenses screen
+npm run attribution
+```
+
+### Policy files
+
+- **`license-allowlist.json`** — the only licenses that may appear in the
+ production tree. Intended to be permissive/OSI-approved and store-safe; it
+ explicitly excludes strong copyleft licenses (GPL, AGPL, SSPL, LGPL, CC-BY-SA).
+ Unknown/missing licenses are never allowed.
+
+### Exception review process
+
+- **`license-exceptions.json`** — any disallowed license requires a `packages`
+ entry mapping the exact dependency name to an approval reason.
+- Adding an exception requires **maintainer sign-off**: the reviewer must verify
+ the package is essential, cannot be replaced by an allowlisted equivalent, and
+ note the reason for the record. Exceptions should be deleted again once the
+ package is replaced or relicensed.
+
+### Reconcile with the in-app auditor
+
+`src/audit/analyzers/NetworkAnalyzer.ts` reads the same two policy files so the
+in-app auditor and the CI merge gate agree on scope (`--production` only) and on
+the allowlist. There is no separate license blacklist to keep in sync.
+
+### In-app attribution
+
+The **Open Source Licenses** entry under *Settings → App* (route `/licenses`)
+renders `assets/THIRD_PARTY_NOTICES.json`, a committed attribution file
+regenerated from the full production tree by `npm run attribution`.
+
## Related Documentation
- [CI/CD Caching Strategy](../docs/CI_CD_CACHING_STRATEGY.md)
- [Performance Monitoring](../docs/PERFORMANCE_MONITORING.md)
diff --git a/scripts/license-audit.js b/scripts/license-audit.js
new file mode 100644
index 00000000..d39a307d
--- /dev/null
+++ b/scripts/license-audit.js
@@ -0,0 +1,147 @@
+/**
+ * Production dependency license audit.
+ *
+ * Scans the *transitive production* dependency tree (same scope as
+ * `npm install --production`) and fails on any package whose license is not
+ * on the allowlist defined in `license-allowlist.json`, unless that package is
+ * explicitly listed in `license-exceptions.json` with an approved reason.
+ *
+ * Policy files:
+ * - license-allowlist.json : the only licenses that may appear in the tree
+ * - license-exceptions.json : per-package exceptions (pkg -> approval reason)
+ *
+ * The allowlist and exception process are the single source of truth for
+ * dependency licensing. See the "License policy" section in scripts/README.md.
+ *
+ * Usage:
+ * node scripts/license-audit.js # validate, exit non-zero on violation
+ * node scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json
+ */
+'use strict';
+
+const { execFileSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..');
+
+function loadJson(file) {
+ const full = path.join(ROOT, file);
+ if (!fs.existsSync(full)) {
+ throw new Error(`Missing required file: ${file}`);
+ }
+ return JSON.parse(fs.readFileSync(full, 'utf-8'));
+}
+
+const ALLOWLIST = new Set(loadJson('license-allowlist.json'));
+const EXCEPTIONS = loadJson('license-exceptions.json');
+
+function normalizeLicense(raw) {
+ if (!raw) return 'UNKNOWN';
+ return String(raw).trim();
+}
+
+/**
+ * A package is allowed if the whole package is under an approved exception, or
+ * if every license reported for it is on the allowlist. An unknown/missing
+ * license is never allowed (per spec: "failing on anything outside it").
+ */
+function isAllowed(pkgName, rawLicenses) {
+ if (EXCEPTIONS[pkgName]) return true;
+ if (!rawLicenses) return false;
+
+ const fragments = normalizeLicense(rawLicenses)
+ .split(/\s+(?:and|or)\s+/i)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ if (fragments.length === 0) return false;
+ return fragments.every((f) => ALLOWLIST.has(f));
+}
+
+function collectProductionTree() {
+ // --production limits the scan to the production (non-dev) dependency tree.
+ const stdout = execFileSync(
+ path.join(ROOT, 'node_modules', '.bin', 'license-checker'),
+ ['--production', '--json'],
+ { cwd: ROOT, maxBuffer: 64 * 1024 * 1024, encoding: 'utf-8' }
+ );
+ return JSON.parse(stdout);
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ const genIdx = args.indexOf('--generate-attribution');
+ const genOut = genIdx !== -1 ? args[genIdx + 1] : null;
+
+ let tree;
+ try {
+ tree = collectProductionTree();
+ } catch (err) {
+ console.error('Failed to resolve the production dependency tree:', err.message);
+ process.exit(1);
+ }
+
+ const violations = [];
+ const attributions = [];
+
+ for (const key of Object.keys(tree).sort()) {
+ const entry = tree[key];
+ const pkgName = key.split('@').slice(0, -1).join('@') || key;
+
+ if (genOut) {
+ attributions.push({
+ name: pkgName,
+ version: entry.version || '',
+ license: normalizeLicense(entry.licenses),
+ publisher: entry.publisher || undefined,
+ });
+ }
+
+ if (!isAllowed(pkgName, entry.licenses)) {
+ violations.push({
+ packageName: pkgName,
+ license: normalizeLicense(entry.licenses),
+ reason: EXCEPTIONS[pkgName]
+ ? `unknown license not on allowlist: ${entry.licenses}`
+ : `license not on allowlist (${licenseAllowlistLabel()}): ${entry.licenses}`,
+ });
+ }
+ }
+
+ if (genOut) {
+ const outFile = path.join(ROOT, genOut);
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
+ fs.writeFileSync(
+ outFile,
+ JSON.stringify(
+ {
+ generatedAt: new Date().toISOString(),
+ packages: attributions,
+ },
+ null,
+ 2
+ ) + '\n'
+ );
+ console.log(`Wrote ${attributions.length} production packages to ${genOut}`);
+ }
+
+ if (violations.length > 0) {
+ console.error('❌ LICENSE CHECK FAILED');
+ console.error('The following production dependencies are not on the allowlist:');
+ for (const v of violations) {
+ console.error(` - ${v.packageName} (${v.license}): ${v.reason}`);
+ }
+ console.error('\nReview license-allowlist.json and license-exceptions.json (see scripts/README.md).');
+ process.exit(1);
+ }
+
+ console.log(`✅ LICENSE CHECK PASSED (${Object.keys(tree).length} production packages)`);
+ process.exit(0);
+}
+
+function licenseAllowlistLabel() {
+ return `allowlist has ${ALLOWLIST.size} license(s)`;
+}
+
+main();
diff --git a/src/audit/analyzers/NetworkAnalyzer.ts b/src/audit/analyzers/NetworkAnalyzer.ts
index 183cce52..1deffec6 100644
--- a/src/audit/analyzers/NetworkAnalyzer.ts
+++ b/src/audit/analyzers/NetworkAnalyzer.ts
@@ -445,44 +445,73 @@ export class DependencyAnalyzer implements IPerformanceAnalyzer {
/**
* Check license compliance
+ *
+ * Mirrors the authoritative CI policy (scripts/license-audit.js + the
+ * `license:check` npm script): scans the *production* dependency tree and
+ * fails on any package whose license is not on the allowlist declared in
+ * `license-allowlist.json`, unless the package is listed under an approved
+ * exception in `license-exceptions.json`. "UNKNOWN" licenses are never
+ * allowed. Keeping this in sync with the allowlist files means the in-app
+ * auditor and the merge gate agree.
*/
private async checkLicenseCompliance(): Promise {
const issues: LicenseIssue[] = [];
- const problematicLicenses = ['AGPL', 'SSPL', 'GPLv3'];
-
const pkgJsonPath = path.join(this.projectRoot, 'package.json');
+ const allowlistPath = path.join(this.projectRoot, 'license-allowlist.json');
+ const exceptionsPath = path.join(this.projectRoot, 'license-exceptions.json');
if (!fs.existsSync(pkgJsonPath)) return [];
+ let allowlist: string[] = [];
try {
- const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
- const allDeps = {
- ...pkgJson.dependencies,
- ...pkgJson.devDependencies,
- };
+ allowlist = JSON.parse(fs.readFileSync(allowlistPath, 'utf-8'));
+ } catch {
+ appLogger.errorSync('Error reading license-allowlist.json');
+ return [];
+ }
- for (const [name] of Object.entries(allDeps)) {
+ let exceptions: Record = {};
+ try {
+ exceptions = JSON.parse(fs.readFileSync(exceptionsPath, 'utf-8')).packages ?? {};
+ } catch {
+ // No exceptions file: rely purely on the allowlist.
+ exceptions = {};
+ }
+
+ const isAllowed = (pkgName: string, license?: unknown): boolean => {
+ if (exceptions[pkgName]) return true;
+ if (!license) return false;
+ const fragments = String(license)
+ .trim()
+ .split(/\s+(?:and|or)\s+/i)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (fragments.length === 0) return false;
+ return fragments.every((f) => allowlist.includes(f));
+ };
+
+ try {
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
+ // Production scope only — devDependencies do not ship to the stores.
+ for (const name of Object.keys(pkgJson.dependencies ?? {})) {
const pkgPath = path.join(this.projectRoot, 'node_modules', name, 'package.json');
- if (fs.existsSync(pkgPath)) {
- try {
- const depPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
- const license = depPkg.license || 'UNKNOWN';
-
- for (const problematic of problematicLicenses) {
- if (license.includes(problematic)) {
- issues.push({
- packageName: name,
- license,
- status: 'violation',
- reason: `${problematic} license is not compatible with this project`,
- });
- break;
- }
- }
- } catch {
- // Ignore
+ if (!fs.existsSync(pkgPath)) continue;
+
+ try {
+ const depPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
+ const license = depPkg.license || depPkg.licenses || 'UNKNOWN';
+
+ if (!isAllowed(name, license)) {
+ issues.push({
+ packageName: name,
+ license: String(license),
+ status: 'violation',
+ reason: `license not on allowlist (${allowlist.join(', ')}): ${String(license)}`,
+ });
}
+ } catch {
+ // Ignore packages we cannot resolve locally.
}
}
} catch (error) {
diff --git a/src/components/mobile/LicensesScreen.tsx b/src/components/mobile/LicensesScreen.tsx
new file mode 100644
index 00000000..01e348df
--- /dev/null
+++ b/src/components/mobile/LicensesScreen.tsx
@@ -0,0 +1,68 @@
+import React, { useMemo } from 'react';
+import { FlatList, View } from 'react-native';
+
+import { AppText } from '../common/AppText';
+
+// Committed attribution snapshot generated by `npm run attribution`
+// (scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json).
+// CI regenerates the authoritative, full transitive production tree.
+const THIRD_PARTY_NOTICES = require('../../../assets/THIRD_PARTY_NOTICES.json');
+
+interface PackageAttribution {
+ name: string;
+ version?: string;
+ license: string;
+}
+
+export const LicensesScreen = () => {
+ const packages = useMemo(
+ () => THIRD_PARTY_NOTICES.packages ?? [],
+ []
+ );
+
+ const renderItem = ({ item }: { item: PackageAttribution }) => (
+
+
+
+ {item.name}
+
+ {item.version ? (
+
+ v{item.version}
+
+ ) : null}
+
+
+
+ {item.license}
+
+
+
+ );
+
+ return (
+
+
+
+ Open Source Licenses
+
+
+ This app is built on {packages.length} open-source packages. Licensing
+ is enforced in CI by an allowlist; see license-allowlist.json for the
+ accepted licenses and license-exceptions.json for approved exceptions.
+
+
+ `${item.name}-${index}`}
+ renderItem={renderItem}
+ contentContainerStyle={{ paddingBottom: 32 }}
+ ListEmptyComponent={
+
+ No attribution data available.
+
+ }
+ />
+
+ );
+};
diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx
index ce72189e..a4399f0f 100644
--- a/src/components/mobile/MobileSettings.tsx
+++ b/src/components/mobile/MobileSettings.tsx
@@ -7,6 +7,7 @@ import {
Database,
Download,
Eye,
+ FileText,
Fingerprint as FingerprintPattern,
Lock,
LogOut,
@@ -100,6 +101,7 @@ const ICON_CREDIT_CARD_YELLOW = ;
const ICON_CREDIT_CARD_GREEN = ;
const ICON_SUN = ;
const ICON_DATABASE = ;
+const ICON_FILE = ;
const ICON_BAR_CHART = ;
const ICON_TRASH_RED = ;
const ICON_DOWNLOAD_INDIGO = ;
@@ -506,6 +508,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }
description="Reduces bandwidth by disabling prefetch and lowering image quality"
right={dataSaverRight}
/>
+
+ router.push('/licenses')}
+ />
{/* ── PROGRESSIVE DISCLOSURE: ADVANCED SETTINGS ──────── */}