mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 22:58:20 -05:00
Merge pull request #1474 from selfxyz/release/staging-2025-12-05
Release to Staging - 2025-12-05
This commit is contained in:
@@ -214,9 +214,6 @@ NOTICE
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
|
||||
# Patch files
|
||||
patches/
|
||||
|
||||
# ========================================
|
||||
# Project Specific Patterns
|
||||
# ========================================
|
||||
|
||||
12
.github/actions/cache-bundler/action.yml
vendored
12
.github/actions/cache-bundler/action.yml
vendored
@@ -24,12 +24,22 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- id: get-hash
|
||||
name: Hash lock file
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f "${{ inputs.lock-file }}" ]; then
|
||||
echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "::warning::Lock file '${{ inputs.lock-file }}' not found."
|
||||
echo "hash=no-lock-file" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- id: cache
|
||||
name: Cache Ruby gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
|
||||
key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gems-${{ inputs.cache-version }}-
|
||||
${{ runner.os }}-gems-
|
||||
|
||||
2
.github/actions/cache-gradle/action.yml
vendored
2
.github/actions/cache-gradle/action.yml
vendored
@@ -29,7 +29,7 @@ runs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/build.gradle', '**/settings.gradle', '**/gradle-wrapper.properties', '**/gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-${{ inputs.cache-version }}-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
4
.github/actions/cache-pods/action.yml
vendored
4
.github/actions/cache-pods/action.yml
vendored
@@ -9,7 +9,7 @@ inputs:
|
||||
default: |
|
||||
ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
lock-file:
|
||||
lockfile:
|
||||
description: Path to Podfile.lock
|
||||
required: false
|
||||
default: ios/Podfile.lock
|
||||
@@ -31,7 +31,7 @@ runs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
|
||||
key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lockfile) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pods-${{ inputs.cache-version }}-
|
||||
${{ runner.os }}-pods-
|
||||
|
||||
12
.github/actions/cache-yarn/action.yml
vendored
12
.github/actions/cache-yarn/action.yml
vendored
@@ -25,12 +25,22 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- id: get-hash
|
||||
name: Hash lock file
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f "${{ inputs.lock-file }}" ]; then
|
||||
echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "::warning::Lock file '${{ inputs.lock-file }}' not found."
|
||||
echo "hash=no-lock-file" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- id: cache
|
||||
name: Cache Yarn dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
|
||||
key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-${{ inputs.cache-version }}-
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
6
.github/actions/yarn-install/action.yml
vendored
6
.github/actions/yarn-install/action.yml
vendored
@@ -22,9 +22,11 @@ runs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: "yarn"
|
||||
cache-dependency-path: yarn.lock
|
||||
cache-dependency-path: |
|
||||
yarn.lock
|
||||
.yarnrc.yml
|
||||
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
|
||||
25
.github/actions/yarnrc-hash/action.yml
vendored
Normal file
25
.github/actions/yarnrc-hash/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Compute .yarnrc.yml hash
|
||||
|
||||
description: Compute a stable hash for .yarnrc.yml to use in cache keys.
|
||||
|
||||
outputs:
|
||||
hash:
|
||||
description: Hash of .yarnrc.yml (or "no-yarnrc" if the file is missing)
|
||||
value: ${{ steps.compute-yarnrc-hash.outputs.hash }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Compute .yarnrc.yml hash
|
||||
id: compute-yarnrc-hash
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f .yarnrc.yml ]; then
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
echo "hash=$(shasum -a 256 .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "hash=$(sha256sum .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "hash=no-yarnrc" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
21
.github/workflows/gitguardian.yml
vendored
21
.github/workflows/gitguardian.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: GitGuardian Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gitguardian:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history so multiple commits can be scanned
|
||||
- name: GitGuardian scan
|
||||
uses: GitGuardian/ggshield/actions/secret@v1.41.0
|
||||
env:
|
||||
GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
GITHUB_PUSH_BASE_SHA: ${{ github.event.base }}
|
||||
GITHUB_PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
|
||||
45
.github/workflows/mobile-bundle-analysis.yml
vendored
45
.github/workflows/mobile-bundle-analysis.yml
vendored
@@ -37,32 +37,26 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Yarn
|
||||
uses: ./.github/actions/cache-yarn
|
||||
with:
|
||||
path: |
|
||||
.yarn/cache
|
||||
node_modules
|
||||
app/node_modules
|
||||
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
|
||||
- name: Cache Ruby Bundler
|
||||
uses: actions/cache@v4
|
||||
cache-version: node-${{ env.NODE_VERSION_SANITIZED }}
|
||||
- name: Cache Bundler
|
||||
uses: ./.github/actions/cache-bundler
|
||||
with:
|
||||
path: app/vendor/bundle
|
||||
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-
|
||||
lock-file: app/Gemfile.lock
|
||||
cache-version: ruby${{ env.RUBY_VERSION }}
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: ./.github/actions/cache-gradle
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('app/android/**/gradle-wrapper.properties', 'app/android/**/gradle-wrapper.jar') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- name: Install Mobile Dependencies
|
||||
uses: ./.github/actions/mobile-setup
|
||||
with:
|
||||
@@ -100,30 +94,25 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
- name: Cache Yarn
|
||||
uses: ./.github/actions/cache-yarn
|
||||
with:
|
||||
path: |
|
||||
.yarn/cache
|
||||
node_modules
|
||||
app/node_modules
|
||||
key: ${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn-
|
||||
- name: Cache Ruby Bundler
|
||||
uses: actions/cache@v4
|
||||
cache-version: node-${{ env.NODE_VERSION_SANITIZED }}
|
||||
- name: Cache Bundler
|
||||
uses: ./.github/actions/cache-bundler
|
||||
with:
|
||||
path: app/vendor/bundle
|
||||
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-
|
||||
lock-file: app/Gemfile.lock
|
||||
cache-version: ruby${{ env.RUBY_VERSION }}
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: ./.github/actions/cache-pods
|
||||
with:
|
||||
path: app/ios/Pods
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('app/ios/Podfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pods-
|
||||
lockfile: app/ios/Podfile.lock
|
||||
- name: Install Mobile Dependencies
|
||||
uses: ./.github/actions/mobile-setup
|
||||
with:
|
||||
|
||||
2
.github/workflows/mobile-ci.yml
vendored
2
.github/workflows/mobile-ci.yml
vendored
@@ -277,7 +277,7 @@ jobs:
|
||||
path: |
|
||||
app/ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
lock-file: app/ios/Podfile.lock
|
||||
lockfile: app/ios/Podfile.lock
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}
|
||||
- name: Cache Xcode build
|
||||
uses: actions/cache@v4
|
||||
|
||||
14
.github/workflows/mobile-deploy.yml
vendored
14
.github/workflows/mobile-deploy.yml
vendored
@@ -367,6 +367,10 @@ jobs:
|
||||
echo "Xcode path:"
|
||||
xcode-select -p
|
||||
|
||||
- name: Compute .yarnrc.yml hash
|
||||
id: yarnrc-hash
|
||||
uses: ./.github/actions/yarnrc-hash
|
||||
|
||||
- name: Cache Yarn artifacts
|
||||
id: yarn-cache
|
||||
uses: ./.github/actions/cache-yarn
|
||||
@@ -375,7 +379,7 @@ jobs:
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
|
||||
|
||||
- name: Cache Ruby gems
|
||||
id: gems-cache
|
||||
@@ -392,7 +396,7 @@ jobs:
|
||||
path: |
|
||||
${{ env.APP_PATH }}/ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
lock-file: app/ios/Podfile.lock
|
||||
lockfile: app/ios/Podfile.lock
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}
|
||||
|
||||
- name: Log cache status
|
||||
@@ -976,6 +980,10 @@ jobs:
|
||||
# Use version-manager script to apply versions
|
||||
node ${{ env.APP_PATH }}/scripts/version-manager.cjs apply "$VERSION" "$IOS_BUILD" "$ANDROID_BUILD"
|
||||
|
||||
- name: Compute .yarnrc.yml hash
|
||||
id: yarnrc-hash
|
||||
uses: ./.github/actions/yarnrc-hash
|
||||
|
||||
- name: Cache Yarn artifacts
|
||||
id: yarn-cache
|
||||
uses: ./.github/actions/cache-yarn
|
||||
@@ -984,7 +992,7 @@ jobs:
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
|
||||
|
||||
- name: Cache Ruby gems
|
||||
id: gems-cache
|
||||
|
||||
12
.github/workflows/mobile-e2e.yml
vendored
12
.github/workflows/mobile-e2e.yml
vendored
@@ -56,6 +56,9 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable
|
||||
- run: corepack prepare yarn@4.6.0 --activate
|
||||
- name: Compute .yarnrc.yml hash
|
||||
id: yarnrc-hash
|
||||
uses: ./.github/actions/yarnrc-hash
|
||||
- name: Cache Yarn dependencies
|
||||
uses: ./.github/actions/cache-yarn
|
||||
with:
|
||||
@@ -63,7 +66,7 @@ jobs:
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
|
||||
- name: Toggle Yarn hardened mode for trusted PRs
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
||||
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
|
||||
@@ -246,6 +249,9 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable
|
||||
- run: corepack prepare yarn@4.6.0 --activate
|
||||
- name: Compute .yarnrc.yml hash
|
||||
id: yarnrc-hash
|
||||
uses: ./.github/actions/yarnrc-hash
|
||||
- name: Cache Yarn dependencies
|
||||
uses: ./.github/actions/cache-yarn
|
||||
with:
|
||||
@@ -253,7 +259,7 @@ jobs:
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
|
||||
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
|
||||
- name: Toggle Yarn hardened mode for trusted PRs
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
||||
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
|
||||
@@ -330,7 +336,7 @@ jobs:
|
||||
path: |
|
||||
app/ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
lock-file: app/ios/Podfile.lock
|
||||
lockfile: app/ios/Podfile.lock
|
||||
# DerivedData caching disabled - caused intermittent build failures due to stale cache
|
||||
# Pod caching still speeds up pod install significantly
|
||||
- name: Verify iOS Runtime
|
||||
|
||||
4
.github/workflows/mobile-sdk-demo-e2e.yml
vendored
4
.github/workflows/mobile-sdk-demo-e2e.yml
vendored
@@ -31,6 +31,7 @@ on:
|
||||
|
||||
jobs:
|
||||
android-e2e:
|
||||
name: Android E2E Tests Demo App
|
||||
# Currently build-only for Android. E2E steps are preserved but skipped (if: false).
|
||||
# To re-enable full E2E: change `if: false` to `if: true` on emulator steps.
|
||||
concurrency:
|
||||
@@ -192,6 +193,7 @@ jobs:
|
||||
ios-e2e:
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest-large
|
||||
name: iOS E2E Tests Demo App
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-ios-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -291,7 +293,7 @@ jobs:
|
||||
path: |
|
||||
packages/mobile-sdk-demo/ios/Pods
|
||||
~/Library/Caches/CocoaPods
|
||||
lock-file: packages/mobile-sdk-demo/ios/Podfile.lock
|
||||
lockfile: packages/mobile-sdk-demo/ios/Podfile.lock
|
||||
# DerivedData caching disabled - caused intermittent build failures due to stale cache
|
||||
# Pod caching still speeds up pod install significantly
|
||||
- name: Verify iOS Runtime
|
||||
|
||||
2
.github/workflows/npm-publish.yml
vendored
2
.github/workflows/npm-publish.yml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
4
.github/workflows/qrcode-sdk-ci.yml
vendored
4
.github/workflows/qrcode-sdk-ci.yml
vendored
@@ -68,6 +68,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
yarn workspace @selfxyz/common build
|
||||
yarn workspace @selfxyz/sdk-common build
|
||||
yarn workspace @selfxyz/qrcode build
|
||||
|
||||
- name: Cache build artifacts
|
||||
@@ -258,6 +259,9 @@ jobs:
|
||||
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Build SDK common dependency
|
||||
run: yarn workspace @selfxyz/sdk-common build
|
||||
|
||||
- name: Run tests
|
||||
run: yarn workspace @selfxyz/qrcode test
|
||||
|
||||
|
||||
@@ -658,6 +658,8 @@ regexes = [
|
||||
stopwords = [
|
||||
"000000",
|
||||
"6fe4476ee5a1832882e326b506d14126",
|
||||
"8853c3c635164864da68a6dbbcec7148506c3bcf",
|
||||
"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
||||
"_ec2_",
|
||||
"aaaaaa",
|
||||
"about",
|
||||
@@ -3188,4 +3190,3 @@ id = "zendesk-secret-key"
|
||||
description = "Detected a Zendesk Secret Key, risking unauthorized access to customer support services and sensitive ticketing data."
|
||||
regex = '''(?i)[\w.-]{0,50}?(?:zendesk)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)'''
|
||||
keywords = ["zendesk"]
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:73
|
||||
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:74
|
||||
8bc1e85075f73906767652ab35d5563efce2a931:packages/mobile-sdk-alpha/src/animations/passport_verify.json:aws-access-token:6
|
||||
f506113a22e5b147132834e4659f5af308448389:app/tests/utils/deeplinks.test.ts:generic-api-key:183
|
||||
5a67b5cc50f291401d1da4e51706d0cfcf1c2316:app/tests/utils/deeplinks.test.ts:generic-api-key:182
|
||||
0e4555eee6589aa9cca68f451227b149277d8c90:app/tests/src/utils/points/api.test.ts:generic-api-key:34
|
||||
app/ios/Podfile.lock:generic-api-key:2594
|
||||
app/tests/src/navigation/deeplinks.test.ts:generic-api-key:208
|
||||
circuits/circuits/gcp_jwt_verifier/example_jwt.txt:jwt:1
|
||||
cadd7ae5b768c261230f84426eac879c1853ce70:app/ios/Podfile.lock:generic-api-key:2586
|
||||
aeb8287078f088ecd8e9430e3f6a9f2c593ef1fc:app/src/utils/points/constants.ts:generic-api-key:7
|
||||
app/src/services/points/constants.ts:generic-api-key:10
|
||||
|
||||
@@ -24,7 +24,7 @@ Currently, Self supports electronic passports, biometric ID cards following the
|
||||
|
||||
**Passports:** Biometric passports have the [biometric passport logo](https://en.wikipedia.org/wiki/Biometric_passport) on their front cover.
|
||||
|
||||
**Aadhaar:** Indian [Aadhaar](https://en.wikipedia.org/wiki/Aadhaar) cards are supported for privacy-preserving identity verification.
|
||||
**Aadhaar:** Indian [Aadhaar](https://en.wikipedia.org/wiki/Aadhaar) cards are supported for privacy-preserving identity verification. Use the mAadhaar app to generate a QR code and import it into Self.
|
||||
|
||||
**Coverage:** Checkout our [coverage map here](http://map.self.xyz/) to see supported documents and countries.
|
||||
|
||||
@@ -90,11 +90,13 @@ These guides provide comprehensive context for AI-assisted development with Chat
|
||||
|
||||
We are actively looking for contributors. Please check the [open issues](https://github.com/selfxyz/self/issues) if you don't know where to start! We offer bounties for significant contributions.
|
||||
|
||||
> **Important:** Please open your pull request from the `staging` branch. Pull requests from other branches will be automatically closed.
|
||||
> **Important:** Please read and follow the guidelines in [contribute.md](contribute.md) when opening your pull request.
|
||||
|
||||
## Contact us
|
||||
|
||||
[Contact us](https://t.me/selfprotocolbuilder) on telegram for feedback or questions.
|
||||
- [Discord](https://discord.gg/AQ3TrX6dce) for technical support or reporting a bug.
|
||||
- [Telegram's Self builder channel](https://t.me/selfprotocolbuilder) for technical questions about the sdk implementation.
|
||||
- [Telegram's Self public group](https://t.me/selfxyz) for general questions and updates.
|
||||
|
||||
Thanks [Rémi](https://github.com/remicolin), [Florent](https://github.com/0xturboblitz), [Ayman](https://github.com/Nesopie), [Justin](https://github.com/transphorm), [Seshanth](https://github.com/seshanthS), [Nico](https://github.com/motemotech) and all other contributors for building Self.
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ GEM
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
algoliasearch (1.27.5)
|
||||
httpclient (~> 2.8, >= 2.8.3)
|
||||
json (>= 1.5.1)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1184.0)
|
||||
aws-sdk-core (3.237.0)
|
||||
aws-partitions (1.1190.0)
|
||||
aws-sdk-core (3.239.2)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -31,10 +31,10 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.1)
|
||||
aws-sdk-s3 (1.206.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -87,7 +87,7 @@ GEM
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
connection_pool (3.0.2)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -222,7 +222,7 @@ GEM
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
@@ -231,7 +231,7 @@ GEM
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.26.2)
|
||||
molinillo (0.8.0)
|
||||
multi_json (1.17.0)
|
||||
multi_json (1.18.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
@@ -311,4 +311,4 @@ RUBY VERSION
|
||||
ruby 3.2.7p253
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.19
|
||||
2.6.9
|
||||
|
||||
@@ -135,7 +135,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 121
|
||||
versionName "2.9.2"
|
||||
versionName "2.9.4"
|
||||
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
ENABLE_DEBUG_LOGS=
|
||||
GITGUARDIAN_API_KEY=
|
||||
GITLEAKS_LICENSE=
|
||||
GOOGLE_SIGNIN_ANDROID_CLIENT_ID=
|
||||
GRAFANA_LOKI_URL=
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.9.2</string>
|
||||
<string>2.9.4</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -546,7 +546,7 @@
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/MoproKit/Libs",
|
||||
);
|
||||
MARKETING_VERSION = 2.9.2;
|
||||
MARKETING_VERSION = 2.9.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -686,7 +686,7 @@
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/MoproKit/Libs",
|
||||
);
|
||||
MARKETING_VERSION = 2.9.2;
|
||||
MARKETING_VERSION = 2.9.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
||||
@@ -31,6 +31,7 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@env$': '<rootDir>/tests/__setup__/@env.js',
|
||||
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
|
||||
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@$': '<rootDir>/src',
|
||||
'^@tests/(.*)$': '<rootDir>/tests/src/$1',
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
/* global jest */
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
// Set up Buffer globally for tests that need it
|
||||
const { Buffer } = require('buffer');
|
||||
|
||||
global.Buffer = Buffer;
|
||||
|
||||
// Mock React Native PixelRatio globally before anything else loads
|
||||
const mockPixelRatio = {
|
||||
get: jest.fn(() => 2),
|
||||
@@ -16,21 +21,136 @@ const mockPixelRatio = {
|
||||
|
||||
global.PixelRatio = mockPixelRatio;
|
||||
|
||||
// Also make it available for require() calls
|
||||
const Module = require('module');
|
||||
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (id) {
|
||||
if (id === 'react-native') {
|
||||
const RN = originalRequire.apply(this, arguments);
|
||||
if (!RN.PixelRatio || !RN.PixelRatio.getFontScale) {
|
||||
RN.PixelRatio = mockPixelRatio;
|
||||
}
|
||||
return RN;
|
||||
}
|
||||
return originalRequire.apply(this, arguments);
|
||||
// Define NativeModules early so it's available for react-native mock
|
||||
// This will be assigned to global.NativeModules later, but we define it here
|
||||
// so the react-native mock can reference it
|
||||
const NativeModules = {
|
||||
PassportReader: {
|
||||
configure: jest.fn(),
|
||||
scanPassport: jest.fn(),
|
||||
trackEvent: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
},
|
||||
ReactNativeBiometrics: {
|
||||
isSensorAvailable: jest.fn().mockResolvedValue({
|
||||
available: true,
|
||||
biometryType: 'TouchID',
|
||||
}),
|
||||
createKeys: jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' }),
|
||||
deleteKeys: jest.fn().mockResolvedValue(true),
|
||||
createSignature: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ signature: 'mock-signature' }),
|
||||
simplePrompt: jest.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
NativeLoggerBridge: {
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
RNPassportReader: {
|
||||
configure: jest.fn(),
|
||||
scanPassport: jest.fn(),
|
||||
trackEvent: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Assign to global so it's available everywhere
|
||||
global.NativeModules = NativeModules;
|
||||
|
||||
// Mock react-native comprehensively - single source of truth for all tests
|
||||
// Note: NativeModules will be defined later and assigned to global.NativeModules
|
||||
// This mock accesses it at runtime via global.NativeModules
|
||||
jest.mock('react-native', () => {
|
||||
// Create AppState mock with listener tracking
|
||||
// Expose listeners array globally so tests can access it
|
||||
const appStateListeners = [];
|
||||
global.mockAppStateListeners = appStateListeners;
|
||||
|
||||
const mockAppState = {
|
||||
currentState: 'active',
|
||||
addEventListener: jest.fn((eventType, handler) => {
|
||||
appStateListeners.push(handler);
|
||||
return {
|
||||
remove: () => {
|
||||
const index = appStateListeners.indexOf(handler);
|
||||
if (index >= 0) {
|
||||
appStateListeners.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
AppState: mockAppState,
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
select: jest.fn(obj => obj.ios || obj.default),
|
||||
Version: 14,
|
||||
},
|
||||
// NativeModules is defined above and assigned to global.NativeModules
|
||||
// Use getter to access it at runtime (jest.mock is hoisted)
|
||||
get NativeModules() {
|
||||
return global.NativeModules || {};
|
||||
},
|
||||
NativeEventEmitter: jest.fn().mockImplementation(nativeModule => {
|
||||
return {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
};
|
||||
}),
|
||||
PixelRatio: mockPixelRatio,
|
||||
Dimensions: {
|
||||
get: jest.fn(() => ({
|
||||
window: { width: 375, height: 667, scale: 2 },
|
||||
screen: { width: 375, height: 667, scale: 2 },
|
||||
})),
|
||||
},
|
||||
Linking: {
|
||||
getInitialURL: jest.fn().mockResolvedValue(null),
|
||||
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||
removeEventListener: jest.fn(),
|
||||
openURL: jest.fn().mockResolvedValue(undefined),
|
||||
canOpenURL: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
StyleSheet: {
|
||||
create: jest.fn(styles => styles),
|
||||
flatten: jest.fn(style => style),
|
||||
hairlineWidth: 1,
|
||||
absoluteFillObject: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
View: 'View',
|
||||
Text: 'Text',
|
||||
ScrollView: 'ScrollView',
|
||||
TouchableOpacity: 'TouchableOpacity',
|
||||
TouchableHighlight: 'TouchableHighlight',
|
||||
Image: 'Image',
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
requireNativeComponent: jest.fn(name => {
|
||||
// Return a mock component function for any native component
|
||||
const MockNativeComponent = jest.fn(props => props.children || null);
|
||||
MockNativeComponent.displayName = `Mock(${name})`;
|
||||
return MockNativeComponent;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
require('react-native-gesture-handler/jestSetup');
|
||||
|
||||
// Mock NativeAnimatedHelper - using virtual mock during RN 0.76.9 prep phase
|
||||
@@ -51,6 +171,23 @@ global.__fbBatchedBridgeConfig = {
|
||||
// Set up global React Native test environment
|
||||
global.__DEV__ = true;
|
||||
|
||||
// Set up global mock navigation ref for tests
|
||||
global.mockNavigationRef = {
|
||||
isReady: jest.fn(() => true),
|
||||
getCurrentRoute: jest.fn(() => ({ name: 'Home' })),
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
|
||||
addListener: jest.fn(() => jest.fn()),
|
||||
removeListener: jest.fn(),
|
||||
};
|
||||
|
||||
// Load grouped mocks
|
||||
require('./tests/__setup__/mocks/navigation');
|
||||
require('./tests/__setup__/mocks/ui');
|
||||
|
||||
// Mock TurboModuleRegistry to provide required native modules for BOTH main app and mobile-sdk-alpha
|
||||
jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
|
||||
getEnforcing: jest.fn(name => {
|
||||
@@ -125,21 +262,44 @@ jest.mock(
|
||||
startDetecting: jest.fn(),
|
||||
};
|
||||
|
||||
const RN = jest.requireActual('react-native');
|
||||
// Override the PixelRatio immediately
|
||||
RN.PixelRatio = PixelRatio;
|
||||
|
||||
// Make sure both the default and named exports work
|
||||
const mockedRN = {
|
||||
...RN,
|
||||
// Return a simple object with all the mocks we need
|
||||
// Avoid nested requireActual/requireMock to prevent OOM in CI
|
||||
return {
|
||||
__esModule: true,
|
||||
PixelRatio,
|
||||
default: {
|
||||
...RN,
|
||||
PixelRatio,
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
select: jest.fn(obj => obj.ios || obj.default),
|
||||
Version: 14,
|
||||
},
|
||||
Dimensions: {
|
||||
get: jest.fn(() => ({
|
||||
window: { width: 375, height: 667, scale: 2 },
|
||||
screen: { width: 375, height: 667, scale: 2 },
|
||||
})),
|
||||
},
|
||||
StyleSheet: {
|
||||
create: jest.fn(styles => styles),
|
||||
flatten: jest.fn(style => style),
|
||||
hairlineWidth: 1,
|
||||
absoluteFillObject: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
View: 'View',
|
||||
Text: 'Text',
|
||||
ScrollView: 'ScrollView',
|
||||
TouchableOpacity: 'TouchableOpacity',
|
||||
requireNativeComponent: jest.fn(name => {
|
||||
const MockNativeComponent = jest.fn(props => props.children || null);
|
||||
MockNativeComponent.displayName = `Mock(${name})`;
|
||||
return MockNativeComponent;
|
||||
}),
|
||||
};
|
||||
|
||||
return mockedRN;
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
@@ -291,12 +451,19 @@ jest.mock('react-native-gesture-handler', () => {
|
||||
const MockFlatList = jest.fn(props => null);
|
||||
|
||||
return {
|
||||
...jest.requireActual('react-native-gesture-handler/jestSetup'),
|
||||
// Provide gesture handler mock without requireActual to avoid OOM
|
||||
GestureHandlerRootView: ({ children }) => children,
|
||||
ScrollView: MockScrollView,
|
||||
TouchableOpacity: MockTouchableOpacity,
|
||||
TouchableHighlight: MockTouchableHighlight,
|
||||
FlatList: MockFlatList,
|
||||
Directions: {},
|
||||
State: {},
|
||||
Swipeable: jest.fn(() => null),
|
||||
DrawerLayout: jest.fn(() => null),
|
||||
PanGestureHandler: jest.fn(() => null),
|
||||
TapGestureHandler: jest.fn(() => null),
|
||||
LongPressGestureHandler: jest.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -745,20 +912,8 @@ jest.mock('react-native-passport-reader', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock NativeModules without requiring react-native to avoid memory issues
|
||||
// Create a minimal NativeModules mock for PassportReader
|
||||
const NativeModules = {
|
||||
PassportReader: {
|
||||
configure: jest.fn(),
|
||||
scanPassport: jest.fn(),
|
||||
trackEvent: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Make it available globally for any code that expects it
|
||||
global.NativeModules = NativeModules;
|
||||
// NativeModules is already defined at the top of the file and assigned to global.NativeModules
|
||||
// No need to redefine it here
|
||||
|
||||
// Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests
|
||||
jest.mock('./src/integrations/nfc/passportReader', () => {
|
||||
@@ -1001,226 +1156,60 @@ jest.mock('react-native-svg', () => {
|
||||
});
|
||||
|
||||
// Mock React Navigation
|
||||
jest.mock('@react-navigation/native', () => {
|
||||
const actualNav = jest.requireActual('@react-navigation/native');
|
||||
|
||||
// Mock react-native-biometrics to prevent NativeModules errors
|
||||
jest.mock('react-native-biometrics', () => {
|
||||
class MockReactNativeBiometrics {
|
||||
constructor(options) {
|
||||
// Constructor accepts options but doesn't need to do anything
|
||||
}
|
||||
isSensorAvailable = jest.fn().mockResolvedValue({
|
||||
available: true,
|
||||
biometryType: 'TouchID',
|
||||
});
|
||||
createKeys = jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' });
|
||||
deleteKeys = jest.fn().mockResolvedValue(true);
|
||||
createSignature = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ signature: 'mock-signature' });
|
||||
simplePrompt = jest.fn().mockResolvedValue({ success: true });
|
||||
}
|
||||
return {
|
||||
...actualNav,
|
||||
useFocusEffect: jest.fn(callback => {
|
||||
// Immediately invoke the effect for testing without requiring a container
|
||||
return callback();
|
||||
}),
|
||||
useNavigation: jest.fn(() => ({
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
})),
|
||||
createNavigationContainerRef: jest.fn(() => ({
|
||||
current: null,
|
||||
getCurrentRoute: jest.fn(),
|
||||
})),
|
||||
createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })),
|
||||
__esModule: true,
|
||||
default: MockReactNativeBiometrics,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@react-navigation/native-stack', () => ({
|
||||
createNativeStackNavigator: jest.fn(() => ({
|
||||
displayName: 'MockStackNavigator',
|
||||
})),
|
||||
createNavigatorFactory: jest.fn(),
|
||||
// Mock NativeAppState native module to prevent getCurrentAppState errors
|
||||
jest.mock('react-native/Libraries/AppState/NativeAppState', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getConstants: jest.fn(() => ({ initialAppState: 'active' })),
|
||||
getCurrentAppState: jest.fn(() => Promise.resolve({ app_state: 'active' })),
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock core navigation to avoid requiring a NavigationContainer for hooks
|
||||
jest.mock('@react-navigation/core', () => {
|
||||
const actualCore = jest.requireActual('@react-navigation/core');
|
||||
return {
|
||||
...actualCore,
|
||||
useNavigation: jest.fn(() => ({
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
|
||||
// Note: Individual test files can override this with their own more specific mocks
|
||||
jest.mock('react-native-webview', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Return a simple pass-through mock - tests can override with JSX mocks if needed
|
||||
const MockWebView = jest.fn(() => null);
|
||||
MockWebView.displayName = 'MockWebView';
|
||||
// Mock AppState to prevent getCurrentAppState errors
|
||||
jest.mock('react-native/Libraries/AppState/AppState', () => {
|
||||
// Use the global appStateListeners array so tests can access it
|
||||
const appStateListeners = global.mockAppStateListeners || [];
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockWebView,
|
||||
WebView: MockWebView,
|
||||
default: {
|
||||
currentState: 'active',
|
||||
addEventListener: jest.fn((eventType, handler) => {
|
||||
appStateListeners.push(handler);
|
||||
return {
|
||||
remove: () => {
|
||||
const index = appStateListeners.indexOf(handler);
|
||||
if (index >= 0) {
|
||||
appStateListeners.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
|
||||
jest.mock('@/layouts/ExpandableBottomLayout', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// These need to pass through children so WebView is rendered
|
||||
const Layout = ({ children, ...props }) => children;
|
||||
const TopSection = ({ children, ...props }) => children;
|
||||
const BottomSection = ({ children, ...props }) => children;
|
||||
const FullSection = ({ children, ...props }) => children;
|
||||
return {
|
||||
__esModule: true,
|
||||
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Create mock components that work with React testing library
|
||||
// Button needs to render a host element with onPress so tests can interact with it
|
||||
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
|
||||
// Render as a mock-touchable-opacity host element so fireEvent.press works
|
||||
// This allows tests to query by testID and press the button
|
||||
return (
|
||||
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
|
||||
{icon || children || null}
|
||||
</mock-touchable-opacity>
|
||||
);
|
||||
});
|
||||
Button.displayName = 'MockButton';
|
||||
|
||||
const XStack = jest.fn(({ children, ...props }) => children || null);
|
||||
XStack.displayName = 'MockXStack';
|
||||
|
||||
const Text = jest.fn(({ children, ...props }) => children || null);
|
||||
Text.displayName = 'MockText';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Button,
|
||||
XStack,
|
||||
// Provide minimal Text to satisfy potential usages
|
||||
Text,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
|
||||
jest.mock('tamagui', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Create mock components that work with React testing library
|
||||
|
||||
// Helper to create a simple pass-through mock component
|
||||
const createMockComponent = displayName => {
|
||||
const Component = jest.fn(props => props.children || null);
|
||||
Component.displayName = displayName;
|
||||
return Component;
|
||||
};
|
||||
|
||||
// Mock styled function - simplified version that returns the component
|
||||
const styled = jest.fn(Component => Component);
|
||||
|
||||
// Create all Tamagui component mocks
|
||||
const Button = createMockComponent('MockButton');
|
||||
const XStack = createMockComponent('MockXStack');
|
||||
const YStack = createMockComponent('MockYStack');
|
||||
const ZStack = createMockComponent('MockZStack');
|
||||
const Text = createMockComponent('MockText');
|
||||
const View = createMockComponent('MockView');
|
||||
const ScrollView = createMockComponent('MockScrollView');
|
||||
const Spinner = createMockComponent('MockSpinner');
|
||||
const Image = createMockComponent('MockImage');
|
||||
const Card = createMockComponent('MockCard');
|
||||
const Separator = createMockComponent('MockSeparator');
|
||||
const TextArea = createMockComponent('MockTextArea');
|
||||
const Input = createMockComponent('MockInput');
|
||||
const Anchor = createMockComponent('MockAnchor');
|
||||
|
||||
// Mock Select component with nested components
|
||||
const Select = Object.assign(createMockComponent('MockSelect'), {
|
||||
Trigger: createMockComponent('MockSelectTrigger'),
|
||||
Value: createMockComponent('MockSelectValue'),
|
||||
Content: createMockComponent('MockSelectContent'),
|
||||
Item: createMockComponent('MockSelectItem'),
|
||||
Group: createMockComponent('MockSelectGroup'),
|
||||
Label: createMockComponent('MockSelectLabel'),
|
||||
Viewport: createMockComponent('MockSelectViewport'),
|
||||
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
|
||||
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
|
||||
});
|
||||
|
||||
// Mock Sheet component with nested components
|
||||
const Sheet = Object.assign(createMockComponent('MockSheet'), {
|
||||
Frame: createMockComponent('MockSheetFrame'),
|
||||
Overlay: createMockComponent('MockSheetOverlay'),
|
||||
Handle: createMockComponent('MockSheetHandle'),
|
||||
ScrollView: createMockComponent('MockSheetScrollView'),
|
||||
});
|
||||
|
||||
// Mock Adapt component
|
||||
const Adapt = createMockComponent('MockAdapt');
|
||||
|
||||
// Mock TamaguiProvider - simple pass-through that renders children
|
||||
const TamaguiProvider = jest.fn(({ children }) => children || null);
|
||||
TamaguiProvider.displayName = 'MockTamaguiProvider';
|
||||
|
||||
// Mock configuration factory functions
|
||||
const createFont = jest.fn(() => ({}));
|
||||
const createTamagui = jest.fn(() => ({}));
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
styled,
|
||||
Button,
|
||||
XStack,
|
||||
YStack,
|
||||
ZStack,
|
||||
Text,
|
||||
View,
|
||||
ScrollView,
|
||||
Spinner,
|
||||
Image,
|
||||
Card,
|
||||
Separator,
|
||||
TextArea,
|
||||
Input,
|
||||
Anchor,
|
||||
Select,
|
||||
Sheet,
|
||||
Adapt,
|
||||
TamaguiProvider,
|
||||
createFont,
|
||||
createTamagui,
|
||||
// Provide default exports for other common components
|
||||
default: jest.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Tamagui lucide icons to simple components to avoid theme context
|
||||
jest.mock('@tamagui/lucide-icons', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Return mock components that can be queried by testID
|
||||
const makeIcon = name => {
|
||||
// Use a mock element tag that React can render
|
||||
const Icon = props => ({
|
||||
$$typeof: Symbol.for('react.element'),
|
||||
type: `mock-icon-${name}`,
|
||||
props: { testID: `icon-${name}`, ...props },
|
||||
key: null,
|
||||
ref: null,
|
||||
});
|
||||
Icon.displayName = `MockIcon(${name})`;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
ExternalLink: makeIcon('external-link'),
|
||||
X: makeIcon('x'),
|
||||
Clipboard: makeIcon('clipboard'),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebViewFooter to avoid SDK rendering complexity
|
||||
jest.mock('@/components/WebViewFooter', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
const WebViewFooter = jest.fn(() => null);
|
||||
return { __esModule: true, WebViewFooter };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfxyz/mobile-app",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -29,11 +29,14 @@
|
||||
"format": "yarn nice",
|
||||
"ia": "yarn install-app",
|
||||
"imports:fix": "node ./scripts/alias-imports.cjs",
|
||||
"postinstall": "npx patch-package --patch-dir ../patches || true",
|
||||
"install-app": "yarn install-app:setup && yarn clean:xcode-env-local",
|
||||
"install-app:mobile-deploy": "yarn install && yarn build:deps && yarn clean:xcode-env-local",
|
||||
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..",
|
||||
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
|
||||
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
|
||||
"jest:clear": "node ./node_modules/jest/bin/jest.js --clearCache",
|
||||
"jest:run": "node ./node_modules/jest/bin/jest.js",
|
||||
"lint": "eslint . --cache --cache-location .eslintcache",
|
||||
"lint:fix": "eslint --fix . --cache --cache-location .eslintcache",
|
||||
"mobile-deploy": "node scripts/mobile-deploy-confirm.cjs both",
|
||||
@@ -42,8 +45,7 @@
|
||||
"mobile-local-deploy": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs both",
|
||||
"mobile-local-deploy:android": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs android",
|
||||
"mobile-local-deploy:ios": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs ios",
|
||||
"nice": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn build:deps; fi; yarn imports:fix && yarn lint:fix && yarn fmt:fix'",
|
||||
"postinstall": "npx patch-package --patch-dir ../patches || true",
|
||||
"nice": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn build:deps; fi; yarn imports:fix && yarn lint:fix'",
|
||||
"reinstall": "yarn --top-level run reinstall-app",
|
||||
"release": "./scripts/release.sh",
|
||||
"release:major": "./scripts/release.sh major",
|
||||
@@ -67,17 +69,20 @@
|
||||
"test:tree-shaking": "node ./scripts/test-tree-shaking.cjs",
|
||||
"test:web-build": "yarn jest:run tests/web-build-render.test.ts --testTimeout=180000",
|
||||
"types": "tsc --noEmit",
|
||||
"jest:run": "node ./node_modules/jest/bin/jest.js",
|
||||
"watch:sdk": "yarn workspace @selfxyz/mobile-sdk-alpha watch",
|
||||
"web": "vite",
|
||||
"web:build": "yarn build:deps && vite build",
|
||||
"web:preview": "vite preview"
|
||||
},
|
||||
"resolutions": {
|
||||
"punycode": "npm:punycode.js@2.3.1"
|
||||
"punycode": "npm:punycode.js@2.3.1",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-webview": "13.16.0"
|
||||
},
|
||||
"overrides": {
|
||||
"punycode": "npm:punycode.js@2.3.1"
|
||||
"punycode": "npm:punycode.js@2.3.1",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-webview": "13.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
@@ -137,6 +142,7 @@
|
||||
"react-native": "0.76.9",
|
||||
"react-native-app-auth": "^8.0.3",
|
||||
"react-native-biometrics": "^3.0.1",
|
||||
"react-native-blur-effect": "^1.1.3",
|
||||
"react-native-check-version": "^1.3.0",
|
||||
"react-native-cloud-storage": "^2.2.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
|
||||
3
app/src/assets/icons/discord.svg
Normal file
3
app/src/assets/icons/discord.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
|
||||
<path fill="currentColor" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 774 B |
@@ -6,6 +6,8 @@ import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Anchor, styled } from 'tamagui';
|
||||
|
||||
import { androidBackupDocsUrl, appleICloudDocsUrl } from '@/consts/links';
|
||||
|
||||
const StyledAnchor = styled(Anchor, {
|
||||
fontSize: 15,
|
||||
fontFamily: 'DINOT-Medium',
|
||||
@@ -15,16 +17,13 @@ const StyledAnchor = styled(Anchor, {
|
||||
const BackupDocumentationLink: React.FC = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<StyledAnchor unstyled href="https://support.apple.com/en-us/102651">
|
||||
<StyledAnchor unstyled href={appleICloudDocsUrl}>
|
||||
iCloud data
|
||||
</StyledAnchor>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StyledAnchor
|
||||
unstyled
|
||||
href="https://developer.android.com/identity/data/autobackup"
|
||||
>
|
||||
<StyledAnchor unstyled href={androidBackupDocsUrl}>
|
||||
Android Backup
|
||||
</StyledAnchor>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import CogHollowIcon from '@/assets/icons/cog_hollow.svg';
|
||||
import PlusCircleIcon from '@/assets/icons/plus_circle.svg';
|
||||
import ScanIcon from '@/assets/icons/qr_scan.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { apiBaseUrl } from '@/consts/links';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
@@ -40,7 +41,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
|
||||
if (uuidRegex.test(content)) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.self.xyz/consume-deferred-linking-token?token=${content}`,
|
||||
`${apiBaseUrl}/consume-deferred-linking-token?token=${content}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.status !== 'success') {
|
||||
|
||||
@@ -29,6 +29,7 @@ import StarBlackIcon from '@/assets/icons/star_black.svg';
|
||||
import LogoInversed from '@/assets/images/logo_inversed.svg';
|
||||
import MajongImage from '@/assets/images/majong.png';
|
||||
import { PointHistoryList } from '@/components/PointHistoryList';
|
||||
import { appsUrl } from '@/consts/links';
|
||||
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
|
||||
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
@@ -428,7 +429,7 @@ const Points: React.FC = () => {
|
||||
onPress={() => {
|
||||
selfClient.trackEvent(PointEvents.EXPLORE_APPS);
|
||||
navigation.navigate('WebView', {
|
||||
url: 'https://apps.self.xyz',
|
||||
url: appsUrl,
|
||||
title: 'Explore Apps',
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -2,22 +2,41 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// =============================================================================
|
||||
// External Links
|
||||
// =============================================================================
|
||||
// All external URLs used in the mobile app are centralized here for easy
|
||||
// maintenance and testing. Links are sorted alphabetically.
|
||||
|
||||
export const androidBackupDocsUrl =
|
||||
'https://developer.android.com/identity/data/autobackup';
|
||||
export const apiBaseUrl = 'https://api.self.xyz';
|
||||
export const apiPingUrl = 'https://api.self.xyz/ping';
|
||||
export const appStoreUrl = 'https://apps.apple.com/app/self-zk/id6478563710';
|
||||
|
||||
export const appleICloudDocsUrl = 'https://support.apple.com/en-us/102651';
|
||||
export const appsUrl = 'https://apps.self.xyz';
|
||||
export const discordUrl = 'https://discord.gg/selfxyz';
|
||||
export const gitHubUrl = 'https://github.com/selfxyz/self';
|
||||
|
||||
export const googleDriveAppDataScope =
|
||||
'https://www.googleapis.com/auth/drive.appdata';
|
||||
export const googleOAuthAuthorizationEndpoint =
|
||||
'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
export const googleOAuthTokenEndpoint = 'https://oauth2.googleapis.com/token';
|
||||
export const notificationApiStagingUrl =
|
||||
'https://notification.staging.self.xyz';
|
||||
export const notificationApiUrl = 'https://notification.self.xyz';
|
||||
export const playStoreUrl =
|
||||
'https://play.google.com/store/apps/details?id=com.proofofpassportapp';
|
||||
|
||||
export const pointsApiBaseUrl = 'https://points.self.xyz';
|
||||
export const privacyUrl = 'https://self.xyz/privacy';
|
||||
|
||||
export const referralBaseUrl = 'https://referral.self.xyz';
|
||||
export const selfLogoReverseUrl =
|
||||
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png';
|
||||
export const selfUrl = 'https://self.xyz';
|
||||
|
||||
export const supportedBiometricIdsUrl =
|
||||
'https://docs.self.xyz/use-self/self-map-countries-list';
|
||||
|
||||
export const telegramUrl = 'https://t.me/selfprotocolbuilder';
|
||||
|
||||
export const telegramUrl = 'https://t.me/selfxyz';
|
||||
export const termsUrl = 'https://self.xyz/terms';
|
||||
|
||||
export const turnkeyOAuthRedirectAndroidUri = 'https://redirect.self.xyz';
|
||||
export const turnkeyOAuthRedirectIosUri = 'https://oauth-redirect.turnkey.com';
|
||||
export const xUrl = 'https://x.com/selfprotocol';
|
||||
|
||||
8
app/src/consts/recoveryPrompts.ts
Normal file
8
app/src/consts/recoveryPrompts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export type RecoveryPromptAllowedRoute =
|
||||
(typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number];
|
||||
|
||||
export const RECOVERY_PROMPT_ALLOWED_ROUTES = ['Home'] as const;
|
||||
@@ -2,11 +2,16 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// Turnkey OAuth redirect URIs
|
||||
export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID = 'https://redirect.self.xyz';
|
||||
import {
|
||||
turnkeyOAuthRedirectAndroidUri,
|
||||
turnkeyOAuthRedirectIosUri,
|
||||
} from '@/consts/links';
|
||||
|
||||
export const TURNKEY_OAUTH_REDIRECT_URI_IOS =
|
||||
'https://oauth-redirect.turnkey.com';
|
||||
// Turnkey OAuth redirect URIs
|
||||
export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID =
|
||||
turnkeyOAuthRedirectAndroidUri;
|
||||
|
||||
export const TURNKEY_OAUTH_REDIRECT_URI_IOS = turnkeyOAuthRedirectIosUri;
|
||||
|
||||
// Re-export all mocks for easier imports
|
||||
export { parseScanResponse, scan } from '@/devtools/mocks/nfcScanner';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Linking, Platform } from 'react-native';
|
||||
|
||||
import { SettingsEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
|
||||
import { apiPingUrl } from '@/consts/links';
|
||||
import { useModal } from '@/hooks/useModal';
|
||||
import { useNetInfo } from '@/hooks/useNetInfo';
|
||||
import { navigationRef } from '@/navigation';
|
||||
@@ -34,7 +35,7 @@ const connectionModalParams = {
|
||||
|
||||
export default function useConnectionModal() {
|
||||
const { isConnected, isInternetReachable } = useNetInfo({
|
||||
reachabilityUrl: 'https://api.self.xyz/ping',
|
||||
reachabilityUrl: apiPingUrl,
|
||||
});
|
||||
const { showModal, dismissModal, visible } = useModal(connectionModalParams);
|
||||
//isConnected and isInternetReachable can be null for unknown state
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import type { ModalParams } from '@/screens/app/ModalScreen';
|
||||
import {
|
||||
getModalCallbacks,
|
||||
@@ -16,23 +14,47 @@ import {
|
||||
|
||||
export const useModal = (params: ModalParams) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const callbackIdRef = useRef<number>();
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
params.onModalDismiss();
|
||||
}, [params]);
|
||||
|
||||
const handleModalButtonPress = useCallback(() => {
|
||||
setVisible(false);
|
||||
return params.onButtonPress();
|
||||
}, [params]);
|
||||
|
||||
const showModal = useCallback(() => {
|
||||
if (!navigationRef.isReady()) {
|
||||
// Navigation not ready yet; avoid throwing and simply skip showing
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
const { onButtonPress, onModalDismiss, ...rest } = params;
|
||||
const id = registerModalCallbacks({ onButtonPress, onModalDismiss });
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onButtonPress: _ignored,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onModalDismiss: _ignored2,
|
||||
...rest
|
||||
} = params;
|
||||
const id = registerModalCallbacks({
|
||||
onButtonPress: handleModalButtonPress,
|
||||
onModalDismiss: handleModalDismiss,
|
||||
});
|
||||
callbackIdRef.current = id;
|
||||
navigation.navigate('Modal', { ...rest, callbackId: id });
|
||||
}, [params, navigation]);
|
||||
navigationRef.navigate('Modal', { ...rest, callbackId: id });
|
||||
}, [handleModalButtonPress, handleModalDismiss, params]);
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
setVisible(false);
|
||||
const routes = navigation.getState()?.routes;
|
||||
if (!navigationRef.isReady()) {
|
||||
return;
|
||||
}
|
||||
const routes = navigationRef.getState()?.routes;
|
||||
if (routes?.at(routes.length - 1)?.name === 'Modal') {
|
||||
navigation.goBack();
|
||||
navigationRef.goBack();
|
||||
}
|
||||
if (callbackIdRef.current !== undefined) {
|
||||
const callbacks = getModalCallbacks(callbackIdRef.current);
|
||||
@@ -47,7 +69,7 @@ export const useModal = (params: ModalParams) => {
|
||||
unregisterModalCallbacks(callbackIdRef.current);
|
||||
callbackIdRef.current = undefined;
|
||||
}
|
||||
}, [navigation]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showModal,
|
||||
|
||||
@@ -2,64 +2,167 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { AppStateStatus } from 'react-native';
|
||||
import { AppState } from 'react-native';
|
||||
|
||||
import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts';
|
||||
import { useModal } from '@/hooks/useModal';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
// TODO: need to debug and test the logic. it pops up too often.
|
||||
export default function useRecoveryPrompts() {
|
||||
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
|
||||
const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES;
|
||||
|
||||
type UseRecoveryPromptsOptions = {
|
||||
allowedRoutes?: readonly string[];
|
||||
};
|
||||
|
||||
export default function useRecoveryPrompts({
|
||||
allowedRoutes = DEFAULT_ALLOWED_ROUTES,
|
||||
}: UseRecoveryPromptsOptions = {}) {
|
||||
const { homeScreenViewCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
|
||||
useSettingStore();
|
||||
const { getAllDocuments } = usePassport();
|
||||
const hasRecoveryEnabled = cloudBackupEnabled || hasViewedRecoveryPhrase;
|
||||
|
||||
const { showModal, visible } = useModal({
|
||||
titleText: 'Protect your account',
|
||||
bodyText:
|
||||
'Enable cloud backup or save your recovery phrase so you can recover your account.',
|
||||
buttonText: 'Back up now',
|
||||
onButtonPress: async () => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('CloudBackupSettings', {
|
||||
nextScreen: 'SaveRecoveryPhrase',
|
||||
});
|
||||
if (!navigationRef.isReady()) {
|
||||
return;
|
||||
}
|
||||
navigationRef.navigate('CloudBackupSettings', {
|
||||
nextScreen: 'SaveRecoveryPhrase',
|
||||
});
|
||||
},
|
||||
onModalDismiss: () => {},
|
||||
} as const);
|
||||
|
||||
useEffect(() => {
|
||||
async function maybePrompt() {
|
||||
if (!navigationRef.isReady()) {
|
||||
const lastPromptCount = useRef<number | null>(null);
|
||||
const appStateStatus = useRef<AppStateStatus>(
|
||||
(AppState.currentState as AppStateStatus | null) ?? 'active',
|
||||
);
|
||||
const allowedRouteSet = useMemo(
|
||||
() => new Set(allowedRoutes),
|
||||
[allowedRoutes],
|
||||
);
|
||||
|
||||
const isRouteEligible = useCallback(
|
||||
(routeName: string | undefined): routeName is string => {
|
||||
if (!routeName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowedRouteSet.has(routeName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[allowedRouteSet],
|
||||
);
|
||||
|
||||
const maybePrompt = useCallback(async () => {
|
||||
if (!navigationRef.isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStateStatus.current !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRouteName = navigationRef.getCurrentRoute?.()?.name;
|
||||
|
||||
if (!isRouteEligible(currentRouteName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasRecoveryEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const docs = await getAllDocuments();
|
||||
const hasRegisteredDocument = Object.values(docs).some(
|
||||
doc => doc.metadata.isRegistered === true,
|
||||
);
|
||||
|
||||
if (!hasRegisteredDocument) {
|
||||
return;
|
||||
}
|
||||
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
|
||||
try {
|
||||
const docs = await getAllDocuments();
|
||||
if (Object.keys(docs).length === 0) {
|
||||
return;
|
||||
}
|
||||
const shouldPrompt =
|
||||
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
|
||||
if (shouldPrompt) {
|
||||
showModal();
|
||||
}
|
||||
} catch {
|
||||
// Silently fail to avoid breaking the hook
|
||||
// If we can't get documents, we shouldn't show the prompt
|
||||
return;
|
||||
const shouldPrompt =
|
||||
homeScreenViewCount >= 5 && homeScreenViewCount % 5 === 0;
|
||||
|
||||
if (
|
||||
shouldPrompt &&
|
||||
!visible &&
|
||||
lastPromptCount.current !== homeScreenViewCount
|
||||
) {
|
||||
// Double-check route eligibility right before showing modal
|
||||
// to prevent showing on wrong screen if user navigated during async call
|
||||
const currentRouteNameAfterAsync =
|
||||
navigationRef.getCurrentRoute?.()?.name;
|
||||
|
||||
if (isRouteEligible(currentRouteNameAfterAsync)) {
|
||||
showModal();
|
||||
lastPromptCount.current = homeScreenViewCount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail to avoid breaking the hook
|
||||
// If we can't get documents, we shouldn't show the prompt
|
||||
return;
|
||||
}
|
||||
maybePrompt().catch(() => {});
|
||||
}, [
|
||||
loginCount,
|
||||
cloudBackupEnabled,
|
||||
hasViewedRecoveryPhrase,
|
||||
showModal,
|
||||
getAllDocuments,
|
||||
hasRecoveryEnabled,
|
||||
homeScreenViewCount,
|
||||
isRouteEligible,
|
||||
showModal,
|
||||
visible,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runMaybePrompt = () => {
|
||||
maybePrompt().catch(() => {
|
||||
// Ignore promise rejection - already handled in maybePrompt
|
||||
});
|
||||
};
|
||||
|
||||
runMaybePrompt();
|
||||
|
||||
const handleAppStateChange = (nextState: AppStateStatus) => {
|
||||
const previousState = appStateStatus.current;
|
||||
appStateStatus.current = nextState;
|
||||
|
||||
if (
|
||||
(previousState === 'background' || previousState === 'inactive') &&
|
||||
nextState === 'active'
|
||||
) {
|
||||
runMaybePrompt();
|
||||
}
|
||||
};
|
||||
|
||||
const appStateSubscription = AppState.addEventListener(
|
||||
'change',
|
||||
handleAppStateChange,
|
||||
);
|
||||
const navigationUnsubscribe = navigationRef.addListener?.(
|
||||
'state',
|
||||
runMaybePrompt,
|
||||
);
|
||||
|
||||
return () => {
|
||||
appStateSubscription.remove();
|
||||
if (typeof navigationUnsubscribe === 'function') {
|
||||
navigationUnsubscribe();
|
||||
}
|
||||
};
|
||||
}, [maybePrompt]);
|
||||
|
||||
return { visible };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { referralBaseUrl } from '@/consts/links';
|
||||
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
@@ -15,8 +16,7 @@ interface ReferralMessageResult {
|
||||
const buildReferralMessageFromAddress = (
|
||||
userPointsAddress: string,
|
||||
): ReferralMessageResult => {
|
||||
const baseDomain = 'https://referral.self.xyz';
|
||||
const referralLink = `${baseDomain}/referral/${userPointsAddress}`;
|
||||
const referralLink = `${referralBaseUrl}/referral/${userPointsAddress}`;
|
||||
return {
|
||||
message: `Join Self and use my referral link:\n\n${referralLink}`,
|
||||
referralLink,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { Buffer } from 'buffer';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import type { PassportData } from '@selfxyz/common/types';
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { DefaultNavBar } from '@/components/navbar';
|
||||
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import accountScreens from '@/navigation/account';
|
||||
import appScreens from '@/navigation/app';
|
||||
@@ -198,6 +199,7 @@ const { trackScreenView } = analytics();
|
||||
const Navigation = createStaticNavigation(AppNavigation);
|
||||
|
||||
const NavigationWithTracking = () => {
|
||||
useRecoveryPrompts();
|
||||
const selfClient = useSelfClient();
|
||||
const trackScreen = () => {
|
||||
const currentRoute = navigationRef.getCurrentRoute();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
NativeStackScreenProps,
|
||||
} from '@react-navigation/native-stack';
|
||||
|
||||
import { selfUrl } from '@/consts/links';
|
||||
import type { SharedRoutesParamList } from '@/navigation/types';
|
||||
import ComingSoonScreen from '@/screens/shared/ComingSoonScreen';
|
||||
import { WebViewScreen } from '@/screens/shared/WebViewScreen';
|
||||
@@ -33,7 +34,7 @@ const sharedScreens: { [K in ScreenName]: ScreenConfig<K> } = {
|
||||
headerShown: false,
|
||||
} as NativeStackNavigationOptions,
|
||||
initialParams: {
|
||||
url: 'https://self.xyz',
|
||||
url: selfUrl,
|
||||
title: undefined,
|
||||
shareTitle: undefined,
|
||||
shareMessage: undefined,
|
||||
|
||||
@@ -260,7 +260,6 @@ export const AuthProvider = ({
|
||||
|
||||
setIsAuthenticatingPromise(null);
|
||||
setIsAuthenticated(true);
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
|
||||
setAuthenticatedTimeout(previousTimeout => {
|
||||
if (previousTimeout) {
|
||||
@@ -369,6 +368,11 @@ export async function getOrGeneratePointsAddress(
|
||||
return pointsAddr;
|
||||
}
|
||||
|
||||
export function getPrivateKeyFromMnemonic(mnemonic: string) {
|
||||
const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic);
|
||||
return wallet.privateKey;
|
||||
}
|
||||
|
||||
export async function hasSecretStored() {
|
||||
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
|
||||
return !!seed;
|
||||
@@ -470,8 +474,7 @@ export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) {
|
||||
return null;
|
||||
}
|
||||
const mnemonic = JSON.parse(foundMnemonic) as Mnemonic;
|
||||
const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic.phrase);
|
||||
return wallet.privateKey;
|
||||
return getPrivateKeyFromMnemonic(mnemonic.phrase);
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
|
||||
@@ -89,6 +89,23 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
},
|
||||
},
|
||||
documents: selfClientDocumentsAdapter,
|
||||
navigation: {
|
||||
goBack: () => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.goBack();
|
||||
}
|
||||
},
|
||||
goTo: (routeName, params) => {
|
||||
if (navigationRef.isReady()) {
|
||||
if (params !== undefined) {
|
||||
// @ts-expect-error
|
||||
navigationRef.navigate(routeName, params);
|
||||
} else {
|
||||
navigationRef.navigate(routeName as never);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
crypto: {
|
||||
async hash(
|
||||
data: Uint8Array,
|
||||
|
||||
@@ -32,9 +32,9 @@ import RestoreAccountSvg from '@/assets/icons/restore_account.svg';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useAuth } from '@/providers/authProvider';
|
||||
import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider';
|
||||
import {
|
||||
loadPassportDataAndSecret,
|
||||
loadPassportData,
|
||||
reStorePassportDataWithRightCSCA,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { STORAGE_NAME, useBackupMnemonic } from '@/services/cloud-backup';
|
||||
@@ -50,7 +50,8 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
// const { turnkeyWallets, refreshWallets } = useTurnkeyUtils();
|
||||
// const { getMnemonic } = useTurnkeyUtils();
|
||||
// const { authState } = useTurnkey();
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [_restoringFromTurnkey, _setRestoringFromTurnkey] = useState(false);
|
||||
const [restoringFromCloud, setRestoringFromCloud] = useState(false);
|
||||
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
|
||||
useSettingStore();
|
||||
const { download } = useBackupMnemonic();
|
||||
@@ -73,6 +74,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
async (
|
||||
mnemonic: Mnemonic,
|
||||
isCloudRestore: boolean = false,
|
||||
setRestoring: (value: boolean) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const result = await restoreAccountFromMnemonic(mnemonic.phrase);
|
||||
@@ -85,32 +87,55 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passportDataAndSecret =
|
||||
(await loadPassportDataAndSecret()) as string;
|
||||
const { passportData, secret } = JSON.parse(passportDataAndSecret);
|
||||
const { isRegistered, csca } =
|
||||
await isUserRegisteredWithAlternativeCSCA(passportData, secret, {
|
||||
getCommitmentTree(docCategory) {
|
||||
return useProtocolStore.getState()[docCategory].commitment_tree;
|
||||
},
|
||||
getAltCSCA(docCategory) {
|
||||
if (docCategory === 'aadhaar') {
|
||||
const publicKeys =
|
||||
useProtocolStore.getState().aadhaar.public_keys;
|
||||
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
|
||||
return publicKeys
|
||||
? Object.fromEntries(publicKeys.map(key => [key, key]))
|
||||
: {};
|
||||
}
|
||||
const passportData = await loadPassportData();
|
||||
const secret = getPrivateKeyFromMnemonic(mnemonic.phrase);
|
||||
|
||||
return useProtocolStore.getState()[docCategory].alternative_csca;
|
||||
},
|
||||
if (!passportData || !secret) {
|
||||
console.warn('Failed to load passport data or secret');
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
|
||||
reason: 'no_passport_data_or_secret',
|
||||
});
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const passportDataParsed = JSON.parse(passportData);
|
||||
|
||||
const { isRegistered, csca } =
|
||||
await isUserRegisteredWithAlternativeCSCA(
|
||||
passportDataParsed,
|
||||
secret as string,
|
||||
{
|
||||
getCommitmentTree(docCategory) {
|
||||
return useProtocolStore.getState()[docCategory].commitment_tree;
|
||||
},
|
||||
getAltCSCA(docCategory) {
|
||||
if (docCategory === 'aadhaar') {
|
||||
const publicKeys =
|
||||
useProtocolStore.getState().aadhaar.public_keys;
|
||||
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
|
||||
return publicKeys
|
||||
? Object.fromEntries(publicKeys.map(key => [key, key]))
|
||||
: {};
|
||||
}
|
||||
|
||||
return useProtocolStore.getState()[docCategory]
|
||||
.alternative_csca;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!isRegistered) {
|
||||
console.warn(
|
||||
'Secret provided did not match a registered ID. Please try again.',
|
||||
);
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED);
|
||||
trackEvent(
|
||||
BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED,
|
||||
{
|
||||
reason: 'document_not_registered',
|
||||
hasCSCA: !!csca,
|
||||
},
|
||||
);
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return false;
|
||||
@@ -118,7 +143,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
if (isCloudRestore && !cloudBackupEnabled) {
|
||||
toggleCloudBackupEnabled();
|
||||
}
|
||||
reStorePassportDataWithRightCSCA(passportData, csca as string);
|
||||
await reStorePassportDataWithRightCSCA(
|
||||
passportDataParsed,
|
||||
csca as string,
|
||||
);
|
||||
await markCurrentDocumentAsRegistered(selfClient);
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS);
|
||||
trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED);
|
||||
@@ -126,7 +154,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
setRestoring(false);
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
console.error(
|
||||
'Restore account error:',
|
||||
e instanceof Error ? e.message : 'Unknown error',
|
||||
);
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
|
||||
setRestoring(false);
|
||||
return false;
|
||||
@@ -146,7 +177,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
|
||||
// DISABLED FOR NOW: Turnkey functionality
|
||||
// const onRestoreFromTurnkeyPress = useCallback(async () => {
|
||||
// setRestoring(true);
|
||||
// setRestoringFromTurnkey(true);
|
||||
// try {
|
||||
// const mnemonicPhrase = await getMnemonic();
|
||||
// const mnemonic: Mnemonic = {
|
||||
@@ -157,7 +188,11 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
// },
|
||||
// entropy: '',
|
||||
// };
|
||||
// const success = await restoreAccountFlow(mnemonic);
|
||||
// const success = await restoreAccountFlow(
|
||||
// mnemonic,
|
||||
// false,
|
||||
// setRestoringFromTurnkey,
|
||||
// );
|
||||
// if (success) {
|
||||
// setTurnkeyBackupEnabled(true);
|
||||
// }
|
||||
@@ -165,19 +200,22 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
// console.error('Turnkey restore error:', error);
|
||||
// trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
|
||||
// } finally {
|
||||
// setRestoring(false);
|
||||
// setRestoringFromTurnkey(false);
|
||||
// }
|
||||
// }, [getMnemonic, restoreAccountFlow, setTurnkeyBackupEnabled, trackEvent]);
|
||||
|
||||
const onRestoreFromCloudPress = useCallback(async () => {
|
||||
setRestoring(true);
|
||||
setRestoringFromCloud(true);
|
||||
try {
|
||||
const mnemonic = await download();
|
||||
await restoreAccountFlow(mnemonic, true);
|
||||
await restoreAccountFlow(mnemonic, true, setRestoringFromCloud);
|
||||
} catch (error) {
|
||||
console.error('Cloud restore error:', error);
|
||||
console.error(
|
||||
'Cloud restore error:',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
|
||||
setRestoring(false);
|
||||
setRestoringFromCloud(false);
|
||||
}
|
||||
}, [download, restoreAccountFlow, trackEvent]);
|
||||
|
||||
@@ -218,23 +256,23 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
onPress={onRestoreFromTurnkeyPress}
|
||||
testID="button-from-turnkey"
|
||||
disabled={
|
||||
restoring ||
|
||||
restoringFromTurnkey ||
|
||||
!biometricsAvailable ||
|
||||
(authState === AuthState.Authenticated &&
|
||||
turnkeyWallets.length === 0)
|
||||
}
|
||||
>
|
||||
{restoring ? 'Restoring' : 'Restore'} from Turnkey
|
||||
{restoring ? '…' : ''}
|
||||
{restoringFromTurnkey ? 'Restoring' : 'Restore'} from Turnkey
|
||||
{restoringFromTurnkey ? '…' : ''}
|
||||
</PrimaryButton> */}
|
||||
<PrimaryButton
|
||||
trackEvent={BackupEvents.CLOUD_BACKUP_STARTED}
|
||||
onPress={onRestoreFromCloudPress}
|
||||
testID="button-from-teststorage"
|
||||
disabled={restoring || !biometricsAvailable}
|
||||
disabled={restoringFromCloud || !biometricsAvailable}
|
||||
>
|
||||
{restoring ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
|
||||
{restoring ? '…' : ''}
|
||||
{restoringFromCloud ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
|
||||
{restoringFromCloud ? '…' : ''}
|
||||
</PrimaryButton>
|
||||
<XStack gap={64} alignItems="center" justifyContent="space-between">
|
||||
<Separator flexGrow={1} />
|
||||
@@ -244,7 +282,6 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
<SecondaryButton
|
||||
trackEvent={BackupEvents.MANUAL_RECOVERY_SELECTED}
|
||||
onPress={handleManualRecoveryPress}
|
||||
disabled={restoring}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="center">
|
||||
<Keyboard height={25} width={40} color={slate500} />
|
||||
|
||||
@@ -31,9 +31,9 @@ import {
|
||||
|
||||
import Paste from '@/assets/icons/paste.svg';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useAuth } from '@/providers/authProvider';
|
||||
import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider';
|
||||
import {
|
||||
loadPassportDataAndSecret,
|
||||
loadPassportData,
|
||||
reStorePassportDataWithRightCSCA,
|
||||
} from '@/providers/passportDataProvider';
|
||||
|
||||
@@ -74,8 +74,10 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const passportDataAndSecret = await loadPassportDataAndSecret();
|
||||
if (!passportDataAndSecret) {
|
||||
const passportData = await loadPassportData();
|
||||
const secret = getPrivateKeyFromMnemonic(slimMnemonic);
|
||||
|
||||
if (!passportData || !secret) {
|
||||
console.warn(
|
||||
'No passport data found on device. Please scan or import your document.',
|
||||
);
|
||||
@@ -86,9 +88,10 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
const { passportData, secret } = JSON.parse(passportDataAndSecret);
|
||||
const passportDataParsed = JSON.parse(passportData);
|
||||
|
||||
const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA(
|
||||
passportData,
|
||||
passportDataParsed,
|
||||
secret as string,
|
||||
{
|
||||
getCommitmentTree(docCategory) {
|
||||
@@ -122,7 +125,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
if (csca) {
|
||||
await reStorePassportDataWithRightCSCA(passportData, csca);
|
||||
await reStorePassportDataWithRightCSCA(passportDataParsed, csca);
|
||||
}
|
||||
|
||||
await markCurrentDocumentAsRegistered(selfClient);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import Discord from '@/assets/icons/discord.svg';
|
||||
import Github from '@/assets/icons/github.svg';
|
||||
import Cloud from '@/assets/icons/settings_cloud_backup.svg';
|
||||
import Data from '@/assets/icons/settings_data.svg';
|
||||
@@ -35,6 +36,7 @@ import Web from '@/assets/icons/webpage.svg';
|
||||
import X from '@/assets/icons/x.svg';
|
||||
import {
|
||||
appStoreUrl,
|
||||
discordUrl,
|
||||
gitHubUrl,
|
||||
playStoreUrl,
|
||||
selfUrl,
|
||||
@@ -106,6 +108,7 @@ const social = [
|
||||
[Github, gitHubUrl],
|
||||
[Web, selfUrl],
|
||||
[Telegram, telegramUrl],
|
||||
[Discord, discordUrl],
|
||||
] as [React.FC<SvgProps>, string][];
|
||||
|
||||
const MenuButton: React.FC<MenuButtonProps> = ({ children, Icon, onPress }) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { StaticScreenProps } from '@react-navigation/native';
|
||||
import { useFocusEffect, useIsFocused } from '@react-navigation/native';
|
||||
|
||||
import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha';
|
||||
import {
|
||||
advercase,
|
||||
dinot,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
} from '@selfxyz/mobile-sdk-alpha';
|
||||
import failAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/fail.json';
|
||||
import proveLoadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/prove.json';
|
||||
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha/browser';
|
||||
import {
|
||||
black,
|
||||
slate400,
|
||||
|
||||
@@ -65,32 +65,26 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss the modal BEFORE calling the callback
|
||||
// This prevents race conditions when the callback navigates to another screen
|
||||
try {
|
||||
// Try to execute the callback first
|
||||
await callbacks.onButtonPress();
|
||||
navigation.goBack();
|
||||
unregisterModalCallbacks(params.callbackId);
|
||||
} catch (navigationError) {
|
||||
console.error(
|
||||
'Navigation error while dismissing modal:',
|
||||
navigationError,
|
||||
);
|
||||
// Don't execute callback if modal couldn't be dismissed
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If callback succeeds, try to navigate back
|
||||
navigation.goBack();
|
||||
// Only unregister after successful navigation
|
||||
unregisterModalCallbacks(params.callbackId);
|
||||
} catch (navigationError) {
|
||||
console.error('Navigation error:', navigationError);
|
||||
// Don't cleanup if navigation fails - modal might still be visible
|
||||
}
|
||||
// Now execute the callback (which may navigate to another screen)
|
||||
// This only runs if dismissal succeeded
|
||||
try {
|
||||
await callbacks.onButtonPress();
|
||||
} catch (callbackError) {
|
||||
console.error('Callback error:', callbackError);
|
||||
// If callback fails, we should still try to navigate and cleanup
|
||||
try {
|
||||
navigation.goBack();
|
||||
unregisterModalCallbacks(params.callbackId);
|
||||
} catch (navigationError) {
|
||||
console.error(
|
||||
'Navigation error after callback failure:',
|
||||
navigationError,
|
||||
);
|
||||
// Don't cleanup if navigation fails
|
||||
}
|
||||
}
|
||||
}, [callbacks, navigation, params.callbackId]);
|
||||
|
||||
@@ -108,6 +102,9 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
|
||||
padding={20}
|
||||
borderRadius={10}
|
||||
marginHorizontal={8}
|
||||
width="79.5%"
|
||||
maxWidth={460}
|
||||
alignSelf="center"
|
||||
>
|
||||
<YStack gap={40}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
|
||||
@@ -174,7 +174,7 @@ const AadhaarUploadScreen: React.FC = () => {
|
||||
<BodyText
|
||||
style={{ fontWeight: 'bold', fontSize: 18, textAlign: 'center' }}
|
||||
>
|
||||
Generate a QR code from the mAadaar app
|
||||
Generate a QR code from the Aadhaar app
|
||||
</BodyText>
|
||||
<BodyText
|
||||
style={{ fontSize: 16, textAlign: 'center', color: slate500 }}
|
||||
|
||||
@@ -80,7 +80,7 @@ const DocumentCameraScreen: React.FC = () => {
|
||||
</YStack>
|
||||
|
||||
<Additional style={styles.disclaimer}>
|
||||
SELF WILL NOT CAPTURE AN IMAGE OF YOUR PASSPORT.
|
||||
Self will not capture an image of your ID.
|
||||
</Additional>
|
||||
|
||||
<SecondaryButton
|
||||
|
||||
@@ -2,17 +2,8 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { YStack } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { slate100 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import SDKCountryPickerScreen from '@selfxyz/mobile-sdk-alpha/onboarding/country-picker-screen';
|
||||
|
||||
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
|
||||
|
||||
export default function CountryPickerScreen() {
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={slate100}>
|
||||
<DocumentFlowNavBar title="GETTING STARTED" />
|
||||
<SDKCountryPickerScreen />
|
||||
</YStack>
|
||||
);
|
||||
return <SDKCountryPickerScreen />;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
|
||||
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
|
||||
const HomeScreen: React.FC = () => {
|
||||
@@ -67,6 +68,7 @@ const HomeScreen: React.FC = () => {
|
||||
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const hasIncrementedOnFocus = useRef(false);
|
||||
|
||||
const { amount: selfPoints } = usePoints();
|
||||
|
||||
@@ -124,6 +126,21 @@ const HomeScreen: React.FC = () => {
|
||||
}, [loadDocuments]),
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (hasIncrementedOnFocus.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasIncrementedOnFocus.current = true;
|
||||
useSettingStore.getState().incrementHomeScreenViewCount();
|
||||
|
||||
return () => {
|
||||
hasIncrementedOnFocus.current = false;
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
useFocusEffect(() => {
|
||||
if (isNewVersionAvailable && !isModalDismissed) {
|
||||
showAppUpdateModal();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
|
||||
import { WebViewNavBar } from '@/components/navbar/WebViewNavBar';
|
||||
import { WebViewFooter } from '@/components/WebViewFooter';
|
||||
import { selfUrl } from '@/consts/links';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { SharedRoutesParamList } from '@/navigation/types';
|
||||
|
||||
@@ -39,7 +40,7 @@ type WebViewScreenProps = NativeStackScreenProps<
|
||||
'WebView'
|
||||
>;
|
||||
|
||||
const defaultUrl = 'https://self.xyz';
|
||||
const defaultUrl = selfUrl;
|
||||
|
||||
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -7,6 +7,12 @@ import { authorize } from 'react-native-app-auth';
|
||||
import { GOOGLE_SIGNIN_ANDROID_CLIENT_ID } from '@env';
|
||||
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
|
||||
|
||||
import {
|
||||
googleDriveAppDataScope,
|
||||
googleOAuthAuthorizationEndpoint,
|
||||
googleOAuthTokenEndpoint,
|
||||
} from '@/consts/links';
|
||||
|
||||
// Ensure the client ID is available at runtime (skip in test environment)
|
||||
const isTestEnvironment =
|
||||
process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID;
|
||||
@@ -22,10 +28,10 @@ const config: AuthConfiguration = {
|
||||
// ensure this prints the correct values before calling authorize
|
||||
clientId: GOOGLE_SIGNIN_ANDROID_CLIENT_ID || 'mock-client-id',
|
||||
redirectUrl: 'com.proofofpassportapp:/oauth2redirect',
|
||||
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
|
||||
scopes: [googleDriveAppDataScope],
|
||||
serviceConfiguration: {
|
||||
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
||||
authorizationEndpoint: googleOAuthAuthorizationEndpoint,
|
||||
tokenEndpoint: googleOAuthTokenEndpoint,
|
||||
},
|
||||
additionalParameters: { access_type: 'offline', prompt: 'consent' as const },
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { notificationApiStagingUrl, notificationApiUrl } from '@/consts/links';
|
||||
|
||||
export interface DeviceTokenRegistration {
|
||||
session_id: string;
|
||||
device_token: string;
|
||||
@@ -18,9 +20,9 @@ export interface RemoteMessage {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const API_URL = 'https://notification.self.xyz';
|
||||
export const API_URL = notificationApiUrl;
|
||||
|
||||
export const API_URL_STAGING = 'https://notification.staging.self.xyz';
|
||||
export const API_URL_STAGING = notificationApiStagingUrl;
|
||||
export const getStateMessage = (state: string): string => {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
|
||||
@@ -2,4 +2,9 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export const POINTS_API_BASE_URL = 'https://points.self.xyz';
|
||||
import { pointsApiBaseUrl } from '@/consts/links';
|
||||
|
||||
export const POINTS_API_BASE_URL = pointsApiBaseUrl;
|
||||
|
||||
export const POINTS_TOKEN_CONTRACT_ADDRESS =
|
||||
'0xfa6279293dfa5b38486ee179e4ddf6806c517a49'; // gitleaks:allow
|
||||
|
||||
@@ -6,6 +6,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { SelfAppBuilder } from '@selfxyz/common/utils/appType';
|
||||
|
||||
import { selfLogoReverseUrl } from '@/consts/links';
|
||||
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
|
||||
import { POINTS_API_BASE_URL } from '@/services/points/constants';
|
||||
import type { IncomingPoints } from '@/services/points/types';
|
||||
@@ -175,8 +176,7 @@ export const pointsSelfApp = async () => {
|
||||
userId: v4(),
|
||||
userIdType: 'uuid',
|
||||
disclosures: {},
|
||||
logoBase64:
|
||||
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png',
|
||||
logoBase64: selfLogoReverseUrl,
|
||||
header: '',
|
||||
});
|
||||
|
||||
|
||||
@@ -7,34 +7,34 @@ import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface PersistedSettingsState {
|
||||
hasPrivacyNoteBeenDismissed: boolean;
|
||||
dismissPrivacyNote: () => void;
|
||||
addSubscribedTopic: (topic: string) => void;
|
||||
biometricsAvailable: boolean;
|
||||
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
|
||||
cloudBackupEnabled: boolean;
|
||||
toggleCloudBackupEnabled: () => void;
|
||||
loginCount: number;
|
||||
incrementLoginCount: () => void;
|
||||
hasViewedRecoveryPhrase: boolean;
|
||||
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
|
||||
isDevMode: boolean;
|
||||
setDevModeOn: () => void;
|
||||
setDevModeOff: () => void;
|
||||
hasCompletedKeychainMigration: boolean;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
dismissPrivacyNote: () => void;
|
||||
fcmToken: string | null;
|
||||
hasCompletedBackupForPoints: boolean;
|
||||
hasCompletedKeychainMigration: boolean;
|
||||
hasPrivacyNoteBeenDismissed: boolean;
|
||||
hasViewedRecoveryPhrase: boolean;
|
||||
homeScreenViewCount: number;
|
||||
incrementHomeScreenViewCount: () => void;
|
||||
isDevMode: boolean;
|
||||
pointsAddress: string | null;
|
||||
removeSubscribedTopic: (topic: string) => void;
|
||||
resetBackupForPoints: () => void;
|
||||
setBackupForPointsCompleted: () => void;
|
||||
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
|
||||
setDevModeOff: () => void;
|
||||
setDevModeOn: () => void;
|
||||
setFcmToken: (token: string | null) => void;
|
||||
turnkeyBackupEnabled: boolean;
|
||||
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
setPointsAddress: (address: string | null) => void;
|
||||
setSubscribedTopics: (topics: string[]) => void;
|
||||
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
|
||||
subscribedTopics: string[];
|
||||
setSubscribedTopics: (topics: string[]) => void;
|
||||
addSubscribedTopic: (topic: string) => void;
|
||||
removeSubscribedTopic: (topic: string) => void;
|
||||
hasCompletedBackupForPoints: boolean;
|
||||
setBackupForPointsCompleted: () => void;
|
||||
resetBackupForPoints: () => void;
|
||||
pointsAddress: string | null;
|
||||
setPointsAddress: (address: string | null) => void;
|
||||
toggleCloudBackupEnabled: () => void;
|
||||
turnkeyBackupEnabled: boolean;
|
||||
}
|
||||
|
||||
interface NonPersistedSettingsState {
|
||||
@@ -64,20 +64,33 @@ export const useSettingStore = create<SettingsState>()(
|
||||
toggleCloudBackupEnabled: () =>
|
||||
set(oldState => ({
|
||||
cloudBackupEnabled: !oldState.cloudBackupEnabled,
|
||||
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0,
|
||||
homeScreenViewCount: oldState.cloudBackupEnabled
|
||||
? oldState.homeScreenViewCount
|
||||
: 0,
|
||||
})),
|
||||
|
||||
loginCount: 0,
|
||||
incrementLoginCount: () =>
|
||||
set(oldState => ({ loginCount: oldState.loginCount + 1 })),
|
||||
homeScreenViewCount: 0,
|
||||
incrementHomeScreenViewCount: () =>
|
||||
set(oldState => {
|
||||
if (
|
||||
oldState.cloudBackupEnabled ||
|
||||
oldState.hasViewedRecoveryPhrase === true
|
||||
) {
|
||||
return oldState;
|
||||
}
|
||||
const nextCount = oldState.homeScreenViewCount + 1;
|
||||
return {
|
||||
homeScreenViewCount: nextCount >= 100 ? 0 : nextCount,
|
||||
};
|
||||
}),
|
||||
hasViewedRecoveryPhrase: false,
|
||||
setHasViewedRecoveryPhrase: viewed =>
|
||||
set(oldState => ({
|
||||
hasViewedRecoveryPhrase: viewed,
|
||||
loginCount:
|
||||
homeScreenViewCount:
|
||||
viewed && !oldState.hasViewedRecoveryPhrase
|
||||
? 0
|
||||
: oldState.loginCount,
|
||||
: oldState.homeScreenViewCount,
|
||||
})),
|
||||
|
||||
isDevMode: false,
|
||||
|
||||
6
app/tests/__setup__/imageMock.js
Normal file
6
app/tests/__setup__/imageMock.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// Mock for image files in Jest tests
|
||||
module.exports = 'test-file-stub';
|
||||
71
app/tests/__setup__/mocks/navigation.js
Normal file
71
app/tests/__setup__/mocks/navigation.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// Grouped navigation mocks to avoid cluttering jest.setup.js
|
||||
jest.mock('@react-navigation/native', () => {
|
||||
// Avoid nested requireActual to prevent OOM in CI
|
||||
// Create mock navigator without requiring React
|
||||
const MockNavigator = (props, _ref) => props.children;
|
||||
MockNavigator.displayName = 'MockNavigator';
|
||||
|
||||
return {
|
||||
useFocusEffect: jest.fn(callback => {
|
||||
// Immediately invoke the effect for testing without requiring a container
|
||||
return callback();
|
||||
}),
|
||||
useNavigation: jest.fn(() => ({
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
|
||||
})),
|
||||
useRoute: jest.fn(() => ({
|
||||
key: 'mock-route-key',
|
||||
name: 'MockRoute',
|
||||
params: {},
|
||||
})),
|
||||
useIsFocused: jest.fn(() => true),
|
||||
useLinkTo: jest.fn(() => jest.fn()),
|
||||
createNavigationContainerRef: jest.fn(() => global.mockNavigationRef),
|
||||
createStaticNavigation: jest.fn(() => MockNavigator),
|
||||
NavigationContainer: ({ children }) => children,
|
||||
DefaultTheme: {},
|
||||
DarkTheme: {},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@react-navigation/native-stack', () => ({
|
||||
createNativeStackNavigator: jest.fn(config => config),
|
||||
createNavigatorFactory: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock core navigation to avoid requiring a NavigationContainer for hooks
|
||||
jest.mock('@react-navigation/core', () => {
|
||||
// Avoid nested requireActual to prevent OOM in CI
|
||||
return {
|
||||
useNavigation: jest.fn(() => ({
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
|
||||
})),
|
||||
useRoute: jest.fn(() => ({
|
||||
key: 'mock-route-key',
|
||||
name: 'MockRoute',
|
||||
params: {},
|
||||
})),
|
||||
useIsFocused: jest.fn(() => true),
|
||||
useLinkTo: jest.fn(() => jest.fn()),
|
||||
NavigationContext: {
|
||||
Provider: ({ children }) => children,
|
||||
Consumer: ({ children }) => children(null),
|
||||
},
|
||||
NavigationRouteContext: {
|
||||
Provider: ({ children }) => children,
|
||||
Consumer: ({ children }) => children(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
199
app/tests/__setup__/mocks/ui.js
Normal file
199
app/tests/__setup__/mocks/ui.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// UI-related mocks grouped to keep jest.setup.js concise
|
||||
|
||||
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
|
||||
// Note: Individual test files can override this with their own more specific mocks
|
||||
jest.mock('react-native-webview', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Return a simple pass-through mock - tests can override with JSX mocks if needed
|
||||
const MockWebView = jest.fn(() => null);
|
||||
MockWebView.displayName = 'MockWebView';
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockWebView,
|
||||
WebView: MockWebView,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
|
||||
jest.mock('@/layouts/ExpandableBottomLayout', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// These need to pass through children so WebView is rendered
|
||||
const Layout = ({ children, ...props }) => children;
|
||||
const TopSection = ({ children, ...props }) => children;
|
||||
const BottomSection = ({ children, ...props }) => children;
|
||||
const FullSection = ({ children, ...props }) => children;
|
||||
return {
|
||||
__esModule: true,
|
||||
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Create mock components that work with React testing library
|
||||
// Button needs to render a host element with onPress so tests can interact with it
|
||||
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
|
||||
// Render as a mock-touchable-opacity host element so fireEvent.press works
|
||||
// This allows tests to query by testID and press the button
|
||||
return (
|
||||
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
|
||||
{icon || children || null}
|
||||
</mock-touchable-opacity>
|
||||
);
|
||||
});
|
||||
Button.displayName = 'MockButton';
|
||||
|
||||
const XStack = jest.fn(({ children, ...props }) => children || null);
|
||||
XStack.displayName = 'MockXStack';
|
||||
|
||||
const Text = jest.fn(({ children, ...props }) => children || null);
|
||||
Text.displayName = 'MockText';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Button,
|
||||
XStack,
|
||||
// Provide minimal Text to satisfy potential usages
|
||||
Text,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
|
||||
jest.mock('tamagui', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Create mock components that work with React testing library
|
||||
|
||||
// Helper to create a simple pass-through mock component
|
||||
const createMockComponent = displayName => {
|
||||
const Component = jest.fn(props => props.children || null);
|
||||
Component.displayName = displayName;
|
||||
return Component;
|
||||
};
|
||||
|
||||
// Mock styled function - simplified version that returns the component
|
||||
const styled = jest.fn(Component => Component);
|
||||
|
||||
// Create all Tamagui component mocks
|
||||
const Button = createMockComponent('MockButton');
|
||||
const XStack = createMockComponent('MockXStack');
|
||||
const YStack = createMockComponent('MockYStack');
|
||||
const ZStack = createMockComponent('MockZStack');
|
||||
const Text = createMockComponent('MockText');
|
||||
const View = createMockComponent('MockView');
|
||||
const ScrollView = createMockComponent('MockScrollView');
|
||||
const Spinner = createMockComponent('MockSpinner');
|
||||
const Image = createMockComponent('MockImage');
|
||||
const Card = createMockComponent('MockCard');
|
||||
const Separator = createMockComponent('MockSeparator');
|
||||
const TextArea = createMockComponent('MockTextArea');
|
||||
const Input = createMockComponent('MockInput');
|
||||
const Anchor = createMockComponent('MockAnchor');
|
||||
|
||||
// Mock Select component with nested components
|
||||
const Select = Object.assign(createMockComponent('MockSelect'), {
|
||||
Trigger: createMockComponent('MockSelectTrigger'),
|
||||
Value: createMockComponent('MockSelectValue'),
|
||||
Content: createMockComponent('MockSelectContent'),
|
||||
Item: createMockComponent('MockSelectItem'),
|
||||
Group: createMockComponent('MockSelectGroup'),
|
||||
Label: createMockComponent('MockSelectLabel'),
|
||||
Viewport: createMockComponent('MockSelectViewport'),
|
||||
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
|
||||
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
|
||||
});
|
||||
|
||||
// Mock Sheet component with nested components
|
||||
const Sheet = Object.assign(createMockComponent('MockSheet'), {
|
||||
Frame: createMockComponent('MockSheetFrame'),
|
||||
Overlay: createMockComponent('MockSheetOverlay'),
|
||||
Handle: createMockComponent('MockSheetHandle'),
|
||||
ScrollView: createMockComponent('MockSheetScrollView'),
|
||||
});
|
||||
|
||||
// Mock Adapt component
|
||||
const Adapt = createMockComponent('MockAdapt');
|
||||
|
||||
// Mock TamaguiProvider - simple pass-through that renders children
|
||||
const TamaguiProvider = jest.fn(({ children }) => children || null);
|
||||
TamaguiProvider.displayName = 'MockTamaguiProvider';
|
||||
|
||||
// Mock configuration factory functions
|
||||
const createFont = jest.fn(() => ({}));
|
||||
const createTamagui = jest.fn(() => ({}));
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
styled,
|
||||
Button,
|
||||
XStack,
|
||||
YStack,
|
||||
ZStack,
|
||||
Text,
|
||||
View,
|
||||
ScrollView,
|
||||
Spinner,
|
||||
Image,
|
||||
Card,
|
||||
Separator,
|
||||
TextArea,
|
||||
Input,
|
||||
Anchor,
|
||||
Select,
|
||||
Sheet,
|
||||
Adapt,
|
||||
TamaguiProvider,
|
||||
createFont,
|
||||
createTamagui,
|
||||
// Provide default exports for other common components
|
||||
default: jest.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Tamagui lucide icons to simple components to avoid theme context
|
||||
jest.mock('@tamagui/lucide-icons', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
// Return mock components that can be queried by testID
|
||||
const makeIcon = name => {
|
||||
// Use a mock element tag that React can render
|
||||
const Icon = props => ({
|
||||
$$typeof: Symbol.for('react.element'),
|
||||
type: `mock-icon-${name}`,
|
||||
props: { testID: `icon-${name}`, ...props },
|
||||
key: null,
|
||||
ref: null,
|
||||
});
|
||||
Icon.displayName = `MockIcon(${name})`;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
ExternalLink: makeIcon('external-link'),
|
||||
X: makeIcon('x'),
|
||||
Clipboard: makeIcon('clipboard'),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebViewFooter to avoid SDK rendering complexity
|
||||
jest.mock('@/components/WebViewFooter', () => {
|
||||
// Avoid requiring React to prevent nested require memory issues
|
||||
const WebViewFooter = jest.fn(() => null);
|
||||
return { __esModule: true, WebViewFooter };
|
||||
});
|
||||
|
||||
// Mock screens that use mobile-sdk-alpha flows with PixelRatio issues or missing dependencies
|
||||
jest.mock('@/screens/documents/selection/ConfirmBelongingScreen', () => {
|
||||
const MockScreen = jest.fn(() => null);
|
||||
MockScreen.displayName = 'MockConfirmBelongingScreen';
|
||||
return { __esModule: true, default: MockScreen };
|
||||
});
|
||||
|
||||
jest.mock('@/screens/documents/selection/CountryPickerScreen', () => {
|
||||
const MockScreen = jest.fn(() => null);
|
||||
MockScreen.displayName = 'MockCountryPickerScreen';
|
||||
return { __esModule: true, default: MockScreen };
|
||||
});
|
||||
129
app/tests/src/consts/links.test.ts
Normal file
129
app/tests/src/consts/links.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import * as links from '@/consts/links';
|
||||
|
||||
describe('links', () => {
|
||||
describe('URL format validation', () => {
|
||||
it('should export only valid HTTPS URLs', () => {
|
||||
const allLinks = Object.entries(links);
|
||||
|
||||
allLinks.forEach(([_name, url]) => {
|
||||
expect(url).toMatch(/^https:\/\/.+/);
|
||||
expect(url).not.toContain(' ');
|
||||
// Ensure no trailing slashes (consistency)
|
||||
expect(url).not.toMatch(/\/$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique URLs (no duplicates)', () => {
|
||||
const allUrls = Object.values(links);
|
||||
const uniqueUrls = new Set(allUrls);
|
||||
|
||||
expect(uniqueUrls.size).toBe(allUrls.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Critical URL validation', () => {
|
||||
it('should have correct Telegram URL', () => {
|
||||
expect(links.telegramUrl).toBe('https://t.me/selfxyz');
|
||||
});
|
||||
|
||||
it('should have correct Discord URL', () => {
|
||||
expect(links.discordUrl).toBe('https://discord.gg/selfxyz');
|
||||
});
|
||||
|
||||
it('should have correct GitHub URL', () => {
|
||||
expect(links.gitHubUrl).toBe('https://github.com/selfxyz/self');
|
||||
});
|
||||
|
||||
it('should have correct X (Twitter) URL', () => {
|
||||
expect(links.xUrl).toBe('https://x.com/selfprotocol');
|
||||
});
|
||||
|
||||
it('should have correct Self main URL', () => {
|
||||
expect(links.selfUrl).toBe('https://self.xyz');
|
||||
});
|
||||
|
||||
it('should have correct privacy policy URL', () => {
|
||||
expect(links.privacyUrl).toBe('https://self.xyz/privacy');
|
||||
});
|
||||
|
||||
it('should have correct terms URL', () => {
|
||||
expect(links.termsUrl).toBe('https://self.xyz/terms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Self platform URLs', () => {
|
||||
it('should use self.xyz domain for platform URLs', () => {
|
||||
expect(links.selfUrl).toContain('self.xyz');
|
||||
expect(links.privacyUrl).toContain('self.xyz');
|
||||
expect(links.termsUrl).toContain('self.xyz');
|
||||
expect(links.appsUrl).toContain('self.xyz');
|
||||
expect(links.referralBaseUrl).toContain('self.xyz');
|
||||
expect(links.apiBaseUrl).toContain('self.xyz');
|
||||
expect(links.pointsApiBaseUrl).toContain('self.xyz');
|
||||
expect(links.notificationApiUrl).toContain('self.xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App store URLs', () => {
|
||||
it('should have valid App Store URL', () => {
|
||||
expect(links.appStoreUrl).toMatch(/^https:\/\/apps\.apple\.com\//);
|
||||
expect(links.appStoreUrl).toContain('id6478563710');
|
||||
});
|
||||
|
||||
it('should have valid Play Store URL', () => {
|
||||
expect(links.playStoreUrl).toMatch(
|
||||
/^https:\/\/play\.google\.com\/store\//,
|
||||
);
|
||||
expect(links.playStoreUrl).toContain('com.proofofpassportapp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth URLs', () => {
|
||||
it('should have valid Google OAuth endpoints', () => {
|
||||
expect(links.googleOAuthAuthorizationEndpoint).toBe(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
);
|
||||
expect(links.googleOAuthTokenEndpoint).toBe(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have valid Turnkey redirect URIs', () => {
|
||||
expect(links.turnkeyOAuthRedirectAndroidUri).toContain('self.xyz');
|
||||
expect(links.turnkeyOAuthRedirectIosUri).toContain('turnkey.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export completeness', () => {
|
||||
it('should export at least 20 links', () => {
|
||||
// Ensures we don't accidentally remove links
|
||||
const linkCount = Object.keys(links).length;
|
||||
expect(linkCount).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('should have descriptive variable names', () => {
|
||||
const allLinks = Object.keys(links);
|
||||
|
||||
allLinks.forEach(name => {
|
||||
// Should be camelCase
|
||||
expect(name).toMatch(/^[a-z][a-zA-Z0-9]*$/);
|
||||
// Should end with Url, Uri, Endpoint, or Scope
|
||||
const isValid =
|
||||
name.endsWith('Url') ||
|
||||
name.endsWith('Uri') ||
|
||||
name.endsWith('Endpoint') ||
|
||||
name.endsWith('Scope');
|
||||
if (!isValid) {
|
||||
console.log(`Invalid variable name: ${name}`);
|
||||
}
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,32 +2,22 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
|
||||
import { useModal } from '@/hooks/useModal';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockGoBack = jest.fn();
|
||||
const mockGetState = jest.fn(() => ({
|
||||
routes: [{ name: 'Home' }, { name: 'Modal' }],
|
||||
}));
|
||||
|
||||
describe('useModal', () => {
|
||||
beforeEach(() => {
|
||||
(useNavigation as jest.Mock).mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
goBack: mockGoBack,
|
||||
getState: mockGetState,
|
||||
// Reset all mocks including the global navigationRef
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up the navigation ref mock with proper methods
|
||||
global.mockNavigationRef.isReady.mockReturnValue(true);
|
||||
global.mockNavigationRef.getState.mockReturnValue({
|
||||
routes: [{ name: 'Home' }, { name: 'Modal' }],
|
||||
index: 1,
|
||||
});
|
||||
mockNavigate.mockClear();
|
||||
mockGoBack.mockClear();
|
||||
mockGetState.mockClear();
|
||||
});
|
||||
|
||||
it('should navigate to Modal with callbackId and handle dismissal', () => {
|
||||
@@ -45,8 +35,10 @@ describe('useModal', () => {
|
||||
|
||||
act(() => result.current.showModal());
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
const params = mockNavigate.mock.calls[0][1];
|
||||
expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
|
||||
const [screenName, params] =
|
||||
global.mockNavigationRef.navigate.mock.calls[0];
|
||||
expect(screenName).toBe('Modal');
|
||||
expect(params).toMatchObject({
|
||||
titleText: 'Title',
|
||||
bodyText: 'Body',
|
||||
@@ -58,7 +50,7 @@ describe('useModal', () => {
|
||||
|
||||
act(() => result.current.dismissModal());
|
||||
|
||||
expect(mockGoBack).toHaveBeenCalled();
|
||||
expect(global.mockNavigationRef.goBack).toHaveBeenCalled();
|
||||
expect(onModalDismiss).toHaveBeenCalled();
|
||||
expect(getModalCallbacks(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { AppState } from 'react-native';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import { useModal } from '@/hooks/useModal';
|
||||
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
const navigationStateListeners: Array<() => void> = [];
|
||||
let isNavigationReady = true;
|
||||
// Use global appStateListeners from jest.setup.js mock
|
||||
const appStateListeners = global.mockAppStateListeners || [];
|
||||
|
||||
jest.mock('@/hooks/useModal');
|
||||
jest.mock('@/providers/passportDataProvider');
|
||||
jest.mock('@/navigation', () => ({
|
||||
navigationRef: {
|
||||
isReady: jest.fn(() => true),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
navigationRef: global.mockNavigationRef,
|
||||
}));
|
||||
// Use global react-native mock from jest.setup.js - no need to mock here
|
||||
|
||||
const showModal = jest.fn();
|
||||
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
|
||||
const getAllDocuments = jest.fn();
|
||||
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
|
||||
|
||||
const getAppState = (): {
|
||||
currentState: string;
|
||||
addEventListener: jest.Mock;
|
||||
} =>
|
||||
AppState as unknown as {
|
||||
currentState: string;
|
||||
addEventListener: jest.Mock;
|
||||
};
|
||||
|
||||
describe('useRecoveryPrompts', () => {
|
||||
beforeEach(() => {
|
||||
showModal.mockClear();
|
||||
getAllDocuments.mockResolvedValue({ doc1: {} as any });
|
||||
jest.clearAllMocks();
|
||||
navigationStateListeners.length = 0;
|
||||
appStateListeners.length = 0;
|
||||
isNavigationReady = true;
|
||||
|
||||
// Setup the global navigation ref mock
|
||||
global.mockNavigationRef.isReady.mockImplementation(
|
||||
() => isNavigationReady,
|
||||
);
|
||||
global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' });
|
||||
global.mockNavigationRef.addListener.mockImplementation(
|
||||
(_: string, callback: () => void) => {
|
||||
navigationStateListeners.push(callback);
|
||||
return () => {
|
||||
const index = navigationStateListeners.indexOf(callback);
|
||||
if (index >= 0) {
|
||||
navigationStateListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
|
||||
getAllDocuments.mockResolvedValue({
|
||||
doc1: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: true } as any,
|
||||
},
|
||||
});
|
||||
const mockAppState = getAppState();
|
||||
mockAppState.currentState = 'active';
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 0,
|
||||
homeScreenViewCount: 0,
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal on first login', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
it('does not show modal before the fifth home view', async () => {
|
||||
for (const count of [1, 2, 3, 4]) {
|
||||
showModal.mockClear();
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: count });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show modal when login count is 4', async () => {
|
||||
it('waits for navigation readiness before prompting', async () => {
|
||||
isNavigationReady = false;
|
||||
global.mockNavigationRef.isReady.mockImplementation(
|
||||
() => isNavigationReady,
|
||||
);
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 4 });
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
isNavigationReady = true;
|
||||
navigationStateListeners.forEach(listener => listener());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal on eighth login', async () => {
|
||||
it('respects custom allow list overrides', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 8 });
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
showModal.mockClear();
|
||||
global.mockNavigationRef.getCurrentRoute.mockReturnValue({
|
||||
name: 'Settings',
|
||||
});
|
||||
|
||||
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('prompts when returning from background on eligible route', async () => {
|
||||
// This test verifies that the hook registers an app state listener
|
||||
// and that the prompt logic can be triggered multiple times for different view counts
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
|
||||
const { rerender, unmount } = renderHook(() => useRecoveryPrompts());
|
||||
|
||||
// Wait for initial prompt
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Clear and test with a different login count that should trigger again
|
||||
showModal.mockClear();
|
||||
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 10 }); // next multiple of 5
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
// Wait for second prompt with new login count
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not show modal for non-multiple-of-five view counts', async () => {
|
||||
for (const count of [6, 7, 8, 9]) {
|
||||
showModal.mockClear();
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: count });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('shows modal on fifth home view', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
|
||||
|
||||
it('does not show modal if backup already enabled', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
|
||||
useSettingStore.setState({
|
||||
homeScreenViewCount: 5,
|
||||
cloudBackupEnabled: true,
|
||||
});
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show modal when navigation is not ready', async () => {
|
||||
const navigationRef = require('@/navigation').navigationRef;
|
||||
navigationRef.isReady.mockReturnValueOnce(false);
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
});
|
||||
it('does not show modal if already visible', async () => {
|
||||
(useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
|
||||
it('does not show modal when recovery phrase has been viewed', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 1,
|
||||
homeScreenViewCount: 5,
|
||||
hasViewedRecoveryPhrase: true,
|
||||
});
|
||||
});
|
||||
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
|
||||
it('does not show modal when no documents exist', async () => {
|
||||
getAllDocuments.mockResolvedValueOnce({});
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal for other valid login counts', async () => {
|
||||
for (const count of [2, 3, 13, 18]) {
|
||||
it('does not show modal when only unregistered documents exist', async () => {
|
||||
getAllDocuments.mockResolvedValueOnce({
|
||||
doc1: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: false } as any,
|
||||
},
|
||||
doc2: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: undefined } as any,
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal when registered documents exist', async () => {
|
||||
getAllDocuments.mockResolvedValueOnce({
|
||||
doc1: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: false } as any,
|
||||
},
|
||||
doc2: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: true } as any,
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal for other valid view counts (multiples of five)', async () => {
|
||||
for (const count of [5, 10, 15]) {
|
||||
showModal.mockClear();
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: count });
|
||||
useSettingStore.setState({ homeScreenViewCount: count });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -125,6 +284,32 @@ describe('useRecoveryPrompts', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show modal again for same login count when state changes', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
showModal.mockClear();
|
||||
|
||||
act(() => {
|
||||
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useSettingStore.setState({ hasViewedRecoveryPhrase: false });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns correct visible state', () => {
|
||||
const { result } = renderHook(() => useRecoveryPrompts());
|
||||
expect(result.current.visible).toBe(false);
|
||||
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
expect(useModal).toHaveBeenCalledWith({
|
||||
titleText: 'Protect your account',
|
||||
bodyText:
|
||||
bodyText: expect.stringContaining(
|
||||
'Enable cloud backup or save your recovery phrase so you can recover your account.',
|
||||
),
|
||||
buttonText: 'Back up now',
|
||||
onButtonPress: expect.any(Function),
|
||||
onModalDismiss: expect.any(Function),
|
||||
|
||||
107
app/tests/src/navigation.test.tsx
Normal file
107
app/tests/src/navigation.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
|
||||
jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn());
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
useSelfClient: jest.fn(() => ({})),
|
||||
}));
|
||||
jest.mock('@/navigation/deeplinks', () => ({
|
||||
setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
jest.mock('@/services/analytics', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
trackEvent: jest.fn(),
|
||||
trackScreenView: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct navigation screens', () => {
|
||||
// Unmock @/navigation for this test to get the real navigationScreens
|
||||
jest.unmock('@/navigation');
|
||||
jest.isolateModules(() => {
|
||||
const navigationScreens = require('@/navigation').navigationScreens;
|
||||
const listOfScreens = Object.keys(navigationScreens).sort();
|
||||
expect(listOfScreens).toEqual([
|
||||
'AadhaarUpload',
|
||||
'AadhaarUploadError',
|
||||
'AadhaarUploadSuccess',
|
||||
'AccountRecovery',
|
||||
'AccountRecoveryChoice',
|
||||
'AccountVerifiedSuccess',
|
||||
'CloudBackupSettings',
|
||||
'ComingSoon',
|
||||
'ConfirmBelonging',
|
||||
'CountryPicker',
|
||||
'CreateMock',
|
||||
'DeferredLinkingInfo',
|
||||
'DevFeatureFlags',
|
||||
'DevHapticFeedback',
|
||||
'DevLoadingScreen',
|
||||
'DevPrivateKey',
|
||||
'DevSettings',
|
||||
'Disclaimer',
|
||||
'DocumentCamera',
|
||||
'DocumentCameraTrouble',
|
||||
'DocumentDataInfo',
|
||||
'DocumentDataNotFound',
|
||||
'DocumentNFCMethodSelection',
|
||||
'DocumentNFCScan',
|
||||
'DocumentNFCTrouble',
|
||||
'DocumentOnboarding',
|
||||
'Gratification',
|
||||
'Home',
|
||||
'IDPicker',
|
||||
'IdDetails',
|
||||
'Loading',
|
||||
'ManageDocuments',
|
||||
'MockDataDeepLink',
|
||||
'Modal',
|
||||
'Points',
|
||||
'PointsInfo',
|
||||
'ProofHistory',
|
||||
'ProofHistoryDetail',
|
||||
'ProofRequestStatus',
|
||||
'Prove',
|
||||
'QRCodeTrouble',
|
||||
'QRCodeViewFinder',
|
||||
'RecoverWithPhrase',
|
||||
'Referral',
|
||||
'SaveRecoveryPhrase',
|
||||
'Settings',
|
||||
'ShowRecoveryPhrase',
|
||||
'Splash',
|
||||
'WebView',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('wires recovery prompts hook into navigation', () => {
|
||||
// Temporarily restore the React mock and unmock @/navigation for this test
|
||||
jest.unmock('@/navigation');
|
||||
const useRecoveryPrompts =
|
||||
require('@/hooks/useRecoveryPrompts') as jest.Mock;
|
||||
|
||||
// Since we're testing the wiring and not the actual rendering,
|
||||
// we can just check if the module exports the default component
|
||||
// and verify the hook is called when the component is imported
|
||||
const navigation = require('@/navigation');
|
||||
expect(navigation.default).toBeDefined();
|
||||
|
||||
// Render the component to trigger the hooks
|
||||
const NavigationWithTracking = navigation.default;
|
||||
render(<NavigationWithTracking />);
|
||||
|
||||
expect(useRecoveryPrompts).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -228,7 +228,7 @@ describe('deeplinks', () => {
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature';
|
||||
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature'; // gitleaks:allow
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
@@ -265,7 +265,7 @@ describe('deeplinks', () => {
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow
|
||||
handleUrl({} as SelfClient, url);
|
||||
|
||||
const { navigationRef } = require('@/navigation');
|
||||
@@ -530,7 +530,7 @@ describe('deeplinks', () => {
|
||||
|
||||
it('returns id_token and scope parameters', () => {
|
||||
const url =
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
|
||||
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow
|
||||
const result = parseAndValidateUrlParams(url);
|
||||
expect(result.id_token).toBeTruthy();
|
||||
expect(result.scope).toBe('email profile');
|
||||
|
||||
@@ -12,23 +12,27 @@ describe('provingUtils', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = encryptAES256GCM(plaintext, forge.util.createBuffer(key));
|
||||
|
||||
// Convert arrays to Uint8Array first to ensure proper byte conversion
|
||||
const nonceBytes = new Uint8Array(encrypted.nonce);
|
||||
const authTagBytes = new Uint8Array(encrypted.auth_tag);
|
||||
const cipherTextBytes = new Uint8Array(encrypted.cipher_text);
|
||||
|
||||
// Validate tag length (128 bits = 16 bytes)
|
||||
expect(authTagBytes.length).toBe(16);
|
||||
|
||||
const decipher = forge.cipher.createDecipher(
|
||||
'AES-GCM',
|
||||
forge.util.createBuffer(key),
|
||||
);
|
||||
decipher.start({
|
||||
iv: forge.util.createBuffer(
|
||||
Buffer.from(encrypted.nonce).toString('binary'),
|
||||
),
|
||||
iv: forge.util.createBuffer(Buffer.from(nonceBytes).toString('binary')),
|
||||
tagLength: 128,
|
||||
tag: forge.util.createBuffer(
|
||||
Buffer.from(encrypted.auth_tag).toString('binary'),
|
||||
Buffer.from(authTagBytes).toString('binary'),
|
||||
),
|
||||
});
|
||||
decipher.update(
|
||||
forge.util.createBuffer(
|
||||
Buffer.from(encrypted.cipher_text).toString('binary'),
|
||||
),
|
||||
forge.util.createBuffer(Buffer.from(cipherTextBytes).toString('binary')),
|
||||
);
|
||||
const success = decipher.finish();
|
||||
const decrypted = decipher.output.toString();
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { act } from '@testing-library/react-native';
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
describe('settingStore', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 0,
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('increments login count', () => {
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(1);
|
||||
});
|
||||
|
||||
it('increments login count multiple times', () => {
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(3);
|
||||
});
|
||||
|
||||
it('increments login count from non-zero initial value', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 5 });
|
||||
});
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(6);
|
||||
});
|
||||
|
||||
it('resets login count when recovery phrase viewed', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 2 });
|
||||
});
|
||||
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
|
||||
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not reset login count when setting recovery phrase viewed to false', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 3,
|
||||
hasViewedRecoveryPhrase: true,
|
||||
});
|
||||
});
|
||||
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
|
||||
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
|
||||
expect(useSettingStore.getState().loginCount).toBe(3);
|
||||
});
|
||||
|
||||
it('resets login count when enabling cloud backup', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
|
||||
});
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not reset login count when disabling cloud backup', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
|
||||
});
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
|
||||
expect(useSettingStore.getState().loginCount).toBe(4);
|
||||
});
|
||||
|
||||
it('handles sequential actions that reset login count', () => {
|
||||
// Increment login count
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(2);
|
||||
|
||||
// Toggle cloud backup (should reset to 0)
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
|
||||
// Increment again
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(1);
|
||||
|
||||
// Set recovery phrase viewed (should reset to 0)
|
||||
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
|
||||
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 5,
|
||||
hasViewedRecoveryPhrase: true,
|
||||
});
|
||||
});
|
||||
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
|
||||
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(5);
|
||||
});
|
||||
|
||||
it('handles complex sequence of mixed operations', () => {
|
||||
// Start with some increments
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
expect(useSettingStore.getState().loginCount).toBe(3);
|
||||
|
||||
// Disable cloud backup (should not reset)
|
||||
act(() => {
|
||||
useSettingStore.setState({ cloudBackupEnabled: true });
|
||||
});
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
|
||||
expect(useSettingStore.getState().loginCount).toBe(3);
|
||||
|
||||
// Set recovery phrase viewed to false (should not reset)
|
||||
act(() => {
|
||||
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
|
||||
});
|
||||
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
|
||||
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
|
||||
expect(useSettingStore.getState().loginCount).toBe(3);
|
||||
|
||||
// Enable cloud backup (should reset)
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
});
|
||||
|
||||
it('maintains login count when toggling cloud backup from true to false then back to true', () => {
|
||||
// Start with cloud backup enabled and some login count
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
|
||||
});
|
||||
|
||||
// Toggle to disable (should not reset)
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
|
||||
expect(useSettingStore.getState().loginCount).toBe(2);
|
||||
|
||||
// Toggle to enable (should reset)
|
||||
useSettingStore.getState().toggleCloudBackupEnabled();
|
||||
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
|
||||
expect(useSettingStore.getState().loginCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"ios": {
|
||||
"build": 190,
|
||||
"lastDeployed": "2025-11-19T05:35:50.564Z"
|
||||
"build": 192,
|
||||
"lastDeployed": "2025-12-05T00:06:05.459Z"
|
||||
},
|
||||
"android": {
|
||||
"build": 122,
|
||||
"lastDeployed": "2025-11-19T05:35:50.564Z"
|
||||
"build": 123,
|
||||
"lastDeployed": "2025-11-21T00:06:05.459Z"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ include "../utils/gcp_jwt/verifyCertificateSignature.circom";
|
||||
include "../utils/gcp_jwt/verifyJSONFieldExtraction.circom";
|
||||
include "circomlib/circuits/comparators.circom";
|
||||
include "@openpassport/zk-email-circuits/utils/array.circom";
|
||||
include "@openpassport/zk-email-circuits/utils/bytes.circom";
|
||||
|
||||
/// @title GCPJWTVerifier
|
||||
/// @notice Verifies GCP JWT signature and full x5c certificate chain
|
||||
@@ -70,11 +71,11 @@ template GCPJWTVerifier(
|
||||
|
||||
|
||||
// GCP spec: nonce must be 10-74 bytes decoded
|
||||
// Base64url encoding: 10 bytes = 14 chars, 74 bytes = 99 chars
|
||||
// https://cloud.google.com/confidential-computing/confidential-space/docs/connect-external-resources
|
||||
// EAT nonce (payload.eat_nonce[0])
|
||||
var MAX_EAT_NONCE_B64_LENGTH = 99; // Max length for base64url string (74 bytes decoded = 99 b64url chars)
|
||||
var MAX_EAT_NONCE_B64_LENGTH = 74; // Max length for base64url string (74 bytes decoded = 99 b64url chars)
|
||||
var MAX_EAT_NONCE_KEY_LENGTH = 10; // Length of "eat_nonce" key (without quotes)
|
||||
var EAT_NONCE_PACKED_CHUNKS = computeIntChunkLength(MAX_EAT_NONCE_B64_LENGTH);
|
||||
signal input eat_nonce_0_b64_length; // Length of base64url string
|
||||
signal input eat_nonce_0_key_offset; // Offset in payload where "eat_nonce" key starts (after opening quote)
|
||||
signal input eat_nonce_0_value_offset; // Offset in payload where eat_nonce[0] value appears
|
||||
@@ -83,6 +84,7 @@ template GCPJWTVerifier(
|
||||
var MAX_IMAGE_DIGEST_LENGTH = 71; // "sha256:" + 64 hex chars
|
||||
var IMAGE_HASH_LENGTH = 64; // Just the hex hash portion
|
||||
var MAX_IMAGE_DIGEST_KEY_LENGTH = 12; // Length of "image_digest" key (without quotes)
|
||||
var IMAGE_HASH_PACKED_CHUNKS = computeIntChunkLength(IMAGE_HASH_LENGTH);
|
||||
signal input image_digest_length; // Length of full string (should be 71)
|
||||
signal input image_digest_key_offset; // Offset in payload where "image_digest" key starts (after opening quote)
|
||||
signal input image_digest_value_offset; // Offset in payload where image_digest value appears
|
||||
@@ -91,8 +93,8 @@ template GCPJWTVerifier(
|
||||
var maxPayloadLength = (maxB64PayloadLength * 3) \ 4;
|
||||
|
||||
signal output rootCAPubkeyHash; // Root CA (x5c[2]) pubkey, trust anchor
|
||||
signal output eat_nonce_0_b64_output[MAX_EAT_NONCE_B64_LENGTH]; // eat_nonce[0] base64url string
|
||||
signal output image_hash[IMAGE_HASH_LENGTH]; // Container image SHA256 hash (without "sha256:" prefix)
|
||||
signal output eat_nonce_0_b64_packed[EAT_NONCE_PACKED_CHUNKS]; // eat_nonce[0] base64url string packed with PackBytes
|
||||
signal output image_hash_packed[IMAGE_HASH_PACKED_CHUNKS]; // Container image SHA256 hash (64 hex chars) packed with PackBytes
|
||||
|
||||
// Verify JWT Signature (using x5c[0] public key)
|
||||
component jwtVerifier = JWTVerifier(n, k, maxMessageLength, maxB64HeaderLength, maxB64PayloadLength);
|
||||
@@ -162,7 +164,7 @@ template GCPJWTVerifier(
|
||||
// Validate nonce maximum length (74 bytes decoded = 99 base64url chars)
|
||||
component length_max_check = LessEqThan(log2Ceil(MAX_EAT_NONCE_B64_LENGTH));
|
||||
length_max_check.in[0] <== eat_nonce_0_b64_length;
|
||||
length_max_check.in[1] <== 99;
|
||||
length_max_check.in[1] <== MAX_EAT_NONCE_B64_LENGTH;
|
||||
length_max_check.out === 1;
|
||||
|
||||
// Validate nonce offset bounds (prevent reading beyond payload)
|
||||
@@ -195,7 +197,7 @@ template GCPJWTVerifier(
|
||||
eatNonceExtractor.expected_key_name <== expected_eat_nonce_key;
|
||||
|
||||
// Output the extracted base64url string
|
||||
eat_nonce_0_b64_output <== eatNonceExtractor.extracted_value;
|
||||
eat_nonce_0_b64_packed <== PackBytes(MAX_EAT_NONCE_B64_LENGTH)(eatNonceExtractor.extracted_value);
|
||||
|
||||
// Validate length is exactly 71 ("sha256:" + 64 hex chars)
|
||||
image_digest_length === 71;
|
||||
@@ -244,9 +246,12 @@ template GCPJWTVerifier(
|
||||
extracted_image_digest[6] === 58; // ':'
|
||||
|
||||
// Extract and output only the 64-char hash (skip "sha256:" prefix)
|
||||
signal image_hash_bytes[IMAGE_HASH_LENGTH];
|
||||
for (var i = 0; i < IMAGE_HASH_LENGTH; i++) {
|
||||
image_hash[i] <== extracted_image_digest[7 + i];
|
||||
image_hash_bytes[i] <== extracted_image_digest[7 + i];
|
||||
}
|
||||
|
||||
image_hash_packed <== PackBytes(IMAGE_HASH_LENGTH)(image_hash_bytes);
|
||||
}
|
||||
|
||||
component main = GCPJWTVerifier(1, 120, 35);
|
||||
|
||||
@@ -251,10 +251,6 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Decode for verification/logging (not used in circuit)
|
||||
const eatNonce0Buffer = Buffer.from(eatNonce0Base64url, 'base64url');
|
||||
console.log(`[INFO] eat_nonce[0] decoded: ${eatNonce0Buffer.length} bytes`);
|
||||
|
||||
// Find offset of eat_nonce[0] in the decoded payload JSON
|
||||
// Decode the payload from base64url to get the exact JSON string
|
||||
const payloadJSON = Buffer.from(payloadB64, 'base64url').toString('utf8');
|
||||
@@ -285,6 +281,30 @@ async function main() {
|
||||
eatNonce0CharCodes[i] = eatNonce0Base64url.charCodeAt(i);
|
||||
}
|
||||
|
||||
const eatNonce1Base64url = payload.eat_nonce[1];
|
||||
console.log(`[INFO] eat_nonce[1] (base64url): ${eatNonce1Base64url}`);
|
||||
console.log(`[INFO] eat_nonce[1] string length: ${eatNonce1Base64url.length} characters`);
|
||||
|
||||
if (eatNonce1Base64url.length > MAX_EAT_NONCE_B64_LENGTH) {
|
||||
throw new Error(
|
||||
`[ERROR] eat_nonce[1] length ${eatNonce1Base64url.length} exceeds max ${MAX_EAT_NONCE_B64_LENGTH}`
|
||||
);
|
||||
}
|
||||
|
||||
const eatNonce1ValueOffset = payloadJSON.indexOf(eatNonce1Base64url);
|
||||
if (eatNonce1ValueOffset === -1) {
|
||||
console.error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON');
|
||||
console.error('[DEBUG] Payload JSON:', payloadJSON);
|
||||
console.error('[DEBUG] Looking for:', eatNonce1Base64url);
|
||||
throw new Error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON');
|
||||
}
|
||||
console.log(`[INFO] eat_nonce[1] value offset in payload: ${eatNonce1ValueOffset}`);
|
||||
|
||||
const eatNonce1CharCodes = new Array(MAX_EAT_NONCE_B64_LENGTH).fill(0);
|
||||
for (let i = 0; i < eatNonce1Base64url.length; i++) {
|
||||
eatNonce1CharCodes[i] = eatNonce1Base64url.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Extract image_digest from payload.submods.container.image_digest
|
||||
if (!payload.submods?.container?.image_digest) {
|
||||
throw new Error('[ERROR] No image_digest found in payload.submods.container');
|
||||
@@ -378,6 +398,9 @@ async function main() {
|
||||
eat_nonce_0_key_offset: eatNonce0KeyOffset.toString(),
|
||||
eat_nonce_0_value_offset: eatNonce0ValueOffset.toString(),
|
||||
|
||||
// EAT nonce[1] (circuit will extract value directly from payload)
|
||||
eat_nonce_1_b64_length: eatNonce1Base64url.length.toString(),
|
||||
|
||||
// Container image digest (circuit will extract value directly from payload)
|
||||
image_digest_length: imageDigest.length.toString(),
|
||||
image_digest_key_offset: imageDigestKeyOffset.toString(),
|
||||
|
||||
@@ -74,39 +74,30 @@ template ExtractAndVerifyJSONField(
|
||||
// Check character at colon+1: must be '[' (91) or space (32)
|
||||
signal char_after_colon <== ItemAtIndex(maxJSONLength)(json, colon_position + 1);
|
||||
|
||||
signal value_start <== ItemAtIndex(maxJSONLength)(json, value_offset);
|
||||
|
||||
// is_bracket: 1 if char is '[', 0 otherwise
|
||||
component is_bracket = IsEqual();
|
||||
is_bracket.in[0] <== char_after_colon;
|
||||
is_bracket.in[1] <== 91; // '['
|
||||
|
||||
// is_space: 1 if char is space, 0 otherwise
|
||||
component is_space = IsEqual();
|
||||
is_space.in[0] <== char_after_colon;
|
||||
is_space.in[1] <== 32; // ' '
|
||||
// is_quote: 1 if char is quote, 0 otherwise
|
||||
component is_quote = IsEqual();
|
||||
is_quote.in[0] <== char_after_colon;
|
||||
is_quote.in[1] <== 34; // "
|
||||
|
||||
// Exactly one must be true: char is either '[' or space
|
||||
is_bracket.out + is_space.out === 1;
|
||||
// Exactly one must be true: char is either [ or quote
|
||||
is_bracket.out + is_quote.out === 1;
|
||||
|
||||
// If bracket at colon+1: check quote at colon+2, value at colon+3
|
||||
// If space at colon+1: check bracket at colon+2, quote at colon+3, value at colon+4
|
||||
|
||||
// When is_bracket=1 (no space): expect quote at colon+2
|
||||
// When is_bracket=1 : expect quote at colon+2
|
||||
signal char_at_plus2 <== ItemAtIndex(maxJSONLength)(json, colon_position + 2);
|
||||
// When is_space=1: expect bracket at colon+2
|
||||
// Constraint: if is_bracket=1, char_at_plus2 must be quote(34)
|
||||
// if is_space=1, char_at_plus2 must be bracket(91)
|
||||
// if is_quote=1, char_at_plus2 must be value[0]
|
||||
is_bracket.out * (char_at_plus2 - 34) === 0; // If bracket at +1, quote at +2
|
||||
is_space.out * (char_at_plus2 - 91) === 0; // If space at +1, bracket at +2
|
||||
|
||||
// When is_space=1: check quote at colon+3
|
||||
signal char_at_plus3 <== ItemAtIndex(maxJSONLength)(json, colon_position + 3);
|
||||
is_space.out * (char_at_plus3 - 34) === 0; // If space at +1, quote at +3
|
||||
|
||||
// Enforce value_offset based on pattern
|
||||
// Pattern 1 (no space): :[" -> value at colon+3
|
||||
// Pattern 2 (space): : [" -> value at colon+4
|
||||
signal expected_value_offset <== colon_position + 3 + is_space.out;
|
||||
value_offset === expected_value_offset;
|
||||
component is_value_after_quote = IsEqual();
|
||||
is_value_after_quote.in[0] <== char_at_plus2;
|
||||
is_value_after_quote.in[1] <== value_start;
|
||||
is_quote.out * (1 - is_value_after_quote.out) === 0;
|
||||
|
||||
// Extract value from JSON and output directly
|
||||
extracted_value <== SelectSubArray(
|
||||
@@ -114,10 +105,20 @@ template ExtractAndVerifyJSONField(
|
||||
maxValueLength
|
||||
)(json, value_offset, value_length);
|
||||
|
||||
// Validate value ends with closing quote and bracket: "value"]
|
||||
// Validate value ends with closing quote and then either ']' or ',' after
|
||||
signal closing_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length);
|
||||
closing_quote === 34; // ASCII code for "
|
||||
|
||||
signal closing_bracket <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1);
|
||||
closing_bracket === 93; // ASCII code for ]
|
||||
// The character following the closing quote must be either ']' (93) or ',' (44)
|
||||
signal char_after_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1);
|
||||
component is_closing_bracket = IsEqual();
|
||||
is_closing_bracket.in[0] <== char_after_quote;
|
||||
is_closing_bracket.in[1] <== 93; // ']'
|
||||
|
||||
component is_comma = IsEqual();
|
||||
is_comma.in[0] <== char_after_quote;
|
||||
is_comma.in[1] <== 44; // ','
|
||||
|
||||
// Exactly one of the two must be true
|
||||
is_closing_bracket.out + is_comma.out === 1;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"test-disclose-id": "yarn test-base 'tests/disclose/vc_and_disclose_id.test.ts' --exit",
|
||||
"test-dsc": "yarn test-base --max-old-space-size=51200 'tests/dsc/dsc.test.ts' --exit",
|
||||
"test-ecdsa": "yarn test-base 'tests/utils/ecdsa.test.ts' --exit",
|
||||
"test-gcp-jwt-verifier": "yarn test-base 'tests/gcp_jwt_verifier/gcp_jwt_verifier.test.ts' --exit",
|
||||
"test-is-older-than": "yarn test-base 'tests/other_circuits/is_older_than.test.ts' --exit",
|
||||
"test-is-valid": "yarn test-base 'tests/other_circuits/is_valid.test.ts' --exit",
|
||||
"test-not-in-list": "yarn test-base 'tests/other_circuits/prove_country_is_not_in_list.test.ts' --exit",
|
||||
@@ -49,9 +50,9 @@
|
||||
"@zk-email/jwt-tx-builder-circuits": "0.1.0",
|
||||
"@zk-email/jwt-tx-builder-helpers": "0.1.0",
|
||||
"@zk-email/zk-regex-circom": "^1.2.1",
|
||||
"@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree",
|
||||
"@zk-kit/binary-merkle-root.circom": "npm:@selfxyz/binary-merkle-root.circom@^0.0.1",
|
||||
"@zk-kit/circuits": "^1.0.0-beta",
|
||||
"anon-aadhaar-circuits": "https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main",
|
||||
"anon-aadhaar-circuits": "https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits",
|
||||
"asn1": "^0.2.6",
|
||||
"asn1.js": "^5.4.1",
|
||||
"asn1js": "^3.0.5",
|
||||
|
||||
@@ -17,9 +17,28 @@ import nameAndYobAadhaarjson from '../consts/ofac/nameAndYobAadhaarSMT.json' wit
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// const privateKeyPath = path.join(__dirname, '../../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem');
|
||||
|
||||
// Dynamically resolve the anon-aadhaar-circuits package location
|
||||
function resolvePackagePath(packageName: string, subpath: string): string {
|
||||
try {
|
||||
// Try to resolve the package's package.json
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`, {
|
||||
paths: [__dirname],
|
||||
});
|
||||
const packageDir = path.dirname(packageJsonPath);
|
||||
return path.join(packageDir, subpath);
|
||||
} catch (error) {
|
||||
// Fallback to traditional node_modules search
|
||||
const modulePath = path.join(__dirname, '../../node_modules', packageName, subpath);
|
||||
if (fs.existsSync(modulePath)) {
|
||||
return modulePath;
|
||||
}
|
||||
throw new Error(`Could not resolve ${packageName}/${subpath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const privateKeyPem = fs.readFileSync(
|
||||
path.join(__dirname, '../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem'),
|
||||
resolvePackagePath('anon-aadhaar-circuits', 'assets/testPrivateKey.pem'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import forge from 'node-forge';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
|
||||
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';
|
||||
|
||||
@@ -9,6 +9,7 @@ This is the implementation of contracts for verification and management of ident
|
||||
When you do the upgrade, be careful with this storage patterns
|
||||
|
||||
- You can not change the order in which the contract state variables are declared, nor their type.
|
||||
- The upgradeable contracts currently target OpenZeppelin 5.x.
|
||||
|
||||
Pls see this page for more details:
|
||||
https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable#modifying-your-contracts
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
"@nomiclabs/hardhat-ethers": "^2.2.3",
|
||||
"@openpassport/zk-kit-lean-imt": "^0.0.6",
|
||||
"@openpassport/zk-kit-smt": "^0.0.1",
|
||||
"@openzeppelin/contracts": "^5.0.2",
|
||||
"@openzeppelin/contracts-upgradeable": "^5.1.0",
|
||||
"@openzeppelin/contracts": "5.4.0",
|
||||
"@openzeppelin/contracts-upgradeable": "5.4.0",
|
||||
"@selfxyz/common": "workspace:^",
|
||||
"@zk-kit/imt": "^2.0.0-beta.4",
|
||||
"@zk-kit/imt.sol": "^2.0.0-beta.12",
|
||||
|
||||
57
contribute.md
Normal file
57
contribute.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Contributing to Self
|
||||
|
||||
Thank you for your interest in contributing to Self! Please read the following guidelines before submitting your contribution.
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
**Do not open a public PR or GitHub issue for security bugs.**
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly by emailing **team@self.xyz**. This helps protect our users while we work on a fix. Security researchers may be eligible for a bounty.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
### What We Don't Accept
|
||||
|
||||
- **No README-only or typo-fix PRs** — We do not accept pull requests that only fix typos or update documentation. Focus your contributions on meaningful code changes.
|
||||
|
||||
### Branching Strategy
|
||||
|
||||
- **Always branch from `dev`** — All pull requests must be opened against the `dev` branch. PRs targeting `main` or other branches will be rejected.
|
||||
|
||||
### For Complex Features
|
||||
|
||||
- **Open an issue first** — If your contribution targets core components or introduces complex features, please start by opening an issue describing your implementation plan. This allows maintainers to provide feedback before you invest significant time in development.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Follow the naming conventions of the subrepo** — Each workspace (`app`, `circuits`, `common`, `contracts`, `sdk/*`, etc.) may have its own conventions. Review existing code in the target workspace and match its style.
|
||||
|
||||
### Formatting & Linting
|
||||
|
||||
Before submitting your PR, ensure your code passes all formatting and linting checks:
|
||||
|
||||
```bash
|
||||
# Format your code
|
||||
yarn format
|
||||
|
||||
# Run linting
|
||||
yarn lint
|
||||
|
||||
# For workspace-specific formatting (recommended)
|
||||
yarn workspaces foreach -A -p -v --topological-dev --since=origin/dev run nice --if-present
|
||||
```
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] Branch is based on `dev`
|
||||
- [ ] Code follows the naming conventions of the target workspace
|
||||
- [ ] `yarn lint` passes
|
||||
- [ ] `yarn format` has been run
|
||||
- [ ] For complex changes: issue was opened and discussed first
|
||||
- [ ] Commit messages are clear and follow conventional format
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure about anything, feel free to open an issue for discussion before starting work.
|
||||
@@ -4,5 +4,10 @@ path = ".gitleaks.toml"
|
||||
[allowlist]
|
||||
description = "Project-specific overrides"
|
||||
paths = [
|
||||
'''(?:^|/)Podfile\.lock$'''
|
||||
'''(?:^|/)Podfile\.lock$''',
|
||||
'''(?:^|/)app/src/services/points/constants\.ts$''',
|
||||
]
|
||||
regexes = [
|
||||
'''(?i)(?:token|key|address)[\w\s]*contract[\w\s]*=\s*['"]0x[a-f0-9]{40}['"]''',
|
||||
'''POINTS_TOKEN_CONTRACT_ADDRESS\s*=\s*['"]0x[a-f0-9]{40}['"]''',
|
||||
]
|
||||
|
||||
@@ -47,13 +47,16 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-passkey": "3.3.1"
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-passkey": "3.3.1",
|
||||
"react-native-webview": "13.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
"js-sha1": "^0.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-native": "0.76.9"
|
||||
"react-native": "0.76.9",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli-server-api": "^16.0.3",
|
||||
|
||||
@@ -134,10 +134,10 @@
|
||||
"build:ts-only": "tsup && yarn postbuild",
|
||||
"fmt": "prettier --check .",
|
||||
"fmt:fix": "prettier --write .",
|
||||
"format": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn nice; else yarn fmt:fix; fi'",
|
||||
"format": "yarn nice",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"nice": "yarn lint:fix && yarn fmt:fix",
|
||||
"nice": "yarn lint:fix",
|
||||
"prepublishOnly": "npm run build && npm run typecheck && npm run validate:exports && npm run validate:pkg",
|
||||
"report:exports": "node ./scripts/report-exports.mjs",
|
||||
"test": "vitest run",
|
||||
@@ -151,6 +151,7 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
"@selfxyz/common": "workspace:^",
|
||||
"@selfxyz/euclid": "^0.4.1",
|
||||
"@xstate/react": "^5.0.5",
|
||||
"node-forge": "^1.3.1",
|
||||
"react-native-nfc-manager": "^3.17.1",
|
||||
@@ -191,9 +192,11 @@
|
||||
"lottie-react-native": "7.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-blur-effect": "^1.1.3",
|
||||
"react-native-haptic-feedback": "*",
|
||||
"react-native-localize": "*",
|
||||
"react-native-svg": "*"
|
||||
"react-native-svg": "*",
|
||||
"react-native-webview": "^13.16.0"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -42,7 +42,7 @@ const optionalDefaults: Required<Pick<Adapters, 'clock' | 'logger'>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const;
|
||||
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents', 'navigation'] as const;
|
||||
|
||||
export const createListenersMap = (): {
|
||||
map: Map<SDKEvent, Set<(p: any) => void>>;
|
||||
@@ -212,7 +212,12 @@ export function createSelfClient({
|
||||
getMRZState: () => {
|
||||
return useMRZStore.getState();
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
adapters.navigation.goBack();
|
||||
},
|
||||
goTo: (routeName, params) => {
|
||||
adapters.navigation.goTo(routeName, params);
|
||||
},
|
||||
// for reactivity (if needed)
|
||||
useProvingStore,
|
||||
useSelfAppStore,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const BackupEvents = {
|
||||
CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED: 'Backup: Cloud Restore Failed: Passport Not Registered',
|
||||
CLOUD_RESTORE_FAILED_UNKNOWN: 'Backup: Cloud Restore Failed: Unknown Error',
|
||||
CLOUD_RESTORE_SUCCESS: 'Backup: Cloud Restore Success',
|
||||
TURNKEY_RESTORE_FAILED: 'Backup: Turnkey Restore Failed',
|
||||
CREATE_NEW_ACCOUNT: 'Backup: Create New Account',
|
||||
MANUAL_RECOVERY_SELECTED: 'Backup: Manual Recovery Selected',
|
||||
};
|
||||
|
||||
@@ -2,67 +2,26 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { memo, useCallback } from 'react';
|
||||
import { ActivityIndicator, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { commonNames } from '@selfxyz/common/constants/countries';
|
||||
import { CountryPickerScreen as CountryPickerUI } from '@selfxyz/euclid';
|
||||
|
||||
import { BodyText, RoundFlag, XStack, YStack } from '../../components';
|
||||
import { black, slate100, slate500 } from '../../constants/colors';
|
||||
import { advercase, dinot } from '../../constants/fonts';
|
||||
import { RoundFlag } from '../../components';
|
||||
import { useSelfClient } from '../../context';
|
||||
import { useCountries } from '../../documents/useCountries';
|
||||
import { buttonTap } from '../../haptic';
|
||||
import { SdkEvents } from '../../types/events';
|
||||
|
||||
interface CountryListItem {
|
||||
key: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 65;
|
||||
const FLAG_SIZE = 32;
|
||||
|
||||
const CountryItem = memo<{
|
||||
countryCode: string;
|
||||
onSelect: (code: string) => void;
|
||||
}>(({ countryCode, onSelect }) => {
|
||||
const countryName = commonNames[countryCode as keyof typeof commonNames];
|
||||
|
||||
if (!countryName) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={() => onSelect(countryCode)} style={styles.countryItemContainer}>
|
||||
<XStack style={styles.countryItemContent}>
|
||||
<RoundFlag countryCode={countryCode} size={FLAG_SIZE} />
|
||||
<BodyText style={styles.countryItemText}>{countryName}</BodyText>
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
CountryItem.displayName = 'CountryItem';
|
||||
|
||||
const Loading = () => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
);
|
||||
Loading.displayName = 'Loading';
|
||||
|
||||
const CountryPickerScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const { countryData, countryList, loading, userCountryCode, showSuggestion } = useCountries();
|
||||
|
||||
const onPressCountry = useCallback(
|
||||
const onCountrySelect = useCallback(
|
||||
(countryCode: string) => {
|
||||
buttonTap();
|
||||
// if (__DEV__) {
|
||||
// console.log('Selected country code:', countryCode);
|
||||
// console.log('Current countryData:', countryData);
|
||||
// console.log('Available country codes:', Object.keys(countryData));
|
||||
// }
|
||||
const documentTypes = countryData[countryCode];
|
||||
if (__DEV__) {
|
||||
console.log('documentTypes for', countryCode, ':', documentTypes);
|
||||
@@ -87,105 +46,34 @@ const CountryPickerScreen: React.FC = () => {
|
||||
[countryData, selfClient],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: CountryListItem }) => <CountryItem countryCode={item.countryCode} onSelect={onPressCountry} />,
|
||||
[onPressCountry],
|
||||
);
|
||||
const renderFlag = useCallback((countryCode: string, size: number) => {
|
||||
return <RoundFlag countryCode={countryCode} size={size} />;
|
||||
}, []);
|
||||
|
||||
const keyExtractor = useCallback((item: CountryListItem) => item.countryCode, []);
|
||||
const getCountryName = useCallback((countryCode: string) => {
|
||||
return commonNames[countryCode as keyof typeof commonNames] || countryCode;
|
||||
}, []);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<CountryListItem> | null | undefined, index: number) => ({
|
||||
length: ITEM_HEIGHT,
|
||||
offset: ITEM_HEIGHT * index,
|
||||
index,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const onSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<YStack flex={1} paddingTop="$4" paddingHorizontal="$4" backgroundColor={slate100}>
|
||||
<YStack marginTop="$4" marginBottom="$6">
|
||||
<BodyText style={styles.titleText}>Select the country that issued your ID</BodyText>
|
||||
<BodyText style={styles.subtitleText}>
|
||||
Self has support for over 300 ID types. You can select the type of ID in the next step
|
||||
</BodyText>
|
||||
</YStack>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<YStack flex={1}>
|
||||
{showSuggestion && (
|
||||
<YStack marginBottom="$2">
|
||||
<BodyText style={styles.sectionLabel}>SUGGESTION</BodyText>
|
||||
<CountryItem
|
||||
countryCode={userCountryCode as string /*safe due to showSuggestion*/}
|
||||
onSelect={onPressCountry}
|
||||
/>
|
||||
<BodyText style={styles.sectionLabelBottom}>SELECT AN ISSUING COUNTRY</BodyText>
|
||||
</YStack>
|
||||
)}
|
||||
<FlatList
|
||||
data={countryList}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
initialNumToRender={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
getItemLayout={getItemLayout}
|
||||
/>
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
<CountryPickerUI
|
||||
isLoading={loading}
|
||||
countries={countryList}
|
||||
onCountrySelect={onCountrySelect}
|
||||
suggestionCountryCode={userCountryCode ?? undefined}
|
||||
showSuggestion={!!showSuggestion}
|
||||
renderFlag={renderFlag}
|
||||
getCountryName={getCountryName}
|
||||
searchValue={searchValue}
|
||||
onClose={selfClient.goBack}
|
||||
onInfoPress={() => console.log('Info pressed TODO: Implement')}
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CountryPickerScreen.displayName = 'CountryPickerScreen';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
countryItemContainer: {
|
||||
paddingVertical: 13,
|
||||
},
|
||||
countryItemContent: {
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
countryItemText: {
|
||||
fontSize: 16,
|
||||
color: black,
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 29,
|
||||
fontFamily: advercase,
|
||||
color: black,
|
||||
},
|
||||
subtitleText: {
|
||||
fontSize: 16,
|
||||
color: slate500,
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 16,
|
||||
color: black,
|
||||
fontFamily: dinot,
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionLabelBottom: {
|
||||
fontSize: 16,
|
||||
color: black,
|
||||
fontFamily: dinot,
|
||||
letterSpacing: 0.8,
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default CountryPickerScreen;
|
||||
|
||||
@@ -79,7 +79,7 @@ export const DocumentCameraScreen = ({ onBack, onSuccess, safeAreaInsets }: Prop
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<Additional style={styles.disclaimer}>SELF WILL NOT CAPTURE AN IMAGE OF YOUR PASSPORT.</Additional>
|
||||
<Additional style={styles.disclaimer}>Self will not capture an image of your ID.</Additional>
|
||||
|
||||
<SecondaryButton trackEvent={PassportEvents.CAMERA_SCREEN_CLOSED} onPress={onBack ?? (() => {})}>
|
||||
Cancel
|
||||
|
||||
@@ -17,8 +17,10 @@ export type {
|
||||
MRZValidation,
|
||||
NFCScanResult,
|
||||
NFCScannerAdapter,
|
||||
NavigationAdapter,
|
||||
NetworkAdapter,
|
||||
Progress,
|
||||
RouteName,
|
||||
SelfClient,
|
||||
StorageAdapter,
|
||||
TrackEventParams,
|
||||
|
||||
@@ -199,6 +199,36 @@ export interface Adapters {
|
||||
auth: AuthAdapter;
|
||||
/** Required document persistence layer. Implementations must be idempotent. */
|
||||
documents: DocumentsAdapter;
|
||||
/** Required navigation adapter for handling screen transitions. */
|
||||
navigation: NavigationAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map these route names to your navigation configuration.
|
||||
* Includes all screens that the SDK may navigate to across host applications.
|
||||
*/
|
||||
export type RouteName =
|
||||
// Document acquisition flow
|
||||
| 'DocumentCamera'
|
||||
| 'DocumentOnboarding'
|
||||
| 'CountryPicker'
|
||||
| 'IDPicker'
|
||||
| 'DocumentNFCScan'
|
||||
| 'ManageDocuments'
|
||||
// Account/onboarding flow
|
||||
| 'Home'
|
||||
| 'AccountVerifiedSuccess'
|
||||
| 'AccountRecoveryChoice'
|
||||
| 'SaveRecoveryPhrase'
|
||||
// Error/fallback screens
|
||||
| 'ComingSoon'
|
||||
| 'DocumentDataNotFound'
|
||||
// Settings
|
||||
| 'Settings';
|
||||
|
||||
export interface NavigationAdapter {
|
||||
goBack(): void;
|
||||
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +314,8 @@ export interface SelfClient {
|
||||
scanNFC(opts: NFCScanOpts & { signal?: AbortSignal }): Promise<NFCScanResult>;
|
||||
/** Parses MRZ text and returns structured fields plus checksum metadata. */
|
||||
extractMRZInfo(mrz: string): MRZInfo;
|
||||
goBack(): void;
|
||||
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../src';
|
||||
import { createListenersMap, createSelfClient, SdkEvents } from '../src/index';
|
||||
import type { AuthAdapter } from '../src/types/public';
|
||||
import type { AuthAdapter, NavigationAdapter } from '../src/types/public';
|
||||
|
||||
describe('createSelfClient', () => {
|
||||
// Test eager validation during client creation
|
||||
@@ -27,21 +27,21 @@ describe('createSelfClient', () => {
|
||||
|
||||
it('throws when network adapter missing during creation', () => {
|
||||
// @ts-expect-error -- missing adapters
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth } })).toThrow(
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth, navigation } })).toThrow(
|
||||
'network adapter not provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when crypto adapter missing during creation', () => {
|
||||
// @ts-expect-error -- missing adapters
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth } })).toThrow(
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth, navigation } })).toThrow(
|
||||
'crypto adapter not provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when documents adapter missing during creation', () => {
|
||||
// @ts-expect-error -- missing adapters
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth } })).toThrow(
|
||||
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth, navigation } })).toThrow(
|
||||
'documents adapter not provided',
|
||||
);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe('createSelfClient', () => {
|
||||
it('creates client successfully with all required adapters', () => {
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner, network, crypto, documents, auth },
|
||||
adapters: { scanner, network, crypto, documents, auth, navigation },
|
||||
listeners: new Map(),
|
||||
});
|
||||
expect(client).toBeTruthy();
|
||||
@@ -59,7 +59,7 @@ describe('createSelfClient', () => {
|
||||
const scanMock = vi.fn().mockResolvedValue({ passportData: { mock: true } });
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
|
||||
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
|
||||
listeners: new Map(),
|
||||
});
|
||||
const result = await client.scanNFC({
|
||||
@@ -85,7 +85,7 @@ describe('createSelfClient', () => {
|
||||
const scanMock = vi.fn().mockRejectedValue(err);
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
|
||||
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
|
||||
listeners: new Map(),
|
||||
});
|
||||
await expect(
|
||||
@@ -106,7 +106,7 @@ describe('createSelfClient', () => {
|
||||
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner, network, crypto, documents, auth },
|
||||
adapters: { scanner, network, crypto, documents, auth, navigation },
|
||||
listeners: listeners.map,
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('createSelfClient', () => {
|
||||
it('parses MRZ via client', () => {
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner, network, crypto, documents, auth },
|
||||
adapters: { scanner, network, crypto, documents, auth, navigation },
|
||||
listeners: new Map(),
|
||||
});
|
||||
const sample = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
|
||||
@@ -149,6 +149,7 @@ describe('createSelfClient', () => {
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: {
|
||||
navigation,
|
||||
scanner,
|
||||
network,
|
||||
crypto,
|
||||
@@ -171,7 +172,7 @@ describe('createSelfClient', () => {
|
||||
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
|
||||
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
|
||||
listeners: new Map(),
|
||||
});
|
||||
|
||||
@@ -181,7 +182,7 @@ describe('createSelfClient', () => {
|
||||
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
|
||||
const client = createSelfClient({
|
||||
config: {},
|
||||
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
|
||||
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
|
||||
listeners: new Map(),
|
||||
});
|
||||
await expect(client.hasPrivateKey()).resolves.toBe(true);
|
||||
@@ -222,3 +223,8 @@ const documents: DocumentsAdapter = {
|
||||
saveDocument: async () => {},
|
||||
deleteDocument: async () => {},
|
||||
};
|
||||
|
||||
const navigation: NavigationAdapter = {
|
||||
goBack: vi.fn(),
|
||||
goTo: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -35,6 +35,10 @@ const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAda
|
||||
}),
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
goBack: () => {},
|
||||
goTo: (_routeName: string, _params?: Record<string, any>) => {},
|
||||
},
|
||||
scanner: {
|
||||
scan: async () => ({
|
||||
passportData: {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/* eslint-disable sort-exports/sort-exports */
|
||||
import type { NavigationAdapter } from 'src/types/public';
|
||||
|
||||
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../../src';
|
||||
|
||||
// Shared test data
|
||||
@@ -60,12 +62,18 @@ const mockAuth = {
|
||||
getPrivateKey: async () => 'stubbed-private-key',
|
||||
};
|
||||
|
||||
const mockNavigation: NavigationAdapter = {
|
||||
goBack: vi.fn(),
|
||||
goTo: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockAdapters = {
|
||||
scanner: mockScanner,
|
||||
network: mockNetwork,
|
||||
crypto: mockCrypto,
|
||||
documents: mockDocuments,
|
||||
auth: mockAuth,
|
||||
navigation: mockNavigation,
|
||||
};
|
||||
|
||||
// Shared test expectations
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"prebuild": "yarn workspace @selfxyz/mobile-sdk-alpha build",
|
||||
"build": "yarn prebuild && tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"clean": "rm -rf ios/build android/app/build android/build && cd android && ./gradlew clean && cd ..",
|
||||
"format": "prettier --write .",
|
||||
"format": "yarn nice",
|
||||
"ia": "yarn install-app",
|
||||
"install-app": "yarn install && yarn prebuild && cd ios && pod install && cd ..",
|
||||
"preios": "yarn prebuild",
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"nice": "yarn lint:fix && yarn format",
|
||||
"nice": "yarn lint:fix",
|
||||
"reinstall": "yarn clean && yarn install && yarn prebuild && cd ios && pod install && cd ..",
|
||||
"start": "react-native start",
|
||||
"test": "vitest run",
|
||||
@@ -42,6 +42,7 @@
|
||||
"lottie-react-native": "7.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-haptic-feedback": "^2.3.3",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
@@ -49,6 +50,7 @@
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"util": "^0.12.5"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createListenersMap,
|
||||
SdkEvents,
|
||||
type Adapters,
|
||||
type RouteName,
|
||||
type TrackEventParams,
|
||||
type WsConn,
|
||||
reactNativeScannerAdapter,
|
||||
@@ -18,8 +19,40 @@ import {
|
||||
|
||||
import { persistentDocumentsAdapter } from '../utils/documentStore';
|
||||
import { getOrCreateSecret } from '../utils/secureStorage';
|
||||
import type { ScreenName } from '../navigation/NavigationProvider';
|
||||
import { useNavigation } from '../navigation/NavigationProvider';
|
||||
|
||||
/**
|
||||
* Maps SDK RouteName values to demo app ScreenName values.
|
||||
* Routes not in this map are not supported in the demo app.
|
||||
*/
|
||||
const ROUTE_TO_SCREEN_MAP: Partial<Record<RouteName, ScreenName>> = {
|
||||
Home: 'Home',
|
||||
CountryPicker: 'CountrySelection',
|
||||
IDPicker: 'IDSelection',
|
||||
DocumentCamera: 'MRZ',
|
||||
DocumentNFCScan: 'NFC',
|
||||
ManageDocuments: 'Documents',
|
||||
AccountVerifiedSuccess: 'Success',
|
||||
// Routes not implemented in demo app:
|
||||
// 'DocumentOnboarding': null,
|
||||
// 'SaveRecoveryPhrase': null,
|
||||
// 'AccountRecoveryChoice': null,
|
||||
// 'ComingSoon': null,
|
||||
// 'DocumentDataNotFound': null,
|
||||
// 'Settings': null,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Translates SDK RouteName to demo app ScreenName.
|
||||
*
|
||||
* @param routeName - The route name from the SDK
|
||||
* @returns The corresponding demo app screen name, or null if not supported
|
||||
*/
|
||||
function translateRouteToScreen(routeName: RouteName): ScreenName | null {
|
||||
return ROUTE_TO_SCREEN_MAP[routeName] ?? null;
|
||||
}
|
||||
|
||||
const createFetch = () => {
|
||||
const fetchImpl = globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
@@ -129,6 +162,23 @@ export function SelfClientProvider({ children, onNavigate }: SelfClientProviderP
|
||||
},
|
||||
ws: createWsAdapter(),
|
||||
},
|
||||
navigation: {
|
||||
goBack: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
goTo: (routeName, params) => {
|
||||
const screenName = translateRouteToScreen(routeName);
|
||||
if (screenName) {
|
||||
// SDK passes generic Record<string, unknown>, but demo navigation expects specific types
|
||||
// This is safe because we control the route mapping
|
||||
navigation.navigate(screenName, params as any);
|
||||
} else {
|
||||
console.warn(
|
||||
`[SelfClientProvider] SDK route "${routeName}" is not mapped to a demo screen. Ignoring navigation request.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
documents: persistentDocumentsAdapter,
|
||||
crypto: {
|
||||
async hash(data: Uint8Array): Promise<Uint8Array> {
|
||||
|
||||
44
patches/ethereum-cryptography+2.2.1.patch
Normal file
44
patches/ethereum-cryptography+2.2.1.patch
Normal file
@@ -0,0 +1,44 @@
|
||||
diff --git a/node_modules/ethereum-cryptography/utils.js b/node_modules/ethereum-cryptography/utils.js
|
||||
index cedfa36..b494c49 100644
|
||||
--- a/node_modules/ethereum-cryptography/utils.js
|
||||
+++ b/node_modules/ethereum-cryptography/utils.js
|
||||
@@ -8,11 +8,18 @@ exports.bytesToUtf8 = bytesToUtf8;
|
||||
exports.hexToBytes = hexToBytes;
|
||||
exports.equalsBytes = equalsBytes;
|
||||
exports.wrapHash = wrapHash;
|
||||
-const _assert_1 = __importDefault(require("@noble/hashes/_assert"));
|
||||
+const assertModule = __importDefault(require("@noble/hashes/_assert"));
|
||||
const utils_1 = require("@noble/hashes/utils");
|
||||
-const assertBool = _assert_1.default.bool;
|
||||
+const assertBool = (assertModule.default && assertModule.default.bool) ||
|
||||
+ assertModule.bool ||
|
||||
+ ((value) => {
|
||||
+ if (typeof value !== "boolean")
|
||||
+ throw new TypeError(`Expected boolean, not ${value}`);
|
||||
+ });
|
||||
exports.assertBool = assertBool;
|
||||
-const assertBytes = _assert_1.default.bytes;
|
||||
+const assertBytes = (assertModule.default && assertModule.default.bytes) ||
|
||||
+ assertModule.bytes ||
|
||||
+ assertModule.abytes;
|
||||
exports.assertBytes = assertBytes;
|
||||
var utils_2 = require("@noble/hashes/utils");
|
||||
Object.defineProperty(exports, "bytesToHex", { enumerable: true, get: function () { return utils_2.bytesToHex; } });
|
||||
diff --git a/node_modules/ethereum-cryptography/esm/utils.js b/node_modules/ethereum-cryptography/esm/utils.js
|
||||
index 8e771ea..b3eed9d 100644
|
||||
--- a/node_modules/ethereum-cryptography/esm/utils.js
|
||||
+++ b/node_modules/ethereum-cryptography/esm/utils.js
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from "@noble/hashes/_assert";
|
||||
import { hexToBytes as _hexToBytes } from "@noble/hashes/utils";
|
||||
-const assertBool = assert.bool;
|
||||
-const assertBytes = assert.bytes;
|
||||
+const assertBool = (assert?.bool) ||
|
||||
+ ((value) => {
|
||||
+ if (typeof value !== "boolean")
|
||||
+ throw new TypeError(`Expected boolean, not ${value}`);
|
||||
+ });
|
||||
+const assertBytes = assert.bytes || assert.abytes;
|
||||
export { assertBool, assertBytes };
|
||||
export { bytesToHex, bytesToHex as toHex, concatBytes, createView, utf8ToBytes } from "@noble/hashes/utils";
|
||||
// buf.toString('utf8') -> bytesToUtf8(buf)
|
||||
@@ -90,18 +90,46 @@ if (!isExecutableAvailableOnPath('patch-package')) {
|
||||
|
||||
// Run patch-package with better error handling
|
||||
try {
|
||||
const rootPatchRun = spawnSync('patch-package', ['--patch-dir', 'patches'], {
|
||||
cwd: repositoryRootPath,
|
||||
shell: true,
|
||||
stdio: isCI ? 'pipe' : 'inherit',
|
||||
timeout: 30000
|
||||
});
|
||||
if (rootPatchRun.status === 0) {
|
||||
if (!isCI) console.log('✓ Patches applied to root workspace');
|
||||
} else {
|
||||
const errorOutput = rootPatchRun.stderr?.toString() || rootPatchRun.stdout?.toString() || '';
|
||||
console.error(`patch-package failed for root workspace (exit code ${rootPatchRun.status})`);
|
||||
if (errorOutput) console.error(errorOutput);
|
||||
if (!isCI) process.exit(1);
|
||||
}
|
||||
|
||||
// Also patch app/node_modules if it exists
|
||||
const appPath = path.join(repositoryRootPath, 'app');
|
||||
const appNodeModules = path.join(appPath, 'node_modules');
|
||||
if (fs.existsSync(appNodeModules)) {
|
||||
const appPatchRun = spawnSync('patch-package', ['--patch-dir', '../patches'], {
|
||||
cwd: appPath,
|
||||
// Workspaces with isolated node_modules due to limited hoisting
|
||||
const workspaceRoots = [
|
||||
{ name: 'app', path: path.join(repositoryRootPath, 'app') },
|
||||
{ name: 'contracts', path: path.join(repositoryRootPath, 'contracts') }
|
||||
];
|
||||
|
||||
for (const workspace of workspaceRoots) {
|
||||
const workspaceNodeModules = path.join(workspace.path, 'node_modules');
|
||||
if (!fs.existsSync(workspaceNodeModules)) continue;
|
||||
|
||||
const workspacePatchRun = spawnSync('patch-package', ['--patch-dir', '../patches'], {
|
||||
cwd: workspace.path,
|
||||
shell: true,
|
||||
stdio: isCI ? 'pipe' : 'inherit',
|
||||
timeout: 30000
|
||||
});
|
||||
if (appPatchRun.status === 0 && !isCI) {
|
||||
console.log('✓ Patches applied to app workspace');
|
||||
|
||||
if (workspacePatchRun.status === 0) {
|
||||
if (!isCI) console.log(`✓ Patches applied to ${workspace.name} workspace`);
|
||||
} else {
|
||||
const errorOutput = workspacePatchRun.stderr?.toString() || workspacePatchRun.stdout?.toString() || '';
|
||||
console.error(`patch-package failed for ${workspace.name} workspace (exit code ${workspacePatchRun.status})`);
|
||||
if (errorOutput) console.error(errorOutput);
|
||||
if (!isCI) process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -97,7 +97,7 @@ const SelfQRcode = ({
|
||||
return <BounceLoader loading={true} size={200} color="#94FBAB" />;
|
||||
case QRcodeSteps.PROOF_GENERATION_FAILED:
|
||||
return (
|
||||
//@ts-ignore
|
||||
// @ts-expect-error Lottie typings don't match the default export shape
|
||||
<LottieComponent
|
||||
animationData={X_ANIMATION}
|
||||
style={{ width: 200, height: 200 }}
|
||||
@@ -109,7 +109,7 @@ const SelfQRcode = ({
|
||||
);
|
||||
case QRcodeSteps.PROOF_VERIFIED:
|
||||
return (
|
||||
//@ts-ignore
|
||||
// @ts-expect-error Lottie typings don't match the default export shape
|
||||
<LottieComponent
|
||||
animationData={CHECK_ANIMATION}
|
||||
style={{ width: 200, height: 200 }}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"prepublishOnly": "yarn build",
|
||||
"publish": "yarn npm publish --access public",
|
||||
"test": "echo 'no tests found'",
|
||||
"types": "yarn build"
|
||||
"types": "yarn workspace @selfxyz/sdk-common build && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@selfxyz/sdk-common": "workspace:^",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@selfxyz/sdk-common",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"keywords": [],
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
@@ -18,20 +18,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"files": [
|
||||
"./dist/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,4 +291,4 @@ For support and questions:
|
||||
|
||||
- Create an issue in this repository
|
||||
- Check the [Self Protocol documentation](https://docs.self.id)
|
||||
- Join our [Discord community](https://discord.gg/worldcoin)
|
||||
- Join our [Discord community](https://discord.gg/selfxyz)
|
||||
|
||||
73
yarn.lock
73
yarn.lock
@@ -5836,19 +5836,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@openzeppelin/contracts-upgradeable@npm:^5.1.0":
|
||||
version: 5.5.0
|
||||
resolution: "@openzeppelin/contracts-upgradeable@npm:5.5.0"
|
||||
"@openzeppelin/contracts-upgradeable@npm:5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "@openzeppelin/contracts-upgradeable@npm:5.4.0"
|
||||
peerDependencies:
|
||||
"@openzeppelin/contracts": 5.5.0
|
||||
checksum: 10c0/66a0050e8e4d2d68acbf14e272387a1b859a57353d32ac929389b40660d0bc1ff590c904f464f20e307a6632cfcaf86a9400e6c83bc466f5d174595bfc5e0e31
|
||||
"@openzeppelin/contracts": 5.4.0
|
||||
checksum: 10c0/0bf78ad1a42151eb41eb7ff6645714a65649766643514b704d398927fe72533d2a143557306610db93f625124dfef9ee06b2c9ca624ada16074962cbcb622b6b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@openzeppelin/contracts@npm:^5.0.2":
|
||||
version: 5.5.0
|
||||
resolution: "@openzeppelin/contracts@npm:5.5.0"
|
||||
checksum: 10c0/884ab2850ff380c11d042ce50ebecf8fad2f784105554d8826a4160126d17e96b8a636400e5640ac227d7e8f8754477835ead8acb2668355b83e2509ca7fca63
|
||||
"@openzeppelin/contracts@npm:5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "@openzeppelin/contracts@npm:5.4.0"
|
||||
checksum: 10c0/7e05646dee4905fea4493f0fb2ad208abaff84b9f5ef1b9e1a5fc61d0e74d9bc30759bdf0d09a08049cfef6a290623330d83469eadc990a7bba6c2cd09a27e1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7577,9 +7577,9 @@ __metadata:
|
||||
"@zk-email/jwt-tx-builder-circuits": "npm:0.1.0"
|
||||
"@zk-email/jwt-tx-builder-helpers": "npm:0.1.0"
|
||||
"@zk-email/zk-regex-circom": "npm:^1.2.1"
|
||||
"@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree"
|
||||
"@zk-kit/binary-merkle-root.circom": "npm:@selfxyz/binary-merkle-root.circom@^0.0.1"
|
||||
"@zk-kit/circuits": "npm:^1.0.0-beta"
|
||||
anon-aadhaar-circuits: "https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main"
|
||||
anon-aadhaar-circuits: "https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits"
|
||||
asn1: "npm:^0.2.6"
|
||||
asn1.js: "npm:^5.4.1"
|
||||
asn1js: "npm:^3.0.5"
|
||||
@@ -7678,8 +7678,8 @@ __metadata:
|
||||
"@nomiclabs/hardhat-ethers": "npm:^2.2.3"
|
||||
"@openpassport/zk-kit-lean-imt": "npm:^0.0.6"
|
||||
"@openpassport/zk-kit-smt": "npm:^0.0.1"
|
||||
"@openzeppelin/contracts": "npm:^5.0.2"
|
||||
"@openzeppelin/contracts-upgradeable": "npm:^5.1.0"
|
||||
"@openzeppelin/contracts": "npm:5.4.0"
|
||||
"@openzeppelin/contracts-upgradeable": "npm:5.4.0"
|
||||
"@selfxyz/common": "workspace:^"
|
||||
"@typechain/ethers-v6": "npm:^0.4.3"
|
||||
"@typechain/hardhat": "npm:^8.0.3"
|
||||
@@ -7750,6 +7750,19 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@selfxyz/euclid@npm:^0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "@selfxyz/euclid@npm:0.4.1"
|
||||
peerDependencies:
|
||||
react: ">=18.2.0"
|
||||
react-native: ">=0.72.0"
|
||||
react-native-blur-effect: ^1.1.3
|
||||
react-native-svg: ">=15.14.0"
|
||||
react-native-webview: ^13.16.0
|
||||
checksum: 10c0/f25a30b936d5ab1c154008296c64e0b4f97d91cf16e420b9bc3d2f4d9196ae426d1c2b28af653e36a9a580f78a42953f6bca3e9e9fbb36a15860636b9a0cb5fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@selfxyz/mobile-app@workspace:app":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@selfxyz/mobile-app@workspace:app"
|
||||
@@ -7862,6 +7875,7 @@ __metadata:
|
||||
react-native: "npm:0.76.9"
|
||||
react-native-app-auth: "npm:^8.0.3"
|
||||
react-native-biometrics: "npm:^3.0.1"
|
||||
react-native-blur-effect: "npm:^1.1.3"
|
||||
react-native-check-version: "npm:^1.3.0"
|
||||
react-native-cloud-storage: "npm:^2.2.2"
|
||||
react-native-device-info: "npm:^14.0.4"
|
||||
@@ -7909,6 +7923,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.28.3"
|
||||
"@selfxyz/common": "workspace:^"
|
||||
"@selfxyz/euclid": "npm:^0.4.1"
|
||||
"@testing-library/react": "npm:^14.1.2"
|
||||
"@types/react": "npm:^18.3.4"
|
||||
"@types/react-dom": "npm:^18.3.0"
|
||||
@@ -7946,9 +7961,11 @@ __metadata:
|
||||
lottie-react-native: 7.2.2
|
||||
react: ^18.3.1
|
||||
react-native: 0.76.9
|
||||
react-native-blur-effect: ^1.1.3
|
||||
react-native-haptic-feedback: "*"
|
||||
react-native-localize: "*"
|
||||
react-native-svg: "*"
|
||||
react-native-webview: ^13.16.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -14121,12 +14138,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree":
|
||||
version: 1.0.0
|
||||
resolution: "@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree"
|
||||
"@zk-kit/binary-merkle-root.circom@npm:@selfxyz/binary-merkle-root.circom@^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "@selfxyz/binary-merkle-root.circom@npm:0.0.1"
|
||||
dependencies:
|
||||
circomlib: "npm:^2.0.5"
|
||||
checksum: 10c0/f7bef284bae30b261610c5e2791c8aa7cc6ab42f5b4caf1e4b251543f506989dfb27d27963979a1db2c67b9c3d3561ce287fa8e0391bddee567c62963cd8f743
|
||||
checksum: 10c0/616426859e67702ef61c28651e38ebf218d560af13630e183f034f8cc66f40e01fa3b7ecb4ab5ed41ceb9c1f0ec8b3e62bdf7f184de38f1d7a8d7d1058a04fd5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14505,13 +14522,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main":
|
||||
"anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits":
|
||||
version: 2.4.3
|
||||
resolution: "anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main"
|
||||
resolution: "anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#workspace=%40anon-aadhaar%2Fcircuits&commit=1b9efa501cff3cf25dc260b060bf611229e316a4"
|
||||
dependencies:
|
||||
"@anon-aadhaar/core": "npm:^2.4.3"
|
||||
"@zk-email/circuits": "npm:^6.1.1"
|
||||
checksum: 10c0/93138d1c251988402482f1719ed37764b962250a51deb67bf5b855b91a6f89df2776ffe6135e8accc7a0d57dd13e7c210fc02fc6562af249ea4305f24d7d55f4
|
||||
checksum: 10c0/1e092f002e6a413fd034016320eedfb789158996f707d0c8c2055450baa35660fd90657e34e05c4a23094ed397e9088b6e9feb3463287bf9a0b272cc1fde592f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -25613,6 +25630,7 @@ __metadata:
|
||||
react: "npm:^18.3.1"
|
||||
react-dom: "npm:^18.3.1"
|
||||
react-native: "npm:0.76.9"
|
||||
react-native-blur-effect: "npm:1.1.3"
|
||||
react-native-get-random-values: "npm:^1.11.0"
|
||||
react-native-haptic-feedback: "npm:^2.3.3"
|
||||
react-native-keychain: "npm:^10.0.0"
|
||||
@@ -25621,6 +25639,7 @@ __metadata:
|
||||
react-native-svg: "npm:15.12.1"
|
||||
react-native-svg-transformer: "npm:^1.5.1"
|
||||
react-native-vector-icons: "npm:^10.3.0"
|
||||
react-native-webview: "npm:13.16.0"
|
||||
stream-browserify: "npm:^3.0.0"
|
||||
typescript: "npm:^5.9.2"
|
||||
util: "npm:^0.12.5"
|
||||
@@ -28332,6 +28351,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-blur-effect@npm:1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "react-native-blur-effect@npm:1.1.3"
|
||||
peerDependencies:
|
||||
react: ^17.0.2
|
||||
react-native: ^0.66.4
|
||||
react-native-webview: ^13.6.2
|
||||
checksum: 10c0/5036214ac36fd430c7cea41bf0f14b2aa18338ae7f3e5df4142775dd4462f26ea3bc53710397bfe01c3a2c4450c219978f86dbc5d1989deefa39ca3c4ac80bb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-check-version@npm:^1.3.0":
|
||||
version: 1.4.0
|
||||
resolution: "react-native-check-version@npm:1.4.0"
|
||||
@@ -28716,7 +28746,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-webview@npm:^13.16.0":
|
||||
"react-native-webview@npm:13.16.0":
|
||||
version: 13.16.0
|
||||
resolution: "react-native-webview@npm:13.16.0"
|
||||
dependencies:
|
||||
@@ -29936,6 +29966,7 @@ __metadata:
|
||||
react: "npm:^18.3.1"
|
||||
react-native: "npm:0.76.9"
|
||||
typescript: "npm:^5.9.2"
|
||||
uuid: "npm:^13.0.0"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
||||
Reference in New Issue
Block a user