Compare commits

..

No commits in common. "master" and "v7.2.6" have entirely different histories.

509 changed files with 16560 additions and 51313 deletions

View File

@ -33,7 +33,7 @@
"simulator": {
"type": "ios.simulator",
"device": {
"type": "iPhone 17"
"type": "iPhone 16"
}
},
"emulator": {
@ -44,10 +44,6 @@
}
},
"configurations": {
"ios.debug": {
"device": "simulator",
"app": "ios.debug"
},
"ios.release": {
"device": "simulator",
"app": "ios.release"

View File

@ -21,10 +21,6 @@
"react-native/no-unused-styles": "error",
"react/no-is-mounted": "off",
"react-native/no-single-element-style-arrays": "error",
"react-hooks/refs": "off",
"react-hooks/immutability": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
"prettier/prettier": [
"warn",
{

View File

@ -16,7 +16,7 @@ Please provide:
* your phone model and OS version
* BlueWallet app version (settings->about->scroll down)
* self-test passes? Open settings->about->scroll down, tap "Run self-test"
* unique ID for our crash reporting service (option 1: settings->about->scroll down, tap "copy")(option 2: open the settings app->apps->BlueWallet,double tap the unique id text field and select copy)
* unique ID for our crash reporting service (settings->about->scroll down, tap "copy")
## Proposing a feature?

View File

@ -10,13 +10,9 @@ on:
- master
workflow_dispatch:
concurrency:
group: build-ios-release-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-26
runs-on: macos-15
timeout-minutes: 180
outputs:
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
@ -30,7 +26,7 @@ jobs:
steps:
- name: Checkout Project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
with:
fetch-depth: 0 # Ensures the full Git history is
@ -69,12 +65,13 @@ jobs:
echo "Branch Name: ${{ env.CURRENT_BRANCH }}"
- name: Specify Node.js Version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest
@ -208,9 +205,9 @@ jobs:
echo -e "\033[1;34m======================================================\033[0m"
- name: Set Up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
ruby-version: 3.1.6
- name: System Debug Information
run: |
@ -247,21 +244,7 @@ jobs:
- name: Install Node Modules
run: npm ci --omit=dev --yes
- name: Cache CocoaPods
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-ios-release-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-ios-release-
- name: Install CocoaPods Dependencies
env:
RCT_USE_RN_DEP: "1"
RCT_USE_PREBUILT_RNCORE: "1"
run: |
bundle exec fastlane ios install_pods
echo "CocoaPods dependencies installed successfully"
@ -447,7 +430,7 @@ jobs:
- name: Upload Build Logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v6
with:
name: build_logs
path: ./ios/build_logs/
@ -468,7 +451,7 @@ jobs:
- name: Upload IPA as Artifact
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v6
with:
name: BlueWallet_IPA
path: ${{ env.IPA_OUTPUT_PATH }}
@ -480,7 +463,7 @@ jobs:
testflight-upload:
needs: build
runs-on: macos-26
runs-on: macos-15
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
@ -490,20 +473,20 @@ jobs:
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
steps:
- name: Checkout Project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Set Up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
ruby-version: 3.1.6
- name: Install Dependencies with Bundler
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Download IPA from Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@v5
with:
name: BlueWallet_IPA
path: ./
@ -547,7 +530,7 @@ jobs:
- name: Post PR Comment
if: success() && github.event_name == 'pull_request'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@v7
env:
BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}

View File

@ -1,187 +0,0 @@
name: Build Mac Catalyst
on:
workflow_dispatch:
pull_request:
branches:
- master
types: [labeled, synchronize]
concurrency:
group: catalyst-build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
build:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.action == 'labeled' && (github.event.label.name == 'mac-dmg' || github.event.label.name == 'testflight')) ||
github.event.action == 'synchronize'
runs-on: macos-15
timeout-minutes: 120
steps:
- name: Check PR labels
if: github.event_name == 'pull_request'
id: labels
env:
GH_TOKEN: ${{ github.token }}
run: |
LABELS=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" --jq '.[].name' | tr '\n' ',')
echo "all=${LABELS}" >> $GITHUB_OUTPUT
if [[ "$LABELS" == *"mac-dmg"* ]]; then
echo "has_mac_dmg=true" >> $GITHUB_OUTPUT
else
echo "has_mac_dmg=false" >> $GITHUB_OUTPUT
fi
if [[ "$LABELS" == *"testflight"* ]] && [[ "$LABELS" == *"mac-dmg"* ]]; then
echo "upload_testflight=true" >> $GITHUB_OUTPUT
else
echo "upload_testflight=false" >> $GITHUB_OUTPUT
fi
echo "Labels on PR: ${LABELS}"
- name: Skip if mac-dmg label not present
if: github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg != 'true'
run: |
echo "mac-dmg label not found on PR — skipping build."
exit 0
- name: Checkout project
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Setup Node.js
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Setup Xcode
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: latest
- name: Set up Ruby
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: 3.4.9
bundler-cache: true
- name: Install Node modules
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
run: npm ci
- name: Cache CocoaPods
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-catalyst-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-catalyst-
- name: Install CocoaPods dependencies
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
env:
SKIP_APP_STORE_CONNECT_AUTH: '1'
RCT_USE_RN_DEP: "1"
RCT_USE_PREBUILT_RNCORE: "1"
run: bundle exec fastlane ios install_pods
- name: Create temporary keychain for signing
if: (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true') && steps.labels.outputs.upload_testflight == 'true'
run: |
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
- name: Build Mac Catalyst app with Fastlane
if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true'
id: build_catalyst
run: bundle exec fastlane ios build_catalyst_app_lane
env:
SKIP_APP_STORE_CONNECT_AUTH: '1'
SKIP_CLEAR_DERIVED_DATA: '1'
CATALYST_SIGNING_IDENTITY: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_SIGNING_IDENTITY || '' }}
CATALYST_TEAM_ID: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_TEAM_ID || '' }}
GIT_URL: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_URL || '' }}
GIT_ACCESS_TOKEN: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_ACCESS_TOKEN || '' }}
MATCH_READONLY: ${{ steps.labels.outputs.upload_testflight == 'true' && 'false' || 'true' }}
KEYCHAIN_NAME: ${{ steps.labels.outputs.upload_testflight == 'true' && 'build' || '' }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Upload Mac Catalyst DMG
id: upload_dmg
if: success() && (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: BlueWallet-Mac-Catalyst
path: ${{ steps.build_catalyst.outputs.catalyst_dmg_path }}
if-no-files-found: warn
- name: Create App Store Connect API Key JSON
if: success() && steps.labels.outputs.upload_testflight == 'true'
run: echo '${{ secrets.APPLE_API_KEY_CONTENT }}' > ./appstore_api_key.json
- name: Upload to TestFlight
if: success() && steps.labels.outputs.upload_testflight == 'true'
run: bundle exec fastlane ios upload_catalyst_to_testflight
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
CATALYST_TEAM_ID: ${{ secrets.CATALYST_TEAM_ID }}
TEAM_ID: ${{ secrets.TEAM_ID }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
LATEST_COMMIT_MESSAGE: ${{ github.event.pull_request.title || 'Manual build' }}
- name: Cleanup App Store Connect API Key JSON
if: always() && steps.labels.outputs.upload_testflight == 'true'
run: rm -f ./appstore_api_key.json
- name: Cleanup temporary keychain
if: always() && steps.labels.outputs.upload_testflight == 'true'
run: security delete-keychain build.keychain || true
- name: Comment on PR with DMG link
if: success() && github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg == 'true'
env:
GH_TOKEN: ${{ github.token }}
UPLOADED_TO_TF: ${{ steps.labels.outputs.upload_testflight }}
run: |
ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/${{ steps.upload_dmg.outputs.artifact-id }}"
COMMENT_TAG="<!-- catalyst-dmg-link -->"
TF_LINE=""
if [[ "$UPLOADED_TO_TF" == "true" ]]; then
TF_LINE=$'\n\n**Also uploaded to TestFlight.** Check [App Store Connect](https://appstoreconnect.apple.com) for the build.'
fi
COMMENT_FILE="$(mktemp)"
{
printf '%s\n' "${COMMENT_TAG}"
printf '### Mac Catalyst Build\n\n'
printf 'The Mac Catalyst DMG is ready for download:\n\n'
printf '[Download BlueWallet-Mac-Catalyst.dmg](%s)\n' "${ARTIFACT_URL}"
if [[ -n "$TF_LINE" ]]; then
printf '%s\n' "${TF_LINE}"
fi
printf '<sub>Built from `%s`"
} >"${COMMENT_FILE}"
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--paginate --jq '.[] | select(.body | contains("<!-- catalyst-dmg-link -->")) | .id' | \
while read -r comment_id; do
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true
done
gh pr comment "${{ github.event.pull_request.number }}" --body-file "${COMMENT_FILE}"

View File

@ -11,110 +11,75 @@ on:
jobs:
buildReleaseApk:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
with:
fetch-depth: "0"
- name: Free disk space (Android build)
shell: bash
run: |
df -h
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
docker system prune -af || true
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
rm -rf ~/.gradle/caches/build-cache || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
df -h
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Use gradle caches
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install Android SDK components
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;27.1.12297006"
- name: Install node_modules (include dev deps for patch-package)
run: npm ci --yes
- name: Install node_modules
run: npm ci --omit=dev --yes
- name: Set up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
ruby-version: 3.1.6
bundler-cache: true
- name: Cache Ruby Gems
uses: actions/cache@v5
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-
- name: Generate Build Number based on timestamp
id: build_number
run: |
NEW_BUILD_NUMBER="$(date +%s)"
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Build and sign APK
id: build_and_sign_apk
run: bundle exec fastlane android build_release_apk
- name: Prepare Keystore
run: bundle exec fastlane android prepare_keystore
env:
BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }}
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload build logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-build-logs
path: |
fastlane/logs/**/*.log
android/**/*.log
android/**/build/**/*.log
android/**/outputs/logs/**/*.log
android/**/reports/**/*.log
if-no-files-found: warn
- name: Update Version Code, Build, and Sign APK
id: build_and_sign_apk
run: |
bundle exec fastlane android update_version_build_and_sign_apk
env:
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Determine APK Filename and Path
id: determine_apk_path
run: |
BUILD_NUMBER=${{ steps.build_number.outputs.build_number }}
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
else
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
fi
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
@ -122,32 +87,32 @@ jobs:
echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV
- name: Upload APK as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v6
with:
name: signed-apk
path: ${{ env.APK_PATH }}
if-no-files-found: error
browserstack:
runs-on: macos-26
runs-on: ubuntu-latest
needs: buildReleaseApk
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
ruby-version: 3.1.6
bundler-cache: true
- name: Install dependencies with Bundler
run: bundle install --jobs 4 --retry 3
- name: Download APK artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@v5
with:
name: signed-apk
@ -162,4 +127,4 @@ jobs:
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec fastlane upload_to_browserstack_and_comment
run: bundle exec fastlane upload_to_browserstack_and_comment

View File

@ -14,15 +14,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -34,15 +35,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -53,7 +55,6 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
@ -65,15 +66,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install node_modules
run: npm ci || npm ci
@ -84,7 +86,6 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}

View File

@ -1,152 +0,0 @@
name: Tests e2e Android
on: [pull_request]
permissions:
contents: read
concurrency:
group: e2e-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Free disk space (Ubuntu)
run: |
echo "Disk before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc
sudo apt-get clean
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
sudo docker system prune -af || true
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
rm -rf ~/.gradle/caches/build-cache || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
echo "Disk after cleanup:" && df -h
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Use gradle caches
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install node_modules
run: npm ci || npm ci
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '17'
- name: Build
run: npm run e2e:release-build || npm run e2e:release-build
- name: Package APKs
run: |
tar -czf bluewallet-android-apks.tar.gz \
android/app/build/outputs/apk/release/app-release.apk \
android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk
- name: Upload APKs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bluewallet-android-apks
path: bluewallet-android-apks.tar.gz
retention-days: 3
compression-level: 0
if-no-files-found: error
test:
runs-on: ubuntu-24.04
needs: build
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Free disk space (Ubuntu)
run: |
echo "Disk before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc
sudo apt-get clean
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
sudo docker system prune -af || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
echo "Disk after cleanup:" && df -h
- name: Ensure artifacts directory
run: mkdir -p ${{ github.workspace }}/artifacts
- name: Specify node version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Install node_modules
run: npm ci || npm ci
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Download APKs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bluewallet-android-apks
- name: Restore APKs
run: tar -xzf bluewallet-android-apks.tar.gz
- name: Run tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 36
profile: pixel
avd-name: Pixel_API_29_AOSP
force-avd-creation: true
enable-hw-keyboard: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047
arch: x86_64
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 4 --reuse --artifacts-location ${{ github.workspace }}/artifacts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-android-videos
path: ${{ github.workspace }}/artifacts

View File

@ -1,233 +0,0 @@
name: Tests e2e iOS
on: [pull_request]
permissions:
contents: read
concurrency:
group: e2e-ios-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-26
env:
BUILD_CONFIGURATION: Release
CCACHE_MAXSIZE: "2G"
CCACHE_DIR: /Users/runner/.ccache
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Setup Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
with:
ruby-version: "3.4.9"
bundler-cache: true
- name: Install Node dependencies
run: npm ci || npm ci
- name: Cache CocoaPods
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: ${{ runner.os }}-pods-prebuilt-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-prebuilt-
- name: Install ccache
run: brew install ccache
- name: Cache ccache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ccache
key: ${{ runner.os }}-ccache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-ccache-
- name: Delete extension and watch targets
run: |
bundle exec ruby <<'RUBY'
require 'xcodeproj'
project_path = 'ios/BlueWallet.xcodeproj'
project = Xcodeproj::Project.open(project_path)
target_names = %w[BlueWalletWatch WidgetsExtension Stickers]
embed_phase_names = ['Embed Watch Content', 'Embed Foundation Extensions']
removed_any = false
target_names.each do |target_name|
target = project.targets.find { |t| t.name == target_name }
next unless target
puts "Removing target #{target_name}"
target.dependencies.each(&:remove_from_project)
target.build_phases.each(&:remove_from_project)
project.targets.delete(target)
removed_any = true
end
main_target = project.targets.find { |t| t.name == 'BlueWallet' }
if main_target
main_target.build_phases.select { |phase| embed_phase_names.include?(phase.display_name) }.each do |phase|
puts "Removing build phase #{phase.display_name}"
phase.remove_from_project
main_target.build_phases.delete(phase)
removed_any = true
end
end
if removed_any
project.save
puts 'Extension and watch target references removed'
else
puts 'No extension or watch targets found'
end
RUBY
- name: Remove extension and watch schemes
run: |
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WidgetsExtension.xcscheme
rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme
- name: Install CocoaPods dependencies
env:
RCT_USE_RN_DEP: "1"
RCT_USE_PREBUILT_RNCORE: "1"
USE_CCACHE: "1"
run: bundle exec fastlane ios install_pods || bundle exec fastlane ios install_pods
- name: Reset ccache stats
run: ccache -z || true
- name: Build iOS simulator app
working-directory: ios
env:
RCT_NO_LAUNCH_PACKAGER: "1"
CCACHE_BINARY: /opt/homebrew/bin/ccache
run: |
set -eo pipefail
build() {
xcodebuild \
-workspace BlueWallet.xcworkspace \
-scheme BlueWallet \
-configuration "${BUILD_CONFIGURATION}" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
CLANG_ENABLE_EXPLICIT_MODULES=NO \
SWIFT_ENABLE_EXPLICIT_MODULES=NO \
build
}
build || build
- name: ccache stats
if: always()
run: ccache -s || true
- name: Package simulator app
run: |
APP_DIR="ios/build/Build/Products/${BUILD_CONFIGURATION}-iphonesimulator/BlueWallet.app"
if [ ! -d "$APP_DIR" ]; then
echo "Simulator app not found at $APP_DIR"
find ios/build -maxdepth 5 -name '*.app' || true
exit 1
fi
tar -czf BlueWallet.app.tar.gz -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")"
- name: Upload simulator app
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bluewallet-ios-app
path: BlueWallet.app.tar.gz
retention-days: 3
compression-level: 0
if-no-files-found: error
test:
runs-on: macos-26
needs: build
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: 'npm'
- name: Install Node dependencies
run: npm ci || npm ci
- name: Install applesimutils
run: |
brew tap wix/brew
brew install applesimutils
- name: Download simulator app
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bluewallet-ios-app
- name: Restore simulator app
run: |
mkdir -p ios/build/Build/Products/Release-iphonesimulator
tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator
# Pre-boot simulator so first detox launchApp lands warm.
- name: Pre-boot iOS simulator
run: |
DEVICE_TYPE=$(jq -r '.devices.simulator.device.type' .detoxrc.json)
UDID=$(applesimutils --list --byType "$DEVICE_TYPE" | jq -r '.[0].udid // empty')
if [ -z "$UDID" ]; then
echo "ERROR: no simulator of type '$DEVICE_TYPE' found"
exit 1
fi
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b
xcrun simctl launch "$UDID" com.apple.springboard >/dev/null 2>&1 || true
# Cut animations so detox sync stays steady on slow CI VMs; Reduce Motion makes reanimated skip to final value.
- name: Disable simulator animations
run: |
defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
xcrun simctl spawn booted defaults write com.apple.Accessibility ReduceMotionEnabled -bool true
xcrun simctl spawn booted notifyutil -p com.apple.Accessibility.ReduceMotionStatusDidChange
- name: Run detox tests
timeout-minutes: 360
run: |
npm run e2e:test:ios-release -- \
--record-videos failing \
--record-logs failing \
--take-screenshots failing \
--headless \
--retries 3 \
--reuse \
--artifacts-location ./artifacts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-ios-videos
path: ./artifacts/

251
.github/workflows/e2e.yml vendored Normal file
View File

@ -0,0 +1,251 @@
name: Tests e2e
on: [pull_request]
permissions:
contents: read
jobs:
ios:
runs-on: macos-15
env:
BUILD_CONFIGURATION: Release
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.6"
- name: Cache Ruby gems
uses: actions/cache@v5
with:
path: vendor/bundle
key: ${{ runner.os }}-ruby-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby-
- name: Install Ruby gems
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Install Node dependencies
run: npm ci || npm ci
- name: Cache CocoaPods
uses: actions/cache@v5
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Install CocoaPods dependencies
run: bundle exec fastlane ios install_pods || bundle exec fastlane ios install_pods
- name: Delete Apple Watch target
run: |
bundle exec ruby <<'RUBY'
require 'xcodeproj'
project_path = 'ios/BlueWallet.xcodeproj'
project = Xcodeproj::Project.open(project_path)
target_names = %w[BlueWalletWatch]
removed_any = false
target_names.each do |target_name|
target = project.targets.find { |t| t.name == target_name }
next unless target
puts "Removing target #{target_name}"
target.dependencies.each(&:remove_from_project)
target.build_phases.each(&:remove_from_project)
project.targets.delete(target)
removed_any = true
end
main_target = project.targets.find { |t| t.name == 'BlueWallet' }
if main_target
main_target.build_phases.select { |phase| phase.display_name == 'Embed Watch Content' }.each do |phase|
puts "Removing build phase #{phase.display_name}"
phase.remove_from_project
main_target.build_phases.delete(phase)
removed_any = true
end
end
if removed_any
project.save
puts 'Apple Watch target references removed'
else
puts 'No Apple Watch targets found'
end
RUBY
- name: Remove Watch schemes
run: rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme
- name: Build iOS simulator app
working-directory: ios
env:
RCT_NO_LAUNCH_PACKAGER: "1"
run: |
set -eo pipefail
build() {
xcodebuild \
-workspace BlueWallet.xcworkspace \
-scheme BlueWallet \
-configuration "${BUILD_CONFIGURATION}" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
build
}
build || build
- name: Package simulator app
run: |
APP_DIR="ios/build/Build/Products/${BUILD_CONFIGURATION}-iphonesimulator/BlueWallet.app"
if [ ! -d "$APP_DIR" ]; then
echo "Simulator app not found at $APP_DIR"
find ios/build -maxdepth 5 -name '*.app' || true
exit 1
fi
OUTPUT_DIR="BlueWallet-simulator"
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
cp -R "$APP_DIR" "$OUTPUT_DIR/BlueWallet.app"
echo "APP_EXPORT_PATH=$OUTPUT_DIR" >> "$GITHUB_ENV"
- name: Install applesimutils
run: |
brew tap wix/brew
brew install applesimutils
- name: Run detox tests
run: |
npm run e2e:test:ios-release -- \
--record-videos failing \
--record-logs failing \
--take-screenshots failing \
--headless \
--retries 3 \
--reuse \
--artifacts-location ./artifacts
- uses: actions/upload-artifact@v6
if: failure()
with:
name: e2e-ios-videos
path: ./artifacts/
android:
runs-on: ubuntu-latest
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Free disk space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: npm and gradle caches in /mnt
run: |
rm -rf ~/.npm
rm -rf ~/.gradle
sudo mkdir -p /mnt/.npm
sudo mkdir -p /mnt/.gradle
sudo chown -R runner /mnt/.npm
sudo chown -R runner /mnt/.gradle
ln -s /mnt/.npm /home/runner/
ln -s /mnt/.gradle /home/runner/
- name: Create artifacts directory on /mnt
run: |
sudo mkdir -p /mnt/artifacts
sudo chown -R runner /mnt/artifacts
- name: Specify node version
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Use gradle caches
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install node_modules
run: npm ci --omit=dev --yes || npm ci --omit=dev --yes
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build
run: npm run e2e:release-build || npm run e2e:release-build
- name: Install dev deps needed for tests
run: npm i || npm i
- name: Run tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
avd-name: Pixel_API_29_AOSP
force-avd-creation: false
enable-hw-keyboard: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047
arch: x86_64
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 3 --reuse --artifacts-location /mnt/artifacts
- uses: actions/upload-artifact@v6
if: failure()
with:
name: e2e-android-videos
path: /mnt/artifacts/

2
.gitignore vendored
View File

@ -88,11 +88,9 @@ artifacts/
*.realm
*.realm.lock
android/app/.project
android/.settings/org.eclipse.buildship.core.prefs
android/app/.classpath
android/.settings/org.eclipse.buildship.core.prefs
android/.project
android/app/.settings/org.eclipse.jdt.core.prefs
android/.settings/org.eclipse.buildship.core.prefs
android/app/.classpath
android/app/.project

View File

@ -1 +1 @@
3.4.9
3.1.6

View File

