Compare commits
No commits in common. "master" and "v7.2.6" have entirely different histories.
@ -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"
|
||||
|
||||
@ -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",
|
||||
{
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/issue-template.md
vendored
2
.github/ISSUE_TEMPLATE/issue-template.md
vendored
@ -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?
|
||||
|
||||
|
||||
@ -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 }}
|
||||
|
||||
187
.github/workflows/build-mac-catalyst.yml
vendored
187
.github/workflows/build-mac-catalyst.yml
vendored
@ -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}"
|
||||
107
.github/workflows/build-release-apk.yml
vendored
107
.github/workflows/build-release-apk.yml
vendored
@ -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
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
|
||||
152
.github/workflows/e2e-android.yml
vendored
152
.github/workflows/e2e-android.yml
vendored
@ -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
|
||||
233
.github/workflows/e2e-ios.yml
vendored
233
.github/workflows/e2e-ios.yml
vendored
@ -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
251
.github/workflows/e2e.yml
vendored
Normal 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
2
.gitignore
vendored
@ -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
|
||||
|
||||
@ -1 +1 @@
|
||||
3.4.9
|
||||
3.1.6
|
||||
4
App.tsx
4
App.tsx
@ -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
139
BlueComponents.js
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
12
Gemfile
@ -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"
|
||||
|
||||
271
Gemfile.lock
271
Gemfile.lock
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
2
android/.settings/org.eclipse.buildship.core.prefs
Normal file
2
android/.settings/org.eclipse.buildship.core.prefs
Normal file
@ -0,0 +1,2 @@
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
@ -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>
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
connection.project.dir=..
|
||||
eclipse.preferences.version=1
|
||||
eclipse.preferences.version=1
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
BIN
android/app/src/main/assets/fonts/AntDesign.ttf
Normal file
BIN
android/app/src/main/assets/fonts/AntDesign.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Entypo.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Entypo.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/EvilIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/EvilIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Feather.ttf
Executable file
BIN
android/app/src/main/assets/fonts/Feather.ttf
Executable file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Foundation.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Foundation.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Ionicons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Ionicons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/MaterialIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/MaterialIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Octicons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Octicons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/SimpleLineIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/SimpleLineIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Zocial.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Zocial.ttf
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -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"
|
||||
}
|
||||
@ -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
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@ -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
6
android/gradlew
vendored
@ -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
4
android/gradlew.bat
vendored
@ -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
|
||||
|
||||
@ -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/
|
||||
};
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { default, type Spec } from '../codegen/NativeEventEmitter';
|
||||
@ -1 +0,0 @@
|
||||
export { default, type Spec } from '../codegen/NativeMenuElementsEmitter';
|
||||
@ -1 +0,0 @@
|
||||
export { default, type Spec } from '../codegen/NativeWidgetHelper';
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
#import <React/RCTViewManager.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(SegmentedControlManager, RCTViewManager)
|
||||
|
||||
@end
|
||||
|
||||
@ -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"] }
|
||||
}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
4
blue_modules/pako/dist/pako.esm.mjs
vendored
4
blue_modules/pako/dist/pako.esm.mjs
vendored
@ -1,4 +0,0 @@
|
||||
import * as pako from '../index.js';
|
||||
|
||||
export * from '../index.js';
|
||||
export default pako;
|
||||
@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
22
class/index.ts
Normal 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';
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
|
||||
@ -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']>) {
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
Loading…
Reference in New Issue
Block a user