@ -1,4 +1,4 @@
import { NavigationContainer, NavigationContainerRef, ParamListBase } from '@react-navigation/native';
import { NavigationContainer } from '@react-navigation/native';
import React from 'react';
import { useColorScheme } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
@ -13,7 +13,7 @@ import { StorageProvider } from './components/Context/StorageProvider';
const App = () => {
const colorScheme = useColorScheme();
useLogger(navigationRef as unknown as React.RefObject<NavigationContainerRef<ParamListBase>>);
useLogger(navigationRef);
return (
<SizeClassProvider>

139
BlueComponents.js Normal file
View File

@ -0,0 +1,139 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import React, { forwardRef } from 'react';
import { Dimensions, Platform, Pressable, StyleSheet, TextInput, View } from 'react-native';
import { Icon, Text } from '@rneui/themed';
import { useTheme } from './components/themes';
import { useLocale } from '@react-navigation/native';
const { height, width } = Dimensions.get('window');
const aspectRatio = height / width;
let isIpad;
if (aspectRatio > 1.6) {
isIpad = false;
} else {
isIpad = true;
}
/**
* TODO: remove this comment once this file gets properly converted to typescript.
*
* @type {React.FC<any>}
*/
export const BlueButtonLink = forwardRef((props, ref) => {
const { colors } = useTheme();
return (
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.blueButtonLink, pressed && styles.pressed]} {...props} ref={ref}>
<Text style={{ color: colors.foregroundColor, textAlign: 'center', fontSize: 16 }}>{props.title}</Text>
</Pressable>
);
});
export const BlueCard = props => {
return <View {...props} style={{ padding: 20 }} />;
};
export const BlueText = ({ bold = false, ...props }) => {
const { colors } = useTheme();
const { direction } = useLocale();
const style = StyleSheet.compose(
{
color: colors.foregroundColor,
writingDirection: direction,
fontWeight: bold ? 'bold' : 'normal',
},
props.style,
);
return <Text {...props} style={style} />;
};
export const BlueTextCentered = props => {
const { colors } = useTheme();
return <Text {...props} style={{ color: colors.foregroundColor, textAlign: 'center' }} />;
};
export const BlueFormLabel = props => {
const { colors } = useTheme();
const { direction } = useLocale();
return (
<Text
{...props}
style={{
color: colors.foregroundColor,
fontWeight: '400',
marginHorizontal: 20,
writingDirection: direction,
}}
/>
);
};
export const BlueFormMultiInput = props => {
const { colors } = useTheme();
return (
<TextInput
multiline
underlineColorAndroid="transparent"
numberOfLines={4}
editable={!props.editable}
style={{
paddingHorizontal: 8,
paddingVertical: 16,
flex: 1,
marginTop: 5,
marginHorizontal: 20,
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
borderWidth: 1,
borderBottomWidth: 0.5,
borderRadius: 4,
backgroundColor: colors.inputBackgroundColor,
color: colors.foregroundColor,
textAlignVertical: 'top',
}}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
{...props}
selectTextOnFocus={false}
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
/>
);
};
export class is {
static ipad() {
return isIpad;
}
}
export function BlueBigCheckmark({ style = {} }) {
const defaultStyles = {
backgroundColor: '#ccddf9',
width: 120,
height: 120,
borderRadius: 60,
alignSelf: 'center',
justifyContent: 'center',
marginTop: 0,
marginBottom: 0,
};
const mergedStyles = { ...defaultStyles, ...style };
return (
<View style={mergedStyles}>
<Icon name="check" size={50} type="font-awesome" color="#0f5cc0" />
</View>
);
}
const styles = StyleSheet.create({
blueButtonLink: {
minWidth: 100,
minHeight: 36,
justifyContent: 'center',
},
pressed: {
opacity: 0.6,
},
});

View File

@ -60,8 +60,6 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
**Dependencies:** Do not add new dependencies without strong justification. Bonus for removing dependencies.
**Patches:** Local fixes to `node_modules` live in `patches/` and are applied by `patch-package` on `postinstall`. Each patch is documented in `patches/README.md` (what/why + upstream issue link); update it when adding or removing a patch.
**Components:** New components go in `components/`, not legacy `BlueComponents.js`.
**Linting Rules:**
@ -69,7 +67,7 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
- No unused styles (`react-native/no-unused-styles`: error)
- Prettier: single quotes, 140 char width, trailing commas
**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys. See `loc/vocabulary.md` for the canonical glossary of Bitcoin/Lightning terms and their per-language renderings — use it as ground truth when translating or generating translations with LLMs.
**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys.
## Testing

View File

@ -25,9 +25,3 @@ Do *not* add new dependencies. Bonus points if you manage to actually remove a d
All new files must be in typescript. Bonus points if you convert some of the existing files to typescript.
New components must go in `components/`. Bonus points if you refactor some of old components in `BlueComponents.js` to separate files.
Don't forget to add tests. Bonus points for e2e tests.
# PRs
When submitting PR, it must include screenshot (from the emulator or the device) how the proposed change looks, even better - a video; and a short description of why (it was implemented) and how (it works under the hood).

12
Gemfile
View File

@ -1,19 +1,13 @@
source "https://rubygems.org"
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby "3.4.9"
gem "fastlane", "~> 2.234.0"
ruby "3.1.6"
gem "fastlane", "~> 2.228.0"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.8'
# Ruby 3.4.0 removed these from the standard library
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
gem 'concurrent-ruby', '< 1.3.4'
# Required for App Store Connect API
gem "jwt"

View File

@ -1,9 +1,9 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
CFPropertyList (3.0.9)
abbrev (0.1.2)
activesupport (7.2.3.1)
activesupport (7.2.3)
base64
benchmark (>= 0.3)
bigdecimal
@ -12,10 +12,10 @@ GEM
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1, < 6)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.9.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1252.0)
aws-sdk-core (3.247.0)
aws-partitions (1.1217.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -32,11 +32,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.127.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.223.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@ -44,7 +44,7 @@ GEM
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
bigdecimal (4.0.1)
claide (1.1.0)
cocoapods (1.15.2)
addressable (~> 2.8)
@ -87,9 +87,8 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
csv (3.3.5)
concurrent-ruby (1.3.3)
connection_pool (2.5.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@ -127,23 +126,19 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
fastimage (2.4.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -151,24 +146,20 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -188,50 +179,49 @@ GEM
git
xml-simple
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0)
fastlane-sirp (1.1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
git (4.3.1)
git (3.1.1)
activesupport (>= 5.0)
addressable (~> 2.8)
process_executer (~> 4.0)
process_executer (~> 1.3)
rchardet (~> 1.9)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.62.0)
google-apis-core (>= 0.15.0, < 2.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -245,34 +235,31 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.19.5)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0317)
mime-types-data (3.2026.0203)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.27.0)
molinillo (0.8.0)
multi_json (1.21.1)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.3.0)
nap (1.1.0)
naturally (2.3.0)
netrc (0.11.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
process_executer (4.0.2)
track_open_instances (~> 0.1)
process_executer (1.3.0)
public_suffix (4.0.7)
rake (13.4.2)
rake (13.3.1)
rchardet (1.10.0)
representable (3.2.0)
declarative (< 0.1.0)
@ -283,7 +270,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
retriable (3.4.1)
retriable (3.2.1)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
@ -299,17 +286,17 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
track_open_instances (0.1.15)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.6.0)
ethon (>= 0.18.0)
typhoeus (1.4.1)
ethon (>= 0.9.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
@ -334,157 +321,17 @@ PLATFORMS
DEPENDENCIES
activesupport (>= 6.1.7.5, != 7.1.0)
benchmark
bigdecimal
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
concurrent-ruby (< 1.3.8)
fastlane (~> 2.234.0)
concurrent-ruby (< 1.3.4)
fastlane (~> 2.228.0)
fastlane-plugin-browserstack
fastlane-plugin-bugsnag
fastlane-plugin-bugsnag_sourcemaps_upload
jwt
logger
mutex_m
xcodeproj (< 1.26.0)
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1252.0) sha256=b44c74136ebd634d35f3fb8fd37def5214db21b9375f22c6954dbe7a7f2a449d
aws-sdk-core (3.247.0) sha256=789864594ce8cef05ee3d81fa8ed506099280bda6ea12a7612b8b7c5e5e62851
aws-sdk-kms (1.127.0) sha256=5d540b6afb9574327202989db2217741211e1cce3fb443ad0e1e37de730202e5
aws-sdk-s3 (1.223.0) sha256=655e382af34926caa76b77cf0171caed5f61ff52b8b58ae50f6f3e22c39e6cbc
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a
cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136
cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2
cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1
cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a
cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589
cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31
cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169
ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.234.0) sha256=b74835681ad9a8e9c0931a5727dad1bab433895ac534c864a1ed5749625d26e9
fastlane-plugin-browserstack (0.3.4) sha256=a4f3e4a552e2390a4733570857512571535912100ffada177d5374413f2c1333
fastlane-plugin-bugsnag (3.0.0) sha256=8ddac4b79cb4b5d00432cccd5789a9e1a1119c29f7773a27d01b1d8a2363915d
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) sha256=a05afaefa81a7bf56c36386dddeb0931db31ead6886e3eae24f9683bda1a064d
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
git (4.3.1) sha256=91ca566c39766a033e61a148c8f470908bd4786b818f8f3ff566d3a9a0200c50
google-apis-androidpublisher_v3 (0.100.0) sha256=7a82935bee985190e8fe23bf5e53df3a27d65dd084114bb71b846b617de16489
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.27.0) sha256=9289f29968610754ef11d98b9ec627f0153f3e2616fef839aef096de529f6d1e
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.62.0) sha256=f62467c36df53287fb0252ebb4da85f9e25d7b4c5809d045c2aab1fc307760c1
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.60.0) sha256=b21b752d37945d678a4533be5ef4303f15d33a964d8bc709c7c41c3600f650db
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376
nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d
RUBY VERSION
ruby 3.4.9
ruby 3.1.6p260
BUNDLED WITH
4.0.7
2.3.27

View File

@ -104,7 +104,7 @@ Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/issues
## Translations
We accept translations via [Transifex](https://explore.transifex.com/bluewallet/bluewallet/)
We accept translations via [Transifex](https://www.transifex.com/bluewallet/bluewallet/)
To participate you need to:
1. Sign up to Transifex
@ -116,10 +116,6 @@ Please note the values in curly braces should not be translated. These are the n
Transifex automatically creates Pull Request when language reaches 100% translation. We also trigger this by hand before each release, so don't worry if you can't translate everything, every word counts.
### Vocabulary glossaries
[`loc/vocabulary.md`](loc/vocabulary.md) + the per-language files under [`loc/vocabulary/`](loc/vocabulary/) are the canonical glossary of Bitcoin/Lightning terms (Wallet, Vault, Seed, Mnemonic, Passphrase, Multisig, Payment Code, Coin Control, …) and their chosen rendering in each locale, with the reasoning behind each choice and ⚠️ anti-meaning callouts (e.g. Passcode ≠ Password, Change-output ≠ verb "to change"). Use them as ground truth when translating by hand or when feeding `loc/<lang>.json` to an LLM — terminology consistency across screens is the difference between "looks translated" and "is correct for a Bitcoin wallet". When you change a shipped string, update the matching row in the same PR.
## Q&A
Builds automated and tested with BrowserStack

View File

@ -0,0 +1,2 @@
connection.project.dir=
eclipse.preferences.version=1

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

View File

@ -1,2 +1,2 @@
connection.project.dir=..
eclipse.preferences.version=1
eclipse.preferences.version=1

View File

@ -1,4 +0,0 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
org.eclipse.jdt.core.compiler.compliance=17
org.eclipse.jdt.core.compiler.source=17

View File

@ -19,9 +19,9 @@ react {
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@ -87,14 +87,13 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "8.0.1"
versionName "7.2.6"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
// Keep compatibility across react-native-capture-protection flavor changes.
missingDimensionStrategy "react-native-capture-protection", "callbackTiramisu", "base"
missingDimensionStrategy "react-native-capture-protection", "fullMediaCapture"
}
lint {
lintOptions {
abortOnError false
checkReleaseBuilds false
}
@ -102,12 +101,13 @@ android {
sourceSets {
main {
assets.srcDirs = ['src/main/assets', 'src/main/res/assets']
java.srcDirs = ['src/main/java', '../../blue_modules/Views/SegmentedControl/android']
}
}
buildTypes {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
@ -123,21 +123,14 @@ task copyFiatUnits(type: Copy) {
preBuild.dependsOn(copyFiatUnits)
// Ensure fiat units are available before codegen scans JS sources
tasks.configureEach { task ->
if (task.name == 'generateCodegenSchemaFromJavaScript') {
task.dependsOn(copyFiatUnits)
}
}
dependencies {
androidTestImplementation('com.wix:detox:+')
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'androidx.core:core-ktx:1.18.0'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.work:work-runtime-ktx:2.10.5'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.compose.ui:ui:1.10.6'
implementation 'androidx.compose.ui:ui:1.9.4'
implementation 'androidx.compose.material3:material3:1.3.2'
implementation 'androidx.preference:preference-ktx:1.2.1'
@ -146,7 +139,9 @@ dependencies {
} else {
implementation jscFlavor
}
androidTestImplementation('com.wix:detox:0.1.1')
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
apply plugin: 'com.google.gms.google-services' // Google Services plugin

View File

@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
@ -33,18 +34,27 @@
android:networkSecurityConfig="@xml/network_security_config"
android:configChanges="uiMode">
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_name"
android:value="BlueWallet notifications" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_description"
android:value="Notifications about incoming payments" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_foreground"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.channel_create_default"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_color"
android:resource="@color/white" />
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification_icon" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/foreground_color" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@ -54,6 +64,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationActions" />
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationPublisher" />
<receiver
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationBootEventReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".BitcoinPriceWidget" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@ -110,11 +130,19 @@
</intent-filter>
</activity-alias>
<service
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationListenerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:exported="true">

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,43 +39,31 @@ class BitcoinPriceWidget : AppWidgetProvider() {
// Try to load cached data first
val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
val cachedPrice = sharedPref.getString("previous_price", null)
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null)
if (cachedPrice != null) {
// Show cached data immediately
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", "en-US")
try {
val locale = preferredCurrencyLocale
?.let { runCatching { java.util.Locale.forLanguageTag(it) }.getOrNull() }
?.takeIf { it.language.isNotBlank() }
?: java.util.Locale.getDefault()
val localeParts = preferredCurrencyLocale?.split("-") ?: listOf("en", "US")
val locale = if (localeParts.size == 2) {
java.util.Locale(localeParts[0], localeParts[1])
} else {
java.util.Locale.getDefault()
}
val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale)
val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD")
currencyFormat.currency = currency
currencyFormat.maximumFractionDigits = 0
val parsedCached = cachedPrice.toDoubleOrNull()?.toInt()
views.setViewVisibility(R.id.loading_indicator, View.GONE)
views.setViewVisibility(R.id.price_value, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
views.setTextViewText(R.id.price_value, currencyFormat.format(cachedPrice.toDouble().toInt()))
views.setTextViewText(R.id.last_updated_time, java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date()))
views.setViewVisibility(R.id.price_arrow_container, View.GONE)
if (parsedCached != null) {
views.setViewVisibility(R.id.price_value, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
views.setTextViewText(R.id.price_value, currencyFormat.format(parsedCached))
views.setTextViewText(
R.id.last_updated_time,
java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date())
)
} else {
// If parsing fails, show loading state
views.setViewVisibility(R.id.price_value, View.GONE)
views.setViewVisibility(R.id.last_updated_label, View.GONE)
views.setViewVisibility(R.id.last_updated_time, View.GONE)
views.setViewVisibility(R.id.loading_indicator, View.VISIBLE)
}
} catch (e: Exception) {
Log.e(TAG, "Error displaying cached price", e)
// Show loading state if cache display fails

View File

@ -11,13 +11,14 @@ import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.react.modules.fresco.FrescoModule
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.facebook.react.modules.i18nmanager.I18nUtil
import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
class MainApplication : Application(), ReactApplication {
@ -65,25 +66,26 @@ class MainApplication : Application(), ReactApplication {
}
}
override val reactNativeHost: ReactNativeHost by lazy {
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages() =
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(SegmentedControlPackage())
add(CustomSegmentedControlPackage())
add(SettingsPackage())
}
override fun getUseDeveloperSupport() = BuildConfig.DEBUG
override fun getJSMainModuleName(): String = "index"
override fun getJSMainModuleName() = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
}
override val reactHost: ReactHost by lazy {
getDefaultReactHost(applicationContext, reactNativeHost)
}
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
@ -99,15 +101,12 @@ class MainApplication : Application(), ReactApplication {
val sharedI18nUtilInstance = I18nUtil.getInstance()
sharedI18nUtilInstance.allowRTL(applicationContext, true)
// Initialize Fresco before RN mounts views. FrescoModule init can lag behind the first
// frame (e.g. UnlockWith logo) when OkHttp/SSL warms up network security config.
if (!FrescoModule.hasBeenInitialized()) {
Fresco.initialize(this)
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
loadReactNative(this)
initializeDeviceUID()
initializeBugsnag()
}

View File

@ -4,13 +4,12 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.module.annotations.ReactModule
import io.bluewallet.bluewallet.NativeSettingsModuleSpec
import java.util.UUID
@ReactModule(name = SettingsModule.NAME)
class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModuleSpec(reactContext) {
class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private val sharedPref: SharedPreferences = reactContext.getSharedPreferences(
"group.io.bluewallet.bluewallet",
@ -23,7 +22,10 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy"
private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch"
private const val DO_NOT_TRACK_KEY = "donottrack"
const val NAME = "SettingsModule"
}
override fun getName(): String {
return "SettingsModule"
}
/**
@ -31,7 +33,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Uses the same Android ID as react-native-device-info's getUniqueId()
*/
@ReactMethod
override fun initializeDeviceUID(promise: Promise) {
fun initializeDeviceUID(promise: Promise) {
try {
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
@ -84,7 +86,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Get the device UID
*/
@ReactMethod
override fun getDeviceUID(promise: Promise) {
fun getDeviceUID(promise: Promise) {
try {
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
@ -105,7 +107,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Get the device UID copy (for Settings display)
*/
@ReactMethod
override fun getDeviceUIDCopy(promise: Promise) {
fun getDeviceUIDCopy(promise: Promise) {
try {
val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "")
promise.resolve(deviceUIDCopy)
@ -119,7 +121,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Set the clearFilesOnLaunch preference
*/
@ReactMethod
override fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
try {
sharedPref.edit()
.putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value)
@ -136,7 +138,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Get the clearFilesOnLaunch preference
*/
@ReactMethod
override fun getClearFilesOnLaunch(promise: Promise) {
fun getClearFilesOnLaunch(promise: Promise) {
try {
val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false)
promise.resolve(value)
@ -150,7 +152,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Set Do Not Track setting
*/
@ReactMethod
override fun setDoNotTrack(enabled: Boolean, promise: Promise) {
fun setDoNotTrack(enabled: Boolean, promise: Promise) {
try {
val value = if (enabled) "1" else "0"
sharedPref.edit()
@ -182,7 +184,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Get Do Not Track setting
*/
@ReactMethod
override fun getDoNotTrack(promise: Promise) {
fun getDoNotTrack(promise: Promise) {
try {
val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0")
val enabled = value == "1"
@ -197,7 +199,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModu
* Open the settings activity from JavaScript
*/
@ReactMethod
override fun openSettings(promise: Promise) {
fun openSettings(promise: Promise) {
try {
val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)

View File

@ -1,29 +1,16 @@
package io.bluewallet.bluewallet
import com.facebook.react.TurboReactPackage
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.uimanager.ViewManager
class SettingsPackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return if (name == SettingsModule.NAME) SettingsModule(reactContext) else null
class SettingsPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(SettingsModule(reactContext))
}
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
val moduleInfo = ReactModuleInfo(
SettingsModule.NAME,
SettingsModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // hasConstants
false, // isCxxModule
true // isTurboModule
)
mapOf(SettingsModule.NAME to moduleInfo)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
}

View File

@ -205,23 +205,16 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val parsedPrevious = previousPrice?.toDoubleOrNull()
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
setViewVisibility(R.id.price_arrow_container, View.GONE)
if (parsedPrevious != null) {
setTextViewText(R.id.price_value, currencyFormat.format(parsedPrevious.toInt()))
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
} else {
setViewVisibility(R.id.price_value, View.GONE)
setViewVisibility(R.id.last_updated_label, View.GONE)
setViewVisibility(R.id.last_updated_time, View.GONE)
}
setTextViewText(R.id.price_value, currencyFormat.format(previousPrice?.toDouble()?.toInt()))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
setViewVisibility(R.id.price_arrow_container, View.GONE)
}
}
@ -233,45 +226,37 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val currentPrice = fetchedPrice.toDoubleOrNull()?.toInt()
val currentPrice = fetchedPrice.toDouble().toInt()
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
if (currentPrice != null) {
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
setTextViewText(R.id.last_updated_time, currentTime)
setViewVisibility(R.id.price_value, View.VISIBLE)
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
val previousParsed = previousPrice?.toDoubleOrNull()?.toInt()
if (previousParsed != null) {
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
setTextViewText(R.id.previous_price, currencyFormat.format(previousParsed))
setImageViewResource(
R.id.price_arrow,
if (currentPrice > previousParsed) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
)
} else {
setViewVisibility(R.id.price_arrow_container, View.GONE)
}
if (previousPrice != null) {
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
setTextViewText(R.id.previous_price, currencyFormat.format(previousPrice.toDouble().toInt()))
setImageViewResource(
R.id.price_arrow,
if (currentPrice > previousPrice.toDouble().toInt()) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
)
} else {
// Fallback to loading state if parsing failed
setViewVisibility(R.id.price_value, View.GONE)
setViewVisibility(R.id.last_updated_label, View.GONE)
setViewVisibility(R.id.last_updated_time, View.GONE)
setViewVisibility(R.id.price_arrow_container, View.GONE)
setViewVisibility(R.id.loading_indicator, View.VISIBLE)
}
}
}
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
val locale = localeString
?.let { runCatching { Locale.forLanguageTag(it) }.getOrNull() }
?.takeIf { it.language.isNotBlank() }
?: Locale.getDefault()
val localeParts = localeString?.split("-") ?: listOf("en", "US")
val locale = if (localeParts.size == 2) {
Locale(localeParts[0], localeParts[1])
} else {
Locale.getDefault()
}
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
val currency = try {
Currency.getInstance(currencyCode ?: "USD")

View File

@ -9,13 +9,13 @@ import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.UIManagerHelper
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import io.bluewallet.bluewallet.R
class SegmentedControl @JvmOverloads constructor(
class CustomSegmentedControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
@ -23,11 +23,7 @@ class SegmentedControl @JvmOverloads constructor(
private val toggleGroup: MaterialButtonToggleGroup
private var currentSelectedIndex: Int = 0
private var backgroundColorProp: Int? = null
private var tintColorProp: Int? = null
private var textColorProp: Int? = null
private var momentaryProp: Boolean = false
private var isEnabledProp: Boolean = true
private var onChangeEvent: ((WritableMap) -> Unit)? = null
var values: Array<String> = emptyArray()
set(value) {
@ -48,13 +44,10 @@ class SegmentedControl @JvmOverloads constructor(
isSingleSelection = true
isSelectionRequired = true
}
addView(
toggleGroup,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
addView(toggleGroup, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
))
toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
@ -62,9 +55,6 @@ class SegmentedControl @JvmOverloads constructor(
if (newIndex != -1 && newIndex != currentSelectedIndex) {
currentSelectedIndex = newIndex
emitChangeEvent(newIndex)
if (momentaryProp) {
toggleGroup.clearChecked()
}
}
}
}
@ -72,29 +62,28 @@ class SegmentedControl @JvmOverloads constructor(
private fun updateSegments() {
toggleGroup.removeAllViews()
values.forEachIndexed { index, title ->
val button = MaterialButton(
context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle,
com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply {
text = title
id = generateViewId()
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f,
1f
)
isCheckable = true
strokeWidth = 2
applyEnabledState()
val cornerRadius = resources.getDimensionPixelSize(
com.google.android.material.R.dimen.mtrl_btn_corner_radius,
com.google.android.material.R.dimen.mtrl_btn_corner_radius
)
when {
values.size == 1 -> {
this.cornerRadius = cornerRadius
@ -110,10 +99,10 @@ class SegmentedControl @JvmOverloads constructor(
}
}
}
toggleGroup.addView(button)
}
updateButtonColors()
updateSelectedSegment()
}
@ -121,82 +110,54 @@ class SegmentedControl @JvmOverloads constructor(
private fun updateButtonColors() {
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color)
val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color)
val selectedTextColor = resolvedTextColor
val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color)
val selectedBgColor = ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = ContextCompat.getColor(context, R.color.button_disabled_background_color)
val selectedTextColor = ContextCompat.getColor(context, R.color.button_text_color)
val unselectedTextColor = ContextCompat.getColor(context, R.color.button_disabled_text_color)
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
val bgColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(selectedBgColor, unselectedBgColor),
intArrayOf(selectedBgColor, unselectedBgColor)
)
val textColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(selectedTextColor, unselectedTextColor),
intArrayOf(selectedTextColor, unselectedTextColor)
)
val strokeColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(borderColor, borderColor),
intArrayOf(borderColor, borderColor)
)
val rippleColorStateList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(rippleColorSelected, rippleColor),
intArrayOf(rippleColorSelected, rippleColor)
)
button.backgroundTintList = bgColorStateList
button.setTextColor(textColorStateList)
button.strokeColor = strokeColorStateList
button.rippleColor = rippleColorStateList
button.isEnabled = isEnabledProp
}
}
fun setBackgroundColorProp(color: String?) {
backgroundColorProp = parseColor(color)
updateButtonColors()
}
fun setTintColorProp(color: String?) {
tintColorProp = parseColor(color)
updateButtonColors()
}
fun setTextColorProp(color: String?) {
textColorProp = parseColor(color)
updateButtonColors()
}
fun setMomentaryProp(momentary: Boolean) {
momentaryProp = momentary
toggleGroup.isSelectionRequired = !momentary
}
fun setEnabledProp(enabled: Boolean) {
isEnabledProp = enabled
toggleGroup.isEnabled = enabled
applyEnabledState()
}
private fun updateSelectedSegment() {
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
@ -227,37 +188,26 @@ class SegmentedControl @JvmOverloads constructor(
val reactContext = context as? ReactContext ?: return
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
val event = Arguments.createMap().apply {
putInt("selectedIndex", selectedIndex)
}
eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event))
}
private fun applyEnabledState() {
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
button.isEnabled = isEnabledProp
}
}
private fun parseColor(color: String?): Int? {
return try {
color?.let { Color.parseColor(it) }
} catch (_: IllegalArgumentException) {
null
}
}
private inner class ChangeEvent(
surfaceId: Int,
viewId: Int,
private val eventData: WritableMap,
private val eventData: WritableMap
) : Event<ChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = "topChange"
override fun getEventName(): String = "onChangeEvent"
override fun getEventData(): WritableMap = eventData
}
}
fun setOnChangeEvent(callback: ((WritableMap) -> Unit)?) {
onChangeEvent = callback
}
}

View File

@ -0,0 +1,47 @@
package io.bluewallet.bluewallet.components.segmentedcontrol
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>() {
companion object {
const val REACT_CLASS = "CustomSegmentedControl"
private const val ON_CHANGE_EVENT = "onChangeEvent"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): CustomSegmentedControl {
return CustomSegmentedControl(reactContext)
}
@ReactProp(name = "values")
fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
val valuesArray = values?.let { array ->
Array(array.size()) { index ->
array.getString(index) ?: ""
}
} ?: emptyArray()
view.values = valuesArray
}
@ReactProp(name = "selectedIndex", defaultInt = 0)
fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
view.selectedIndex = selectedIndex
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any>? {
return MapBuilder.builder<String, Any>()
.put(ON_CHANGE_EVENT, MapBuilder.of("registrationName", ON_CHANGE_EVENT))
.build()
}
override fun onAfterUpdateTransaction(view: CustomSegmentedControl) {
super.onAfterUpdateTransaction(view)
}
}

View File

@ -5,13 +5,13 @@ import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class SegmentedControlPackage : ReactPackage {
class CustomSegmentedControlPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(SegmentedControlManager())
return listOf(CustomSegmentedControlManager())
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -3,14 +3,16 @@
buildscript {
ext {
minSdkVersion = 24
buildToolsVersion = "36.0.0"
compileSdkVersion = 36
targetSdkVersion = 36
buildToolsVersion = "35.0.0"
compileSdkVersion = 35
targetSdkVersion = 35
googlePlayServicesVersion = "16.+"
googlePlayServicesIidVersion = "16.0.1"
firebaseVersion = "21.1.0"
ndkVersion = "28.2.13676358"
kotlinVersion = '2.1.20'
firebaseVersion = "17.3.4"
firebaseMessagingVersion = "21.1.0"
ndkVersion = "27.1.12297006"
kotlin_version = '2.0.21'
kotlinVersion = '2.0.21'
}
repositories {
google()
@ -19,51 +21,17 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
classpath("com.bugsnag:bugsnag-android-gradle-plugin:8.2.0")
}
}
// Gradle 9 removes jcenter(); add a shim that redirects any jcenter() call to mavenCentral()
def addJcenterShim = { repoContainer, logger ->
def mc = repoContainer.metaClass
if (!mc.respondsTo(repoContainer, 'jcenter')) {
mc.jcenter << { Closure config = null ->
def repo = repoContainer.mavenCentral()
if (config != null) {
config.delegate = repo
config.resolveStrategy = Closure.DELEGATE_FIRST
config.call(repo)
}
logger.lifecycle("Redirected jcenter() to mavenCentral()")
return repo
}
}
if (!mc.respondsTo(repoContainer, 'methodMissing')) {
mc.methodMissing = { String name, args ->
if (name == 'jcenter') {
def repo = repoContainer.mavenCentral()
logger.lifecycle("Redirected jcenter() (methodMissing) to mavenCentral()")
return repo
}
throw new MissingMethodException(name, repoContainer.class, args)
}
}
}
allprojects {
repositories {
maven {
url("$rootDir/../node_modules/detox/Detox-android")
}
// react-native-background-fetch ships com.transistorsoft:tsbackgroundfetch
// as a bundled local Maven repo; the package's own build.gradle adds it
// for itself, but :app's runtime classpath resolution needs it visible
// at the root level too.
maven {
url("$rootDir/../node_modules/react-native-background-fetch/android/libs")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
@ -72,66 +40,26 @@ allprojects {
excludeGroup "com.facebook.react"
}
}
mavenLocal()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
google()
maven { url 'https://www.jitpack.io' }
}
}
// Apply jcenter shim very early for every project before its build.gradle is evaluated
gradle.beforeProject { project ->
addJcenterShim(project.repositories, project.logger)
if (project.buildscript != null) {
addJcenterShim(project.buildscript.repositories, project.logger)
}
}
// Apply to root as well
addJcenterShim(repositories, logger)
if (buildscript != null) {
addJcenterShim(buildscript.repositories, logger)
}
subprojects { project ->
// react-native-device-info's androidTest classpath pulls
// play-services-iid:16.0.1 -> play-services-base:16.0.1 -> support-v4:26.1.0,
// which collides with androidx.core:core:1.13.1 (Duplicate class
// android.support.v4.app.INotificationSideChannel). Exclude the pre-AndroidX
// support-* modules so the AndroidX equivalents in core win.
configurations.all {
exclude group: 'com.android.support', module: 'support-compat'
exclude group: 'com.android.support', module: 'support-annotations'
exclude group: 'com.android.support', module: 'support-core-utils'
}
// Remove and block any jcenter() repositories at both project and buildscript levels
def scrub = { repoContainer ->
repoContainer.all { repo ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
project.logger.lifecycle("Removing jcenter() from ${project.path}")
repoContainer.remove(repo)
}
}
repoContainer.whenObjectAdded { repo ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
project.logger.lifecycle("Blocking jcenter() from ${project.path}")
repoContainer.remove(repo)
repoContainer.mavenCentral()
}
}
}
scrub(project.repositories)
if (project.buildscript != null) {
scrub(project.buildscript.repositories)
}
afterEvaluate {proj ->
if (proj.hasProperty("android")) {
subprojects {
afterEvaluate {project ->
if (project.hasProperty("android")) {
android {
buildToolsVersion "36.0.0"
compileSdkVersion 36
buildToolsVersion "35.0.0"
compileSdkVersion 35
defaultConfig {
minSdkVersion 24
}
@ -140,32 +68,12 @@ subprojects { project ->
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
if (proj.plugins.hasPlugin("com.android.application") || proj.plugins.hasPlugin("com.android.library")) {
if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) {
kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility
} else {
kotlinOptions.jvmTarget = sourceCompatibility
}
}
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {
// Wire Reanimated's generated codegen sources so WorkletsModule spec is visible under new architecture
proj.android.sourceSets.main.java.srcDir("${proj.buildDir}/generated/source/codegen/java")
}
}
}
// Final guard: fail fast if any jcenter repository slips through
gradle.projectsEvaluated {
allprojects { proj ->
def offenders = proj.repositories.findAll { repo ->
repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')
}
if (!offenders.isEmpty()) {
throw new GradleException("jcenter() detected in ${proj.path}; remove or replace with mavenCentral()");
}
}
}
apply plugin: "com.facebook.react.rootproject"
}

View File

@ -21,6 +21,7 @@ org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
@ -31,16 +32,8 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Use legacy NDK symbol upload for Bugsnag (avoids v5.26.0 requirement)
bugsnag.useLegacyNdkSymbolUpload=true
hermesEnabled=true

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

6
android/gradlew vendored
View File

@ -114,6 +114,8 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
@ -170,6 +172,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@ -209,7 +212,8 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.

4
android/gradlew.bat vendored
View File

@ -70,9 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell

View File

@ -1,7 +1,4 @@
module.exports = {
// Pin the @babel/runtime version so Metro resolves a single copy instead of
// bundling duplicate helpers, which bloats the bundle.
// See https://github.com/babel/babel/issues/18050
presets: [['module:@react-native/babel-preset', { enableBabelRuntime: '^7.26.0' }]],
plugins: ['react-native-worklets/plugin'],
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/
};

View File

@ -5,10 +5,7 @@ import RNFS from 'react-native-fs';
import Realm from 'realm';
import { sha256 as _sha256 } from '@noble/hashes/sha256';
import type { LegacyWallet as LegacyWalletT } from '../class/wallets/legacy-wallet';
import type { SegwitBech32Wallet as SegwitBech32WalletT } from '../class/wallets/segwit-bech32-wallet';
import type { SegwitP2SHWallet as SegwitP2SHWalletT } from '../class/wallets/segwit-p2sh-wallet';
import type { TaprootWallet as TaprootWalletT } from '../class/wallets/taproot-wallet';
import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class';
import presentAlert from '../components/Alert';
import loc from '../loc';
import { GROUP_IO_BLUEWALLET } from './currency';
@ -30,7 +27,7 @@ type Utxo = {
wif?: string;
};
export type ElectrumTransaction = {
type ElectrumTransaction = {
txid: string;
hash: string;
version: number;
@ -58,14 +55,13 @@ export type ElectrumTransaction = {
addresses: string[];
};
}[];
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
};
export type ElectrumTransactionWithHex = ElectrumTransaction & {
type ElectrumTransactionWithHex = ElectrumTransaction & {
hex: string;
};
@ -94,6 +90,7 @@ export const hardcodedPeers: Peer[] = [
// { host: 'electrum.jochen-hoenicke.de', ssl: '50006' },
{ host: 'electrum1.bluewallet.io', ssl: 443 },
{ host: 'electrum.acinq.co', ssl: 50002 },
{ host: 'electrum.bitaroo.net', ssl: 50002 },
];
export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
@ -101,84 +98,13 @@ export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
}));
let mainClient: typeof ElectrumClient | undefined;
let mainConnected: boolean = false;
let wasConnectedAtLeastOnce: boolean = false;
let serverName: string | false = false;
let disableBatching: boolean = false;
let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl);
if (currentPeerIndex < 0) currentPeerIndex = 0;
let connectionAttempt: number = 0;
let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length);
let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined };
// --- Single source of truth for connection liveness -----------------------------
// We previously tracked `mainConnected` (boolean) separately from the client's own
// `mainClient.status`. They drifted on iOS suspend/resume: a transient `ping()`
// failure cleared the flag while the socket was still alive, then `waitTillConnected`
// blocked for ~30s on the stale flag and surfaced a false network-error alert. The
// state machine + `ensureConnected()` below is the only place that mutates the
// connection lifecycle, and UI is driven by subscribing to state changes.
export type ConnectionState = 'disabled' | 'disconnected' | 'connecting' | 'connected';
let connState: ConnectionState = 'disconnected';
type ConnectionListener = (state: ConnectionState) => void;
const connectionListeners = new Set<ConnectionListener>();
function setConnectionState(next: ConnectionState): void {
if (connState === next) return;
connState = next;
for (const l of connectionListeners) {
try {
l(next);
} catch (e) {
console.warn('[electrum] connection listener threw:', e);
}
}
}
/** Current connection state for UI. */
export function getConnectionState(): ConnectionState {
return connState;
}
/** Subscribe to state changes. Returns an unsubscribe function. */
export function subscribeConnectionState(listener: ConnectionListener): () => void {
connectionListeners.add(listener);
return () => {
connectionListeners.delete(listener);
};
}
/** Convenience: `true` iff a usable Electrum connection is currently believed to exist. */
export function isConnected(): boolean {
return connState === 'connected';
}
// --- Connection lifecycle internals ---------------------------------------------
/** One liveness check (`server_ping`) wall-time before giving up and marking the socket dead. */
const PING_TIMEOUT_MS = 5_000;
/** One full connect attempt (TLS + `server_version` handshake) wall-time before retrying. */
const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000;
/** Reconnect attempts inside a single `ensureConnected()` call before declaring failure. */
const CONNECT_MAX_ATTEMPTS = 5;
/** Backoff between attempts to avoid hammering a flaky server. */
const CONNECT_BACKOFF_MS = 500;
/** Delay before the auto-reconnect triggered by a live-socket `onError`. Onions are slower. */
const RECONNECT_ONION_DELAY_MS = 4_000;
const RECONNECT_TCP_DELAY_MS = 500;
/** Max wall time one `ensureConnected()` call may take when no live socket exists. */
export const ENSURE_CONNECTED_MAX_WALL_MS =
CONNECT_MAX_ATTEMPTS * CONNECT_ATTEMPT_TIMEOUT_MS + (CONNECT_MAX_ATTEMPTS - 1) * CONNECT_BACKOFF_MS;
/** Coalesces concurrent `ensureConnected()` callers — at most one connect attempt at a time. */
let ensureInFlight: Promise<boolean> | null = null;
/** If any coalesced caller asked for the failure alert, honour it once the in-flight attempt finishes. */
let ensureInFlightShowAlert = false;
/**
* Bumps every time the caller asks us to abandon the current connection
* (`forceDisconnect()` or user disabling Electrum). In-flight `ensureConnected()`
* checks this between attempts so it can bail out promptly instead of racing back
* to `connected` after a disconnect was requested.
*/
let disconnectGeneration = 0;
const txhashHeightCache: Record<string, number> = {};
let _realm: Realm | undefined;
@ -224,10 +150,10 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort });
console.log('Getting preferred server:', { host, tcpPort, sslPort });
if (!host) {
console.warn('[electrum] Preferred server host is undefined');
console.warn('Preferred server host is undefined');
return;
}
@ -237,7 +163,7 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
ssl: sslPort ? Number(sslPort) : undefined,
};
} catch (error) {
console.error('[electrum] Error in getPreferredServer:', error);
console.error('Error in getPreferredServer:', error);
return undefined;
}
};
@ -245,12 +171,12 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
export const removePreferredServer = async () => {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Removing preferred server');
console.log('Removing preferred server');
await DefaultPreference.clear(ELECTRUM_HOST);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
} catch (error) {
console.error('[electrum] Error in removePreferredServer:', error);
console.error('Error in removePreferredServer:', error);
}
};
@ -259,14 +185,14 @@ export async function isDisabled(): Promise<boolean> {
try {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
console.log('[electrum] Getting Electrum connection disabled state:', savedValue);
console.log('Getting Electrum connection disabled state:', savedValue);
if (savedValue === null) {
result = false;
} else {
result = savedValue;
}
} catch (error) {
console.error('[electrum] Error getting Electrum connection disabled state:', error);
console.error('Error getting Electrum connection disabled state:', error);
result = false;
}
return !!result;
@ -274,23 +200,8 @@ export async function isDisabled(): Promise<boolean> {
export async function setDisabled(disabled = true) {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
console.log('[electrum] Setting Electrum connection disabled state to:', disabled);
const result = await DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
// Disabling must abort any in-flight ensureConnected() and tear down the live
// socket so callers don't have to remember to pair this with forceDisconnect().
// Without bumping the generation, an in-flight connect could race back to
// 'connected' after the user toggled Electrum off.
if (disabled) {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disabled');
}
return result;
console.log('Setting Electrum connection disabled state to:', disabled);
return DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
}
function getCurrentPeer() {
@ -303,7 +214,7 @@ function getCurrentPeer() {
function getNextPeer() {
const peer = getCurrentPeer();
currentPeerIndex++;
if (currentPeerIndex >= hardcodedPeers.length) currentPeerIndex = 0;
if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0;
return peer;
}
@ -314,7 +225,7 @@ async function getSavedPeer(): Promise<Peer | null> {
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort });
console.log('Getting saved peer:', { host, tcpPort, sslPort });
if (!host) {
return null;
@ -330,98 +241,53 @@ async function getSavedPeer(): Promise<Peer | null> {
return null;
} catch (error) {
console.error('[electrum] Error in getSavedPeer:', error);
console.error('Error in getSavedPeer:', error);
return null;
}
}
/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */
async function pickPeer(): Promise<Peer> {
export async function connectMain(): Promise<void> {
if (await isDisabled()) {
console.log('Electrum connection disabled by user. Skipping connectMain call');
return;
}
let usingPeer = getNextPeer();
const savedPeer = await getSavedPeer();
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
usingPeer = savedPeer;
}
return usingPeer;
}
function scheduleReconnectFromClient(client: typeof ElectrumClient, usingPeer: Peer, reason: string): void {
if (connState !== 'connected' || mainClient !== client) return;
console.log(`[electrum] scheduling Electrum reconnect after ${reason}`);
try {
// Also neutralises electrum-client's own timers/reconnect hooks for this instance.
client.close();
} catch {}
if (mainClient === client) mainClient = undefined;
setConnectionState('disconnected');
const delay = usingPeer.host.endsWith('.onion') ? RECONNECT_ONION_DELAY_MS : RECONNECT_TCP_DELAY_MS;
const generationAtSchedule = disconnectGeneration;
setTimeout(() => {
if (generationAtSchedule !== disconnectGeneration) return;
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
ensureConnected().catch(() => {
/* ensureConnected never throws, but be defensive */
});
}, delay);
}
/**
* One connect attempt: build a fresh `ElectrumClient`, run the version handshake,
* subscribe to headers. No retries, no UI side effects. Returns the peer used
* (for caller-side telemetry/alerts) and whether the attempt succeeded.
*/
async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
const usingPeer = await pickPeer();
console.log('[electrum] Using peer:', JSON.stringify(usingPeer));
// Drop any prior client before allocating a new one. Closing also neutralises
// electrum-client's internal `reconnect()` loop on the old instance.
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
console.log('Using peer:', JSON.stringify(usingPeer));
try {
console.log('[electrum] begin connection:', JSON.stringify(usingPeer));
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
mainClient = client;
console.log('begin connection:', JSON.stringify(usingPeer));
mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
// Live-socket errors after a successful handshake: schedule a single
// `ensureConnected()` (deduped). Errors during this attempt's own handshake
// are caught below — we must not double-handle them here.
client.onError = function (e: { message: string }) {
console.log('[electrum] electrum mainClient.onError():', e.message);
scheduleReconnectFromClient(client, usingPeer, 'socket error');
mainClient.onError = function (e: { message: string }) {
console.log('electrum mainClient.onError():', e.message);
if (mainConnected) {
// most likely got a timeout from electrum ping. lets reconnect
// but only if we were previously connected (mainConnected), otherwise theres other
// code which does connection retries
mainClient?.close();
mainClient = undefined;
mainConnected = false;
// dropping `mainConnected` flag ensures there wont be reconnection race condition if several
// errors triggered
console.log('reconnecting after socket error');
setTimeout(connectMain, usingPeer.host.endsWith('.onion') ? 4000 : 500);
}
};
const ver = await Promise.race([
client.initElectrum(
{ client: 'bluewallet', version: '1.4' },
{
maxRetry: 0,
callback: () => scheduleReconnectFromClient(client, usingPeer, 'socket close'),
},
),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('connect timeout')), CONNECT_ATTEMPT_TIMEOUT_MS)),
]);
if (mainClient !== client) {
// Caller raced `forceDisconnect()` while we were awaiting. Bail.
try {
client.close();
} catch {}
return { ok: false, peer: usingPeer };
}
const ver = await mainClient.initElectrum({ client: 'bluewallet', version: '1.4' });
if (ver && ver[0]) {
console.log('[electrum] connected to ', ver);
console.log('connected to ', ver);
serverName = ver[0];
mainConnected = true;
wasConnectedAtLeastOnce = true;
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
disableBatching = true;
// exeptions for versions:
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
switch (electrumImplementation) {
case 'electrs':
@ -430,6 +296,8 @@ async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
}
break;
case 'electrs-esplora':
// its a different one, and it does NOT support batching
// nop
break;
case 'Fulcrum':
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
@ -438,156 +306,37 @@ async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> {
break;
}
}
const header = await client.blockchainHeaders_subscribe();
const header = await mainClient.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = {
height: header.height,
time: Math.floor(+new Date() / 1000),
};
}
return { ok: true, peer: usingPeer };
// AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor
}
return { ok: false, peer: usingPeer };
} catch (e) {
console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e);
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
mainConnected = false;
console.log('bad connection:', JSON.stringify(usingPeer), e);
mainClient?.close();
mainClient = undefined;
}
if (!mainConnected) {
console.log('retry');
connectionAttempt = connectionAttempt + 1;
mainClient?.close();
mainClient = undefined;
if (connectionAttempt >= 5) {
presentNetworkErrorAlert(usingPeer);
} else {
console.log('reconnection attempt #', connectionAttempt);
await new Promise(resolve => setTimeout(resolve, 500)); // sleep
return connectMain();
}
return { ok: false, peer: usingPeer };
}
}
/** Single liveness check on the current `mainClient`, bounded by `PING_TIMEOUT_MS`. */
async function pingWithTimeout(timeoutMs: number = PING_TIMEOUT_MS): Promise<boolean> {
if (!mainClient) return false;
const client = mainClient;
try {
await Promise.race([
client.server_ping(),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('ping timeout')), timeoutMs)),
]);
return mainClient === client; // server replied AND client wasn't swapped while we waited
} catch {
return false;
}
}
export type EnsureConnectedOptions = {
/**
* Show the legacy "couldn't connect" alert (Try again / Reset / Cancel) on failure.
* Used by initial bootstrap (`SettingsProvider` re-enabling Electrum) and the manual
* help alert. Off-hot-path callers (refresh, broadcast, etc.) should leave this false
* and surface their own UI.
*/
showAlertOnFailure?: boolean;
};
/**
* Make sure a usable Electrum connection exists, healing if needed.
*
* - If we already think we're connected, run one fast `ping` to verify. If the ping
* succeeds, we're done. If it fails the client is torn down and we fall through
* to a reconnect.
* - Otherwise run up to `CONNECT_MAX_ATTEMPTS` connect attempts (each with its own
* timeout + backoff).
*
* Concurrent callers share the same in-flight promise there is at most one connect
* attempt at a time per process. This replaces the old `mainConnected`-flag-polling
* `waitTillConnected()`, which could block ~30s on a stale flag while the socket was
* still alive.
*/
export async function ensureConnected(opts: EnsureConnectedOptions = {}): Promise<boolean> {
const { showAlertOnFailure = false } = opts;
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
if (ensureInFlight) {
if (showAlertOnFailure) ensureInFlightShowAlert = true;
return ensureInFlight;
}
ensureInFlightShowAlert = showAlertOnFailure;
ensureInFlight = (async (): Promise<boolean> => {
const myGeneration = disconnectGeneration;
/** True iff the current generation no longer matches ours (i.e. `forceDisconnect()` ran). */
const aborted = (where: string): boolean => {
if (myGeneration === disconnectGeneration) return false;
console.log(`[electrum] ensureConnected aborted by forceDisconnect at ${where} (gen ${myGeneration}${disconnectGeneration})`);
return true;
};
let lastPeer: Peer | undefined;
try {
// Fast path: live ping on the existing client.
if (mainClient && connState === 'connected') {
if (await pingWithTimeout()) {
// If a disconnect/disable raced us, the bumper already set the right
// state ('disconnected' or 'disabled'); don't clobber it from here.
if (aborted('post-ping')) return false;
return true;
}
// Stale socket. Tear it down so the attempt loop starts fresh.
try {
mainClient.close();
} catch {}
mainClient = undefined;
setConnectionState('disconnected');
}
if (aborted('pre-loop')) return false;
setConnectionState('connecting');
for (let i = 0; i < CONNECT_MAX_ATTEMPTS; i++) {
if (await isDisabled()) {
setConnectionState('disabled');
return false;
}
// Generation-bumper (`forceDisconnect` or `setDisabled(true)`) already
// set the appropriate terminal state; we must not clobber 'disabled'
// back to 'disconnected' here.
if (aborted(`attempt ${i} start`)) return false;
const { ok, peer } = await attemptConnectOnce();
lastPeer = peer;
if (aborted(`attempt ${i} end`)) {
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
return false;
}
if (ok) {
setConnectionState('connected');
return true;
}
if (i < CONNECT_MAX_ATTEMPTS - 1) {
await new Promise(resolve => setTimeout(resolve, CONNECT_BACKOFF_MS));
}
}
setConnectionState('disconnected');
if (ensureInFlightShowAlert) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file
presentNetworkErrorAlert(lastPeer);
}
return false;
} finally {
ensureInFlight = null;
ensureInFlightShowAlert = false;
}
})();
return ensureInFlight;
}
export async function presentResetToDefaultsAlert(): Promise<boolean> {
const hasPreferredServer = await getPreferredServer();
const serverHistoryStr = await DefaultPreference.get(ELECTRUM_SERVER_HISTORY);
@ -607,7 +356,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
console.log(e); // Must be running on Android
}
resolve(true);
},
@ -626,7 +375,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
} catch (e) {
console.log('[electrum]', e); // Must be running on Android
console.log(e); // Must be running on Android
}
resolve(true);
},
@ -649,16 +398,16 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
});
}
async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
if (await isDisabled()) {
console.log(
'[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
);
return;
}
presentAlert({
allowRepeat,
allowRepeat: false,
title: loc.errors.network,
message: loc.formatString(
usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect,
@ -668,10 +417,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
{
text: loc.wallets.list_tryagain,
onPress: () => {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
},
style: 'default',
},
@ -680,10 +429,10 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
onPress: () => {
presentResetToDefaultsAlert().then(result => {
if (result) {
forceDisconnect();
setTimeout(() => {
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
}, 500);
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
setTimeout(connectMain, 500);
}
});
},
@ -692,21 +441,16 @@ async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
{
text: loc._.cancel,
onPress: () => {
forceDisconnect();
connectionAttempt = 0;
mainClient?.close();
mainClient = undefined;
},
style: 'cancel',
},
],
options: { cancelable: false },
});
}
/**
* Wallets list header when Electrum looks disconnected: same actions as the internal timeout alert, with allowRepeat so the user can open it again after dismiss.
*/
export async function presentElectrumDisconnectedHelpAlert(): Promise<void> {
await presentNetworkErrorAlert(undefined, true);
}
};
/**
* Returns random electrum server out of list of servers
@ -753,27 +497,18 @@ export const getBalanceByAddress = async function (address: string): Promise<{ c
balance.addr = address;
return balance;
} catch (error) {
console.error('[electrum] Error in getBalanceByAddress:', error);
console.error('Error in getBalanceByAddress:', error);
throw error;
}
};
export const getConfig = async function () {
if (!mainClient) {
return {
host: undefined,
port: undefined,
serverName: false as typeof serverName,
connected: connState === 'connected' ? 1 : 0,
};
}
if (!mainClient) throw new Error('Electrum client is not connected');
return {
host: mainClient.host,
port: mainClient.port,
serverName,
// Drive UI "connected" indicator from the single state machine so the settings
// screen agrees with the wallets-list header pill and with `ensureConnected()`.
connected: connState === 'connected' ? 1 : 0,
connected: mainClient.timeLastCall !== 0 && mainClient.status,
};
};
@ -802,24 +537,14 @@ export const getMempoolTransactionsByAddress = async function (address: string):
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
};
/**
* Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()`
* for that). Updates the connection state machine to reflect the probe result so
* subscribers (UI pill, settings screen) stay in sync.
*
* - `true`: server replied within `PING_TIMEOUT_MS`.
* - `false`: client missing, timed out, or server errored.
*/
export const ping = async function (): Promise<boolean> {
if (await isDisabled()) return false;
const ok = await pingWithTimeout();
if (ok) {
// Heal stale `disconnected` state from a transient ping failure earlier.
if (connState !== 'connected') setConnectionState('connected');
} else if (connState === 'connected') {
setConnectionState('disconnected');
}
return ok;
export const ping = async function () {
try {
await mainClient.server_ping();
return true;
} catch (_) {}
mainConnected = false;
return false;
};
// exported only to be used in unit tests
@ -874,21 +599,6 @@ export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWi
let address: false | string = false;
let type: false | string = false;
// Lazy require to avoid the module-scope cycle described above. These
// modules are fully loaded by the time this function is actually invoked.
const { SegwitBech32Wallet } = require('../class/wallets/segwit-bech32-wallet') as {
SegwitBech32Wallet: typeof SegwitBech32WalletT;
};
const { SegwitP2SHWallet } = require('../class/wallets/segwit-p2sh-wallet') as {
SegwitP2SHWallet: typeof SegwitP2SHWalletT;
};
const { LegacyWallet } = require('../class/wallets/legacy-wallet') as {
LegacyWallet: typeof LegacyWalletT;
};
const { TaprootWallet } = require('../class/wallets/taproot-wallet') as {
TaprootWallet: typeof TaprootWalletT;
};
if (SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
type = 'witness_v0_keyhash';
@ -1032,7 +742,7 @@ export const multiGetBalanceByAddress = async (addresses: string[], batchsize: n
}
for (const bal of balances) {
if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error);
if (bal.error) console.warn('multiGetBalanceByAddress():', bal.error);
ret.balance += +bal.result.confirmed;
ret.unconfirmed_balance += +bal.result.unconfirmed;
ret.addresses[scripthash2addr[bal.param]] = bal.result;
@ -1126,7 +836,7 @@ export const multiGetHistoryByAddress = async function (
}
for (const history of results) {
if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error);
if (history.error) console.warn('multiGetHistoryByAddress():', history.error);
ret[scripthash2addr[history.param]] = history.result || [];
for (const result of history.result || []) {
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
@ -1167,7 +877,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
try {
ret[txid] = JSON.parse(jsonString.cache_value as string);
} catch (error) {
console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value);
console.log(error, 'cache failed to parse', jsonString.cache_value);
}
}
@ -1217,7 +927,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
tx = txhexToElectrumTransaction(tx);
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
console.log(err);
}
}
} else {
@ -1233,7 +943,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
results.push({ result: tx, param: txid });
} catch (err) {
console.log('[electrum]', err);
console.log(err);
}
}
}
@ -1288,12 +998,41 @@ export async function multiGetTransactionByTxid<T extends boolean>(
}
});
} catch (writeError) {
console.error('[electrum] Failed to write transaction cache:', writeError);
console.error('Failed to write transaction cache:', writeError);
}
return ret;
}
/**
* Simple waiter till `mainConnected` becomes true (which means
* it Electrum was connected in other function), or timeout 30 sec.
*/
export const waitTillConnected = async function (): Promise<boolean> {
let waitTillConnectedInterval: NodeJS.Timeout | undefined;
let retriesCounter = 0;
if (await isDisabled()) {
console.warn('Electrum connections disabled by user. waitTillConnected skipping...');
return false;
}
return new Promise(function (resolve, reject) {
waitTillConnectedInterval = setInterval(() => {
if (mainConnected) {
clearInterval(waitTillConnectedInterval);
return resolve(true);
}
if (wasConnectedAtLeastOnce && retriesCounter++ >= 150) {
// `wasConnectedAtLeastOnce` needed otherwise theres gona be a race condition with the code that connects
// electrum during app startup
clearInterval(waitTillConnectedInterval);
presentNetworkErrorAlert();
reject(new Error('Waiting for Electrum connection timeout'));
}
}, 100);
});
};
// Returns the value at a given percentile in a sorted numeric array.
// "Linear interpolation between closest ranks" method
function percentile(arr: number[], p: number) {
@ -1445,11 +1184,10 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
client.onError = () => {}; // mute
let timeoutId: NodeJS.Timeout | undefined;
const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000;
try {
const rez = await Promise.race([
new Promise(resolve => {
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
timeoutId = setTimeout(() => resolve('timeout'), 5000);
}),
client.connect(),
]);
@ -1467,19 +1205,8 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
return false;
};
/**
* Drop the current connection and tell any in-flight `ensureConnected()` to abort
* (so it doesn't race the disconnect by setting state back to `connected`).
*/
export const forceDisconnect = (): void => {
disconnectGeneration += 1;
if (mainClient) {
try {
mainClient.close();
} catch {}
mainClient = undefined;
}
setConnectionState('disconnected');
mainClient?.close();
};
export const setBatchingDisabled = () => {

View File

@ -1 +0,0 @@
export { default, type Spec } from '../codegen/NativeEventEmitter';

View File

@ -1 +0,0 @@
export { default, type Spec } from '../codegen/NativeMenuElementsEmitter';

View File

@ -1 +0,0 @@
export { default, type Spec } from '../codegen/NativeWidgetHelper';

View File

@ -1,5 +1,4 @@
import { Platform } from 'react-native';
import NativeSettingsModule from '../codegen/NativeSettingsModule';
import { NativeModules, Platform } from 'react-native';
interface SettingsModuleInterface {
/**
@ -47,7 +46,6 @@ interface SettingsModuleInterface {
}
// Only available on Android
const nativeModule = NativeSettingsModule ?? null;
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? nativeModule : null;
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? NativeModules.SettingsModule : null;
export default SettingsModule;

View File

@ -1,67 +0,0 @@
package io.bluewallet.bluewallet.components.segmentedcontrol
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.common.MapBuilder
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
@ReactModule(name = SegmentedControlManager.REACT_CLASS)
class SegmentedControlManager : SimpleViewManager<SegmentedControl>() {
companion object {
const val REACT_CLASS = "SegmentedControl"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext): SegmentedControl =
SegmentedControl(reactContext)
@ReactProp(name = "values")
fun setValues(view: SegmentedControl, values: ReadableArray?) {
view.values = values?.let { arr -> Array(arr.size()) { arr.getString(it) ?: "" } } ?: emptyArray()
}
@ReactProp(name = "selectedIndex", defaultInt = 0)
fun setSelectedIndex(view: SegmentedControl, selectedIndex: Int) {
view.selectedIndex = selectedIndex
}
@ReactProp(name = "enabled", defaultBoolean = true)
fun setEnabled(view: SegmentedControl, enabled: Boolean) {
view.setEnabledProp(enabled)
}
@ReactProp(name = "momentary", defaultBoolean = false)
fun setMomentary(view: SegmentedControl, momentary: Boolean) {
view.setMomentaryProp(momentary)
}
@ReactProp(name = "backgroundColor")
fun setBackgroundColor(view: SegmentedControl, backgroundColor: String?) {
view.setBackgroundColorProp(backgroundColor)
}
@ReactProp(name = "tintColor")
fun setTintColor(view: SegmentedControl, tintColor: String?) {
view.setTintColorProp(tintColor)
}
@ReactProp(name = "textColor")
fun setTextColor(view: SegmentedControl, textColor: String?) {
view.setTextColorProp(textColor)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any>? =
MapBuilder.builder<String, Any>()
.put(
"topChange",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onChange", "captured", "onChangeCapture"),
),
)
.build()
}

View File

@ -1,6 +0,0 @@
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(SegmentedControlManager, RCTViewManager)
@end

View File

@ -1,23 +0,0 @@
import Foundation
import UIKit
import React
@objc(SegmentedControlManager)
final class SegmentedControlManager: RCTViewManager {
override class func requiresMainQueueSetup() -> Bool { true }
override func view() -> UIView! {
return SegmentedControlView()
}
@objc class func propConfig_values() -> [String]! { ["NSArray"] }
@objc class func propConfig_selectedIndex() -> [String]! { ["NSInteger"] }
@objc class func propConfig_enabled() -> [String]! { ["BOOL"] }
@objc class func propConfig_momentary() -> [String]! { ["BOOL"] }
@objc class func propConfig_tintColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_backgroundColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_textColor() -> [String]! { ["UIColor"] }
@objc class func propConfig_onChange() -> [String]! { ["RCTBubblingEventBlock"] }
}

View File

@ -1,98 +0,0 @@
import UIKit
import React
@objc(SegmentedControlView)
final class SegmentedControlView: UIView {
private let segmentedControl = UISegmentedControl()
// MARK: - Lifecycle
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
segmentedControl.addTarget(self, action: #selector(handleValueChanged(_:)), for: .valueChanged)
addSubview(segmentedControl)
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
segmentedControl.leadingAnchor.constraint(equalTo: leadingAnchor),
segmentedControl.trailingAnchor.constraint(equalTo: trailingAnchor),
segmentedControl.topAnchor.constraint(equalTo: topAnchor),
segmentedControl.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// MARK: - Prop setters
@objc var values: NSArray = [] {
didSet { rebuildSegments() }
}
@objc var selectedIndex: Int = 0 {
didSet {
guard segmentedControl.numberOfSegments > 0 else { return }
let clamped = min(max(selectedIndex, 0), segmentedControl.numberOfSegments - 1)
if segmentedControl.selectedSegmentIndex != clamped {
segmentedControl.selectedSegmentIndex = clamped
}
}
}
@objc var enabled: Bool = true {
didSet { segmentedControl.isEnabled = enabled }
}
@objc var momentary: Bool = false {
didSet { segmentedControl.isMomentary = momentary }
}
@objc var textColor: UIColor? {
didSet { applyTextAttributes() }
}
@objc var onChange: RCTBubblingEventBlock?
override var tintColor: UIColor! {
didSet { segmentedControl.selectedSegmentTintColor = tintColor }
}
override var backgroundColor: UIColor? {
didSet { segmentedControl.backgroundColor = backgroundColor }
}
// MARK: - Private helpers
private func rebuildSegments() {
let titles = values as? [String] ?? []
segmentedControl.removeAllSegments()
for (i, title) in titles.enumerated() {
segmentedControl.insertSegment(withTitle: title, at: i, animated: false)
}
guard !titles.isEmpty else { return }
let clamped = min(max(selectedIndex, 0), titles.count - 1)
segmentedControl.selectedSegmentIndex = clamped
}
private func applyTextAttributes() {
if let color = textColor {
segmentedControl.setTitleTextAttributes([.foregroundColor: color], for: .normal)
segmentedControl.setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected)
} else {
segmentedControl.setTitleTextAttributes(nil, for: .normal)
segmentedControl.setTitleTextAttributes(nil, for: .selected)
}
}
@objc private func handleValueChanged(_ sender: UISegmentedControl) {
onChange?(["selectedIndex": sender.selectedSegmentIndex])
}
}

View File

@ -1,7 +1,7 @@
import Bugsnag from '@bugsnag/react-native';
import { getUniqueId } from 'react-native-device-info';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import { BlueApp as BlueAppClass } from '../class';
const BlueApp = BlueAppClass.getInstance();

View File

@ -1,71 +0,0 @@
// Per-wallet Realm storage for notification-suppression entries.
//
// Lives inside the per-wallet Arkade Realm so suppression state is
// bucket-scoped, encrypted by the wallet's existing Realm key, and removed
// automatically when the wallet is deleted (deleteArkadeRealm tears down the
// whole file). Avoids leaking a stable per-wallet handle into a global
// AsyncStorage key.
export type ArkSwapNotificationAction = 'claim' | 'refund';
// Realm schema. `realm` is a peer dependency we don't import here directly;
// the schema is a plain object consumed by realmInstance.ts via the schemas
// array. Pattern matches BoltzSwapSchema in @arkade-os/boltz-swap.
export const ArkSwapNotificationSuppressionSchema = {
name: 'ArkSwapNotificationSuppression',
primaryKey: 'id',
properties: {
id: 'string',
swapId: 'string',
action: 'string',
postedAt: 'int',
},
};
const compositeId = (swapId: string, action: ArkSwapNotificationAction): string => `${swapId}:${action}`;
interface ArkSwapNotificationSuppressionRow {
id: string;
swapId: string;
action: ArkSwapNotificationAction;
postedAt: number;
}
export class RealmNotificationSuppressionRepository {
private readonly realm: any;
constructor(realm: any) {
this.realm = realm;
}
has(swapId: string, action: ArkSwapNotificationAction): boolean {
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
return Boolean(row);
}
record(swapId: string, action: ArkSwapNotificationAction): void {
this.realm.write(() => {
const row: ArkSwapNotificationSuppressionRow = {
id: compositeId(swapId, action),
swapId,
action,
postedAt: Date.now(),
};
this.realm.create('ArkSwapNotificationSuppression', row, 'modified');
});
}
clearForSwap(swapId: string): void {
this.realm.write(() => {
const matches = this.realm.objects('ArkSwapNotificationSuppression').filtered('swapId == $0', swapId);
this.realm.delete(matches);
});
}
clearForSwapAction(swapId: string, action: ArkSwapNotificationAction): void {
this.realm.write(() => {
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
if (row) this.realm.delete(row);
});
}
}

View File

@ -1,197 +0,0 @@
import RNFS from 'react-native-fs';
import Realm from 'realm';
import Keychain, { ACCESSIBLE, SECURITY_LEVEL } from 'react-native-keychain';
import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations } from '@arkade-os/sdk/repositories/realm';
import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm';
import { randomBytes } from '../../../class/rng';
import { uint8ArrayToHex, hexToUint8Array } from '../../uint8array-extras';
import { ArkSwapNotificationSuppressionSchema } from './notificationSuppressionRepository';
const AllArkadeSchemas = [...ArkRealmSchemas, ...BoltzRealmSchemas, ArkSwapNotificationSuppressionSchema];
// App-owned schemas added on top of the SDK's. Bump when an app-owned schema
// changes; SDK bumps are handled by ARK_REALM_SCHEMA_VERSION. Realm requires
// a strictly increasing schemaVersion when objects are added; computing
// `SDK + offset` keeps the local additions ahead of any future SDK bump.
const LOCAL_ARK_SCHEMA_OFFSET = 1;
const ARKADE_REALM_SCHEMA_VERSION = ARK_REALM_SCHEMA_VERSION + LOCAL_ARK_SCHEMA_OFFSET;
const realmInstances: Map<string, Realm> = new Map();
const openInFlight: Map<string, Promise<Realm>> = new Map();
// Files live in a dedicated subdirectory so BlueApp.moveRealmFilesToCacheDirectory()
// — which sweeps top-level *.realm files from Documents into the OS-purgeable cache
// — never sees them. RNFS.readDir is non-recursive, so the subdirectory is invisible
// to that scan. Ark Realm holds non-recoverable swap/claim data and must stay in
// Documents.
const arkadeDir = (): string => `${RNFS.DocumentDirectoryPath}/arkade`;
const realmPathFor = (namespace: string): string => `${arkadeDir()}/arkade-${namespace}.realm`;
const keychainServiceFor = (namespace: string): string => `arkade_realm_${namespace}`;
async function ensureArkadeDir(): Promise<void> {
const dir = arkadeDir();
if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir);
}
async function loadOrCreateEncryptionKey(namespace: string): Promise<Uint8Array> {
const service = keychainServiceFor(namespace);
const credentials = await Keychain.getGenericPassword({ service });
if (credentials) return hexToUint8Array(credentials.password);
const buf = await randomBytes(64);
const password = uint8ArrayToHex(buf);
// Accessibility: match the rest of the app's secret accessibility. RNSecureKeyStore
// in class/blue-app.ts and hooks/useBiometrics.ts both use WHEN_UNLOCKED_THIS_DEVICE_ONLY;
// the default of AFTER_FIRST_UNLOCK would expose the Realm key while the device is locked.
//
// Security level: preflight via getSecurityLevel() rather than try/catch around
// SECURE_HARDWARE. getSecurityLevel returns null on iOS (where the option is moot)
// and the highest supported level on Android. We only opt into SECURE_HARDWARE when
// the device actually backs it; otherwise let react-native-keychain pick its default.
// Catching every setGenericPassword error and silently retrying with ANY (the previous
// shape) downgrades on unrelated failures — preflight surfaces those instead.
const supportedLevel = await Keychain.getSecurityLevel();
const opts: Parameters<typeof Keychain.setGenericPassword>[2] = {
service,
accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};
if (supportedLevel === SECURITY_LEVEL.SECURE_HARDWARE) {
opts.securityLevel = SECURITY_LEVEL.SECURE_HARDWARE;
}
await Keychain.setGenericPassword(service, password, opts);
return hexToUint8Array(password);
}
/**
* Returns a per-wallet Realm instance keyed by `namespace`. Each Ark wallet
* gets its own encrypted Realm file and its own Keychain entry so wallets
* never collide on WalletState/contracts/swaps and storage buckets stay
* isolated.
*
* Concurrent callers for the same namespace receive the same in-flight
* promise. Errors are surfaced to the caller; the in-flight entry is cleared
* so a later retry can succeed.
*/
export async function getArkadeRealm(namespace: string): Promise<Realm> {
const cached = realmInstances.get(namespace);
if (cached && !cached.isClosed) return cached;
if (cached && cached.isClosed) realmInstances.delete(namespace);
const inFlight = openInFlight.get(namespace);
if (inFlight) return inFlight;
const opening = (async () => {
await ensureArkadeDir();
const encryptionKey = await loadOrCreateEncryptionKey(namespace);
const realm = await Realm.open({
schema: AllArkadeSchemas as unknown as Realm.ObjectSchema[],
schemaVersion: ARKADE_REALM_SCHEMA_VERSION,
onMigration: (oldRealm, newRealm) => {
runArkRealmMigrations(oldRealm, newRealm);
},
path: realmPathFor(namespace),
encryptionKey,
excludeFromIcloudBackup: true,
});
realmInstances.set(namespace, realm);
return realm;
})();
openInFlight.set(namespace, opening);
try {
return await opening;
} finally {
openInFlight.delete(namespace);
}
}
/**
* Close the cached Realm for `namespace`, if any. The file and Keychain
* entry are preserved.
*/
export function closeArkadeRealm(namespace: string): void {
const realm = realmInstances.get(namespace);
if (realm && !realm.isClosed) {
realm.removeAllListeners();
realm.close();
}
realmInstances.delete(namespace);
}
/**
* Close every cached Arkade Realm instance. Used on app shutdown / sign out.
*/
export function closeAllArkadeRealms(): void {
for (const ns of Array.from(realmInstances.keys())) {
closeArkadeRealm(ns);
}
}
/**
* Delete the Realm file and the Keychain entry for `namespace`. Used when
* an Ark wallet is removed. Failures are logged but do not throw leaving
* an orphan file or Keychain entry is preferable to crashing the app's
* delete path. Ark Realm failures stay scoped to the Ark wallet path.
*
* The Keychain encryption key is reset only when the Realm file is gone
* (or never existed). Resetting the key while the encrypted file remains
* would leave the user unable to open the orphan on a future re-import:
* a fresh random key would be generated and the old file's ciphertext
* could not be decrypted.
*/
export async function deleteArkadeRealm(namespace: string): Promise<void> {
closeArkadeRealm(namespace);
const path = realmPathFor(namespace);
let realmRemoved = false;
try {
// Realm.deleteFile is sync and removes the .realm + .lock + .management
// siblings in one call. It is forgiving when the file does not exist
// (no-op), but we guard via Realm.exists to keep behavior explicit.
if (Realm.exists(path)) {
Realm.deleteFile({ path });
}
realmRemoved = true;
} catch (e: any) {
console.log(`[ArkadeRealm] Realm.deleteFile failed for ${path}:`, e?.message ?? e);
}
// Best-effort sweep of any sibling files Realm.deleteFile might have left
// behind. These are not load-bearing for re-import; failures are tolerated.
for (const suffix of ['.note']) {
const sibling = `${path}${suffix}`;
try {
if (await RNFS.exists(sibling)) await RNFS.unlink(sibling);
} catch (e: any) {
console.log(`[ArkadeRealm] failed to delete ${sibling}:`, e?.message ?? e);
}
}
if (!realmRemoved) {
console.log(
`[ArkadeRealm] keeping encryption key for ${namespace} because Realm file cleanup failed; key preserved so a future delete retry can still decrypt the orphan`,
);
return;
}
try {
await Keychain.resetGenericPassword({ service: keychainServiceFor(namespace) });
} catch (e: any) {
console.log(`[ArkadeRealm] failed to reset keychain for ${namespace}:`, e?.message ?? e);
}
}
// Exported for tests only.
export const __testing__ = {
realmInstances,
openInFlight,
realmPathFor,
keychainServiceFor,
};

View File

@ -1,423 +0,0 @@
// Background task module for Ark swap monitoring.
//
// Responsibilities:
// - Passive monitoring: poll Boltz swap status for non-terminal swaps in
// every Ark wallet's per-wallet Realm and persist remote changes through
// the SDK update helpers.
// - Post a local notification when an SDK predicate flags a swap as
// claimable/refundable. No claim, refund, recover, or signing happens in
// background — those remain foreground-only.
//
// State here is in-process: it survives configure→fetch→fetch ticks within a
// single JS runtime but is gone after process kill. Realm remains the
// durable source of truth for swap status and notification suppression.
import BackgroundFetch from 'react-native-background-fetch';
import {
BoltzSwapProvider,
isChainFinalStatus,
isReverseFinalStatus,
isSubmarineFinalStatus,
updateChainSwapStatus,
updateReverseSwapStatus,
updateSubmarineSwapStatus,
} from '@arkade-os/boltz-swap';
import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap';
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { getArkadeRealm } from './arkade-adapters/realm/realmInstance';
import {
RealmNotificationSuppressionRepository,
type ArkSwapNotificationAction,
} from './arkade-adapters/realm/notificationSuppressionRepository';
import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications';
const BlueApp = BlueAppClass.getInstance();
// Single shared provider. The constructor only stores config; it does not
// open sockets. Re-using one instance avoids per-poll allocation.
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' });
const DEFAULT_MAX_RUN_MS = 25_000;
let maxRunMs = DEFAULT_MAX_RUN_MS;
interface ArkTaskState {
lastRegisteredAt: number | null;
lastUnregisteredAt: number | null;
lastRunStartedAt: number | null;
lastRunFinishedAt: number | null;
walletsScanned: number;
swapsPolled: number;
swapsUpdated: number;
lastError: string | null;
exitedDueToUnavailableStorage: boolean;
availability: 'unknown' | 'available' | 'denied' | 'restricted';
// Set whenever swapsUpdated is incremented. Used by reconcile() to detect
// updates that crossed run boundaries (per-run swapsUpdated is reset).
lastSwapUpdateAt: number;
lastReconciledAt: number;
}
const state: ArkTaskState = {
lastRegisteredAt: null,
lastUnregisteredAt: null,
lastRunStartedAt: null,
lastRunFinishedAt: null,
walletsScanned: 0,
swapsPolled: 0,
swapsUpdated: 0,
lastError: null,
exitedDueToUnavailableStorage: false,
availability: 'unknown',
lastSwapUpdateAt: 0,
lastReconciledAt: 0,
};
// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key:
// swap ID; value: last status this background module observed. Diagnostic +
// reconciliation hint only — Realm is durable.
const swapStatusCache: Map<string, Map<string, string>> = new Map();
// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`.
// Used to detect predicate flips (true → false or claim ↔ refund) so we can
// clear the corresponding Realm suppression row even when the swap status
// has not yet reached a terminal state. In-process only; cleared by
// stopArkBackgroundTask so a later run does not falsely diagnose a flip on
// the first poll after restart.
const lastSeenActionMap: Map<string, ArkSwapNotificationAction> = new Map();
let configured = false;
let running = false;
let cancelRequested = false;
let runDeadline: number | null = null;
export function getArkTaskState(): Readonly<ArkTaskState> {
return Object.freeze({ ...state });
}
function recordError(message: string): void {
state.lastError = message;
}
function shouldStopRun(): boolean {
return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline);
}
function remainingRunMs(): number {
if (runDeadline === null) return maxRunMs;
return Math.max(runDeadline - Date.now(), 0);
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('deadline exceeded')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function isFinalStatus(swap: BoltzSwap): boolean {
switch (swap.type) {
case 'reverse':
return isReverseFinalStatus(swap.status);
case 'submarine':
return isSubmarineFinalStatus(swap.status);
case 'chain':
return isChainFinalStatus(swap.status);
}
}
async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise<void> {
if (swap.type === 'reverse') {
await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s));
} else if (swap.type === 'submarine') {
await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s));
} else {
await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s));
}
}
async function pollSwap(
swap: BoltzSwap,
namespace: string,
repo: RealmSwapRepository,
suppression: RealmNotificationSuppressionRepository,
walletID: string,
walletLabel: string,
): Promise<void> {
if (shouldStopRun()) return;
state.swapsPolled += 1;
let response;
try {
response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs());
} catch (e: any) {
recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`);
if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true;
return;
}
if (shouldStopRun()) return;
const remoteStatus = response.status;
const statusChanged = remoteStatus !== swap.status;
// The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do
// not mutate `swap`, so any post-persist predicate or terminal check on
// `swap` would read the pre-update status. effectiveSwap carries the
// status we want subsequent checks to evaluate against.
const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap;
if (statusChanged) {
try {
await persistStatusChange(swap, remoteStatus, repo);
} catch (e: any) {
recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`);
return;
}
state.swapsUpdated += 1;
state.lastSwapUpdateAt = Date.now();
let perWallet = swapStatusCache.get(namespace);
if (!perWallet) {
perWallet = new Map();
swapStatusCache.set(namespace, perWallet);
}
perWallet.set(swap.id, remoteStatus);
}
// Actionable evaluation runs on every non-terminal poll, NOT only after a
// status change. Otherwise a swap that became actionable in a previous run
// but never received a successful post (notify failed mid-run, OS-level
// drop, permission-denied skip, app cold-started with already-actionable
// Realm state) would never be re-checked because subsequent polls observe
// remoteStatus === swap.status and would otherwise exit. The Realm
// suppression repo is the dedup layer.
const lastKey = `${namespace}:${effectiveSwap.id}`;
if (isFinalStatus(effectiveSwap)) {
try {
suppression.clearForSwap(effectiveSwap.id);
} catch (e: any) {
recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.delete(lastKey);
return;
}
const action = resolveActionableAction(effectiveSwap);
const lastSeen = lastSeenActionMap.get(lastKey);
if (lastSeen && lastSeen !== action) {
// Predicate flipped out of `lastSeen` (either to null or to the other
// action). Clear the stale suppression so the next observed flip back
// re-fires.
try {
suppression.clearForSwapAction(effectiveSwap.id, lastSeen);
} catch (e: any) {
recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`);
}
}
if (action) {
try {
await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel);
} catch (e: any) {
recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.set(lastKey, action);
} else {
lastSeenActionMap.delete(lastKey);
}
}
async function processWallet(wallet: LightningArkWallet): Promise<void> {
state.walletsScanned += 1;
const namespace = wallet.getNamespace();
const walletID = wallet.getID();
const walletLabel = wallet.getLabel();
let realm;
try {
realm = await getArkadeRealm(namespace);
} catch (e: any) {
// Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or
// the Realm file is unreachable. Either way the background task no-ops
// for this wallet — claim/refund is foreground-only anyway.
state.exitedDueToUnavailableStorage = true;
recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`);
return;
}
let swaps: BoltzSwap[];
const repo = new RealmSwapRepository(realm as any);
const suppression = new RealmNotificationSuppressionRepository(realm);
try {
swaps = await repo.getAllSwaps<BoltzSwap>();
} catch (e: any) {
recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`);
return;
}
for (const swap of swaps) {
if (isFinalStatus(swap)) continue;
if (shouldStopRun()) return;
await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel);
}
}
export async function runArkBackgroundTask(taskId: string): Promise<void> {
if (running) {
BackgroundFetch.finish(taskId);
return;
}
running = true;
cancelRequested = false;
runDeadline = Date.now() + maxRunMs;
state.lastRunStartedAt = Date.now();
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.exitedDueToUnavailableStorage = false;
try {
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
if (wallets.length === 0) return;
for (const wallet of wallets) {
if (shouldStopRun()) break;
try {
await processWallet(wallet);
} catch (e: any) {
recordError(`processWallet: ${e?.message ?? e}`);
}
}
} finally {
state.lastRunFinishedAt = Date.now();
runDeadline = null;
cancelRequested = false;
running = false;
BackgroundFetch.finish(taskId);
}
}
export function onArkBackgroundTaskTimeout(taskId: string): void {
cancelRequested = true;
state.lastError = 'timeout';
state.lastRunFinishedAt = Date.now();
BackgroundFetch.finish(taskId);
}
function availabilityFromStatus(status: number): ArkTaskState['availability'] {
if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available';
if (status === BackgroundFetch.STATUS_DENIED) return 'denied';
if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted';
return 'unknown';
}
export async function registerArkBackgroundTask(): Promise<void> {
if (configured) {
await BackgroundFetch.start();
state.lastRegisteredAt = Date.now();
return;
}
const config: Parameters<typeof BackgroundFetch.configure>[0] = {
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
};
try {
const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout);
state.availability = availabilityFromStatus(status);
if (state.availability === 'available') {
configured = true;
state.lastRegisteredAt = Date.now();
} else {
console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`);
}
} catch (e: any) {
recordError(`configure: ${e?.message ?? e}`);
}
}
export async function stopArkBackgroundTask(): Promise<void> {
cancelRequested = true;
try {
await BackgroundFetch.stop();
} catch (e: any) {
recordError(`stop: ${e?.message ?? e}`);
}
// Await in-flight run completion (draining). A live background run keeps
// Detox's FabricTimersIdlingResource busy and disconnects the JS bridge.
const start = Date.now();
// eslint-disable-next-line no-unmodified-loop-condition
while (running && Date.now() - start < 30_000) {
await new Promise(resolve => setTimeout(resolve, 50));
}
swapStatusCache.clear();
// Clear in-process predicate-flip tracker so a later run does not
// diagnose a flip on the first poll after restart. Persistent suppression
// (Realm) is intentionally untouched — re-registering must keep history.
lastSeenActionMap.clear();
state.lastUnregisteredAt = Date.now();
}
export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void {
if (state.lastSwapUpdateAt <= state.lastReconciledAt) return;
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
for (const wallet of wallets) {
const namespace = wallet.getNamespace();
const perWallet = swapStatusCache.get(namespace);
if (perWallet && perWallet.size > 0) {
triggerRefreshForWallet(wallet.getID());
}
}
state.lastReconciledAt = Date.now();
}
// Exported for tests only.
export const __testing__ = {
state,
swapStatusCache,
lastSeenActionMap,
resetConfigured: (): void => {
configured = false;
},
setMaxRunMs: (ms: number): void => {
maxRunMs = ms;
},
reset: (): void => {
state.lastRegisteredAt = null;
state.lastUnregisteredAt = null;
state.lastRunStartedAt = null;
state.lastRunFinishedAt = null;
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.lastError = null;
state.exitedDueToUnavailableStorage = false;
state.availability = 'unknown';
state.lastSwapUpdateAt = 0;
state.lastReconciledAt = 0;
swapStatusCache.clear();
lastSeenActionMap.clear();
configured = false;
running = false;
cancelRequested = false;
runDeadline = null;
maxRunMs = DEFAULT_MAX_RUN_MS;
},
};

View File

@ -1,163 +0,0 @@
// Local-notification posting for actionable Ark swaps. Imported from headless
// background runtimes (no React dependency).
//
// Design notes:
// - Suppression state lives per-wallet in the Arkade Realm
// (RealmNotificationSuppressionRepository), not in a global AsyncStorage
// key — bucket-scoped and encrypted, so the suppression record never
// leaks a stable handle outside the wallet's encryption boundary.
// - Permission and app-level opt-out are checked read-only before each post
// (no prompting from headless context). Suppression is NOT recorded when
// the post is skipped, so a later state where the user grants permission
// triggers a fresh post on the next wake.
// - Notification payload deliberately does NOT include `namespace`. The OS
// notification database persists payloads and is global across BlueWallet
// encryption buckets; embedding a deterministic per-wallet identifier
// would tie a stable handle to the OS-visible record.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState, Platform } from 'react-native';
import { Notification, Notifications } from 'react-native-notifications';
import { checkNotifications, RESULTS } from 'react-native-permissions';
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
import type { BoltzSwap } from '@arkade-os/boltz-swap';
import loc from '../loc';
import { NOTIFICATIONS_NO_AND_DONT_ASK_FLAG } from './notifications';
import type {
RealmNotificationSuppressionRepository,
ArkSwapNotificationAction,
} from './arkade-adapters/realm/notificationSuppressionRepository';
export const ARK_SWAP_NOTIFICATION_TYPE = 100;
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
let channelEnsured = false;
export function ensureArkNotificationChannel(): void {
if (Platform.OS !== 'android') return;
if (channelEnsured) return;
channelEnsured = true;
// Reuses the BlueWallet channel from blue_modules/notifications.ts:80-91 so
// headless runs do not register a second channel under a different name.
Notifications.setNotificationChannel({
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
name: 'BlueWallet notifications',
description: 'Notifications about incoming payments',
importance: 4,
enableVibration: true,
showBadge: true,
});
}
// Channel registration runs lazily on the first post (see notifyArkSwapActionable).
// Calling it at module-top would invoke the native bridge during JS bundle
// evaluation, which racy-blocks RN bootstrap on some devices and breaks
// Detox's RN-context wait. The existing blue_modules/notifications.ts pattern
// also defers channel setup to lazy invocation.
export function resolveActionableAction(swap: BoltzSwap): ArkSwapNotificationAction | null {
if (isReverseSwapClaimable(swap) || isChainSwapClaimable(swap)) return 'claim';
if (isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap)) return 'refund';
return null;
}
const interpolate = (template: string, walletLabel: string): string => template.replace('{walletLabel}', walletLabel);
// Static references so scripts/find-unused-loc.js can detect these keys.
const titleFor = (): string => loc.lndViewInvoice.notification_action_title;
const bodyFor = (action: ArkSwapNotificationAction): string =>
action === 'claim' ? loc.lndViewInvoice.notification_claim_body : loc.lndViewInvoice.notification_refund_body;
let appStateOverrideForTest: string | null = null;
let permissionResultOverrideForTest: string | null = null;
let optOutFlagOverrideForTest: string | null | undefined;
function currentAppState(): string {
return appStateOverrideForTest ?? AppState.currentState;
}
async function isOsNotificationPermissionGranted(): Promise<boolean> {
if (permissionResultOverrideForTest !== null) {
return permissionResultOverrideForTest === RESULTS.GRANTED;
}
try {
const { status } = await checkNotifications();
return status === RESULTS.GRANTED;
} catch {
return false;
}
}
async function isAppLevelOptedOut(): Promise<boolean> {
if (optOutFlagOverrideForTest !== undefined) {
return optOutFlagOverrideForTest === 'true';
}
try {
const flag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
return flag === 'true';
} catch {
return false;
}
}
export async function notifyArkSwapActionable(
swap: BoltzSwap,
suppression: RealmNotificationSuppressionRepository,
walletID: string,
walletLabel: string,
): Promise<void> {
const action = resolveActionableAction(swap);
if (!action) return;
if (currentAppState() === 'active') return;
if (suppression.has(swap.id, action)) return;
if (!(await isOsNotificationPermissionGranted())) return;
if (await isAppLevelOptedOut()) return;
ensureArkNotificationChannel();
const title = titleFor();
const body = interpolate(bodyFor(action), walletLabel);
try {
Notifications.postLocalNotification(
// namespace is intentionally omitted; tap routing re-derives it from the loaded wallet.
new Notification({
title,
body,
type: ARK_SWAP_NOTIFICATION_TYPE,
walletID,
swapId: swap.id,
action,
}),
);
} catch (e: any) {
console.warn('[ArkNotifications] postLocalNotification failed:', e?.message ?? e);
return;
}
try {
suppression.record(swap.id, action);
} catch (e: any) {
console.warn('[ArkNotifications] suppression.record failed:', e?.message ?? e);
}
}
export const __testing__ = {
resetChannel: (): void => {
channelEnsured = false;
},
setAppStateForTest: (state: string | null): void => {
appStateOverrideForTest = state;
},
setPermissionResultForTest: (result: string | null): void => {
permissionResultOverrideForTest = result;
},
setOptOutFlagForTest: (value: string | null | undefined): void => {
optOutFlagOverrideForTest = value;
},
};

View File

@ -2,7 +2,4 @@
* Let's keep config vars, constants and definitions here
*/
export const groundControlUri: string = 'https://groundcontrol.bluewallet.io';
/** bitcoin-payment-push-service base URL, no trailing slash. Empty = disabled. */
export const arkadePaymentPushUri: string = 'https://electrum2.bluewallet.io:444';
export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com';

View File

@ -1,98 +1,23 @@
import { cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { randomBytes } from '@noble/hashes/utils';
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
/**
* OpenSSL EVP_BytesToKey using MD5 with 1 iteration.
*
* Reproduces the default key+IV derivation used by CryptoJS@4.x's
* `AES.encrypt(string, password)` so the on-disk wire format stays
* bit-identical after we swap the underlying library.
*
* D1 = MD5( password || salt )
* Di = MD5( D(i-1) || password || salt ) for i 2
* key||iv = D1 || D2 || ... (take first `byteLength` bytes)
*
* MD5 is intentional: it matches the legacy OpenSSL format. The
* cryptographic weakness of MD5 is not relevant here the function is
* only used as a deterministic byte-stretcher; the password's entropy is
* what protects the wallet, not MD5.
*/
export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array {
if (!Number.isInteger(byteLength) || byteLength < 0) {
throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer');
}
const out = new Uint8Array(byteLength);
let written = 0;
let prev: Uint8Array = new Uint8Array(0);
while (written < byteLength) {
prev = md5(concatUint8Arrays([prev, password, salt]));
const take = Math.min(prev.length, byteLength - written);
out.set(prev.subarray(0, take), written);
written += take;
}
return out;
}
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
// format cannot drift through any encoder.
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
const SALT_LEN = 8;
const KEY_LEN = 32;
const IV_LEN = 16;
const BLOCK_LEN = 16;
/**
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
* key derivation and PKCS7 padding. Output is base64-encoded.
*
* Wire format is bit-identical to CryptoJS@4.x's default
* `AES.encrypt(data, password).toString()` we kept the swap-the-library
* change a drop-in replacement so existing encrypted wallets on user
* devices remain readable, with no migration step.
*/
export function encrypt(data: string, password: string): string {
if (data.length < 10) throw new Error('data length cant be < 10');
const salt = randomBytes(SALT_LEN);
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
const key = kdf.subarray(0, KEY_LEN);
const iv = kdf.subarray(KEY_LEN);
const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data));
return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext]));
const ciphertext = AES.encrypt(data, password);
return ciphertext.toString();
}
/**
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
* password, bad padding) collapses to `false`.
*/
export function decrypt(data: string, password: string): string | false {
const bytes = AES.decrypt(data, password);
let str: string | false = false;
try {
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
// export/import flows (manual file paste, clipboard transit, email-based
// wallet transfer) introduced stray newlines or padding spaces. Strip them
// before strict base64 decode so legacy backups still open. `\s` does not
// include `=`, so base64 padding survives.
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
const key = kdf.subarray(0, KEY_LEN);
const iv = kdf.subarray(KEY_LEN);
const plain = cbc(key, iv).decrypt(ciphertext);
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
// strict too; we preserve that gate by using `fatal: true`).
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
// (enforced by encrypt()), so anything shorter is rejected.
if (str.length < 10) return false;
return str;
} catch (e) {
return false;
}
str = bytes.toString(Utf8);
} catch (e) {}
// For some reason, sometimes decrypt would succeed with an incorrect password and return random characters.
// In this TypeScript version, we are not allowing the encryption of data that is shorter than
// 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed.
if (str && str.length < 10) return false;
return str;
}

View File

@ -2,7 +2,7 @@ import { Platform } from 'react-native';
import { pick, types, keepLocalCopy, errorCodes } from '@react-native-documents/picker';
import RNFS from 'react-native-fs';
import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker';
import { detectQRCodeInImage } from 'react-native-camera-kit-no-google';
import RNQRGenerator from 'rn-qr-generator';
import Share from 'react-native-share';
import presentAlert from '../components/Alert';
@ -113,7 +113,6 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
maxHeight: 800,
maxWidth: 600,
selectionLimit: 1,
includeBase64: true,
});
if (response.didCancel) {
@ -121,12 +120,19 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
} else if (response.errorCode) {
throw new Error(response.errorMessage);
} else if (response.assets) {
const base64 = response.assets[0].base64;
if (base64) {
const result = await detectQRCodeInImage(base64);
if (result) return result;
try {
const uri = response.assets[0].uri;
if (uri) {
const result = await RNQRGenerator.detect({ uri: decodeURI(uri.toString()) });
if (result?.values.length > 0) {
return result?.values[0];
}
}
throw new Error(loc.send.qr_error_no_qrcode);
} catch (error) {
console.error(error);
presentAlert({ message: loc.send.qr_error_no_qrcode });
}
throw new Error(loc.send.qr_error_no_qrcode);
}
return undefined;
@ -180,21 +186,31 @@ export const showFilePickerAndReadFile = async function (): Promise<{ data: stri
}
};
const readFileAsBase64 = async (uri: string): Promise<string> => {
try {
return await RNFS.readFile(uri, 'base64');
} catch {
return await RNFS.readFile(uri.replace(/^file:\/\//, ''), 'base64');
}
};
const handleImageFile = async (fileCopyUri: string): Promise<{ data: string | false; uri: string | false }> => {
const base64 = await readFileAsBase64(fileCopyUri);
const result = await detectQRCodeInImage(base64);
if (result) {
return { data: result, uri: fileCopyUri };
try {
const exists = await RNFS.exists(fileCopyUri);
if (!exists) {
presentAlert({ message: 'File does not exist' });
return { data: false, uri: false };
}
// First attempt: use original URI
let result = await RNQRGenerator.detect({ uri: decodeURI(fileCopyUri) });
if (result?.values && result.values.length > 0) {
return { data: result.values[0], uri: fileCopyUri };
}
// Second attempt: remove file:// prefix and try again
const altUri = fileCopyUri.replace(/^file:\/\//, '');
result = await RNQRGenerator.detect({ uri: decodeURI(altUri) });
if (result?.values && result.values.length > 0) {
return { data: result.values[0], uri: fileCopyUri };
}
presentAlert({ message: loc.send.qr_error_no_qrcode });
return { data: false, uri: false };
} catch (error: any) {
console.error(error);
presentAlert({ message: loc.send.qr_error_no_qrcode });
return { data: false, uri: false };
}
throw new Error(loc.send.qr_error_no_qrcode);
};
export const readFileOutsideSandbox = (filePath: string) => {

View File

@ -26,93 +26,44 @@ export interface TinySecp256k1InterfaceExtended {
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
}
// @noble/hashes types differ slightly from @noble/secp256k1 v3 hash slot typings.
necc.hashes.sha256 = sha256 as NonNullable<typeof necc.hashes.sha256>;
necc.hashes.hmacSha256 = ((key: Uint8Array, message: Uint8Array) => hmac(sha256, key, message)) as NonNullable<
typeof necc.hashes.hmacSha256
>;
// Removed from @noble/secp256k1 v1.7; vendored from noble test vectors.
// @see https://github.com/paulmillr/noble-secp256k1/blob/1.7.2/test/index.ts
type Hex = string | Uint8Array;
const { mod, secretKeyToScalar, numberToBytesBE, bytesToNumberBE, hexToBytes } = necc.etc;
const CURVE_N = necc.Point.CURVE().n;
function pointFromBytes(p: Uint8Array): necc.Point {
if (p.length === 32) {
const prefixed = new Uint8Array(33);
prefixed[0] = 0x02;
prefixed.set(p, 1);
return necc.Point.fromBytes(prefixed);
}
return necc.Point.fromBytes(p);
}
const tweakUtils = {
privateAdd: (privateKey: Hex, tweak: Hex): Uint8Array => {
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
return numberToBytesBE(mod(p + t, CURVE_N));
},
privateNegate: (privateKey: Hex): Uint8Array => {
const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey);
return numberToBytesBE(CURVE_N - p);
},
pointAddScalar: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak);
const Q = P.add(necc.Point.BASE.multiply(t));
if (Q.is0()) throw new Error('Tweaked point at infinity');
return Q.toBytes(isCompressed);
},
pointMultiply: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => {
const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p);
const tweakBytes = typeof tweak === 'string' ? hexToBytes(tweak) : tweak;
const t = mod(bytesToNumberBE(tweakBytes), CURVE_N);
if (t === 0n) throw new Error('Point at infinity');
return P.multiply(t).toBytes(isCompressed);
},
necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => {
const combinedMessages = messages.reduce((acc, msg) => {
const newArray = new Uint8Array(acc.length + msg.length);
newArray.set(acc);
newArray.set(msg, acc.length);
return newArray;
}, new Uint8Array(0));
return sha256(combinedMessages);
};
necc.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => {
const combinedMessages = messages.reduce((acc, msg) => {
const newArray = new Uint8Array(acc.length + msg.length);
newArray.set(acc);
newArray.set(msg, acc.length);
return newArray;
}, new Uint8Array(0));
return hmac(sha256, key, combinedMessages);
};
/* const normal = necc.utils._normalizePrivateKey;
type Hex = string | Uint8Array;
type PrivKey = Hex | bigint | number;
necc.utils.privateAdd = (privateKey: PrivKey, tweak: Hex) => {
console.log({ privateKey, tweak });
const p = normal(privateKey);
const t = normal(tweak);
return necc.utils.privateAdd(necc.utils.mod(p + t, necc.CURVE.n));
}; */
const defaultTrue = (param?: boolean): boolean => param !== false;
function compactToDER(sig: Uint8Array): Uint8Array {
const encodeInt = (bytes: Uint8Array): Uint8Array => {
let i = 0;
while (i < bytes.length - 1 && bytes[i] === 0) i++;
let trimmed = bytes.subarray(i);
if (trimmed[0] >= 0x80) {
const prefixed = new Uint8Array(trimmed.length + 1);
prefixed[0] = 0;
prefixed.set(trimmed, 1);
trimmed = prefixed;
}
const encoded = new Uint8Array(2 + trimmed.length);
encoded[0] = 0x02;
encoded[1] = trimmed.length;
encoded.set(trimmed, 2);
return encoded;
};
const rDer = encodeInt(sig.subarray(0, 32));
const sDer = encodeInt(sig.subarray(32, 64));
const seqLen = rDer.length + sDer.length;
const der = new Uint8Array(2 + seqLen);
der[0] = 0x30;
der[1] = seqLen;
der.set(rDer, 2);
der.set(sDer, 2 + rDer.length);
return der;
}
function throwToNull<Type>(fn: () => Type): Type | null {
try {
return fn();
} catch (e) {
// console.log(e);
return null;
}
}
@ -120,8 +71,7 @@ function throwToNull<Type>(fn: () => Type): Type | null {
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
if ((p.length === 32) !== xOnly) return false;
try {
pointFromBytes(p);
return true;
return !!necc.Point.fromHex(p);
} catch (e) {
return false;
}
@ -129,12 +79,23 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean {
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d),
isPrivate: (d: Uint8Array): boolean => {
/* if (
[
'0000000000000000000000000000000000000000000000000000000000000000',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142',
].includes(d.toString('hex'))
) {
return false;
} */
return necc.utils.isValidPrivateKey(d);
},
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
throwToNull(() => {
const P = tweakUtils.pointAddScalar(p, tweak, true);
const P = necc.utils.pointAddScalar(p, tweak, true);
const parity = P[0] % 2 === 1 ? 1 : 0;
return { parity, xOnlyPubkey: P.slice(1) };
}),
@ -143,56 +104,60 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
return pointFromBytes(p).toBytes(defaultTrue(compressed));
return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed));
},
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))),
throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))),
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => {
const A = pointFromBytes(a);
const B = pointFromBytes(b);
return A.add(B).toBytes(defaultTrue(compressed));
const A = necc.Point.fromHex(a);
const B = necc.Point.fromHex(b);
return A.add(B).toRawBytes(defaultTrue(compressed));
}),
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
throwToNull(() => tweakUtils.pointAddScalar(p, tweak, defaultTrue(compressed))),
throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))),
privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
throwToNull(() => {
// console.log({ d, tweak });
if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') {
return new Uint8Array(d); // make test_ecc happy
}
const ret = tweakUtils.privateAdd(d, tweak);
const ret = necc.utils.privateAdd(d, tweak);
// console.log(ret);
if (ret.join('') === '00000000000000000000000000000000') {
return null;
}
return ret;
}),
privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d),
privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d),
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return necc.sign(h, d, { prehash: false, extraEntropy: e });
return necc.signSync(h, d, { der: false, extraEntropy: e });
},
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e }));
return necc.signSync(h, d, { der: true, extraEntropy: e });
},
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
return necc.schnorr.sign(h, d, e);
return necc.schnorr.signSync(h, d, e);
},
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false });
return necc.verify(signature, h, Q, { strict });
},
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
return necc.schnorr.verify(signature, h, Q);
return necc.schnorr.verifySync(signature, h, Q);
},
};
export default ecc;
// module.exports.ecc = ecc;

View File

@ -1,176 +1,47 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native';
import PushNotificationIOS from '@react-native-community/push-notification-ios';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info';
import {
Notification as RNNotification,
NotificationBackgroundFetchResult,
NotificationCompletion,
Notifications,
} from 'react-native-notifications';
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
import PushNotification, { ReceivedNotification } from 'react-native-push-notification';
import loc from '../loc';
import { arkadePaymentPushUri, groundControlUri } from './constants';
import { groundControlUri } from './constants';
import { fetch } from '../util/fetch';
const PUSH_TOKEN = 'PUSH_TOKEN';
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
const baseURI = groundControlUri;
let notificationSubscriptions: EmitterSubscription[] = [];
let onProcessNotificationsHandler: undefined | (() => void | Promise<void>);
const handledNotificationKeys = new Set<string>();
let pendingRegistrationPromise: Promise<boolean> | null = null;
let pendingRegistrationResolve: ((value: boolean) => void) | null = null;
let pendingRegistrationTimeout: ReturnType<typeof setTimeout> | undefined;
let alreadyConfigured = false;
let baseURI = groundControlUri;
type TPushToken = {
token: string;
os: 'ios' | 'android';
os: string; // its actually ('ios' | 'android'), but types for the lib are a bit more generic...
};
// thats unwrapped `ReceivedNotification`, withall `data` fields inline
type TPayload = {
// inherited from `ReceivedNotification`:
subText?: string;
title?: string;
identifier?: string;
message?: string | object;
foreground: boolean;
userInteraction: boolean;
// hopefully stuffed in `data` and uwrapped when received:
address: string;
txid: string;
type: number;
hash: string;
[key: string]: any;
};
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
const createPushToken = (deviceToken: string): TPushToken => ({
token: deviceToken,
os: Platform.OS as TPushToken['os'],
});
const settlePendingRegistration = (value: boolean) => {
if (!pendingRegistrationResolve) return;
const resolve = pendingRegistrationResolve;
pendingRegistrationResolve = null;
pendingRegistrationPromise = null;
if (pendingRegistrationTimeout) {
clearTimeout(pendingRegistrationTimeout);
pendingRegistrationTimeout = undefined;
}
resolve(value);
};
const waitForRemoteRegistration = (timeoutMs = 10_000): Promise<boolean> => {
if (pendingRegistrationPromise) return pendingRegistrationPromise;
pendingRegistrationPromise = new Promise<boolean>(resolve => {
pendingRegistrationResolve = resolve;
pendingRegistrationTimeout = setTimeout(() => {
settlePendingRegistration(false);
}, timeoutMs);
});
Notifications.registerRemoteNotifications();
return pendingRegistrationPromise;
};
const ensureAndroidNotificationChannel = () => {
if (Platform.OS !== 'android') return;
Notifications.setNotificationChannel({
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
name: 'BlueWallet notifications',
description: 'Notifications about incoming payments',
importance: 4,
enableVibration: true,
showBadge: true,
});
};
const getNotificationKey = (payload: Partial<TPayload>, notification?: RNNotification) => {
return JSON.stringify({
identifier: notification?.identifier ?? payload.identifier ?? '',
type: payload.type ?? '',
hash: payload.hash ?? '',
txid: payload.txid ?? '',
address: payload.address ?? '',
message: payload.message ?? '',
});
};
const markNotificationHandled = (key: string) => {
handledNotificationKeys.add(key);
if (handledNotificationKeys.size > 100) {
const oldestKey = handledNotificationKeys.values().next().value;
if (oldestKey) handledNotificationKeys.delete(oldestKey);
}
};
const normalizeNotificationPayload = (notification: RNNotification, status: Pick<TPayload, 'foreground' | 'userInteraction'>): TPayload => {
const rawPayload =
notification.payload && typeof notification.payload === 'object' ? (deepClone(notification.payload) as Record<string, any>) : {};
const nestedPayload = rawPayload.data && typeof rawPayload.data === 'object' ? rawPayload.data : {};
const nestedData = nestedPayload.data && typeof nestedPayload.data === 'object' ? nestedPayload.data : {};
const payload: TPayload = {
...rawPayload,
...nestedPayload,
...nestedData,
title: notification.title ?? rawPayload.title,
subText: rawPayload.subText ?? rawPayload.subtitle ?? notification.title,
message: rawPayload.message ?? notification.body,
identifier: notification.identifier,
foreground: status.foreground,
userInteraction: status.userInteraction,
} as TPayload;
delete payload.data;
return payload;
};
const storeIncomingNotification = async (
notification: RNNotification,
status: Pick<TPayload, 'foreground' | 'userInteraction'>,
completion?: ((response: NotificationCompletion) => void) | ((response: NotificationBackgroundFetchResult) => void),
) => {
try {
const payload = normalizeNotificationPayload(notification, status);
const notificationKey = getNotificationKey(payload, notification);
if (handledNotificationKeys.has(notificationKey)) {
return;
}
markNotificationHandled(notificationKey);
if (!payload.subText && !payload.message) {
console.warn('Notification missing required fields:', payload);
return;
}
await addNotification(payload);
if (payload.foreground && onProcessNotificationsHandler) {
await onProcessNotificationsHandler();
}
} catch (error) {
console.error('Failed to store incoming notification:', error);
} finally {
if (completion) {
if (status.foreground) {
(completion as (response: NotificationCompletion) => void)({ alert: false, sound: false, badge: false });
} else {
(completion as (response: NotificationBackgroundFetchResult) => void)(NotificationBackgroundFetchResult.NO_DATA);
}
}
}
};
const checkAndroidNotificationPermission = async () => {
try {
const { status } = await checkNotifications();
console.log('Notification permission check:', status);
console.debug('Notification permission check:', status);
return status === RESULTS.GRANTED;
} catch (err) {
console.error('Failed to check notification permission:', err);
@ -219,14 +90,22 @@ export const cleanUserOptOutFlag = async () => {
* Should be called when user is most interested in receiving push notifications.
* If we dont have a token it will show alert asking whether
* user wants to receive notifications, and if yes - will configure push notifications.
* FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask,
* we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box.
*
* @returns {Promise<boolean>} TRUE if permissions were obtained, FALSE otherwise
*/
export const tryToObtainPermissions = async (): Promise<boolean> => {
console.log('tryToObtainPermissions: Starting user-triggered permission request');
/**
* Attempts to obtain permissions and configure notifications.
* Shows a rationale on Android if permissions are needed.
*
* @returns {Promise<boolean>}
*/
export const tryToObtainPermissions = async () => {
console.debug('tryToObtainPermissions: Starting user-triggered permission request');
if (!isNotificationsCapable) {
console.log('tryToObtainPermissions: Device not capable');
console.debug('tryToObtainPermissions: Device not capable');
return false;
}
@ -243,7 +122,7 @@ export const tryToObtainPermissions = async (): Promise<boolean> => {
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
);
if (status !== RESULTS.GRANTED) {
console.log('tryToObtainPermissions: Permission denied');
console.debug('tryToObtainPermissions: Permission denied');
return false;
}
return configureNotifications();
@ -252,29 +131,6 @@ export const tryToObtainPermissions = async (): Promise<boolean> => {
return false;
}
};
export const enqueueTestPushNotification = async (): Promise<void> => {
const pushToken = await getPushToken();
if (!pushToken?.token || !pushToken?.os) {
throw new Error('No push token available');
}
const response = await fetch(`${baseURI}/enqueue`, {
method: 'POST',
headers: _getHeaders(),
body: JSON.stringify({
type: 5,
token: pushToken.token,
os: pushToken.os,
text: 'Test push notification',
}),
});
if (!response.ok) {
throw new Error(`Enqueue request failed with status ${response.status}: ${response.statusText}`);
}
};
/**
* Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could
* be notified if they were paid
@ -285,7 +141,7 @@ export const enqueueTestPushNotification = async (): Promise<void> => {
* @returns {Promise<object>} Response object from API rest call
*/
export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => {
console.log('majorTomToGroundControl: Starting notification registration', {
console.debug('majorTomToGroundControl: Starting notification registration', {
addressCount: addresses?.length,
hashCount: hashes?.length,
txidCount: txids?.length,
@ -303,7 +159,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
const pushToken = await getPushToken();
console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken);
console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken);
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
@ -318,7 +174,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
let response;
try {
console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
method: 'POST',
headers: _getHeaders(),
@ -350,44 +206,6 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
};
/**
* Registers an Ark swap with the bitcoin-payment-push-service so the device is
* pushed when the invoice gets paid. Fire-and-forget: never throws, gated by
* the same opt-out/token rules as majorTomToGroundControl(). The swap's
* preimage is always stripped before leaving the device.
*/
export const registerArkPaymentPush = async (paymentHash: string, label: string, pendingSwap: BoltzReverseSwap): Promise<void> => {
if (!arkadePaymentPushUri) return;
try {
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
if (noAndDontAskFlag === 'true') {
console.warn('User has opted out of notifications.');
return;
}
const pushToken = await getPushToken();
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
const response = await fetch(`${arkadePaymentPushUri}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: paymentHash,
label,
swap: { ...pendingSwap, preimage: '' },
}),
});
if (!response.ok) {
throw new Error(`status ${response.status}`);
}
console.log('[ARK] payment push registration ok');
} catch (e: any) {
console.log('[ARK] payment push registration failed:', e?.message ?? e);
}
};
/**
* Returns a permissions object:
* alert: boolean
@ -398,18 +216,11 @@ export const registerArkPaymentPush = async (paymentHash: string, label: string,
*/
export const checkPermissions = async () => {
try {
if (Platform.OS === 'ios') {
return Notifications.ios.checkPermissions();
}
const { status } = await checkNotifications();
const granted = status === RESULTS.GRANTED;
return {
alert: granted,
badge: granted,
sound: granted,
status,
};
return new Promise(function (resolve) {
PushNotification.checkPermissions((result: any) => {
resolve(result);
});
});
} catch (error) {
console.error('Error checking permissions:', error);
throw error;
@ -444,14 +255,12 @@ export const setLevels = async (levelAll: boolean) => {
}
if (!levelAll) {
console.log('Disabling notifications as user opted out...');
Notifications.removeAllDeliveredNotifications();
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(0);
Notifications.ios.cancelAllLocalNotifications();
}
console.debug('Disabling notifications as user opted out...');
PushNotification.removeAllDeliveredNotifications();
PushNotification.setApplicationIconBadgeNumber(0);
PushNotification.cancelAllLocalNotifications();
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
console.log('Notifications disabled successfully');
console.debug('Notifications disabled successfully');
} else {
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
}
@ -477,19 +286,19 @@ export const addNotification = async (notification: TPayload) => {
};
const postTokenConfig = async () => {
console.log('postTokenConfig: Starting token configuration');
console.debug('postTokenConfig: Starting token configuration');
const pushToken = await getPushToken();
console.log('postTokenConfig: Retrieved push token:', !!pushToken);
console.debug('postTokenConfig: Retrieved push token:', !!pushToken);
if (!pushToken || !pushToken.token || !pushToken.os) {
console.log('postTokenConfig: Invalid token or missing OS info');
console.debug('postTokenConfig: Invalid token or missing OS info');
return;
}
try {
const lang = (await AsyncStorage.getItem('lang')) || 'en';
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
console.log('postTokenConfig: Posting configuration', { lang, appVersion });
console.debug('postTokenConfig: Posting configuration', { lang, appVersion });
await fetch(`${baseURI}/setTokenConfiguration`, {
method: 'POST',
@ -520,72 +329,101 @@ const _setPushToken = async (token: TPushToken) => {
/**
* Configures notifications. For Android, it will show a native rationale prompt if necessary.
*
* @returns {Promise<boolean>} whether successfully registered for remote push notifications
* @returns {Promise<boolean>}
*/
const configureNotifications = async (onProcessNotifications?: () => void): Promise<boolean> => {
console.log('configureNotifications()');
if (onProcessNotifications) {
onProcessNotificationsHandler = onProcessNotifications;
export const configureNotifications = async (onProcessNotifications?: () => void) => {
if (alreadyConfigured) {
console.debug('configureNotifications: Already configured, skipping');
return true;
}
try {
const { status } = await checkNotifications();
if (status !== RESULTS.GRANTED) {
console.log('configureNotifications: Permissions not granted');
return false;
}
return new Promise(resolve => {
const handleRegistration = async (token: TPushToken) => {
if (__DEV__) {
console.debug('configureNotifications: Token received:', token);
}
alreadyConfigured = true;
await _setPushToken(token);
resolve(true);
};
ensureAndroidNotificationChannel();
// const handleNotification = async (notification: TPushNotification & { data: any }) => {
const handleNotification = async (notification: Omit<ReceivedNotification, 'userInfo'>) => {
// Deep clone to avoid modifying the original object
// @ts-ignore some missing properties hopefully will be unwrapped from `.data`
const payload: TPayload = deepClone({
...notification,
...notification.data,
});
if (notificationSubscriptions.length === 0) {
notificationSubscriptions = [
Notifications.events().registerRemoteNotificationsRegistered(async event => {
console.log('processing event', event);
const token = createPushToken(event.deviceToken);
if (__DEV__) {
console.log('configureNotifications: Token received:', token);
}
await _setPushToken(token);
await postTokenConfig().catch(error => console.error('Failed to post token configuration:', error));
settlePendingRegistration(true);
}),
Notifications.events().registerRemoteNotificationsRegistrationFailed(error => {
console.error('Registration error:', error);
settlePendingRegistration(false);
}),
Notifications.events().registerRemoteNotificationsRegistrationDenied(() => {
console.log('Remote notification registration denied');
settlePendingRegistration(false);
}),
Notifications.events().registerNotificationReceivedForeground(async (notification, completion) => {
await storeIncomingNotification(notification, { foreground: true, userInteraction: false }, completion);
}),
Notifications.events().registerNotificationReceivedBackground(async (notification, completion) => {
await storeIncomingNotification(notification, { foreground: false, userInteraction: false }, completion);
}),
Notifications.events().registerNotificationOpened(async (notification, completion) => {
try {
await storeIncomingNotification(notification, { foreground: false, userInteraction: true });
} finally {
completion();
}
}),
];
}
if (notification.data?.data) {
const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null));
Object.assign(payload, validData);
}
Notifications.getInitialNotification()
.then(async initialNotification => {
if (initialNotification) {
console.log('App was launched by a push notification:', initialNotification);
await storeIncomingNotification(initialNotification, { foreground: false, userInteraction: true });
// @ts-ignore stfu ts, its cleanup
payload.data = undefined;
if (!payload.subText && !payload.message) {
console.warn('Notification missing required fields:', payload);
return;
}
await addNotification(payload);
notification.finish(PushNotificationIOS.FetchResult.NoData);
if (payload.foreground && onProcessNotifications) {
await onProcessNotifications();
}
};
const configure = async () => {
try {
const { status } = await checkNotifications();
if (status !== RESULTS.GRANTED) {
console.debug('configureNotifications: Permissions not granted');
return resolve(false);
}
})
.catch(error => console.error('Failed to retrieve initial notification:', error));
// waiting and returning actual result of remote pushes registration: success or failure
return await waitForRemoteRegistration();
} catch (error) {
console.error('Error in configureNotifications:', error);
const existingToken = await getPushToken();
if (existingToken) {
alreadyConfigured = true;
console.debug('Notifications already configured with existing token');
return resolve(true);
}
PushNotification.configure({
onRegister: handleRegistration,
onNotification: handleNotification,
onRegistrationError: (error: any) => {
console.error('Registration error:', error);
resolve(false);
},
permissions: { alert: true, badge: true, sound: true },
popInitialNotification: true,
});
} catch (error) {
console.error('Error in configure:', error);
resolve(false);
}
};
configure();
});
};
/**
* Validates whether the provided GroundControl URI is valid by pinging it.
*
* @param uri {string}
* @returns {Promise<boolean>} TRUE if valid, FALSE otherwise
*/
export const isGroundControlUriValid = async (uri: string) => {
try {
const response = await fetch(`${uri}/ping`, { headers: _getHeaders() });
const json = await response.json();
return !!json.description;
} catch (_) {
return false;
}
};
@ -690,15 +528,9 @@ export const clearStoredNotifications = async () => {
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
try {
if (Platform.OS !== 'ios') {
return Promise.resolve([]);
}
return Notifications.ios
.getDeliveredNotifications()
.then(notifications =>
notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })),
);
return new Promise(resolve => {
PushNotification.getDeliveredNotifications((notifications: Record<string, any>[]) => resolve(notifications));
});
} catch (error) {
console.error('Error getting delivered notifications:', error);
throw error;
@ -706,19 +538,47 @@ export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = (
};
export const removeDeliveredNotifications = (identifiers = []) => {
if (Platform.OS === 'ios') {
Notifications.ios.removeDeliveredNotifications(identifiers);
}
PushNotification.removeDeliveredNotifications(identifiers);
};
export const setApplicationIconBadgeNumber = (badges: number) => {
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(badges);
}
PushNotification.setApplicationIconBadgeNumber(badges);
};
export const removeAllDeliveredNotifications = () => {
Notifications.removeAllDeliveredNotifications();
PushNotification.removeAllDeliveredNotifications();
};
export const getDefaultUri = () => {
return groundControlUri;
};
export const saveUri = async (uri: string) => {
try {
baseURI = uri || groundControlUri;
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, baseURI);
} catch (error) {
console.error('Error saving URI:', error);
throw error;
}
};
export const getSavedUri = async () => {
try {
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
if (baseUriStored) {
baseURI = baseUriStored;
}
return baseUriStored;
} catch (e) {
console.error(e);
try {
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri);
} catch (storageError) {
console.error('Failed to reset URI:', storageError);
}
throw e;
}
};
export const isNotificationsEnabled = async () => {
@ -759,22 +619,25 @@ export const getStoredNotifications = async (): Promise<TPayload[]> => {
// on app launch (load module):
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
console.log('initializeNotifications: Starting initialization');
console.debug('initializeNotifications: Starting initialization');
try {
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
console.log('initializeNotifications: No ask flag status:', noAndDontAskFlag);
console.debug('initializeNotifications: No ask flag status:', noAndDontAskFlag);
if (noAndDontAskFlag === 'true') {
console.warn('User has opted out of notifications.');
return;
}
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
baseURI = baseUriStored || groundControlUri;
console.debug('Base URI set to:', baseURI);
setApplicationIconBadgeNumber(0);
// Only check permissions, never request
currentPermissionStatus = await checkNotificationPermissionStatus();
console.log('initializeNotifications: Permission status:', currentPermissionStatus);
console.debug('initializeNotifications: Permission status:', currentPermissionStatus);
// Handle Android 13+ permissions differently
const canProceed =
@ -783,12 +646,23 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
: currentPermissionStatus === 'granted';
if (canProceed) {
console.log('initializeNotifications: Can proceed with notification setup');
await configureNotifications(onProcessNotifications);
console.debug('initializeNotifications: Can proceed with notification setup');
const token = await getPushToken();
if (token) {
console.debug('initializeNotifications: Existing token found, configuring');
await configureNotifications(onProcessNotifications);
await postTokenConfig();
} else {
console.debug('initializeNotifications: No token found, will request permissions');
await tryToObtainPermissions();
}
} else {
console.log('Notifications require user action to enable');
console.debug('Notifications require user action to enable');
}
} catch (error) {
console.error('Failed to initialize notifications:', error);
baseURI = groundControlUri;
await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri).catch(err => console.error('Failed to reset URI:', err));
}
};

View File

@ -1,4 +0,0 @@
import * as pako from '../index.js';
export * from '../index.js';
export default pako;

View File

@ -1,26 +1,7 @@
import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { useEffect, useState } from 'react';
import { Dimensions, Platform, AppState, AppStateStatus } from 'react-native';
import { useState, useEffect } from 'react';
import { isDesktop } from './environment';
type NativeSizeClassPayload = {
horizontal?: number;
vertical?: number;
sizeClass?: number;
orientation?: string;
isLargeScreen?: boolean;
};
const sizeClassNativeModule = NativeModules.SizeClassEmitter as
| {
getCurrentSizeClass?: () => Promise<NativeSizeClassPayload>;
addListener: (eventType: string) => any;
removeListeners: (count: number) => void;
}
| undefined;
const sizeClassNativeEmitter = sizeClassNativeModule ? new NativeEventEmitter(sizeClassNativeModule) : null;
const NATIVE_EVENT_NAME = 'sizeClassDidChange';
// Size class definitions following iOS conventions
export enum SizeClass {
Compact, // Small size (iPhone width or height in landscape)
@ -48,40 +29,64 @@ export interface SizeClassInfo {
isLargeScreen: boolean;
}
const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait');
const coerceSizeClassValue = (value?: number): SizeClass => {
if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) {
return value;
}
return SizeClass.Regular;
};
const calculateFromDimensions = (): SizeClassInfo => {
/**
* Get current size class information based on device dimensions
*/
export function getSizeClass(): SizeClassInfo {
// Get device dimensions
const { width, height } = Dimensions.get('window');
const isLandscape = width > height;
const orientation = isLandscape ? 'landscape' : 'portrait';
const horizontalSizeClass =
Platform.OS === 'ios' && Platform.isPad
? SizeClass.Regular
: isDesktop
? SizeClass.Large
: isLandscape && width >= 667
? SizeClass.Regular
: SizeClass.Compact;
// Determine horizontal size class (following iOS conventions)
let horizontalSizeClass: SizeClass;
const verticalSizeClass =
Platform.OS === 'ios' && Platform.isPad
? SizeClass.Regular
: isDesktop
? SizeClass.Large
: isLandscape
? SizeClass.Compact
: SizeClass.Regular;
if (Platform.OS === 'ios' && Platform.isPad) {
// iPads always have Regular width
horizontalSizeClass = SizeClass.Regular;
} else if (isDesktop) {
// Desktop systems get Large width
horizontalSizeClass = SizeClass.Large;
} else if (isLandscape && width >= 667) {
// iPhone Plus models (and modern equivalent sizes) in landscape: Regular width
// 667 points corresponds roughly to iPhone Plus models
horizontalSizeClass = SizeClass.Regular;
} else {
// Regular iPhones: Compact width
horizontalSizeClass = SizeClass.Compact;
}
const sizeClass = coerceSizeClassValue(horizontalSizeClass);
const isLargeScreen = sizeClass === SizeClass.Large;
// Determine vertical size class (following iOS conventions)
let verticalSizeClass: SizeClass;
if (Platform.OS === 'ios' && Platform.isPad) {
// iPads always have Regular height
verticalSizeClass = SizeClass.Regular;
} else if (isDesktop) {
// Desktop systems get Large height
verticalSizeClass = SizeClass.Large;
} else if (isLandscape) {
// All iPhones in landscape: Compact height
verticalSizeClass = SizeClass.Compact;
} else {
// iPhones in portrait: Regular height
verticalSizeClass = SizeClass.Regular;
}
// Derive overall size class - simplified logic to avoid redundant comparisons
let sizeClass: SizeClass;
if (horizontalSizeClass === SizeClass.Compact) {
// If width is compact, overall is compact
sizeClass = SizeClass.Compact;
} else {
// Otherwise, width is Regular or Large, so overall is Large
// (per requirements that any non-Compact width device is considered Large)
sizeClass = SizeClass.Large;
}
// Determine isLargeScreen property (true for Regular and Large widths)
const isLargeScreen = horizontalSizeClass !== SizeClass.Compact;
return {
horizontalSizeClass,
@ -92,126 +97,43 @@ const calculateFromDimensions = (): SizeClassInfo => {
isLarge: sizeClass === SizeClass.Large,
isLargeScreen,
};
};
const normalizeNativePayload = (payload?: NativeSizeClassPayload | null): SizeClassInfo | null => {
if (!payload) {
return null;
}
const horizontalSizeClass = coerceSizeClassValue(payload.horizontal);
const verticalSizeClass = coerceSizeClassValue(payload.vertical);
const sizeClass = coerceSizeClassValue(payload.sizeClass);
const isLargeScreen = payload.isLargeScreen ?? sizeClass === SizeClass.Large;
const orientation = normalizeOrientation(payload.orientation);
return {
horizontalSizeClass,
verticalSizeClass,
sizeClass,
orientation,
isCompact: sizeClass === SizeClass.Compact,
isLarge: sizeClass === SizeClass.Large,
isLargeScreen,
};
};
let cachedSizeClassInfo: SizeClassInfo = calculateFromDimensions();
let nativeInitRequested = false;
const fetchNativeSizeClass = async (): Promise<SizeClassInfo | null> => {
if (!sizeClassNativeModule?.getCurrentSizeClass) {
return null;
}
try {
const result = await sizeClassNativeModule.getCurrentSizeClass();
return normalizeNativePayload(result);
} catch (error) {
console.debug('[SizeClass] Failed to read native size class', error);
return null;
}
};
/**
* Get current size class information.
*/
export function getSizeClass(): SizeClassInfo {
if (!sizeClassNativeModule) {
cachedSizeClassInfo = calculateFromDimensions();
} else if (!nativeInitRequested) {
nativeInitRequested = true;
fetchNativeSizeClass().then(nativeInfo => {
if (nativeInfo) {
cachedSizeClassInfo = nativeInfo;
}
});
}
return cachedSizeClassInfo;
}
/**
* React hook to use size classes in components
*/
export function useSizeClass(): SizeClassInfo {
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(cachedSizeClassInfo);
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(getSizeClass());
useEffect(() => {
let isMounted = true;
const applySizeClass = (info: SizeClassInfo) => {
if (!isMounted) return;
cachedSizeClassInfo = info;
setSizeClassInfo(info);
// Update size class when dimensions change
const updateSizeClass = () => {
const newInfo = getSizeClass();
setSizeClassInfo(newInfo);
console.debug(
`[SizeClass] Updated:`,
`horizontal=${SizeClass[info.horizontalSizeClass]}`,
`vertical=${SizeClass[info.verticalSizeClass]}`,
`orientation=${info.orientation}`,
`isLargeScreen=${info.isLargeScreen}`,
`horizontal=${SizeClass[newInfo.horizontalSizeClass]}`,
`vertical=${SizeClass[newInfo.verticalSizeClass]}`,
`orientation=${newInfo.orientation}`,
`isLargeScreen=${newInfo.isLargeScreen}`,
);
};
const updateFromDimensions = () => {
const calculated = calculateFromDimensions();
applySizeClass(calculated);
};
const dimensionSubscription = Dimensions.addEventListener('change', updateSizeClass);
const requestNativeUpdate = async () => {
const nativeInfo = await fetchNativeSizeClass();
if (nativeInfo) {
applySizeClass(nativeInfo);
}
};
const dimensionSubscription = Dimensions.addEventListener('change', () => {
updateFromDimensions();
requestNativeUpdate();
});
const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
// Also update when app becomes active
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
requestNativeUpdate();
updateSizeClass();
}
});
};
const nativeSubscription = sizeClassNativeEmitter?.addListener(NATIVE_EVENT_NAME, (payload: NativeSizeClassPayload) => {
const normalized = normalizeNativePayload(payload);
if (normalized) {
applySizeClass(normalized);
}
});
// Kick off an initial native fetch to override the heuristic when available.
requestNativeUpdate();
const appStateSubscription = AppState.addEventListener('change', handleAppStateChange);
// Clean up
return () => {
isMounted = false;
dimensionSubscription.remove();
appStateSubscription.remove();
nativeSubscription?.remove();
};
}, []);

View File

@ -1,6 +1,6 @@
import { Platform } from 'react-native';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import { BlueApp as BlueAppClass } from '../class/';
import prompt from '../helpers/prompt';
import { showKeychainWipeAlert } from '../hooks/useBiometrics';
import loc from '../loc';
@ -23,7 +23,7 @@ export const startAndDecrypt = async (retry?: boolean, passwordPrompt?: Password
password = await passwordPrompt();
} else {
do {
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, { cancelable: false });
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false);
} while (!password);
}
}

View File

@ -1,41 +0,0 @@
// Display state for the transaction detail screen.
//
// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing
// confirmations-based logic. Ark/Lightning rows synthesized by
// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a
// `confirmations` field, so their state is derived from row semantics instead.
// The off-chain branch mirrors the off-chain cases of
// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail
// screen always agree. A `boarding-utxo-` row is a refill still awaiting
// settlement and is pending (matches TransactionListItem.isPendingRefill); a
// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark
// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice
// cases are handled defensively.
export type TxDisplayState = 'pending' | 'sent' | 'received';
export function isOnChainTransaction(tx: any): boolean {
return typeof tx?.hash === 'string' && tx.hash.length > 0;
}
export function resolveTxDisplayState(tx: any): TxDisplayState {
if (isOnChainTransaction(tx)) {
const confs = Number(tx?.confirmations);
const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations;
if (pending) return 'pending';
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
// A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is
// pending until it promotes to a settled `boarding-<txid>` refill — mirror
// TransactionListItem.isPendingRefill so the list row and detail screen agree.
if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending';
// Off-chain Ark/Lightning row — never confirmations-based.
switch (tx?.type) {
case 'paid_invoice':
return 'sent';
case 'user_invoice':
case 'payment_request':
return tx?.ispaid ? 'received' : 'pending';
default: // settled refill (boarding-<txid>), native Ark legs (ark-), any other hash-less row
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
}

View File

@ -13,8 +13,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { Psbt } from 'bitcoinjs-lib';
import b58 from 'bs58check';
import { MultisigCosigner } from '../../class/multisig-cosigner';
import { MultisigHDWallet } from '../../class/wallets/multisig-hd-wallet';
import { MultisigCosigner, MultisigHDWallet } from '../../class';
import { joinQRs } from '../bbqr/join';
import {
concatUint8Arrays,

View File

@ -147,10 +147,11 @@ export class BlueApp {
console.warn('error reading', key, error.message);
console.warn('fallback to realm');
const realmKeyValue = await this.openRealmKeyValue();
const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', key);
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
value = obj?.value;
realmKeyValue.close();
if (value) {
// @ts-ignore value.length
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
return value;
}
@ -546,11 +547,10 @@ export class BlueApp {
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
}
} else {
// Legacy single-address wallets - store under index 0
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || {};
walletToInflate._txs_by_external_index[0] = walletToInflate._txs_by_external_index[0] || [];
if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = [];
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || [];
const transaction = JSON.parse(tx.tx);
walletToInflate._txs_by_external_index[0].push(transaction);
(walletToInflate._txs_by_external_index as Transaction[]).push(transaction);
}
}
}
@ -559,6 +559,32 @@ export class BlueApp {
const id = wallet.getID();
const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet;
if (Array.isArray(walletToSave._txs_by_external_index)) {
// if this var is an array that means its a single-address wallet class, and this var is a flat array
// with transactions
realm.write(() => {
// cleanup all existing transactions for the wallet first
const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`);
realm.delete(walletTransactionsToDelete);
// @ts-ignore walletToSave._txs_by_external_index is array
for (const tx of walletToSave._txs_by_external_index) {
realm.create(
'WalletTransactions',
{
walletid: id,
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
);
}
});
return;
}
/// ########################################################################################################
if (walletToSave._txs_by_external_index) {
realm.write(() => {
// cleanup all existing transactions for the wallet first
@ -566,14 +592,16 @@ export class BlueApp {
realm.delete(walletTransactionsToDelete);
// insert new ones:
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) {
for (const index of Object.keys(walletToSave._txs_by_external_index)) {
// @ts-ignore index is number
const txs = walletToSave._txs_by_external_index[index];
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: false,
index: parseInt(indexStr, 10),
index: parseInt(index, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
@ -581,14 +609,16 @@ export class BlueApp {
}
}
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) {
for (const index of Object.keys(walletToSave._txs_by_internal_index)) {
// @ts-ignore index is number
const txs = walletToSave._txs_by_internal_index[index];
for (const tx of txs) {
realm.create(
'WalletTransactions',
{
walletid: id,
internal: true,
index: parseInt(indexStr, 10),
index: parseInt(index, 10),
tx: JSON.stringify(tx),
},
Realm.UpdateMode.Modified,
@ -726,12 +756,10 @@ export class BlueApp {
}
}
} else {
await Promise.all(
this.wallets.map(async wallet => {
console.log('fetching balance for', wallet.getLabel());
await wallet.fetchBalance();
}),
);
for (const wallet of this.wallets) {
console.log('fetching balance for', wallet.getLabel());
await wallet.fetchBalance();
}
}
};
@ -760,15 +788,13 @@ export class BlueApp {
}
}
} else {
await Promise.all(
this.wallets.map(async wallet => {
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
await wallet.fetchUserInvoices();
}
}),
);
for (const wallet of this.wallets) {
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
await wallet.fetchUserInvoices();
}
}
}
};
@ -783,16 +809,14 @@ export class BlueApp {
console.error('Failed to fetch sender payment codes for wallet', index, error);
}
} else {
await Promise.all(
this.wallets.map(async wallet => {
try {
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return;
await wallet.fetchBIP47SenderPaymentCodes();
} catch (error) {
console.error('Failed to fetch sender payment codes for wallet', wallet.label, error);
}
}),
);
for (const wallet of this.wallets) {
try {
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) continue;
await wallet.fetchBIP47SenderPaymentCodes();
} catch (error) {
console.error('Failed to fetch sender payment codes for wallet', wallet.label, error);
}
}
}
};

View File

@ -3,7 +3,7 @@ import * as bitcoin from 'bitcoinjs-lib';
import URL from 'url';
import { readFileOutsideSandbox } from '../blue_modules/fs';
import { Chain } from '../models/bitcoinUnits';
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
import { WatchOnlyWallet } from './';
import Azteco from './azteco';
import Lnurl from './lnurl';
import type { TWallet } from './wallets/types';

View File

@ -390,7 +390,7 @@ export class HDSegwitBech32Transaction {
}
}
// Non-null assertions are safe here because the while loop always runs at least once (add starts at 0)
return { tx: tx!, inputs: inputs!, outputs: outputs!, fee: fee! };
// @ts-ignore stfu
return { tx, inputs, outputs, fee };
}
}

22
class/index.ts Normal file
View File

@ -0,0 +1,22 @@
export * from './blue-app';
export * from './hd-segwit-bech32-transaction';
export * from './multisig-cosigner';
export * from './wallets/abstract-hd-wallet';
export * from './wallets/abstract-wallet';
export * from './wallets/hd-aezeed-wallet';
export * from './wallets/hd-legacy-breadwallet-wallet';
export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
export * from './wallets/hd-legacy-p2pkh-wallet';
export * from './wallets/hd-segwit-bech32-wallet';
export * from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
export * from './wallets/hd-segwit-p2sh-wallet';
export * from './wallets/hd-taproot-wallet';
export * from './wallets/legacy-wallet';
export * from './wallets/lightning-custodian-wallet';
export * from './wallets/lightning-ark-wallet';
export * from './wallets/multisig-hd-wallet';
export * from './wallets/segwit-bech32-wallet';
export * from './wallets/segwit-p2sh-wallet';
export * from './wallets/slip39-wallets';
export * from './wallets/taproot-wallet';
export * from './wallets/watch-only-wallet';

View File

@ -2,7 +2,7 @@ import { bech32 } from 'bech32';
import bolt11 from 'bolt11';
import { sha256 } from '@noble/hashes/sha256';
import { hmac } from '@noble/hashes/hmac';
import { cbc } from '@noble/ciphers/aes';
import CryptoJS from 'crypto-js';
import ecc from '../blue_modules/noble_ecc';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { fetch } from '../util/fetch';
@ -321,24 +321,13 @@ export default class Lnurl {
}
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
// crypto-js's old implementation silently returned '' on malformed
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
// catch every throw and return '' — the call site at
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
// try/catch, so a misbehaving LNURL server should not crash the screen.
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
// safer than the old behaviour for this user-facing path.
try {
const key = hexToUint8Array(preimageHex);
const iv = base64ToUint8Array(ivBase64);
const ct = base64ToUint8Array(ciphertextBase64);
const pt = cbc(key, iv).decrypt(ct);
return uint8ArrayToString(pt);
} catch (_) {
return '';
}
const iv = CryptoJS.enc.Base64.parse(ivBase64);
const key = CryptoJS.enc.Hex.parse(preimageHex);
return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, {
iv,
mode: CryptoJS.mode.CBC,
format: CryptoJS.format.Hex,
}).toString(CryptoJS.enc.Utf8);
}
getCommentAllowed(): number | false {

View File

@ -106,31 +106,23 @@ export class MultisigCosigner {
this._valid = false;
}
// is it coldcard / unchained json?
// is it coldcard json?
try {
const json = JSON.parse(data);
// p2wsh_p2sh (Coldcard), p2sh_p2wsh (Unchained)
// same script type with reversed naming
const xpub = json.p2wsh_p2sh || json.p2sh_p2wsh;
const path = (json.p2wsh_p2sh_deriv || json.p2sh_p2wsh_deriv)?.replace(/h/g, "'");
const p2sh_deriv = json.p2sh_deriv?.replace(/h/g, "'");
const p2wsh_deriv = json.p2wsh_deriv?.replace(/h/g, "'");
if (json.p2sh && p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, p2sh_deriv));
if (json.p2sh && json.p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (xpub && path && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (json.p2wsh && p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
this._valid = true;
this._cosigners.push(cc);
}

View File

@ -11,7 +11,7 @@
* @return {Promise.<Uint8Array>} The random bytes
*/
export async function randomBytes(size: number): Promise<Uint8Array> {
const g = globalThis as any;
const g: any = globalThis as any;
const rnCrypto = g && g.crypto;
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
throw new Error('crypto.getRandomValues is not available');

View File

@ -1,3 +1,4 @@
import { useTheme } from '../components/themes';
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
@ -29,7 +30,8 @@ export default class WalletGradient {
static aezeedWallet: string[] = ['#8584FF', '#5351FB'];
static createWallet = () => {
return WalletGradient.defaultGradients[0];
const { colors } = useTheme();
return colors.lightButton;
};
static gradientsFor(type: string): string[] {

View File

@ -2,23 +2,27 @@ import bip38 from 'bip38';
import wif from 'wif';
import loc from '../loc';
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet';
import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet';
import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet';
import { HDTaprootWallet } from './wallets/hd-taproot-wallet';
import { LegacyWallet } from './wallets/legacy-wallet';
import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet';
import { LightningArkWallet } from './wallets/lightning-ark-wallet';
import { MultisigHDWallet } from './wallets/multisig-hd-wallet';
import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet';
import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet';
import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets';
import { TaprootWallet } from './wallets/taproot-wallet';
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
import {
HDAezeedWallet,
HDLegacyBreadwalletWallet,
HDLegacyElectrumSeedP2PKHWallet,
HDLegacyP2PKHWallet,
HDSegwitBech32Wallet,
HDSegwitElectrumSeedP2WPKHWallet,
HDSegwitP2SHWallet,
HDTaprootWallet,
LegacyWallet,
LightningCustodianWallet,
LightningArkWallet,
MultisigHDWallet,
SegwitBech32Wallet,
SegwitP2SHWallet,
SLIP39LegacyP2PKHWallet,
SLIP39SegwitBech32Wallet,
SLIP39SegwitP2SHWallet,
TaprootWallet,
WatchOnlyWallet,
} from '.';
import bip39WalletFormatsElectrum from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json
import bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json';
import type { TWallet } from './wallets/types';
@ -216,35 +220,10 @@ const startImport = (
if (text.startsWith('arkade://')) {
const ark = new LightningArkWallet();
ark.setSecret(text);
// Defer init() to first wallet open when offline — init touches the ASP
// and delegator over the network. We still detect the wallet by prefix
// and persist it with its secret.
// A network or SDK failure during init must not abort the import: the
// wallet type and secret are known, and the SDK runtime can be brought
// up the next time the wallet is opened.
await ark.init();
if (!offline) {
try {
await ark.init();
// Restore any previous Boltz swap activity for this seed exactly
// once, here at import time. We never run this on later wallet
// opens — the app does not sweep all swaps on bootstrap. A failure
// must not block the import: the wallet itself is fine, the
// restored rows are an optional bonus for imported-from-elsewhere
// wallets.
try {
await ark.restoreSwaps();
} catch (e: any) {
console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e);
}
try {
await ark.fetchBalance();
await ark.fetchTransactions();
} catch (e: any) {
console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e);
}
} catch (e: any) {
console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e);
}
await ark.fetchBalance();
await ark.fetchTransactions();
}
yield { wallet: ark };
}
@ -344,7 +323,6 @@ const startImport = (
}
yield { progress: 'wif' };
const segwitWallet = new SegwitP2SHWallet();
segwitWallet.setSecret(text);
if (segwitWallet.getAddress()) {
@ -408,89 +386,6 @@ const startImport = (
yield { wallet: legacyWallet };
}
yield { progress: 'Private key in hex/base64' };
// check if text is in hex or base64 format
const isHexKey = /^[0-9a-fA-F]{64}$/.test(text);
const isBase64Key = /^[A-Za-z0-9+/=]{43,44}$/.test(text);
let rawKeyBuffer;
let privateKey;
if (isHexKey) {
rawKeyBuffer = Buffer.from(text, 'hex');
} else if (isBase64Key) {
rawKeyBuffer = Buffer.from(text, 'base64');
}
if (rawKeyBuffer && rawKeyBuffer.length === 32) {
let walletFound = false;
// convert the bytes to Wallet import format, 0x80 for mainnet,
// start with uncompressed p2pkh
privateKey = wif.encode(0x80, rawKeyBuffer, false);
yield { progress: 'p2pkh uncompressed' };
const legacyWalletUncompressed = new LegacyWallet('Legacy (P2PKH) - Uncompressed');
legacyWalletUncompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletUncompressed)) {
await fetch(legacyWalletUncompressed, true);
walletFound = true;
yield { wallet: legacyWalletUncompressed };
}
// compressed is true for other wallet types
privateKey = wif.encode(0x80, rawKeyBuffer, true);
yield { progress: 'p2wpkh' };
const segwitBech32Wallet = new SegwitBech32Wallet();
segwitBech32Wallet.setSecret(privateKey);
if (await wasUsed(segwitBech32Wallet)) {
await fetch(segwitBech32Wallet, true);
walletFound = true;
yield { wallet: segwitBech32Wallet };
}
yield { progress: 'p2tr' };
const taprootWallet = new TaprootWallet();
taprootWallet.setSecret(privateKey);
if (await wasUsed(taprootWallet)) {
await fetch(taprootWallet, true);
walletFound = true;
yield { wallet: taprootWallet };
}
yield { progress: 'p2wpkh-p2sh' };
segwitWallet.setSecret(privateKey);
if (await wasUsed(segwitWallet)) {
await fetch(segwitWallet, true);
walletFound = true;
yield { wallet: segwitWallet };
}
yield { progress: 'p2pkh compressed' };
const legacyWalletCompressed = new LegacyWallet('Legacy (P2PKH) - Compressed');
legacyWalletCompressed.setSecret(privateKey);
if (await wasUsed(legacyWalletCompressed)) {
await fetch(legacyWalletCompressed, true);
walletFound = true;
yield { wallet: legacyWalletCompressed };
}
if (!walletFound) {
yield { wallet: segwitBech32Wallet };
yield { wallet: segwitWallet };
yield { wallet: legacyWalletCompressed };
yield { wallet: taprootWallet };
yield { wallet: legacyWalletUncompressed };
}
}
// maybe its a watch-only address?
yield { progress: 'watch only' };
const wo1 = new WatchOnlyWallet();

View File

@ -45,7 +45,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
_balances_by_external_index: Record<number, BalanceByIndex>;
_balances_by_internal_index: Record<number, BalanceByIndex>;
// @ts-ignore
_txs_by_external_index: Record<number, Transaction[]>;
// @ts-ignore
_txs_by_internal_index: Record<number, Transaction[]>;
_utxo: any[];
@ -156,15 +158,6 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
return ret;
}
getBalanceForExternalIndex(index: number): number {
const bal = this._balances_by_external_index[index];
return (bal?.c || 0) + (bal?.u || 0);
}
getTransactionCountForExternalIndex(index: number): number {
return this._txs_by_external_index[index]?.length ?? 0;
}
async generate() {
const buf = await randomBytes(16);
this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(buf));
@ -202,37 +195,70 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
return child.toWIF();
}
_getNodeByIndex(node: 0 | 1, index: number): BIP32Interface {
const cachedNode = node === 0 ? this._node0 : this._node1;
if (cachedNode) {
return cachedNode.derive(index);
_getNodeAddressByIndex(node: number, index: number): string {
index = index * 1; // cast to int
if (node === 0) {
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
}
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub).derive(node);
if (node === 1) {
if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit
}
if (node === 0 && !this._node0) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node0 = hdNode.derive(node);
}
if (node === 1 && !this._node1) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node1 = hdNode.derive(node);
}
let address: string;
if (node === 0) {
// @ts-ignore
address = this._hdNodeToAddress(this._node0.derive(index));
} else {
// tbh the only possible else is node === 1
// @ts-ignore
address = this._hdNodeToAddress(this._node1.derive(index));
}
if (node === 0) {
this._node0 = hdNode;
return (this.external_addresses_cache[index] = address);
} else {
this._node1 = hdNode;
// tbh the only possible else option is node === 1
return (this.internal_addresses_cache[index] = address);
}
}
_getNodePubkeyByIndex(node: number, index: number) {
index = index * 1; // cast to int
if (node === 0 && !this._node0) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node0 = hdNode.derive(node);
}
return hdNode.derive(index);
}
if (node === 1 && !this._node1) {
const xpub = this._zpubToXpub(this.getXpub());
const hdNode = bip32.fromBase58(xpub);
this._node1 = hdNode.derive(node);
}
_getNodeAddressByIndex(node: 0 | 1, index: number): string {
const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache;
if (node === 0 && this._node0) {
return this._node0.derive(index).publicKey;
}
if (cache[index]) return cache[index]; // cache hit
if (node === 1 && this._node1) {
return this._node1.derive(index).publicKey;
}
const hdNode = this._getNodeByIndex(node, index);
const address = this._hdNodeToAddress(hdNode);
return (cache[index] = address);
}
_getNodePubkeyByIndex(node: 0 | 1, index: number) {
return this._getNodeByIndex(node, index).publicKey;
throw new Error('Internal error: this._node0 or this._node1 is undefined');
}
_getExternalAddressByIndex(index: number): string {
@ -389,95 +415,137 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
// now, we need to put transactions in all relevant `cells` of internal hashmaps:
// this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index
// address -> index lookup maps; the single pass over transactions below uses them
// to find which cells a transaction belongs to
const externalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_address_index + this.gap_limit; c++) {
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
}
}
const internalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) {
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
}
}
const paymentCodeIndexByAddress = new Map<string, { pc: string; c: number }>();
for (const pc of this._receive_payment_codes) {
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
}
}
for (const tx of Object.values(txdatas)) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only iterate `tx.vout`
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47AddressReceive(pc, c)) !== -1) {
// this TX is related to our address
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// per-cell txid -> position lookup, used to replace-or-push a transaction into a cell in constant time
const cellPositionsByTxid = new Map<Transaction[], Map<string, number>>();
const getCellPositions = (cell: Transaction[]): Map<string, number> => {
let positions = cellPositionsByTxid.get(cell);
if (!positions) {
positions = new Map();
for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc);
cellPositionsByTxid.set(cell, positions);
}
return positions;
};
for (const tx of Object.values(txdatas)) {
// collecting which of our address `cells` this transaction touches:
const externalCells = new Set<number>();
const internalCells = new Set<number>();
const paymentCodeCells = new Map<string, { pc: string; c: number }>();
const matchAddress = (address: string, isVout: boolean) => {
const externalIndex = externalIndexByAddress.get(address);
if (externalIndex !== undefined) externalCells.add(externalIndex);
const internalIndex = internalIndexByAddress.get(address);
if (internalIndex !== undefined) internalCells.add(internalIndex);
if (isVout) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only check `tx.vout`
const paymentCodeIndex = paymentCodeIndexByAddress.get(address);
if (paymentCodeIndex) paymentCodeCells.set(address, paymentCodeIndex);
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) {
if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_payment_code_index[pc][c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx);
}
}
}
};
for (const vin of tx.vin) {
for (const address of vin.addresses ?? []) matchAddress(address, false);
}
for (const vout of tx.vout) {
for (const address of vout.scriptPubKey.addresses ?? []) matchAddress(address, true);
}
if (externalCells.size === 0 && internalCells.size === 0 && paymentCodeCells.size === 0) continue;
// this TX is related to our address(es)
const upsertClone = (cell: Transaction[]) => {
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
const positions = getCellPositions(cell);
const existingPosition = positions.get(clonedTx.txid);
if (existingPosition !== undefined) {
cell[existingPosition] = clonedTx;
} else {
positions.set(clonedTx.txid, cell.length);
cell.push(clonedTx);
}
};
for (const c of externalCells) {
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
upsertClone(this._txs_by_external_index[c]);
}
for (const c of internalCells) {
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
upsertClone(this._txs_by_internal_index[c]);
}
for (const { pc, c } of paymentCodeCells.values()) {
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
upsertClone(this._txs_by_payment_code_index[pc][c]);
}
}
@ -524,7 +592,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
const ret: Transaction[] = [];
for (const tx of txs) {
tx.timestamp = tx.blocktime || Math.floor(+new Date() / 1000) - 30; // fallback for unconfirmed
tx.timestamp = tx.blocktime;
if (!tx.blocktime) tx.timestamp = Math.floor(+new Date() / 1000) - 30; // unconfirmed
tx.confirmations = tx.confirmations || 0; // unconfirmed
tx.hash = tx.txid;
tx.value = 0;
@ -575,7 +644,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -617,7 +687,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;
@ -659,7 +730,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
let lastHistoriesWithUsedAddresses = null;
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c));
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
// @ts-ignore
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
// in this particular chunk we have used addresses
lastChunkWithUsedAddressesNum = c;
lastHistoriesWithUsedAddresses = histories;

View File

@ -315,7 +315,7 @@ export class AbstractHDWallet extends LegacyWallet {
throw new Error('Not implemented');
}
_getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined {
_getNodePubkeyByIndex(node: number, index: number): Uint8Array | undefined {
throw new Error('Not implemented');
}

View File

@ -136,14 +136,6 @@ export class AbstractWallet {
return BitcoinUnit.BTC;
}
setPreferredBalanceUnit(unit: BitcoinUnit): void {
if (Object.values(BitcoinUnit).includes(unit)) {
this.preferredBalanceUnit = unit;
return;
}
this.preferredBalanceUnit = BitcoinUnit.BTC;
}
async allowOnchainAddress(): Promise<boolean> {
throw new Error('allowOnchainAddress: Not implemented');
}

View File

@ -8,7 +8,7 @@ import { ECPairAPI, ECPairFactory, Signer } from 'ecpair';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import ecc from '../../blue_modules/noble_ecc';
import { hexToUint8Array, concatUint8Arrays } from '../../blue_modules/uint8array-extras';
import type { HDSegwitBech32Wallet as HDSegwitBech32WalletT } from './hd-segwit-bech32-wallet';
import { HDSegwitBech32Wallet } from '..';
import { randomBytes } from '../rng';
import { AbstractWallet } from './abstract-wallet';
import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
@ -21,20 +21,14 @@ bitcoin.initEccLib(ecc);
*/
export class LegacyWallet extends AbstractWallet {
static readonly type = 'legacy';
static readonly defaultTypeReadable = 'Legacy (P2PKH)';
static readonly typeReadable = 'Legacy (P2PKH)';
// @ts-ignore: override
public readonly type = LegacyWallet.type;
// @ts-ignore: override
public readonly typeReadable: string;
public readonly typeReadable = LegacyWallet.typeReadable;
_txs_by_external_index: Record<number, Transaction[]> = {};
_txs_by_internal_index: Record<number, Transaction[]> = {};
constructor(typeReadable?: string) {
super();
this.typeReadable = typeReadable ?? LegacyWallet.defaultTypeReadable;
}
_txs_by_external_index: Transaction[] = [];
_txs_by_internal_index: Transaction[] = [];
/**
* Simple function which says that we havent tried to fetch balance
@ -344,18 +338,15 @@ export class LegacyWallet extends AbstractWallet {
}
}
this._txs_by_external_index = { 0: _txsByExternalIndex };
this._txs_by_external_index = _txsByExternalIndex;
this._lastTxFetch = +new Date();
}
getTransactions(): Transaction[] {
// a hacky code reuse from electrum HD wallet:
this._txs_by_external_index = this._txs_by_external_index || {};
this._txs_by_internal_index = {};
this._txs_by_external_index = this._txs_by_external_index || [];
this._txs_by_internal_index = [];
const { HDSegwitBech32Wallet } = require('./hd-segwit-bech32-wallet') as {
HDSegwitBech32Wallet: typeof HDSegwitBech32WalletT;
};
const hd = new HDSegwitBech32Wallet();
return hd.getTransactions.apply(this);
}

File diff suppressed because it is too large Load Diff

View File

@ -104,11 +104,6 @@ export type LightningTransaction = {
timestamp: number; // seconds, not milliseconds
expire_time?: number;
ispaid?: boolean;
// Terminal non-success state (failed/refunded/expired swap). Distinct from
// `ispaid:false`, which on its own only means "not settled yet" and is also
// true for in-flight rows. Consumers that gate on pending vs. dead state
// (e.g. the wallet-card pending pill) must treat `failed` rows as terminal.
failed?: boolean;
walletID?: string;
value?: number;
amt?: number;
@ -128,11 +123,10 @@ export type Transaction = {
locktime: number;
inputs: TransactionInput[];
outputs: TransactionOutput[];
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
blockhash?: string;
confirmations?: number;
time?: number;
blocktime?: number;
blockhash: string;
confirmations: number;
time: number;
blocktime: number;
timestamp: number; // seconds, not milliseconds
value?: number;

View File

@ -197,13 +197,12 @@ export class WatchOnlyWallet extends LegacyWallet {
async fetchUtxo() {
if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo();
// Single-address watch-only uses LegacyWallet UTXO + derivation from txs (no HD instance).
return super.fetchUtxo();
throw new Error('Not initialized');
}
getUtxo(...args: Parameters<THDWalletForWatchOnly['getUtxo']>) {
if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args);
return super.getUtxo(...args);
throw new Error('Not initialized');
}
combinePsbt(...args: Parameters<THDWalletForWatchOnly['combinePsbt']>) {

View File

@ -1,14 +0,0 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import type { Double, UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
export interface Spec extends TurboModule {
addListener(eventName: string): void;
removeListeners(count: Double): void;
getMostRecentUserActivity(): Promise<UnsafeObject | null>;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('EventEmitter');
export default moduleProxy;

View File

@ -1,18 +0,0 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import type { Double } from 'react-native/Libraries/Types/CodegenTypes';
export interface Spec extends TurboModule {
addListener(eventName: string): void;
removeListeners(count: Double): void;
openSettings(): void;
addWalletMenuAction(): void;
importWalletMenuAction(): void;
reloadTransactionsMenuAction(): void;
sharedInstance?(): void;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('MenuElementsEmitter');
export default moduleProxy;

View File

@ -1,17 +0,0 @@
import { TurboModuleRegistry } from 'react-native';
import type { TurboModule } from 'react-native';
export interface Spec extends TurboModule {
initializeDeviceUID(): Promise<string>;
getDeviceUID(): Promise<string | null>;
getDeviceUIDCopy(): Promise<string>;
setClearFilesOnLaunch(value: boolean): Promise<boolean>;
getClearFilesOnLaunch(): Promise<boolean>;
setDoNotTrack(enabled: boolean): Promise<boolean>;
getDoNotTrack(): Promise<boolean>;
openSettings(): Promise<boolean>;
}
const nativeModule = TurboModuleRegistry.get<Spec>('SettingsModule');
export default nativeModule;

View File

@ -1,10 +0,0 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
reloadAllWidgets(): void;
}
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('WidgetHelper');
export default moduleProxy;

Some files were not shown because too many files have changed in this diff Show More