Compare commits
389 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d3f77f4f | ||
|
|
f26ff9189c | ||
|
|
1fa290652c | ||
|
|
099f6f46a6 | ||
|
|
01a11bc8dd | ||
|
|
6639891c24 | ||
|
|
4029d294f8 | ||
|
|
276a9ea8f8 | ||
|
|
d415f1a0b8 | ||
|
|
6124cf1c04 | ||
|
|
b922346bb6 | ||
|
|
64f1bd78db | ||
|
|
1412a302a1 | ||
|
|
6785427fe8 | ||
|
|
1f0ce7c813 | ||
|
|
f5379795de | ||
|
|
c76db2f84a | ||
|
|
cb6e3ae69b | ||
|
|
7bc2c0e797 | ||
|
|
5a7c514548 | ||
|
|
81cf0011b3 | ||
|
|
94062ffc9f | ||
|
|
0449a25c6a | ||
|
|
ff98ca0c1c | ||
|
|
c9dcbf40e7 | ||
|
|
c8a7887808 | ||
|
|
e4504b2355 | ||
|
|
13cedbe49e | ||
|
|
abb80665af | ||
|
|
681cbcf2dc | ||
|
|
78c9d49359 | ||
|
|
da606dbff0 | ||
|
|
8fda883933 | ||
|
|
c4abe9562e | ||
|
|
5d8e605fe7 | ||
|
|
387f8dffd6 | ||
|
|
b34f52fdce | ||
|
|
a3c12a27eb | ||
|
|
a04cad686f | ||
|
|
61877ed0db | ||
|
|
859877979e | ||
|
|
71c76bd8c8 | ||
|
|
dd52747191 | ||
|
|
fa9434b0de | ||
|
|
770a7a4075 | ||
|
|
f6f0238e0a | ||
|
|
236791f32e | ||
|
|
0181f0a849 | ||
|
|
f334b985e8 | ||
|
|
bfeda40284 | ||
|
|
a4e224ec96 | ||
|
|
d259e68a85 | ||
|
|
926a863d27 | ||
|
|
f0336e1789 | ||
|
|
2c8cfd3690 | ||
|
|
e37c4a693c | ||
|
|
2a3de6f473 | ||
|
|
bbd6101ddb | ||
|
|
687e007c56 | ||
|
|
0641bf70cd | ||
|
|
cbe6ebb423 | ||
|
|
e19ce1136f | ||
|
|
1a88c085ec | ||
|
|
d7261d4d2a | ||
|
|
4aa07ed904 | ||
|
|
b315d587ff | ||
|
|
d367a2f383 | ||
|
|
e4c8a3057d | ||
|
|
1109a836e9 | ||
|
|
8e62aee2fc | ||
|
|
220cd7e61d | ||
|
|
d043b86310 | ||
|
|
8310c5f9fa | ||
|
|
8599607574 | ||
|
|
1a0ac1188e | ||
|
|
66ca6a9fca | ||
|
|
90fdee2a8f | ||
|
|
d14556ff80 | ||
|
|
047b675faf | ||
|
|
7085dd4a81 | ||
|
|
8c58df270a | ||
|
|
3952ec7e12 | ||
|
|
26b6419507 | ||
|
|
964ceecd6a | ||
|
|
df782c5f7a | ||
|
|
f7c72e13d4 | ||
|
|
9e66ed003a | ||
|
|
08c4c02491 | ||
|
|
3c199ab9f2 | ||
|
|
0881efbd40 | ||
|
|
a5a7d34478 | ||
|
|
c8344e6037 | ||
|
|
54c5f34e11 | ||
|
|
431a8006ea | ||
|
|
092c437557 | ||
|
|
f65ccb5427 | ||
|
|
aa5a6ba7f0 | ||
|
|
9e907566f0 | ||
|
|
265cebef62 | ||
|
|
c1c13e9e58 | ||
|
|
87b2bb2156 | ||
|
|
7a5589eb00 | ||
|
|
87cf8a5600 | ||
|
|
1da0414474 | ||
|
|
151dbbbc67 | ||
|
|
bcc393dca8 | ||
|
|
1766cadcdf | ||
|
|
4524882015 | ||
|
|
4429f127c6 | ||
|
|
44b5640ff3 | ||
|
|
8d8b2fb9cf | ||
|
|
714984cefe | ||
|
|
42b1154543 | ||
|
|
492a617d42 | ||
|
|
ebf8e245ec | ||
|
|
14b3695a36 | ||
|
|
f3294d1fc6 | ||
|
|
ae9a5605f3 | ||
|
|
0749457c04 | ||
|
|
4fe998aad8 | ||
|
|
5f865777c9 | ||
|
|
66736f9c91 | ||
|
|
fef40b4990 | ||
|
|
8b16c53e5c | ||
|
|
32f810cb46 | ||
|
|
fb8fd51d86 | ||
|
|
71dd93ede2 | ||
|
|
1728f33f0a | ||
|
|
295a32caef | ||
|
|
8195855f05 | ||
|
|
d09bd69b96 | ||
|
|
ccb1dcaef7 | ||
|
|
3a48182105 | ||
|
|
e2bcae3818 | ||
|
|
f039c993cd | ||
|
|
11d367b975 | ||
|
|
31820f4cad | ||
|
|
167dc05cdf | ||
|
|
8276b8d22b | ||
|
|
edff2468d4 | ||
|
|
55f7004440 | ||
|
|
5e7f61fba4 | ||
|
|
6c23fdc0d4 | ||
|
|
ae24973596 | ||
|
|
620553d0a2 | ||
|
|
6256258fa0 | ||
|
|
e9cd4262dd | ||
|
|
4181019ad3 | ||
|
|
7560f92c4b | ||
|
|
2376abb2d3 | ||
|
|
c063ce81b6 | ||
|
|
48e880dbb5 | ||
|
|
f75eb7bf71 | ||
|
|
03412bbf07 | ||
|
|
fc05826092 | ||
|
|
4d662809c2 | ||
|
|
00b5129787 | ||
|
|
35a39e2153 | ||
|
|
ab01de5ef8 | ||
|
|
7b6731b875 | ||
|
|
992e0c987b | ||
|
|
8b3bc67818 | ||
|
|
eaae18d002 | ||
|
|
9d5af27d02 | ||
|
|
0594f09a8e | ||
|
|
3866b6cd2c | ||
|
|
53026182f5 | ||
|
|
8f8c080ed8 | ||
|
|
e098e89dc3 | ||
|
|
f1a2d29a1b | ||
|
|
038cabedaf | ||
|
|
f87ffa9633 | ||
|
|
4645ad5c3c | ||
|
|
15319ed2e6 | ||
|
|
f82de26f59 | ||
|
|
81170856fb | ||
|
|
d28ed3f706 | ||
|
|
2e7785bc60 | ||
|
|
1a7b24635a | ||
|
|
167617d929 | ||
|
|
9cf59da550 | ||
|
|
5b1422d049 | ||
|
|
b393e97ed5 | ||
|
|
e5308334c1 | ||
|
|
3087e8a9ef | ||
|
|
1e4edfafdb | ||
|
|
a0a42b30dd | ||
|
|
480bf9f3c5 | ||
|
|
0fd5ec7315 | ||
|
|
4f2a1e1f33 | ||
|
|
af7fc44a3b | ||
|
|
ce4486f80d | ||
|
|
b055c05f08 | ||
|
|
2499d0816d | ||
|
|
7073d54089 | ||
|
|
c98173471e | ||
|
|
35d6699085 | ||
|
|
66cf16fba3 | ||
|
|
adbeeaf5f3 | ||
|
|
0caa48d5ed | ||
|
|
2d9d3eb19f | ||
|
|
ec9a78a88e | ||
|
|
6a00db50cb | ||
|
|
f9766ae5fd | ||
|
|
8d5ec224c9 | ||
|
|
270cafb89a | ||
|
|
f3cac05b07 | ||
|
|
d670c5f029 | ||
|
|
016ea1c9ad | ||
|
|
650973848f | ||
|
|
e129791a19 | ||
|
|
b8efd854c3 | ||
|
|
581ce99f26 | ||
|
|
2d4e1c6d3c | ||
|
|
6a72414495 | ||
|
|
972886ac7f | ||
|
|
9a9c1fcaf0 | ||
|
|
58a9cd77b1 | ||
|
|
94fa299c32 | ||
|
|
24d002f2dc | ||
|
|
881f07b021 | ||
|
|
0b5d9817df | ||
|
|
d3dccde0d6 | ||
|
|
c04fcf149a | ||
|
|
65ec292138 | ||
|
|
aa9bd9f545 | ||
|
|
77cea7102b | ||
|
|
c314dcf596 | ||
|
|
f872bc9769 | ||
|
|
2b80bbbe2e | ||
|
|
1da5d67959 | ||
|
|
c3639a6283 | ||
|
|
1c6655cc55 | ||
|
|
839284783b | ||
|
|
24737e5714 | ||
|
|
5813ac9fc5 | ||
|
|
9738edde92 | ||
|
|
c935334ca7 | ||
|
|
24a069fb5d | ||
|
|
e9d26ac107 | ||
|
|
0b2144b8e0 | ||
|
|
302f87b881 | ||
|
|
987449f77c | ||
|
|
49cd0c34c4 | ||
|
|
fffbbadd2e | ||
|
|
2172d4a20b | ||
|
|
adc52b53c7 | ||
|
|
eed5e77447 | ||
|
|
d4c2d05808 | ||
|
|
c4fefbd7f8 | ||
|
|
d2a9280785 | ||
|
|
a846cd46ad | ||
|
|
9ec2cf9b67 | ||
|
|
08cc14a420 | ||
|
|
c52c0fd63f | ||
|
|
22e6ecbb39 | ||
|
|
ad28079f7c | ||
|
|
2cc5979f9c | ||
|
|
5e1c60c5ff | ||
|
|
416a643baf | ||
|
|
9dc35cecb9 | ||
|
|
1d208142e8 | ||
|
|
c331616a95 | ||
|
|
dd8ec1ce6e | ||
|
|
642208d22b | ||
|
|
e8a7890822 | ||
|
|
db652eeb32 | ||
|
|
fc37c6f07e | ||
|
|
44f497a1cb | ||
|
|
7e06e24062 | ||
|
|
5a62ae3fa0 | ||
|
|
26a6cb2c02 | ||
|
|
ac2d65ee5a | ||
|
|
92ffb5779f | ||
|
|
ea39e4ee1a | ||
|
|
919dd83d1a | ||
|
|
4b9aedaa3a | ||
|
|
c640591af8 | ||
|
|
030ba4dd8b | ||
|
|
c24a53fc27 | ||
|
|
a79ca9d17b | ||
|
|
d7a71bc170 | ||
|
|
93b7bc6234 | ||
|
|
91a79addf6 | ||
|
|
65387377ec | ||
|
|
2a5131f278 | ||
|
|
d60efba9b8 | ||
|
|
965f1a9f2c | ||
|
|
023ca31536 | ||
|
|
282cd6cfd7 | ||
|
|
dab363dbd7 | ||
|
|
2bfcc75882 | ||
|
|
3e21bfcfa4 | ||
|
|
5240fc4e0d | ||
|
|
e60ddbc071 | ||
|
|
98a6becf32 | ||
|
|
cfbb01571e | ||
|
|
a1abd15a5e | ||
|
|
844f01b42a | ||
|
|
e5670e99de | ||
|
|
1b7a27839c | ||
|
|
82b7db8f48 | ||
|
|
af4d7556c6 | ||
|
|
58c4544a50 | ||
|
|
48e00dd57b | ||
|
|
3e8906e31b | ||
|
|
e64d805f07 | ||
|
|
50e4e3a581 | ||
|
|
8382fd601e | ||
|
|
f631ef37b9 | ||
|
|
e1b8fd91a8 | ||
|
|
70aadbe9ba | ||
|
|
8355954b9b | ||
|
|
69a0569998 | ||
|
|
22853c1079 | ||
|
|
638254edce | ||
|
|
077fddf75f | ||
|
|
baf9f17231 | ||
|
|
f9413a8c1b | ||
|
|
ee05946c2a | ||
|
|
f04e2afe5e | ||
|
|
362cff7783 | ||
|
|
37916ab2e5 | ||
|
|
ae761a7ea8 | ||
|
|
61bb1823f7 | ||
|
|
abbc780323 | ||
|
|
511c3c4c66 | ||
|
|
16aacd4bbb | ||
|
|
c60839445d | ||
|
|
2b2a40f22d | ||
|
|
adf20d84d3 | ||
|
|
020177c30a | ||
|
|
d436ae7db2 | ||
|
|
3e280edf1b | ||
|
|
2b1dd08716 | ||
|
|
8db841add2 | ||
|
|
882717e775 | ||
|
|
3d1c5d7466 | ||
|
|
d1c457c6ae | ||
|
|
2315ea7fdf | ||
|
|
01e4b37348 | ||
|
|
0d1f09408b | ||
|
|
8bb4ee916c | ||
|
|
4ad7cfe6f0 | ||
|
|
e646bdaee3 | ||
|
|
0f8b04d43a | ||
|
|
248182c1f6 | ||
|
|
f70685b769 | ||
|
|
d5c56b6e69 | ||
|
|
2956f2e5a5 | ||
|
|
019f93d7c1 | ||
|
|
403573a8d3 | ||
|
|
64414e40d3 | ||
|
|
feecc46490 | ||
|
|
207f5b549f | ||
|
|
5280b7b521 | ||
|
|
b3b635ef00 | ||
|
|
da3767266f | ||
|
|
0ae944abbc | ||
|
|
9c8f660604 | ||
|
|
0cf466910f | ||
|
|
e991ed8814 | ||
|
|
849191f413 | ||
|
|
a066d6c377 | ||
|
|
4b240b429b | ||
|
|
1386e0738a | ||
|
|
f390d2015b | ||
|
|
c3fa43ccb8 | ||
|
|
70dc1e2f5a | ||
|
|
2b9dbc0a28 | ||
|
|
4dae36b251 | ||
|
|
19c0d3ae39 | ||
|
|
582d6b8e44 | ||
|
|
b77a9b27ca | ||
|
|
93faa47121 | ||
|
|
bfe73af235 | ||
|
|
fb1a25dba7 | ||
|
|
e7a607b6bb | ||
|
|
c36f1f934f | ||
|
|
66b3e261e5 | ||
|
|
67a5f2c9ac | ||
|
|
6078d47d40 | ||
|
|
b33ea3e1cc | ||
|
|
8044d37666 | ||
|
|
c52742e1b6 | ||
|
|
44602e406e | ||
|
|
8ea9b7aa16 | ||
|
|
0905c28989 | ||
|
|
a3a10ff440 |
@ -33,7 +33,7 @@
|
|||||||
"simulator": {
|
"simulator": {
|
||||||
"type": "ios.simulator",
|
"type": "ios.simulator",
|
||||||
"device": {
|
"device": {
|
||||||
"type": "iPhone 16"
|
"type": "iPhone 17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emulator": {
|
"emulator": {
|
||||||
@ -44,6 +44,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"ios.debug": {
|
||||||
|
"device": "simulator",
|
||||||
|
"app": "ios.debug"
|
||||||
|
},
|
||||||
"ios.release": {
|
"ios.release": {
|
||||||
"device": "simulator",
|
"device": "simulator",
|
||||||
"app": "ios.release"
|
"app": "ios.release"
|
||||||
|
|||||||
@ -21,6 +21,10 @@
|
|||||||
"react-native/no-unused-styles": "error",
|
"react-native/no-unused-styles": "error",
|
||||||
"react/no-is-mounted": "off",
|
"react/no-is-mounted": "off",
|
||||||
"react-native/no-single-element-style-arrays": "error",
|
"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": [
|
"prettier/prettier": [
|
||||||
"warn",
|
"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
|
* your phone model and OS version
|
||||||
* BlueWallet app version (settings->about->scroll down)
|
* BlueWallet app version (settings->about->scroll down)
|
||||||
* self-test passes? Open settings->about->scroll down, tap "Run self-test"
|
* self-test passes? Open settings->about->scroll down, tap "Run self-test"
|
||||||
* unique ID for our crash reporting service (settings->about->scroll down, tap "copy")
|
* 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)
|
||||||
|
|
||||||
## Proposing a feature?
|
## Proposing a feature?
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,13 @@ on:
|
|||||||
- master
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-ios-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
timeout-minutes: 180
|
timeout-minutes: 180
|
||||||
outputs:
|
outputs:
|
||||||
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
|
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
|
||||||
@ -26,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Project
|
- name: Checkout Project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Ensures the full Git history is
|
fetch-depth: 0 # Ensures the full Git history is
|
||||||
|
|
||||||
@ -65,13 +69,12 @@ jobs:
|
|||||||
echo "Branch Name: ${{ env.CURRENT_BRANCH }}"
|
echo "Branch Name: ${{ env.CURRENT_BRANCH }}"
|
||||||
|
|
||||||
- name: Specify Node.js Version
|
- name: Specify Node.js Version
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- uses: maxim-lobanov/setup-xcode@v1
|
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||||
with:
|
with:
|
||||||
xcode-version: latest
|
xcode-version: latest
|
||||||
|
|
||||||
@ -205,9 +208,9 @@ jobs:
|
|||||||
echo -e "\033[1;34m======================================================\033[0m"
|
echo -e "\033[1;34m======================================================\033[0m"
|
||||||
|
|
||||||
- name: Set Up Ruby
|
- name: Set Up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.1.6
|
ruby-version: 3.4.9
|
||||||
|
|
||||||
- name: System Debug Information
|
- name: System Debug Information
|
||||||
run: |
|
run: |
|
||||||
@ -244,7 +247,21 @@ jobs:
|
|||||||
- name: Install Node Modules
|
- name: Install Node Modules
|
||||||
run: npm ci --omit=dev --yes
|
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
|
- name: Install CocoaPods Dependencies
|
||||||
|
env:
|
||||||
|
RCT_USE_RN_DEP: "1"
|
||||||
|
RCT_USE_PREBUILT_RNCORE: "1"
|
||||||
run: |
|
run: |
|
||||||
bundle exec fastlane ios install_pods
|
bundle exec fastlane ios install_pods
|
||||||
echo "CocoaPods dependencies installed successfully"
|
echo "CocoaPods dependencies installed successfully"
|
||||||
@ -430,7 +447,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Build Logs
|
- name: Upload Build Logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: build_logs
|
name: build_logs
|
||||||
path: ./ios/build_logs/
|
path: ./ios/build_logs/
|
||||||
@ -451,7 +468,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload IPA as Artifact
|
- name: Upload IPA as Artifact
|
||||||
if: success()
|
if: success()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: BlueWallet_IPA
|
name: BlueWallet_IPA
|
||||||
path: ${{ env.IPA_OUTPUT_PATH }}
|
path: ${{ env.IPA_OUTPUT_PATH }}
|
||||||
@ -463,7 +480,7 @@ jobs:
|
|||||||
|
|
||||||
testflight-upload:
|
testflight-upload:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
|
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
|
||||||
env:
|
env:
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
@ -473,20 +490,20 @@ jobs:
|
|||||||
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
|
BRANCH_NAME: ${{ needs.build.outputs.branch_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Project
|
- name: Checkout Project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set Up Ruby
|
- name: Set Up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.1.6
|
ruby-version: 3.4.9
|
||||||
|
|
||||||
- name: Install Dependencies with Bundler
|
- name: Install Dependencies with Bundler
|
||||||
run: |
|
run: |
|
||||||
bundle config path vendor/bundle
|
bundle config path vendor/bundle
|
||||||
bundle install --jobs 4 --retry 3 --quiet
|
bundle install --jobs 4 --retry 3 --quiet
|
||||||
|
|
||||||
- name: Download IPA from Artifact
|
- name: Download IPA from Artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: BlueWallet_IPA
|
name: BlueWallet_IPA
|
||||||
path: ./
|
path: ./
|
||||||
@ -530,7 +547,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Post PR Comment
|
- name: Post PR Comment
|
||||||
if: success() && github.event_name == 'pull_request'
|
if: success() && github.event_name == 'pull_request'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
|
BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
|
||||||
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}
|
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}
|
||||||
|
|||||||
187
.github/workflows/build-mac-catalyst.yml
vendored
Normal file
187
.github/workflows/build-mac-catalyst.yml
vendored
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
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,75 +11,110 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
buildReleaseApk:
|
buildReleaseApk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout project
|
- name: Checkout project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: "0"
|
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
|
- name: Specify node version
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- name: Use specific Java version for sdkmanager to work
|
- name: Use specific Java version for sdkmanager to work
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: 'gradle'
|
|
||||||
|
|
||||||
- name: Install node_modules
|
- name: Use gradle caches
|
||||||
run: npm ci --omit=dev --yes
|
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: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.1.6
|
ruby-version: 3.4.9
|
||||||
bundler-cache: true
|
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
|
- name: Generate Build Number based on timestamp
|
||||||
id: build_number
|
id: build_number
|
||||||
run: |
|
run: |
|
||||||
NEW_BUILD_NUMBER="$(date +%s)"
|
NEW_BUILD_NUMBER="$(date +%s)"
|
||||||
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
|
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
|
||||||
|
echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Prepare Keystore
|
- name: Build and sign APK
|
||||||
run: bundle exec fastlane android prepare_keystore
|
|
||||||
env:
|
|
||||||
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
|
|
||||||
|
|
||||||
- name: Update Version Code, Build, and Sign APK
|
|
||||||
id: build_and_sign_apk
|
id: build_and_sign_apk
|
||||||
run: |
|
run: bundle exec fastlane android build_release_apk
|
||||||
bundle exec fastlane android update_version_build_and_sign_apk
|
|
||||||
env:
|
env:
|
||||||
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
|
BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }}
|
||||||
|
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
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: Determine APK Filename and Path
|
- name: Determine APK Filename and Path
|
||||||
id: determine_apk_path
|
id: determine_apk_path
|
||||||
run: |
|
run: |
|
||||||
|
BUILD_NUMBER=${{ steps.build_number.outputs.build_number }}
|
||||||
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
|
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
|
||||||
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
|
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
|
||||||
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
|
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
|
||||||
|
|
||||||
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
|
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
|
||||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
|
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk"
|
||||||
else
|
else
|
||||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
|
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
|
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
|
||||||
@ -87,32 +122,32 @@ jobs:
|
|||||||
echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV
|
echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Upload APK as artifact
|
- name: Upload APK as artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: signed-apk
|
name: signed-apk
|
||||||
path: ${{ env.APK_PATH }}
|
path: ${{ env.APK_PATH }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
browserstack:
|
browserstack:
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-26
|
||||||
needs: buildReleaseApk
|
needs: buildReleaseApk
|
||||||
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
|
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.1.6
|
ruby-version: 3.4.9
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Install dependencies with Bundler
|
- name: Install dependencies with Bundler
|
||||||
run: bundle install --jobs 4 --retry 3
|
run: bundle install --jobs 4 --retry 3
|
||||||
|
|
||||||
- name: Download APK artifact
|
- name: Download APK artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: signed-apk
|
name: signed-apk
|
||||||
|
|
||||||
@ -127,4 +162,4 @@ jobs:
|
|||||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||||
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
|
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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,16 +14,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout project
|
- name: Checkout project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Specify node version
|
- name: Specify node version
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- name: Install node_modules
|
- name: Install node_modules
|
||||||
run: npm ci || npm ci
|
run: npm ci || npm ci
|
||||||
@ -35,16 +34,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout project
|
- name: Checkout project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Specify node version
|
- name: Specify node version
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- name: Install node_modules
|
- name: Install node_modules
|
||||||
run: npm ci || npm ci
|
run: npm ci || npm ci
|
||||||
@ -55,6 +53,7 @@ jobs:
|
|||||||
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
||||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||||
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
|
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_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
|
||||||
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
||||||
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
||||||
@ -66,16 +65,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout project
|
- name: Checkout project
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Specify node version
|
- name: Specify node version
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- name: Install node_modules
|
- name: Install node_modules
|
||||||
run: npm ci || npm ci
|
run: npm ci || npm ci
|
||||||
@ -86,6 +84,7 @@ jobs:
|
|||||||
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
|
||||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||||
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
|
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_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
|
||||||
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
||||||
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
|
||||||
|
|||||||
152
.github/workflows/e2e-android.yml
vendored
Normal file
152
.github/workflows/e2e-android.yml
vendored
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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
Normal file
233
.github/workflows/e2e-ios.yml
vendored
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
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
251
.github/workflows/e2e.yml
vendored
@ -1,251 +0,0 @@
|
|||||||
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,9 +88,11 @@ artifacts/
|
|||||||
*.realm
|
*.realm
|
||||||
*.realm.lock
|
*.realm.lock
|
||||||
android/app/.project
|
android/app/.project
|
||||||
|
android/.settings/org.eclipse.buildship.core.prefs
|
||||||
android/app/.classpath
|
android/app/.classpath
|
||||||
android/.settings/org.eclipse.buildship.core.prefs
|
android/.settings/org.eclipse.buildship.core.prefs
|
||||||
android/.project
|
android/.project
|
||||||
|
android/app/.settings/org.eclipse.jdt.core.prefs
|
||||||
android/.settings/org.eclipse.buildship.core.prefs
|
android/.settings/org.eclipse.buildship.core.prefs
|
||||||
android/app/.classpath
|
android/app/.classpath
|
||||||
android/app/.project
|
android/app/.project
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
3.1.6
|
3.4.9
|
||||||
|
|||||||
4
App.tsx
4
App.tsx
@ -1,4 +1,4 @@
|
|||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer, NavigationContainerRef, ParamListBase } from '@react-navigation/native';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useColorScheme } from 'react-native';
|
import { useColorScheme } from 'react-native';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
@ -13,7 +13,7 @@ import { StorageProvider } from './components/Context/StorageProvider';
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
useLogger(navigationRef);
|
useLogger(navigationRef as unknown as React.RefObject<NavigationContainerRef<ParamListBase>>);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SizeClassProvider>
|
<SizeClassProvider>
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
/* 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,6 +60,8 @@ 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.
|
**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`.
|
**Components:** New components go in `components/`, not legacy `BlueComponents.js`.
|
||||||
|
|
||||||
**Linting Rules:**
|
**Linting Rules:**
|
||||||
@ -67,7 +69,7 @@ React Navigation 7.x with native stack. Typed params in `navigation/DetailViewSt
|
|||||||
- No unused styles (`react-native/no-unused-styles`: error)
|
- No unused styles (`react-native/no-unused-styles`: error)
|
||||||
- Prettier: single quotes, 140 char width, trailing commas
|
- Prettier: single quotes, 140 char width, trailing commas
|
||||||
|
|
||||||
**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys.
|
**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.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@ -25,3 +25,9 @@ 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.
|
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.
|
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,13 +1,19 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
||||||
ruby "3.1.6"
|
ruby "3.4.9"
|
||||||
gem "fastlane", "~> 2.228.0"
|
gem "fastlane", "~> 2.234.0"
|
||||||
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||||
gem 'xcodeproj', '< 1.26.0'
|
gem 'xcodeproj', '< 1.26.0'
|
||||||
gem 'concurrent-ruby', '< 1.3.4'
|
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'
|
||||||
|
|
||||||
# Required for App Store Connect API
|
# Required for App Store Connect API
|
||||||
gem "jwt"
|
gem "jwt"
|
||||||
|
|||||||
271
Gemfile.lock
271
Gemfile.lock
@ -1,9 +1,9 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.9)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
activesupport (7.2.3)
|
activesupport (7.2.3.1)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@ -12,10 +12,10 @@ GEM
|
|||||||
drb
|
drb
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
logger (>= 1.4.2)
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1, < 6)
|
||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
algoliasearch (1.27.5)
|
algoliasearch (1.27.5)
|
||||||
httpclient (~> 2.8, >= 2.8.3)
|
httpclient (~> 2.8, >= 2.8.3)
|
||||||
@ -23,8 +23,8 @@ GEM
|
|||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1217.0)
|
aws-partitions (1.1252.0)
|
||||||
aws-sdk-core (3.242.0)
|
aws-sdk-core (3.247.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@ -32,11 +32,11 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.122.0)
|
aws-sdk-kms (1.127.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.247.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.213.0)
|
aws-sdk-s3 (1.223.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.247.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
@ -44,7 +44,7 @@ GEM
|
|||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
cocoapods (1.15.2)
|
cocoapods (1.15.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
@ -87,8 +87,9 @@ GEM
|
|||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
concurrent-ruby (1.3.3)
|
concurrent-ruby (1.3.7)
|
||||||
connection_pool (2.5.5)
|
connection_pool (3.0.2)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
@ -126,19 +127,23 @@ GEM
|
|||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.228.0)
|
fastlane (2.234.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
base64 (~> 0.2)
|
||||||
|
benchmark (>= 0.1.0)
|
||||||
|
bundler (>= 1.17.3, < 5.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
@ -146,20 +151,24 @@ GEM
|
|||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
|
nkf (~> 0.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
@ -179,49 +188,50 @@ GEM
|
|||||||
git
|
git
|
||||||
xml-simple
|
xml-simple
|
||||||
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0)
|
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
ffi (1.17.3)
|
ffi (1.17.3)
|
||||||
fourflusher (2.3.1)
|
fourflusher (2.3.1)
|
||||||
fuzzy_match (2.0.4)
|
fuzzy_match (2.0.4)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
git (3.1.1)
|
git (4.3.1)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
process_executer (~> 1.3)
|
process_executer (~> 4.0)
|
||||||
rchardet (~> 1.9)
|
rchardet (~> 1.9)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.100.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.27.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.62.0)
|
||||||
google-apis-storage_v1 (0.31.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
|
||||||
google-cloud-core (1.8.0)
|
google-cloud-core (1.8.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.47.0)
|
google-cloud-storage (1.60.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.31.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
googleauth (1.11.2)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-env (~> 2.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
@ -235,31 +245,34 @@ GEM
|
|||||||
i18n (1.14.8)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.19.5)
|
||||||
jwt (2.10.2)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2026.0203)
|
mime-types-data (3.2026.0317)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.27.0)
|
minitest (5.27.0)
|
||||||
molinillo (0.8.0)
|
molinillo (0.8.0)
|
||||||
multi_json (1.19.1)
|
multi_json (1.21.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.3.0)
|
nanaimo (0.3.0)
|
||||||
nap (1.1.0)
|
nap (1.1.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
netrc (0.11.0)
|
netrc (0.11.0)
|
||||||
|
nkf (0.2.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
process_executer (1.3.0)
|
process_executer (4.0.2)
|
||||||
|
track_open_instances (~> 0.1)
|
||||||
public_suffix (4.0.7)
|
public_suffix (4.0.7)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rchardet (1.10.0)
|
rchardet (1.10.0)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
@ -270,7 +283,7 @@ GEM
|
|||||||
http-cookie (>= 1.0.2, < 2.0)
|
http-cookie (>= 1.0.2, < 2.0)
|
||||||
mime-types (>= 1.16, < 4.0)
|
mime-types (>= 1.16, < 4.0)
|
||||||
netrc (~> 0.8)
|
netrc (~> 0.8)
|
||||||
retriable (3.2.1)
|
retriable (3.4.1)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby-macho (2.5.1)
|
ruby-macho (2.5.1)
|
||||||
@ -286,17 +299,17 @@ GEM
|
|||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
track_open_instances (0.1.15)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.2)
|
tty-screen (0.8.2)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
typhoeus (1.4.1)
|
typhoeus (1.6.0)
|
||||||
ethon (>= 0.9.0)
|
ethon (>= 0.18.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
@ -321,17 +334,157 @@ PLATFORMS
|
|||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
activesupport (>= 6.1.7.5, != 7.1.0)
|
activesupport (>= 6.1.7.5, != 7.1.0)
|
||||||
|
benchmark
|
||||||
|
bigdecimal
|
||||||
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
||||||
concurrent-ruby (< 1.3.4)
|
concurrent-ruby (< 1.3.8)
|
||||||
fastlane (~> 2.228.0)
|
fastlane (~> 2.234.0)
|
||||||
fastlane-plugin-browserstack
|
fastlane-plugin-browserstack
|
||||||
fastlane-plugin-bugsnag
|
fastlane-plugin-bugsnag
|
||||||
fastlane-plugin-bugsnag_sourcemaps_upload
|
fastlane-plugin-bugsnag_sourcemaps_upload
|
||||||
jwt
|
jwt
|
||||||
|
logger
|
||||||
|
mutex_m
|
||||||
xcodeproj (< 1.26.0)
|
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 VERSION
|
||||||
ruby 3.1.6p260
|
ruby 3.4.9
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.3.27
|
4.0.7
|
||||||
|
|||||||
@ -104,7 +104,7 @@ Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/issues
|
|||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
We accept translations via [Transifex](https://www.transifex.com/bluewallet/bluewallet/)
|
We accept translations via [Transifex](https://explore.transifex.com/bluewallet/bluewallet/)
|
||||||
|
|
||||||
To participate you need to:
|
To participate you need to:
|
||||||
1. Sign up to Transifex
|
1. Sign up to Transifex
|
||||||
@ -116,6 +116,10 @@ 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.
|
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
|
## Q&A
|
||||||
|
|
||||||
Builds automated and tested with BrowserStack
|
Builds automated and tested with BrowserStack
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
connection.project.dir=
|
|
||||||
eclipse.preferences.version=1
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<classpath>
|
<classpath>
|
||||||
<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.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
|
||||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||||
<classpathentry kind="output" path="bin/default"/>
|
<classpathentry kind="output" path="bin/default"/>
|
||||||
</classpath>
|
</classpath>
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
connection.project.dir=..
|
connection.project.dir=..
|
||||||
eclipse.preferences.version=1
|
eclipse.preferences.version=1
|
||||||
|
|||||||
4
android/app/.settings/org.eclipse.jdt.core.prefs
Normal file
4
android/app/.settings/org.eclipse.jdt.core.prefs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
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 */
|
/* Variants */
|
||||||
// The list of variants to that are debuggable. For those we're going to
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
|
||||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
|
||||||
|
|
||||||
/* Bundling */
|
/* Bundling */
|
||||||
// A list containing the node command and its flags. Default is just 'node'.
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
@ -87,13 +87,14 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "7.2.6"
|
versionName "8.0.1"
|
||||||
testBuildType System.getProperty('testBuildType', 'debug')
|
testBuildType System.getProperty('testBuildType', 'debug')
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
missingDimensionStrategy "react-native-capture-protection", "fullMediaCapture"
|
// Keep compatibility across react-native-capture-protection flavor changes.
|
||||||
|
missingDimensionStrategy "react-native-capture-protection", "callbackTiramisu", "base"
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
lint {
|
||||||
abortOnError false
|
abortOnError false
|
||||||
checkReleaseBuilds false
|
checkReleaseBuilds false
|
||||||
}
|
}
|
||||||
@ -101,13 +102,12 @@ android {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
assets.srcDirs = ['src/main/assets', 'src/main/res/assets']
|
assets.srcDirs = ['src/main/assets', 'src/main/res/assets']
|
||||||
|
java.srcDirs = ['src/main/java', '../../blue_modules/Views/SegmentedControl/android']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Caution! In production, you need to generate your own keystore file.
|
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
|
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
|
||||||
@ -123,14 +123,21 @@ task copyFiatUnits(type: Copy) {
|
|||||||
|
|
||||||
preBuild.dependsOn(copyFiatUnits)
|
preBuild.dependsOn(copyFiatUnits)
|
||||||
|
|
||||||
|
// Ensure fiat units are available before codegen scans JS sources
|
||||||
|
tasks.configureEach { task ->
|
||||||
|
if (task.name == 'generateCodegenSchemaFromJavaScript') {
|
||||||
|
task.dependsOn(copyFiatUnits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
androidTestImplementation('com.wix:detox:+')
|
androidTestImplementation('com.wix:detox:+')
|
||||||
// The version of react-native is set by the React Native Gradle Plugin
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
implementation("com.facebook.react:react-android")
|
implementation("com.facebook.react:react-android")
|
||||||
implementation 'androidx.core:core-ktx:1.16.0'
|
implementation 'androidx.core:core-ktx:1.18.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.10.5'
|
implementation 'androidx.work:work-runtime-ktx:2.11.2'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.compose.ui:ui:1.9.4'
|
implementation 'androidx.compose.ui:ui:1.10.6'
|
||||||
implementation 'androidx.compose.material3:material3:1.3.2'
|
implementation 'androidx.compose.material3:material3:1.3.2'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
|
|
||||||
@ -139,9 +146,7 @@ dependencies {
|
|||||||
} else {
|
} else {
|
||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
androidTestImplementation('com.wix:detox:0.1.1')
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||||
}
|
}
|
||||||
apply plugin: 'com.google.gms.google-services' // Google Services plugin
|
apply plugin: 'com.google.gms.google-services' // Google Services plugin
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_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_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
|
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
|
||||||
@ -34,27 +33,18 @@
|
|||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:configChanges="uiMode">
|
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
|
<meta-data
|
||||||
android:name="firebase_messaging_auto_init_enabled"
|
android:name="firebase_messaging_auto_init_enabled"
|
||||||
android:value="false" />
|
android:value="false" />
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="firebase_analytics_collection_enabled"
|
android:name="firebase_analytics_collection_enabled"
|
||||||
android:value="false" />
|
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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
@ -64,16 +54,6 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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">
|
<receiver android:name=".BitcoinPriceWidget" android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
@ -130,19 +110,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity-alias>
|
</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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -39,31 +39,43 @@ class BitcoinPriceWidget : AppWidgetProvider() {
|
|||||||
// Try to load cached data first
|
// Try to load cached data first
|
||||||
val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
val cachedPrice = sharedPref.getString("previous_price", null)
|
val cachedPrice = sharedPref.getString("previous_price", null)
|
||||||
|
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
|
||||||
|
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null)
|
||||||
|
|
||||||
if (cachedPrice != null) {
|
if (cachedPrice != null) {
|
||||||
// Show cached data immediately
|
// Show cached data immediately
|
||||||
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
|
|
||||||
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", "en-US")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val localeParts = preferredCurrencyLocale?.split("-") ?: listOf("en", "US")
|
val locale = preferredCurrencyLocale
|
||||||
val locale = if (localeParts.size == 2) {
|
?.let { runCatching { java.util.Locale.forLanguageTag(it) }.getOrNull() }
|
||||||
java.util.Locale(localeParts[0], localeParts[1])
|
?.takeIf { it.language.isNotBlank() }
|
||||||
} else {
|
?: java.util.Locale.getDefault()
|
||||||
java.util.Locale.getDefault()
|
|
||||||
}
|
|
||||||
val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale)
|
val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale)
|
||||||
val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD")
|
val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD")
|
||||||
currencyFormat.currency = currency
|
currencyFormat.currency = currency
|
||||||
currencyFormat.maximumFractionDigits = 0
|
currencyFormat.maximumFractionDigits = 0
|
||||||
|
|
||||||
|
val parsedCached = cachedPrice.toDoubleOrNull()?.toInt()
|
||||||
|
|
||||||
views.setViewVisibility(R.id.loading_indicator, View.GONE)
|
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)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error displaying cached price", e)
|
Log.e(TAG, "Error displaying cached price", e)
|
||||||
// Show loading state if cache display fails
|
// Show loading state if cache display fails
|
||||||
|
|||||||
@ -11,14 +11,13 @@ import com.facebook.react.PackageList
|
|||||||
import com.facebook.react.ReactApplication
|
import com.facebook.react.ReactApplication
|
||||||
import com.facebook.react.ReactHost
|
import com.facebook.react.ReactHost
|
||||||
import com.facebook.react.ReactNativeHost
|
import com.facebook.react.ReactNativeHost
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
|
||||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.drawee.backends.pipeline.Fresco
|
||||||
import com.facebook.soloader.SoLoader
|
import com.facebook.react.modules.fresco.FrescoModule
|
||||||
import com.facebook.react.modules.i18nmanager.I18nUtil
|
import com.facebook.react.modules.i18nmanager.I18nUtil
|
||||||
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
|
import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage
|
||||||
|
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
@ -66,26 +65,25 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val reactNativeHost: ReactNativeHost =
|
override val reactNativeHost: ReactNativeHost by lazy {
|
||||||
object : DefaultReactNativeHost(this) {
|
object : DefaultReactNativeHost(this) {
|
||||||
override fun getPackages(): List<ReactPackage> =
|
override fun getPackages() =
|
||||||
PackageList(this).packages.apply {
|
PackageList(this).packages.apply {
|
||||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
// add(MyReactNativePackage())
|
// add(MyReactNativePackage())
|
||||||
add(CustomSegmentedControlPackage())
|
add(SegmentedControlPackage())
|
||||||
add(SettingsPackage())
|
add(SettingsPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getUseDeveloperSupport() = BuildConfig.DEBUG
|
||||||
|
|
||||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
override fun getJSMainModuleName() = "index"
|
||||||
|
|
||||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
|
||||||
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override val reactHost: ReactHost
|
override val reactHost: ReactHost by lazy {
|
||||||
get() = getDefaultReactHost(applicationContext, reactNativeHost)
|
getDefaultReactHost(applicationContext, reactNativeHost)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@ -101,12 +99,15 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
|
|
||||||
val sharedI18nUtilInstance = I18nUtil.getInstance()
|
val sharedI18nUtilInstance = I18nUtil.getInstance()
|
||||||
sharedI18nUtilInstance.allowRTL(applicationContext, true)
|
sharedI18nUtilInstance.allowRTL(applicationContext, true)
|
||||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
|
||||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
// Initialize Fresco before RN mounts views. FrescoModule init can lag behind the first
|
||||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
// frame (e.g. UnlockWith logo) when OkHttp/SSL warms up network security config.
|
||||||
load()
|
if (!FrescoModule.hasBeenInitialized()) {
|
||||||
|
Fresco.initialize(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadReactNative(this)
|
||||||
|
|
||||||
initializeDeviceUID()
|
initializeDeviceUID()
|
||||||
initializeBugsnag()
|
initializeBugsnag()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,13 @@ import android.content.Context
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import com.facebook.react.bridge.ReactMethod
|
||||||
import com.facebook.react.bridge.Promise
|
import com.facebook.react.bridge.Promise
|
||||||
import java.util.UUID
|
import com.facebook.react.module.annotations.ReactModule
|
||||||
|
import io.bluewallet.bluewallet.NativeSettingsModuleSpec
|
||||||
|
|
||||||
class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
@ReactModule(name = SettingsModule.NAME)
|
||||||
|
class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModuleSpec(reactContext) {
|
||||||
|
|
||||||
private val sharedPref: SharedPreferences = reactContext.getSharedPreferences(
|
private val sharedPref: SharedPreferences = reactContext.getSharedPreferences(
|
||||||
"group.io.bluewallet.bluewallet",
|
"group.io.bluewallet.bluewallet",
|
||||||
@ -22,10 +23,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy"
|
private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy"
|
||||||
private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch"
|
private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch"
|
||||||
private const val DO_NOT_TRACK_KEY = "donottrack"
|
private const val DO_NOT_TRACK_KEY = "donottrack"
|
||||||
}
|
const val NAME = "SettingsModule"
|
||||||
|
|
||||||
override fun getName(): String {
|
|
||||||
return "SettingsModule"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +31,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Uses the same Android ID as react-native-device-info's getUniqueId()
|
* Uses the same Android ID as react-native-device-info's getUniqueId()
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun initializeDeviceUID(promise: Promise) {
|
override fun initializeDeviceUID(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
||||||
|
|
||||||
@ -86,7 +84,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Get the device UID
|
* Get the device UID
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getDeviceUID(promise: Promise) {
|
override fun getDeviceUID(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
||||||
|
|
||||||
@ -107,7 +105,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Get the device UID copy (for Settings display)
|
* Get the device UID copy (for Settings display)
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getDeviceUIDCopy(promise: Promise) {
|
override fun getDeviceUIDCopy(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "")
|
val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "")
|
||||||
promise.resolve(deviceUIDCopy)
|
promise.resolve(deviceUIDCopy)
|
||||||
@ -121,7 +119,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Set the clearFilesOnLaunch preference
|
* Set the clearFilesOnLaunch preference
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
|
override fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
|
||||||
try {
|
try {
|
||||||
sharedPref.edit()
|
sharedPref.edit()
|
||||||
.putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value)
|
.putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value)
|
||||||
@ -138,7 +136,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Get the clearFilesOnLaunch preference
|
* Get the clearFilesOnLaunch preference
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getClearFilesOnLaunch(promise: Promise) {
|
override fun getClearFilesOnLaunch(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false)
|
val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false)
|
||||||
promise.resolve(value)
|
promise.resolve(value)
|
||||||
@ -152,7 +150,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Set Do Not Track setting
|
* Set Do Not Track setting
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun setDoNotTrack(enabled: Boolean, promise: Promise) {
|
override fun setDoNotTrack(enabled: Boolean, promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val value = if (enabled) "1" else "0"
|
val value = if (enabled) "1" else "0"
|
||||||
sharedPref.edit()
|
sharedPref.edit()
|
||||||
@ -184,7 +182,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Get Do Not Track setting
|
* Get Do Not Track setting
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getDoNotTrack(promise: Promise) {
|
override fun getDoNotTrack(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0")
|
val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0")
|
||||||
val enabled = value == "1"
|
val enabled = value == "1"
|
||||||
@ -199,7 +197,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
|||||||
* Open the settings activity from JavaScript
|
* Open the settings activity from JavaScript
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun openSettings(promise: Promise) {
|
override fun openSettings(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java)
|
val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java)
|
||||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
package io.bluewallet.bluewallet
|
package io.bluewallet.bluewallet
|
||||||
|
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.TurboReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
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
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
class SettingsPackage : ReactPackage {
|
class SettingsPackage : TurboReactPackage() {
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
||||||
return listOf(SettingsModule(reactContext))
|
return if (name == SettingsModule.NAME) SettingsModule(reactContext) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
||||||
return emptyList()
|
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<*, *>> = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -205,16 +205,23 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
|
|||||||
preferredCurrency: String?,
|
preferredCurrency: String?,
|
||||||
preferredCurrencyLocale: String?
|
preferredCurrencyLocale: String?
|
||||||
) {
|
) {
|
||||||
|
val parsedPrevious = previousPrice?.toDoubleOrNull()
|
||||||
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
||||||
|
|
||||||
views.apply {
|
views.apply {
|
||||||
setViewVisibility(R.id.loading_indicator, View.GONE)
|
setViewVisibility(R.id.loading_indicator, 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)
|
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.last_updated_time, currentTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,37 +233,45 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
|
|||||||
preferredCurrency: String?,
|
preferredCurrency: String?,
|
||||||
preferredCurrencyLocale: String?
|
preferredCurrencyLocale: String?
|
||||||
) {
|
) {
|
||||||
val currentPrice = fetchedPrice.toDouble().toInt()
|
val currentPrice = fetchedPrice.toDoubleOrNull()?.toInt()
|
||||||
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
||||||
|
|
||||||
views.apply {
|
views.apply {
|
||||||
setViewVisibility(R.id.loading_indicator, View.GONE)
|
setViewVisibility(R.id.loading_indicator, View.GONE)
|
||||||
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
|
if (currentPrice != null) {
|
||||||
setTextViewText(R.id.last_updated_time, currentTime)
|
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
|
||||||
setViewVisibility(R.id.price_value, View.VISIBLE)
|
setTextViewText(R.id.last_updated_time, currentTime)
|
||||||
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||||
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||||
|
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||||
|
|
||||||
if (previousPrice != null) {
|
val previousParsed = previousPrice?.toDoubleOrNull()?.toInt()
|
||||||
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
|
if (previousParsed != null) {
|
||||||
setTextViewText(R.id.previous_price, currencyFormat.format(previousPrice.toDouble().toInt()))
|
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
|
||||||
setImageViewResource(
|
setTextViewText(R.id.previous_price, currencyFormat.format(previousParsed))
|
||||||
R.id.price_arrow,
|
setImageViewResource(
|
||||||
if (currentPrice > previousPrice.toDouble().toInt()) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
|
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)
|
||||||
|
}
|
||||||
} else {
|
} 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.price_arrow_container, View.GONE)
|
||||||
|
setViewVisibility(R.id.loading_indicator, View.VISIBLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
|
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
|
||||||
val localeParts = localeString?.split("-") ?: listOf("en", "US")
|
val locale = localeString
|
||||||
val locale = if (localeParts.size == 2) {
|
?.let { runCatching { Locale.forLanguageTag(it) }.getOrNull() }
|
||||||
Locale(localeParts[0], localeParts[1])
|
?.takeIf { it.language.isNotBlank() }
|
||||||
} else {
|
?: Locale.getDefault()
|
||||||
Locale.getDefault()
|
|
||||||
}
|
|
||||||
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
|
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
|
||||||
val currency = try {
|
val currency = try {
|
||||||
Currency.getInstance(currencyCode ?: "USD")
|
Currency.getInstance(currencyCode ?: "USD")
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
android/app/src/main/res/drawable/notification_icon.png
Normal file
BIN
android/app/src/main/res/drawable/notification_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@ -3,16 +3,14 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 24
|
||||||
buildToolsVersion = "35.0.0"
|
buildToolsVersion = "36.0.0"
|
||||||
compileSdkVersion = 35
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 35
|
targetSdkVersion = 36
|
||||||
googlePlayServicesVersion = "16.+"
|
googlePlayServicesVersion = "16.+"
|
||||||
googlePlayServicesIidVersion = "16.0.1"
|
googlePlayServicesIidVersion = "16.0.1"
|
||||||
firebaseVersion = "17.3.4"
|
firebaseVersion = "21.1.0"
|
||||||
firebaseMessagingVersion = "21.1.0"
|
ndkVersion = "28.2.13676358"
|
||||||
ndkVersion = "27.1.12297006"
|
kotlinVersion = '2.1.20'
|
||||||
kotlin_version = '2.0.21'
|
|
||||||
kotlinVersion = '2.0.21'
|
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@ -21,17 +19,51 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle")
|
classpath("com.android.tools.build:gradle")
|
||||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
||||||
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
|
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
|
||||||
classpath("com.bugsnag:bugsnag-android-gradle-plugin:8.2.0")
|
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 {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
url("$rootDir/../node_modules/detox/Detox-android")
|
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 {
|
mavenCentral {
|
||||||
// We don't want to fetch react-native from Maven Central as there are
|
// We don't want to fetch react-native from Maven Central as there are
|
||||||
@ -40,26 +72,66 @@ allprojects {
|
|||||||
excludeGroup "com.facebook.react"
|
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()
|
google()
|
||||||
maven { url 'https://www.jitpack.io' }
|
maven { url 'https://www.jitpack.io' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
// Apply jcenter shim very early for every project before its build.gradle is evaluated
|
||||||
afterEvaluate {project ->
|
gradle.beforeProject { project ->
|
||||||
if (project.hasProperty("android")) {
|
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")) {
|
||||||
android {
|
android {
|
||||||
buildToolsVersion "35.0.0"
|
buildToolsVersion "36.0.0"
|
||||||
compileSdkVersion 35
|
compileSdkVersion 36
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 24
|
minSdkVersion 24
|
||||||
}
|
}
|
||||||
@ -68,12 +140,32 @@ subprojects {
|
|||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
|
||||||
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
|
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
|
||||||
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
|
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
|
||||||
if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) {
|
if (proj.plugins.hasPlugin("com.android.application") || proj.plugins.hasPlugin("com.android.library")) {
|
||||||
kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility
|
kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility
|
||||||
} else {
|
} else {
|
||||||
kotlinOptions.jvmTarget = sourceCompatibility
|
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,7 +21,6 @@ org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
|
||||||
# Use this property to specify which architecture you want to build.
|
# Use this property to specify which architecture you want to build.
|
||||||
# You can also override it from the CLI using
|
# You can also override it from the CLI using
|
||||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
@ -32,8 +31,16 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
|||||||
# your application. You should enable this flag either if you want
|
# your application. You should enable this flag either if you want
|
||||||
# to write custom TurboModules/Fabric components OR use libraries that
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
# are providing them.
|
# are providing them.
|
||||||
newArchEnabled=false
|
newArchEnabled=true
|
||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# If set to false, you will be using JSC instead.
|
||||||
hermesEnabled=true
|
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
|
||||||
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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
6
android/gradlew
vendored
6
android/gradlew
vendored
@ -114,8 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
@ -172,7 +170,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@ -212,8 +209,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
4
android/gradlew.bat
vendored
4
android/gradlew.bat
vendored
@ -70,11 +70,9 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:@react-native/babel-preset'],
|
// Pin the @babel/runtime version so Metro resolves a single copy instead of
|
||||||
plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/
|
// 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'],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import RNFS from 'react-native-fs';
|
|||||||
import Realm from 'realm';
|
import Realm from 'realm';
|
||||||
import { sha256 as _sha256 } from '@noble/hashes/sha256';
|
import { sha256 as _sha256 } from '@noble/hashes/sha256';
|
||||||
|
|
||||||
import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class';
|
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 presentAlert from '../components/Alert';
|
import presentAlert from '../components/Alert';
|
||||||
import loc from '../loc';
|
import loc from '../loc';
|
||||||
import { GROUP_IO_BLUEWALLET } from './currency';
|
import { GROUP_IO_BLUEWALLET } from './currency';
|
||||||
@ -27,7 +30,7 @@ type Utxo = {
|
|||||||
wif?: string;
|
wif?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ElectrumTransaction = {
|
export type ElectrumTransaction = {
|
||||||
txid: string;
|
txid: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
version: number;
|
version: number;
|
||||||
@ -55,13 +58,14 @@ type ElectrumTransaction = {
|
|||||||
addresses: string[];
|
addresses: string[];
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
blockhash: string;
|
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
|
||||||
confirmations: number;
|
blockhash?: string;
|
||||||
time: number;
|
confirmations?: number;
|
||||||
blocktime: number;
|
time?: number;
|
||||||
|
blocktime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ElectrumTransactionWithHex = ElectrumTransaction & {
|
export type ElectrumTransactionWithHex = ElectrumTransaction & {
|
||||||
hex: string;
|
hex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,7 +94,6 @@ export const hardcodedPeers: Peer[] = [
|
|||||||
// { host: 'electrum.jochen-hoenicke.de', ssl: '50006' },
|
// { host: 'electrum.jochen-hoenicke.de', ssl: '50006' },
|
||||||
{ host: 'electrum1.bluewallet.io', ssl: 443 },
|
{ host: 'electrum1.bluewallet.io', ssl: 443 },
|
||||||
{ host: 'electrum.acinq.co', ssl: 50002 },
|
{ host: 'electrum.acinq.co', ssl: 50002 },
|
||||||
{ host: 'electrum.bitaroo.net', ssl: 50002 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
|
export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
|
||||||
@ -98,13 +101,84 @@ export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let mainClient: typeof ElectrumClient | undefined;
|
let mainClient: typeof ElectrumClient | undefined;
|
||||||
let mainConnected: boolean = false;
|
|
||||||
let wasConnectedAtLeastOnce: boolean = false;
|
|
||||||
let serverName: string | false = false;
|
let serverName: string | false = false;
|
||||||
let disableBatching: boolean = false;
|
let disableBatching: boolean = false;
|
||||||
let connectionAttempt: number = 0;
|
let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl);
|
||||||
let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length);
|
if (currentPeerIndex < 0) currentPeerIndex = 0;
|
||||||
let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined };
|
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> = {};
|
const txhashHeightCache: Record<string, number> = {};
|
||||||
let _realm: Realm | undefined;
|
let _realm: Realm | undefined;
|
||||||
|
|
||||||
@ -150,10 +224,10 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
|
|||||||
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
|
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
|
||||||
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
|
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
|
||||||
|
|
||||||
console.log('Getting preferred server:', { host, tcpPort, sslPort });
|
console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort });
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
console.warn('Preferred server host is undefined');
|
console.warn('[electrum] Preferred server host is undefined');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +237,7 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
|
|||||||
ssl: sslPort ? Number(sslPort) : undefined,
|
ssl: sslPort ? Number(sslPort) : undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getPreferredServer:', error);
|
console.error('[electrum] Error in getPreferredServer:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -171,12 +245,12 @@ export const getPreferredServer = async (): Promise<ElectrumServerItem | undefin
|
|||||||
export const removePreferredServer = async () => {
|
export const removePreferredServer = async () => {
|
||||||
try {
|
try {
|
||||||
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
||||||
console.log('Removing preferred server');
|
console.log('[electrum] Removing preferred server');
|
||||||
await DefaultPreference.clear(ELECTRUM_HOST);
|
await DefaultPreference.clear(ELECTRUM_HOST);
|
||||||
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
||||||
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in removePreferredServer:', error);
|
console.error('[electrum] Error in removePreferredServer:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -185,14 +259,14 @@ export async function isDisabled(): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
||||||
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
|
const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED);
|
||||||
console.log('Getting Electrum connection disabled state:', savedValue);
|
console.log('[electrum] Getting Electrum connection disabled state:', savedValue);
|
||||||
if (savedValue === null) {
|
if (savedValue === null) {
|
||||||
result = false;
|
result = false;
|
||||||
} else {
|
} else {
|
||||||
result = savedValue;
|
result = savedValue;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting Electrum connection disabled state:', error);
|
console.error('[electrum] Error getting Electrum connection disabled state:', error);
|
||||||
result = false;
|
result = false;
|
||||||
}
|
}
|
||||||
return !!result;
|
return !!result;
|
||||||
@ -200,8 +274,23 @@ export async function isDisabled(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function setDisabled(disabled = true) {
|
export async function setDisabled(disabled = true) {
|
||||||
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
|
||||||
console.log('Setting Electrum connection disabled state to:', disabled);
|
console.log('[electrum] Setting Electrum connection disabled state to:', disabled);
|
||||||
return DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : '');
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentPeer() {
|
function getCurrentPeer() {
|
||||||
@ -214,7 +303,7 @@ function getCurrentPeer() {
|
|||||||
function getNextPeer() {
|
function getNextPeer() {
|
||||||
const peer = getCurrentPeer();
|
const peer = getCurrentPeer();
|
||||||
currentPeerIndex++;
|
currentPeerIndex++;
|
||||||
if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0;
|
if (currentPeerIndex >= hardcodedPeers.length) currentPeerIndex = 0;
|
||||||
return peer;
|
return peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,7 +314,7 @@ async function getSavedPeer(): Promise<Peer | null> {
|
|||||||
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
|
const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT);
|
||||||
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
|
const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT);
|
||||||
|
|
||||||
console.log('Getting saved peer:', { host, tcpPort, sslPort });
|
console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort });
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return null;
|
return null;
|
||||||
@ -241,53 +330,98 @@ async function getSavedPeer(): Promise<Peer | null> {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getSavedPeer:', error);
|
console.error('[electrum] Error in getSavedPeer:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectMain(): Promise<void> {
|
/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */
|
||||||
if (await isDisabled()) {
|
async function pickPeer(): Promise<Peer> {
|
||||||
console.log('Electrum connection disabled by user. Skipping connectMain call');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let usingPeer = getNextPeer();
|
let usingPeer = getNextPeer();
|
||||||
const savedPeer = await getSavedPeer();
|
const savedPeer = await getSavedPeer();
|
||||||
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
|
if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) {
|
||||||
usingPeer = savedPeer;
|
usingPeer = savedPeer;
|
||||||
}
|
}
|
||||||
|
return usingPeer;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Using peer:', JSON.stringify(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;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('begin connection:', JSON.stringify(usingPeer));
|
console.log('[electrum] begin connection:', JSON.stringify(usingPeer));
|
||||||
mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
|
const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp');
|
||||||
|
mainClient = client;
|
||||||
|
|
||||||
mainClient.onError = function (e: { message: string }) {
|
// Live-socket errors after a successful handshake: schedule a single
|
||||||
console.log('electrum mainClient.onError():', e.message);
|
// `ensureConnected()` (deduped). Errors during this attempt's own handshake
|
||||||
if (mainConnected) {
|
// are caught below — we must not double-handle them here.
|
||||||
// most likely got a timeout from electrum ping. lets reconnect
|
client.onError = function (e: { message: string }) {
|
||||||
// but only if we were previously connected (mainConnected), otherwise theres other
|
console.log('[electrum] electrum mainClient.onError():', e.message);
|
||||||
// code which does connection retries
|
scheduleReconnectFromClient(client, usingPeer, 'socket error');
|
||||||
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 mainClient.initElectrum({ client: 'bluewallet', version: '1.4' });
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
if (ver && ver[0]) {
|
if (ver && ver[0]) {
|
||||||
console.log('connected to ', ver);
|
console.log('[electrum] connected to ', ver);
|
||||||
serverName = ver[0];
|
serverName = ver[0];
|
||||||
mainConnected = true;
|
|
||||||
wasConnectedAtLeastOnce = true;
|
|
||||||
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
|
if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) {
|
||||||
disableBatching = true;
|
disableBatching = true;
|
||||||
|
|
||||||
// exeptions for versions:
|
|
||||||
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
|
const [electrumImplementation, electrumVersion] = ver[0].split(' ');
|
||||||
switch (electrumImplementation) {
|
switch (electrumImplementation) {
|
||||||
case 'electrs':
|
case 'electrs':
|
||||||
@ -296,8 +430,6 @@ export async function connectMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'electrs-esplora':
|
case 'electrs-esplora':
|
||||||
// its a different one, and it does NOT support batching
|
|
||||||
// nop
|
|
||||||
break;
|
break;
|
||||||
case 'Fulcrum':
|
case 'Fulcrum':
|
||||||
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
|
if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) {
|
||||||
@ -306,35 +438,154 @@ export async function connectMain(): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const header = await mainClient.blockchainHeaders_subscribe();
|
const header = await client.blockchainHeaders_subscribe();
|
||||||
if (header && header.height) {
|
if (header && header.height) {
|
||||||
latestBlock = {
|
latestBlock = {
|
||||||
height: header.height,
|
height: header.height,
|
||||||
time: Math.floor(+new Date() / 1000),
|
time: Math.floor(+new Date() / 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor
|
return { ok: true, peer: usingPeer };
|
||||||
}
|
}
|
||||||
|
return { ok: false, peer: usingPeer };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mainConnected = false;
|
console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e);
|
||||||
console.log('bad connection:', JSON.stringify(usingPeer), e);
|
if (mainClient) {
|
||||||
mainClient?.close();
|
try {
|
||||||
mainClient = undefined;
|
mainClient.close();
|
||||||
|
} catch {}
|
||||||
|
mainClient = undefined;
|
||||||
|
}
|
||||||
|
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 (!mainConnected) {
|
if (ensureInFlight) {
|
||||||
console.log('retry');
|
if (showAlertOnFailure) ensureInFlightShowAlert = true;
|
||||||
connectionAttempt = connectionAttempt + 1;
|
return ensureInFlight;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function presentResetToDefaultsAlert(): Promise<boolean> {
|
||||||
@ -356,7 +607,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
|
|||||||
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
||||||
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e); // Must be running on Android
|
console.log('[electrum]', e); // Must be running on Android
|
||||||
}
|
}
|
||||||
resolve(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
@ -375,7 +626,7 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
|
|||||||
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
await DefaultPreference.clear(ELECTRUM_SSL_PORT);
|
||||||
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
await DefaultPreference.clear(ELECTRUM_TCP_PORT);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e); // Must be running on Android
|
console.log('[electrum]', e); // Must be running on Android
|
||||||
}
|
}
|
||||||
resolve(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
@ -398,16 +649,16 @@ export async function presentResetToDefaultsAlert(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
|
async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) {
|
||||||
if (await isDisabled()) {
|
if (await isDisabled()) {
|
||||||
console.log(
|
console.log(
|
||||||
'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
|
'[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
presentAlert({
|
presentAlert({
|
||||||
allowRepeat: false,
|
allowRepeat,
|
||||||
title: loc.errors.network,
|
title: loc.errors.network,
|
||||||
message: loc.formatString(
|
message: loc.formatString(
|
||||||
usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect,
|
usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect,
|
||||||
@ -417,10 +668,10 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
|
|||||||
{
|
{
|
||||||
text: loc.wallets.list_tryagain,
|
text: loc.wallets.list_tryagain,
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
connectionAttempt = 0;
|
forceDisconnect();
|
||||||
mainClient?.close();
|
setTimeout(() => {
|
||||||
mainClient = undefined;
|
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
|
||||||
setTimeout(connectMain, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
style: 'default',
|
style: 'default',
|
||||||
},
|
},
|
||||||
@ -429,10 +680,10 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
presentResetToDefaultsAlert().then(result => {
|
presentResetToDefaultsAlert().then(result => {
|
||||||
if (result) {
|
if (result) {
|
||||||
connectionAttempt = 0;
|
forceDisconnect();
|
||||||
mainClient?.close();
|
setTimeout(() => {
|
||||||
mainClient = undefined;
|
ensureConnected({ showAlertOnFailure: true }).catch(() => {});
|
||||||
setTimeout(connectMain, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -441,16 +692,21 @@ const presentNetworkErrorAlert = async (usingPeer?: Peer) => {
|
|||||||
{
|
{
|
||||||
text: loc._.cancel,
|
text: loc._.cancel,
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
connectionAttempt = 0;
|
forceDisconnect();
|
||||||
mainClient?.close();
|
|
||||||
mainClient = undefined;
|
|
||||||
},
|
},
|
||||||
style: 'cancel',
|
style: 'cancel',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
options: { cancelable: false },
|
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
|
* Returns random electrum server out of list of servers
|
||||||
@ -497,18 +753,27 @@ export const getBalanceByAddress = async function (address: string): Promise<{ c
|
|||||||
balance.addr = address;
|
balance.addr = address;
|
||||||
return balance;
|
return balance;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getBalanceByAddress:', error);
|
console.error('[electrum] Error in getBalanceByAddress:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getConfig = async function () {
|
export const getConfig = async function () {
|
||||||
if (!mainClient) throw new Error('Electrum client is not connected');
|
if (!mainClient) {
|
||||||
|
return {
|
||||||
|
host: undefined,
|
||||||
|
port: undefined,
|
||||||
|
serverName: false as typeof serverName,
|
||||||
|
connected: connState === 'connected' ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
host: mainClient.host,
|
host: mainClient.host,
|
||||||
port: mainClient.port,
|
port: mainClient.port,
|
||||||
serverName,
|
serverName,
|
||||||
connected: mainClient.timeLastCall !== 0 && mainClient.status,
|
// 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -537,14 +802,24 @@ export const getMempoolTransactionsByAddress = async function (address: string):
|
|||||||
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
|
return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ping = async function () {
|
/**
|
||||||
try {
|
* Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()`
|
||||||
await mainClient.server_ping();
|
* for that). Updates the connection state machine to reflect the probe result so
|
||||||
return true;
|
* subscribers (UI pill, settings screen) stay in sync.
|
||||||
} catch (_) {}
|
*
|
||||||
|
* - `true`: server replied within `PING_TIMEOUT_MS`.
|
||||||
mainConnected = false;
|
* - `false`: client missing, timed out, or server errored.
|
||||||
return false;
|
*/
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// exported only to be used in unit tests
|
// exported only to be used in unit tests
|
||||||
@ -599,6 +874,21 @@ export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWi
|
|||||||
let address: false | string = false;
|
let address: false | string = false;
|
||||||
let type: 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))) {
|
if (SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) {
|
||||||
address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
|
address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script));
|
||||||
type = 'witness_v0_keyhash';
|
type = 'witness_v0_keyhash';
|
||||||
@ -742,7 +1032,7 @@ export const multiGetBalanceByAddress = async (addresses: string[], batchsize: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const bal of balances) {
|
for (const bal of balances) {
|
||||||
if (bal.error) console.warn('multiGetBalanceByAddress():', bal.error);
|
if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error);
|
||||||
ret.balance += +bal.result.confirmed;
|
ret.balance += +bal.result.confirmed;
|
||||||
ret.unconfirmed_balance += +bal.result.unconfirmed;
|
ret.unconfirmed_balance += +bal.result.unconfirmed;
|
||||||
ret.addresses[scripthash2addr[bal.param]] = bal.result;
|
ret.addresses[scripthash2addr[bal.param]] = bal.result;
|
||||||
@ -836,7 +1126,7 @@ export const multiGetHistoryByAddress = async function (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const history of results) {
|
for (const history of results) {
|
||||||
if (history.error) console.warn('multiGetHistoryByAddress():', history.error);
|
if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error);
|
||||||
ret[scripthash2addr[history.param]] = history.result || [];
|
ret[scripthash2addr[history.param]] = history.result || [];
|
||||||
for (const result of history.result || []) {
|
for (const result of history.result || []) {
|
||||||
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
|
if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height
|
||||||
@ -877,7 +1167,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
|
|||||||
try {
|
try {
|
||||||
ret[txid] = JSON.parse(jsonString.cache_value as string);
|
ret[txid] = JSON.parse(jsonString.cache_value as string);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error, 'cache failed to parse', jsonString.cache_value);
|
console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -927,7 +1217,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
|
|||||||
tx = txhexToElectrumTransaction(tx);
|
tx = txhexToElectrumTransaction(tx);
|
||||||
results.push({ result: tx, param: txid });
|
results.push({ result: tx, param: txid });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log('[electrum]', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -943,7 +1233,7 @@ export async function multiGetTransactionByTxid<T extends boolean>(
|
|||||||
}
|
}
|
||||||
results.push({ result: tx, param: txid });
|
results.push({ result: tx, param: txid });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log('[electrum]', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -998,41 +1288,12 @@ export async function multiGetTransactionByTxid<T extends boolean>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
console.error('Failed to write transaction cache:', writeError);
|
console.error('[electrum] Failed to write transaction cache:', writeError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret;
|
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.
|
// Returns the value at a given percentile in a sorted numeric array.
|
||||||
// "Linear interpolation between closest ranks" method
|
// "Linear interpolation between closest ranks" method
|
||||||
function percentile(arr: number[], p: number) {
|
function percentile(arr: number[], p: number) {
|
||||||
@ -1184,10 +1445,11 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
|
|||||||
|
|
||||||
client.onError = () => {}; // mute
|
client.onError = () => {}; // mute
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000;
|
||||||
try {
|
try {
|
||||||
const rez = await Promise.race([
|
const rez = await Promise.race([
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
timeoutId = setTimeout(() => resolve('timeout'), 5000);
|
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||||
}),
|
}),
|
||||||
client.connect(),
|
client.connect(),
|
||||||
]);
|
]);
|
||||||
@ -1205,8 +1467,19 @@ export const testConnection = async function (host: string, tcpPort?: number, ss
|
|||||||
return false;
|
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 => {
|
export const forceDisconnect = (): void => {
|
||||||
mainClient?.close();
|
disconnectGeneration += 1;
|
||||||
|
if (mainClient) {
|
||||||
|
try {
|
||||||
|
mainClient.close();
|
||||||
|
} catch {}
|
||||||
|
mainClient = undefined;
|
||||||
|
}
|
||||||
|
setConnectionState('disconnected');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setBatchingDisabled = () => {
|
export const setBatchingDisabled = () => {
|
||||||
|
|||||||
1
blue_modules/NativeEventEmitter.ts
Normal file
1
blue_modules/NativeEventEmitter.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default, type Spec } from '../codegen/NativeEventEmitter';
|
||||||
1
blue_modules/NativeMenuElementsEmitter.ts
Normal file
1
blue_modules/NativeMenuElementsEmitter.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default, type Spec } from '../codegen/NativeMenuElementsEmitter';
|
||||||
1
blue_modules/NativeWidgetHelper.ts
Normal file
1
blue_modules/NativeWidgetHelper.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default, type Spec } from '../codegen/NativeWidgetHelper';
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { NativeModules, Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
import NativeSettingsModule from '../codegen/NativeSettingsModule';
|
||||||
|
|
||||||
interface SettingsModuleInterface {
|
interface SettingsModuleInterface {
|
||||||
/**
|
/**
|
||||||
@ -46,6 +47,7 @@ interface SettingsModuleInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only available on Android
|
// Only available on Android
|
||||||
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? NativeModules.SettingsModule : null;
|
const nativeModule = NativeSettingsModule ?? null;
|
||||||
|
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? nativeModule : null;
|
||||||
|
|
||||||
export default SettingsModule;
|
export default SettingsModule;
|
||||||
|
|||||||
@ -9,13 +9,13 @@ import androidx.core.content.ContextCompat
|
|||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.facebook.react.bridge.ReactContext
|
import com.facebook.react.bridge.ReactContext
|
||||||
import com.facebook.react.bridge.WritableMap
|
import com.facebook.react.bridge.WritableMap
|
||||||
import com.facebook.react.uimanager.events.Event
|
|
||||||
import com.facebook.react.uimanager.UIManagerHelper
|
import com.facebook.react.uimanager.UIManagerHelper
|
||||||
|
import com.facebook.react.uimanager.events.Event
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
import io.bluewallet.bluewallet.R
|
import io.bluewallet.bluewallet.R
|
||||||
|
|
||||||
class CustomSegmentedControl @JvmOverloads constructor(
|
class SegmentedControl @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = 0
|
defStyleAttr: Int = 0
|
||||||
@ -23,7 +23,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
|
|
||||||
private val toggleGroup: MaterialButtonToggleGroup
|
private val toggleGroup: MaterialButtonToggleGroup
|
||||||
private var currentSelectedIndex: Int = 0
|
private var currentSelectedIndex: Int = 0
|
||||||
private var onChangeEvent: ((WritableMap) -> Unit)? = null
|
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
|
||||||
|
|
||||||
var values: Array<String> = emptyArray()
|
var values: Array<String> = emptyArray()
|
||||||
set(value) {
|
set(value) {
|
||||||
@ -44,10 +48,13 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
isSingleSelection = true
|
isSingleSelection = true
|
||||||
isSelectionRequired = true
|
isSelectionRequired = true
|
||||||
}
|
}
|
||||||
addView(toggleGroup, LinearLayout.LayoutParams(
|
addView(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
toggleGroup,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams(
|
||||||
))
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
@ -55,6 +62,9 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
if (newIndex != -1 && newIndex != currentSelectedIndex) {
|
if (newIndex != -1 && newIndex != currentSelectedIndex) {
|
||||||
currentSelectedIndex = newIndex
|
currentSelectedIndex = newIndex
|
||||||
emitChangeEvent(newIndex)
|
emitChangeEvent(newIndex)
|
||||||
|
if (momentaryProp) {
|
||||||
|
toggleGroup.clearChecked()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,28 +72,29 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun updateSegments() {
|
private fun updateSegments() {
|
||||||
toggleGroup.removeAllViews()
|
toggleGroup.removeAllViews()
|
||||||
|
|
||||||
values.forEachIndexed { index, title ->
|
values.forEachIndexed { index, title ->
|
||||||
val button = MaterialButton(
|
val button = MaterialButton(
|
||||||
context,
|
context,
|
||||||
null,
|
null,
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle,
|
||||||
).apply {
|
).apply {
|
||||||
text = title
|
text = title
|
||||||
id = generateViewId()
|
id = generateViewId()
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
0,
|
0,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
1f
|
1f,
|
||||||
)
|
)
|
||||||
isCheckable = true
|
isCheckable = true
|
||||||
|
|
||||||
strokeWidth = 2
|
strokeWidth = 2
|
||||||
|
applyEnabledState()
|
||||||
|
|
||||||
val cornerRadius = resources.getDimensionPixelSize(
|
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 {
|
when {
|
||||||
values.size == 1 -> {
|
values.size == 1 -> {
|
||||||
this.cornerRadius = cornerRadius
|
this.cornerRadius = cornerRadius
|
||||||
@ -99,10 +110,10 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleGroup.addView(button)
|
toggleGroup.addView(button)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateButtonColors()
|
updateButtonColors()
|
||||||
updateSelectedSegment()
|
updateSelectedSegment()
|
||||||
}
|
}
|
||||||
@ -110,54 +121,82 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
private fun updateButtonColors() {
|
private fun updateButtonColors() {
|
||||||
for (i in 0 until toggleGroup.childCount) {
|
for (i in 0 until toggleGroup.childCount) {
|
||||||
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
|
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
|
||||||
|
|
||||||
val selectedBgColor = ContextCompat.getColor(context, R.color.button_background_color)
|
val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color)
|
||||||
val unselectedBgColor = ContextCompat.getColor(context, R.color.button_disabled_background_color)
|
val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color)
|
||||||
val selectedTextColor = ContextCompat.getColor(context, R.color.button_text_color)
|
val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color)
|
||||||
val unselectedTextColor = ContextCompat.getColor(context, R.color.button_disabled_text_color)
|
val selectedTextColor = resolvedTextColor
|
||||||
|
val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color)
|
||||||
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
|
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
|
||||||
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
|
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
|
||||||
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
|
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
|
||||||
|
|
||||||
val bgColorStateList = ColorStateList(
|
val bgColorStateList = ColorStateList(
|
||||||
arrayOf(
|
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(selectedBgColor, unselectedBgColor)
|
intArrayOf(selectedBgColor, unselectedBgColor),
|
||||||
)
|
)
|
||||||
|
|
||||||
val textColorStateList = ColorStateList(
|
val textColorStateList = ColorStateList(
|
||||||
arrayOf(
|
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(
|
val strokeColorStateList = ColorStateList(
|
||||||
arrayOf(
|
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(
|
val rippleColorStateList = ColorStateList(
|
||||||
arrayOf(
|
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.backgroundTintList = bgColorStateList
|
||||||
button.setTextColor(textColorStateList)
|
button.setTextColor(textColorStateList)
|
||||||
button.strokeColor = strokeColorStateList
|
button.strokeColor = strokeColorStateList
|
||||||
button.rippleColor = rippleColorStateList
|
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() {
|
private fun updateSelectedSegment() {
|
||||||
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
|
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
|
||||||
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
|
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
|
||||||
@ -188,26 +227,37 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
|||||||
val reactContext = context as? ReactContext ?: return
|
val reactContext = context as? ReactContext ?: return
|
||||||
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
|
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
|
||||||
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
|
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
|
||||||
|
|
||||||
val event = Arguments.createMap().apply {
|
val event = Arguments.createMap().apply {
|
||||||
putInt("selectedIndex", selectedIndex)
|
putInt("selectedIndex", selectedIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event))
|
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(
|
private inner class ChangeEvent(
|
||||||
surfaceId: Int,
|
surfaceId: Int,
|
||||||
viewId: Int,
|
viewId: Int,
|
||||||
private val eventData: WritableMap
|
private val eventData: WritableMap,
|
||||||
) : Event<ChangeEvent>(surfaceId, viewId) {
|
) : Event<ChangeEvent>(surfaceId, viewId) {
|
||||||
|
|
||||||
override fun getEventName(): String = "onChangeEvent"
|
override fun getEventName(): String = "topChange"
|
||||||
|
|
||||||
override fun getEventData(): WritableMap = eventData
|
override fun getEventData(): WritableMap = eventData
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun setOnChangeEvent(callback: ((WritableMap) -> Unit)?) {
|
|
||||||
onChangeEvent = callback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@ -5,13 +5,13 @@ import com.facebook.react.bridge.NativeModule
|
|||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.uimanager.ViewManager
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
class CustomSegmentedControlPackage : ReactPackage {
|
class SegmentedControlPackage : ReactPackage {
|
||||||
|
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
return listOf(CustomSegmentedControlManager())
|
return listOf(SegmentedControlManager())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
#import <React/RCTViewManager.h>
|
||||||
|
|
||||||
|
@interface RCT_EXTERN_MODULE(SegmentedControlManager, RCTViewManager)
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
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"] }
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
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 Bugsnag from '@bugsnag/react-native';
|
||||||
import { getUniqueId } from 'react-native-device-info';
|
import { getUniqueId } from 'react-native-device-info';
|
||||||
|
|
||||||
import { BlueApp as BlueAppClass } from '../class';
|
import { BlueApp as BlueAppClass } from '../class/blue-app';
|
||||||
|
|
||||||
const BlueApp = BlueAppClass.getInstance();
|
const BlueApp = BlueAppClass.getInstance();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
197
blue_modules/arkade-adapters/realm/realmInstance.ts
Normal file
197
blue_modules/arkade-adapters/realm/realmInstance.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
423
blue_modules/arkade-background.ts
Normal file
423
blue_modules/arkade-background.ts
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
163
blue_modules/arkade-notifications.ts
Normal file
163
blue_modules/arkade-notifications.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
// 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,4 +2,7 @@
|
|||||||
* Let's keep config vars, constants and definitions here
|
* Let's keep config vars, constants and definitions here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com';
|
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';
|
||||||
|
|||||||
@ -1,23 +1,98 @@
|
|||||||
import AES from 'crypto-js/aes';
|
import { cbc } from '@noble/ciphers/aes';
|
||||||
import Utf8 from 'crypto-js/enc-utf8';
|
import { md5 } from '@noble/hashes/legacy';
|
||||||
|
import { randomBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
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 {
|
export function encrypt(data: string, password: string): string {
|
||||||
if (data.length < 10) throw new Error('data length cant be < 10');
|
if (data.length < 10) throw new Error('data length cant be < 10');
|
||||||
const ciphertext = AES.encrypt(data, password);
|
const salt = randomBytes(SALT_LEN);
|
||||||
return ciphertext.toString();
|
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]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export function decrypt(data: string, password: string): string | false {
|
||||||
const bytes = AES.decrypt(data, password);
|
|
||||||
let str: string | false = false;
|
|
||||||
try {
|
try {
|
||||||
str = bytes.toString(Utf8);
|
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
||||||
} catch (e) {}
|
// export/import flows (manual file paste, clipboard transit, email-based
|
||||||
|
// wallet transfer) introduced stray newlines or padding spaces. Strip them
|
||||||
// For some reason, sometimes decrypt would succeed with an incorrect password and return random characters.
|
// before strict base64 decode so legacy backups still open. `\s` does not
|
||||||
// In this TypeScript version, we are not allowing the encryption of data that is shorter than
|
// include `=`, so base64 padding survives.
|
||||||
// 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed.
|
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
||||||
if (str && str.length < 10) return false;
|
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
|
||||||
|
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
|
||||||
return str;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Platform } from 'react-native';
|
|||||||
import { pick, types, keepLocalCopy, errorCodes } from '@react-native-documents/picker';
|
import { pick, types, keepLocalCopy, errorCodes } from '@react-native-documents/picker';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker';
|
import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker';
|
||||||
import RNQRGenerator from 'rn-qr-generator';
|
import { detectQRCodeInImage } from 'react-native-camera-kit-no-google';
|
||||||
import Share from 'react-native-share';
|
import Share from 'react-native-share';
|
||||||
|
|
||||||
import presentAlert from '../components/Alert';
|
import presentAlert from '../components/Alert';
|
||||||
@ -113,6 +113,7 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
|
|||||||
maxHeight: 800,
|
maxHeight: 800,
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
selectionLimit: 1,
|
selectionLimit: 1,
|
||||||
|
includeBase64: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.didCancel) {
|
if (response.didCancel) {
|
||||||
@ -120,19 +121,12 @@ export const showImagePickerAndReadImage = async (): Promise<string | undefined>
|
|||||||
} else if (response.errorCode) {
|
} else if (response.errorCode) {
|
||||||
throw new Error(response.errorMessage);
|
throw new Error(response.errorMessage);
|
||||||
} else if (response.assets) {
|
} else if (response.assets) {
|
||||||
try {
|
const base64 = response.assets[0].base64;
|
||||||
const uri = response.assets[0].uri;
|
if (base64) {
|
||||||
if (uri) {
|
const result = await detectQRCodeInImage(base64);
|
||||||
const result = await RNQRGenerator.detect({ uri: decodeURI(uri.toString()) });
|
if (result) return result;
|
||||||
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;
|
return undefined;
|
||||||
@ -186,33 +180,23 @@ export const showFilePickerAndReadFile = async function (): Promise<{ data: stri
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageFile = async (fileCopyUri: string): Promise<{ data: string | false; uri: string | false }> => {
|
const readFileAsBase64 = async (uri: string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const exists = await RNFS.exists(fileCopyUri);
|
return await RNFS.readFile(uri, 'base64');
|
||||||
if (!exists) {
|
} catch {
|
||||||
presentAlert({ message: 'File does not exist' });
|
return await RNFS.readFile(uri.replace(/^file:\/\//, ''), 'base64');
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
throw new Error(loc.send.qr_error_no_qrcode);
|
||||||
|
};
|
||||||
|
|
||||||
export const readFileOutsideSandbox = (filePath: string) => {
|
export const readFileOutsideSandbox = (filePath: string) => {
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
return readFile(filePath);
|
return readFile(filePath);
|
||||||
|
|||||||
@ -26,44 +26,93 @@ export interface TinySecp256k1InterfaceExtended {
|
|||||||
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
|
signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => {
|
// @noble/hashes types differ slightly from @noble/secp256k1 v3 hash slot typings.
|
||||||
const combinedMessages = messages.reduce((acc, msg) => {
|
necc.hashes.sha256 = sha256 as NonNullable<typeof necc.hashes.sha256>;
|
||||||
const newArray = new Uint8Array(acc.length + msg.length);
|
necc.hashes.hmacSha256 = ((key: Uint8Array, message: Uint8Array) => hmac(sha256, key, message)) as NonNullable<
|
||||||
newArray.set(acc);
|
typeof necc.hashes.hmacSha256
|
||||||
newArray.set(msg, acc.length);
|
>;
|
||||||
return newArray;
|
|
||||||
}, new Uint8Array(0));
|
|
||||||
return sha256(combinedMessages);
|
|
||||||
};
|
|
||||||
|
|
||||||
necc.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => {
|
// Removed from @noble/secp256k1 v1.7; vendored from noble test vectors.
|
||||||
const combinedMessages = messages.reduce((acc, msg) => {
|
// @see https://github.com/paulmillr/noble-secp256k1/blob/1.7.2/test/index.ts
|
||||||
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 Hex = string | Uint8Array;
|
||||||
type PrivKey = Hex | bigint | number;
|
|
||||||
|
|
||||||
necc.utils.privateAdd = (privateKey: PrivKey, tweak: Hex) => {
|
const { mod, secretKeyToScalar, numberToBytesBE, bytesToNumberBE, hexToBytes } = necc.etc;
|
||||||
console.log({ privateKey, tweak });
|
const CURVE_N = necc.Point.CURVE().n;
|
||||||
const p = normal(privateKey);
|
|
||||||
const t = normal(tweak);
|
function pointFromBytes(p: Uint8Array): necc.Point {
|
||||||
return necc.utils.privateAdd(necc.utils.mod(p + t, necc.CURVE.n));
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const defaultTrue = (param?: boolean): boolean => param !== false;
|
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 {
|
function throwToNull<Type>(fn: () => Type): Type | null {
|
||||||
try {
|
try {
|
||||||
return fn();
|
return fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.log(e);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,7 +120,8 @@ function throwToNull<Type>(fn: () => Type): Type | null {
|
|||||||
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
|
function isPoint(p: Uint8Array, xOnly: boolean): boolean {
|
||||||
if ((p.length === 32) !== xOnly) return false;
|
if ((p.length === 32) !== xOnly) return false;
|
||||||
try {
|
try {
|
||||||
return !!necc.Point.fromHex(p);
|
pointFromBytes(p);
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -79,23 +129,12 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean {
|
|||||||
|
|
||||||
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
|
const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = {
|
||||||
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
|
isPoint: (p: Uint8Array): boolean => isPoint(p, false),
|
||||||
isPrivate: (d: Uint8Array): boolean => {
|
isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d),
|
||||||
/* if (
|
|
||||||
[
|
|
||||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
|
||||||
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
|
|
||||||
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142',
|
|
||||||
].includes(d.toString('hex'))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
} */
|
|
||||||
return necc.utils.isValidPrivateKey(d);
|
|
||||||
},
|
|
||||||
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
|
isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true),
|
||||||
|
|
||||||
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
|
xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null =>
|
||||||
throwToNull(() => {
|
throwToNull(() => {
|
||||||
const P = necc.utils.pointAddScalar(p, tweak, true);
|
const P = tweakUtils.pointAddScalar(p, tweak, true);
|
||||||
const parity = P[0] % 2 === 1 ? 1 : 0;
|
const parity = P[0] % 2 === 1 ? 1 : 0;
|
||||||
return { parity, xOnlyPubkey: P.slice(1) };
|
return { parity, xOnlyPubkey: P.slice(1) };
|
||||||
}),
|
}),
|
||||||
@ -104,60 +143,56 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256
|
|||||||
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
|
throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))),
|
||||||
|
|
||||||
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
|
pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => {
|
||||||
return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed));
|
return pointFromBytes(p).toBytes(defaultTrue(compressed));
|
||||||
},
|
},
|
||||||
|
|
||||||
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||||
throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))),
|
throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))),
|
||||||
|
|
||||||
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||||
throwToNull(() => {
|
throwToNull(() => {
|
||||||
const A = necc.Point.fromHex(a);
|
const A = pointFromBytes(a);
|
||||||
const B = necc.Point.fromHex(b);
|
const B = pointFromBytes(b);
|
||||||
return A.add(B).toRawBytes(defaultTrue(compressed));
|
return A.add(B).toBytes(defaultTrue(compressed));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null =>
|
||||||
throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))),
|
throwToNull(() => tweakUtils.pointAddScalar(p, tweak, defaultTrue(compressed))),
|
||||||
|
|
||||||
privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
|
privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
|
||||||
throwToNull(() => {
|
throwToNull(() => {
|
||||||
// console.log({ d, tweak });
|
|
||||||
if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') {
|
if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') {
|
||||||
return new Uint8Array(d); // make test_ecc happy
|
return new Uint8Array(d); // make test_ecc happy
|
||||||
}
|
}
|
||||||
|
|
||||||
const ret = necc.utils.privateAdd(d, tweak);
|
const ret = tweakUtils.privateAdd(d, tweak);
|
||||||
// console.log(ret);
|
|
||||||
if (ret.join('') === '00000000000000000000000000000000') {
|
if (ret.join('') === '00000000000000000000000000000000') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d),
|
privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d),
|
||||||
|
|
||||||
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
||||||
return necc.signSync(h, d, { der: false, extraEntropy: e });
|
return necc.sign(h, d, { prehash: false, extraEntropy: e });
|
||||||
},
|
},
|
||||||
|
|
||||||
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => {
|
||||||
return necc.signSync(h, d, { der: true, extraEntropy: e });
|
return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e }));
|
||||||
},
|
},
|
||||||
|
|
||||||
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
|
signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => {
|
||||||
return necc.schnorr.signSync(h, d, e);
|
return necc.schnorr.sign(h, d, e);
|
||||||
},
|
},
|
||||||
|
|
||||||
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
|
verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => {
|
||||||
return necc.verify(signature, h, Q, { strict });
|
return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false });
|
||||||
},
|
},
|
||||||
|
|
||||||
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
|
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
|
||||||
return necc.schnorr.verifySync(signature, h, Q);
|
return necc.schnorr.verify(signature, h, Q);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ecc;
|
export default ecc;
|
||||||
|
|
||||||
// module.exports.ecc = ecc;
|
|
||||||
|
|||||||
@ -1,47 +1,176 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import PushNotificationIOS from '@react-native-community/push-notification-ios';
|
import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native';
|
||||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
|
||||||
import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info';
|
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 { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
|
||||||
import PushNotification, { ReceivedNotification } from 'react-native-push-notification';
|
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
|
||||||
import loc from '../loc';
|
import loc from '../loc';
|
||||||
import { groundControlUri } from './constants';
|
import { arkadePaymentPushUri, groundControlUri } from './constants';
|
||||||
import { fetch } from '../util/fetch';
|
import { fetch } from '../util/fetch';
|
||||||
|
|
||||||
const PUSH_TOKEN = 'PUSH_TOKEN';
|
const PUSH_TOKEN = 'PUSH_TOKEN';
|
||||||
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
|
|
||||||
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
|
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';
|
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
|
||||||
let alreadyConfigured = false;
|
const baseURI = groundControlUri;
|
||||||
let 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;
|
||||||
|
|
||||||
type TPushToken = {
|
type TPushToken = {
|
||||||
token: string;
|
token: string;
|
||||||
os: string; // its actually ('ios' | 'android'), but types for the lib are a bit more generic...
|
os: 'ios' | 'android';
|
||||||
};
|
};
|
||||||
|
|
||||||
// thats unwrapped `ReceivedNotification`, withall `data` fields inline
|
|
||||||
type TPayload = {
|
type TPayload = {
|
||||||
// inherited from `ReceivedNotification`:
|
|
||||||
subText?: string;
|
subText?: string;
|
||||||
|
title?: string;
|
||||||
|
identifier?: string;
|
||||||
message?: string | object;
|
message?: string | object;
|
||||||
foreground: boolean;
|
foreground: boolean;
|
||||||
userInteraction: boolean;
|
userInteraction: boolean;
|
||||||
// hopefully stuffed in `data` and uwrapped when received:
|
|
||||||
address: string;
|
address: string;
|
||||||
txid: string;
|
txid: string;
|
||||||
type: number;
|
type: number;
|
||||||
hash: string;
|
hash: string;
|
||||||
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
function deepClone<T>(obj: T): T {
|
function deepClone<T>(obj: T): T {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
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 () => {
|
const checkAndroidNotificationPermission = async () => {
|
||||||
try {
|
try {
|
||||||
const { status } = await checkNotifications();
|
const { status } = await checkNotifications();
|
||||||
console.debug('Notification permission check:', status);
|
console.log('Notification permission check:', status);
|
||||||
return status === RESULTS.GRANTED;
|
return status === RESULTS.GRANTED;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to check notification permission:', err);
|
console.error('Failed to check notification permission:', err);
|
||||||
@ -90,22 +219,14 @@ export const cleanUserOptOutFlag = async () => {
|
|||||||
* Should be called when user is most interested in receiving push notifications.
|
* Should be called when user is most interested in receiving push notifications.
|
||||||
* If we dont have a token it will show alert asking whether
|
* If we dont have a token it will show alert asking whether
|
||||||
* user wants to receive notifications, and if yes - will configure push notifications.
|
* 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
|
* @returns {Promise<boolean>} TRUE if permissions were obtained, FALSE otherwise
|
||||||
*/
|
*/
|
||||||
/**
|
export const tryToObtainPermissions = async (): Promise<boolean> => {
|
||||||
* Attempts to obtain permissions and configure notifications.
|
console.log('tryToObtainPermissions: Starting user-triggered permission request');
|
||||||
* 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) {
|
if (!isNotificationsCapable) {
|
||||||
console.debug('tryToObtainPermissions: Device not capable');
|
console.log('tryToObtainPermissions: Device not capable');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +243,7 @@ export const tryToObtainPermissions = async () => {
|
|||||||
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
|
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
|
||||||
);
|
);
|
||||||
if (status !== RESULTS.GRANTED) {
|
if (status !== RESULTS.GRANTED) {
|
||||||
console.debug('tryToObtainPermissions: Permission denied');
|
console.log('tryToObtainPermissions: Permission denied');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return configureNotifications();
|
return configureNotifications();
|
||||||
@ -131,6 +252,29 @@ export const tryToObtainPermissions = async () => {
|
|||||||
return false;
|
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
|
* Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could
|
||||||
* be notified if they were paid
|
* be notified if they were paid
|
||||||
@ -141,7 +285,7 @@ export const tryToObtainPermissions = async () => {
|
|||||||
* @returns {Promise<object>} Response object from API rest call
|
* @returns {Promise<object>} Response object from API rest call
|
||||||
*/
|
*/
|
||||||
export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => {
|
export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => {
|
||||||
console.debug('majorTomToGroundControl: Starting notification registration', {
|
console.log('majorTomToGroundControl: Starting notification registration', {
|
||||||
addressCount: addresses?.length,
|
addressCount: addresses?.length,
|
||||||
hashCount: hashes?.length,
|
hashCount: hashes?.length,
|
||||||
txidCount: txids?.length,
|
txidCount: txids?.length,
|
||||||
@ -159,7 +303,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pushToken = await getPushToken();
|
const pushToken = await getPushToken();
|
||||||
console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken);
|
console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken);
|
||||||
if (!pushToken || !pushToken.token || !pushToken.os) {
|
if (!pushToken || !pushToken.token || !pushToken.os) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -174,7 +318,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
|||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
|
console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
|
||||||
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
|
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: _getHeaders(),
|
headers: _getHeaders(),
|
||||||
@ -206,6 +350,44 @@ 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:
|
* Returns a permissions object:
|
||||||
* alert: boolean
|
* alert: boolean
|
||||||
@ -216,11 +398,18 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
|||||||
*/
|
*/
|
||||||
export const checkPermissions = async () => {
|
export const checkPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
return new Promise(function (resolve) {
|
if (Platform.OS === 'ios') {
|
||||||
PushNotification.checkPermissions((result: any) => {
|
return Notifications.ios.checkPermissions();
|
||||||
resolve(result);
|
}
|
||||||
});
|
|
||||||
});
|
const { status } = await checkNotifications();
|
||||||
|
const granted = status === RESULTS.GRANTED;
|
||||||
|
return {
|
||||||
|
alert: granted,
|
||||||
|
badge: granted,
|
||||||
|
sound: granted,
|
||||||
|
status,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking permissions:', error);
|
console.error('Error checking permissions:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -255,12 +444,14 @@ export const setLevels = async (levelAll: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!levelAll) {
|
if (!levelAll) {
|
||||||
console.debug('Disabling notifications as user opted out...');
|
console.log('Disabling notifications as user opted out...');
|
||||||
PushNotification.removeAllDeliveredNotifications();
|
Notifications.removeAllDeliveredNotifications();
|
||||||
PushNotification.setApplicationIconBadgeNumber(0);
|
if (Platform.OS === 'ios') {
|
||||||
PushNotification.cancelAllLocalNotifications();
|
Notifications.ios.setBadgeCount(0);
|
||||||
|
Notifications.ios.cancelAllLocalNotifications();
|
||||||
|
}
|
||||||
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
|
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
|
||||||
console.debug('Notifications disabled successfully');
|
console.log('Notifications disabled successfully');
|
||||||
} else {
|
} else {
|
||||||
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
|
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
|
||||||
}
|
}
|
||||||
@ -286,19 +477,19 @@ export const addNotification = async (notification: TPayload) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const postTokenConfig = async () => {
|
const postTokenConfig = async () => {
|
||||||
console.debug('postTokenConfig: Starting token configuration');
|
console.log('postTokenConfig: Starting token configuration');
|
||||||
const pushToken = await getPushToken();
|
const pushToken = await getPushToken();
|
||||||
console.debug('postTokenConfig: Retrieved push token:', !!pushToken);
|
console.log('postTokenConfig: Retrieved push token:', !!pushToken);
|
||||||
|
|
||||||
if (!pushToken || !pushToken.token || !pushToken.os) {
|
if (!pushToken || !pushToken.token || !pushToken.os) {
|
||||||
console.debug('postTokenConfig: Invalid token or missing OS info');
|
console.log('postTokenConfig: Invalid token or missing OS info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lang = (await AsyncStorage.getItem('lang')) || 'en';
|
const lang = (await AsyncStorage.getItem('lang')) || 'en';
|
||||||
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
|
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
|
||||||
console.debug('postTokenConfig: Posting configuration', { lang, appVersion });
|
console.log('postTokenConfig: Posting configuration', { lang, appVersion });
|
||||||
|
|
||||||
await fetch(`${baseURI}/setTokenConfiguration`, {
|
await fetch(`${baseURI}/setTokenConfiguration`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -329,101 +520,72 @@ const _setPushToken = async (token: TPushToken) => {
|
|||||||
/**
|
/**
|
||||||
* Configures notifications. For Android, it will show a native rationale prompt if necessary.
|
* Configures notifications. For Android, it will show a native rationale prompt if necessary.
|
||||||
*
|
*
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>} whether successfully registered for remote push notifications
|
||||||
*/
|
*/
|
||||||
export const configureNotifications = async (onProcessNotifications?: () => void) => {
|
const configureNotifications = async (onProcessNotifications?: () => void): Promise<boolean> => {
|
||||||
if (alreadyConfigured) {
|
console.log('configureNotifications()');
|
||||||
console.debug('configureNotifications: Already configured, skipping');
|
if (onProcessNotifications) {
|
||||||
return true;
|
onProcessNotificationsHandler = onProcessNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const handleRegistration = async (token: TPushToken) => {
|
|
||||||
if (__DEV__) {
|
|
||||||
console.debug('configureNotifications: Token received:', token);
|
|
||||||
}
|
|
||||||
alreadyConfigured = true;
|
|
||||||
await _setPushToken(token);
|
|
||||||
resolve(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 (notification.data?.data) {
|
|
||||||
const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null));
|
|
||||||
Object.assign(payload, validData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(`${uri}/ping`, { headers: _getHeaders() });
|
const { status } = await checkNotifications();
|
||||||
const json = await response.json();
|
if (status !== RESULTS.GRANTED) {
|
||||||
return !!json.description;
|
console.log('configureNotifications: Permissions not granted');
|
||||||
} catch (_) {
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAndroidNotificationChannel();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Notifications.getInitialNotification()
|
||||||
|
.then(async initialNotification => {
|
||||||
|
if (initialNotification) {
|
||||||
|
console.log('App was launched by a push notification:', initialNotification);
|
||||||
|
await storeIncomingNotification(initialNotification, { foreground: false, userInteraction: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -528,9 +690,15 @@ export const clearStoredNotifications = async () => {
|
|||||||
|
|
||||||
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
|
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
|
||||||
try {
|
try {
|
||||||
return new Promise(resolve => {
|
if (Platform.OS !== 'ios') {
|
||||||
PushNotification.getDeliveredNotifications((notifications: Record<string, any>[]) => resolve(notifications));
|
return Promise.resolve([]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return Notifications.ios
|
||||||
|
.getDeliveredNotifications()
|
||||||
|
.then(notifications =>
|
||||||
|
notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting delivered notifications:', error);
|
console.error('Error getting delivered notifications:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -538,47 +706,19 @@ export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeDeliveredNotifications = (identifiers = []) => {
|
export const removeDeliveredNotifications = (identifiers = []) => {
|
||||||
PushNotification.removeDeliveredNotifications(identifiers);
|
if (Platform.OS === 'ios') {
|
||||||
|
Notifications.ios.removeDeliveredNotifications(identifiers);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setApplicationIconBadgeNumber = (badges: number) => {
|
export const setApplicationIconBadgeNumber = (badges: number) => {
|
||||||
PushNotification.setApplicationIconBadgeNumber(badges);
|
if (Platform.OS === 'ios') {
|
||||||
|
Notifications.ios.setBadgeCount(badges);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeAllDeliveredNotifications = () => {
|
export const removeAllDeliveredNotifications = () => {
|
||||||
PushNotification.removeAllDeliveredNotifications();
|
Notifications.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 () => {
|
export const isNotificationsEnabled = async () => {
|
||||||
@ -619,25 +759,22 @@ export const getStoredNotifications = async (): Promise<TPayload[]> => {
|
|||||||
|
|
||||||
// on app launch (load module):
|
// on app launch (load module):
|
||||||
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
|
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
|
||||||
console.debug('initializeNotifications: Starting initialization');
|
console.log('initializeNotifications: Starting initialization');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
||||||
console.debug('initializeNotifications: No ask flag status:', noAndDontAskFlag);
|
console.log('initializeNotifications: No ask flag status:', noAndDontAskFlag);
|
||||||
|
|
||||||
if (noAndDontAskFlag === 'true') {
|
if (noAndDontAskFlag === 'true') {
|
||||||
console.warn('User has opted out of notifications.');
|
console.warn('User has opted out of notifications.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
|
|
||||||
baseURI = baseUriStored || groundControlUri;
|
|
||||||
console.debug('Base URI set to:', baseURI);
|
|
||||||
|
|
||||||
setApplicationIconBadgeNumber(0);
|
setApplicationIconBadgeNumber(0);
|
||||||
|
|
||||||
// Only check permissions, never request
|
// Only check permissions, never request
|
||||||
currentPermissionStatus = await checkNotificationPermissionStatus();
|
currentPermissionStatus = await checkNotificationPermissionStatus();
|
||||||
console.debug('initializeNotifications: Permission status:', currentPermissionStatus);
|
console.log('initializeNotifications: Permission status:', currentPermissionStatus);
|
||||||
|
|
||||||
// Handle Android 13+ permissions differently
|
// Handle Android 13+ permissions differently
|
||||||
const canProceed =
|
const canProceed =
|
||||||
@ -646,23 +783,12 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
|
|||||||
: currentPermissionStatus === 'granted';
|
: currentPermissionStatus === 'granted';
|
||||||
|
|
||||||
if (canProceed) {
|
if (canProceed) {
|
||||||
console.debug('initializeNotifications: Can proceed with notification setup');
|
console.log('initializeNotifications: Can proceed with notification setup');
|
||||||
const token = await getPushToken();
|
await configureNotifications(onProcessNotifications);
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
console.debug('Notifications require user action to enable');
|
console.log('Notifications require user action to enable');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize notifications:', 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
Normal file
4
blue_modules/pako/dist/pako.esm.mjs
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import * as pako from '../index.js';
|
||||||
|
|
||||||
|
export * from '../index.js';
|
||||||
|
export default pako;
|
||||||
@ -1,7 +1,26 @@
|
|||||||
import { Dimensions, Platform, AppState, AppStateStatus } from 'react-native';
|
import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { isDesktop } from './environment';
|
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
|
// Size class definitions following iOS conventions
|
||||||
export enum SizeClass {
|
export enum SizeClass {
|
||||||
Compact, // Small size (iPhone width or height in landscape)
|
Compact, // Small size (iPhone width or height in landscape)
|
||||||
@ -29,64 +48,40 @@ export interface SizeClassInfo {
|
|||||||
isLargeScreen: boolean;
|
isLargeScreen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||||
* Get current size class information based on device dimensions
|
|
||||||
*/
|
const coerceSizeClassValue = (value?: number): SizeClass => {
|
||||||
export function getSizeClass(): SizeClassInfo {
|
if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) {
|
||||||
// Get device dimensions
|
return value;
|
||||||
|
}
|
||||||
|
return SizeClass.Regular;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateFromDimensions = (): SizeClassInfo => {
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const isLandscape = width > height;
|
const isLandscape = width > height;
|
||||||
const orientation = isLandscape ? 'landscape' : 'portrait';
|
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||||
|
|
||||||
// Determine horizontal size class (following iOS conventions)
|
const horizontalSizeClass =
|
||||||
let horizontalSizeClass: SizeClass;
|
Platform.OS === 'ios' && Platform.isPad
|
||||||
|
? SizeClass.Regular
|
||||||
|
: isDesktop
|
||||||
|
? SizeClass.Large
|
||||||
|
: isLandscape && width >= 667
|
||||||
|
? SizeClass.Regular
|
||||||
|
: SizeClass.Compact;
|
||||||
|
|
||||||
if (Platform.OS === 'ios' && Platform.isPad) {
|
const verticalSizeClass =
|
||||||
// iPads always have Regular width
|
Platform.OS === 'ios' && Platform.isPad
|
||||||
horizontalSizeClass = SizeClass.Regular;
|
? SizeClass.Regular
|
||||||
} else if (isDesktop) {
|
: isDesktop
|
||||||
// Desktop systems get Large width
|
? SizeClass.Large
|
||||||
horizontalSizeClass = SizeClass.Large;
|
: isLandscape
|
||||||
} else if (isLandscape && width >= 667) {
|
? SizeClass.Compact
|
||||||
// iPhone Plus models (and modern equivalent sizes) in landscape: Regular width
|
: SizeClass.Regular;
|
||||||
// 667 points corresponds roughly to iPhone Plus models
|
|
||||||
horizontalSizeClass = SizeClass.Regular;
|
|
||||||
} else {
|
|
||||||
// Regular iPhones: Compact width
|
|
||||||
horizontalSizeClass = SizeClass.Compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine vertical size class (following iOS conventions)
|
const sizeClass = coerceSizeClassValue(horizontalSizeClass);
|
||||||
let verticalSizeClass: SizeClass;
|
const isLargeScreen = sizeClass === SizeClass.Large;
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
horizontalSizeClass,
|
horizontalSizeClass,
|
||||||
@ -97,43 +92,126 @@ export function getSizeClass(): SizeClassInfo {
|
|||||||
isLarge: sizeClass === SizeClass.Large,
|
isLarge: sizeClass === SizeClass.Large,
|
||||||
isLargeScreen,
|
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
|
* React hook to use size classes in components
|
||||||
*/
|
*/
|
||||||
export function useSizeClass(): SizeClassInfo {
|
export function useSizeClass(): SizeClassInfo {
|
||||||
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(getSizeClass());
|
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(cachedSizeClassInfo);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update size class when dimensions change
|
let isMounted = true;
|
||||||
const updateSizeClass = () => {
|
|
||||||
const newInfo = getSizeClass();
|
const applySizeClass = (info: SizeClassInfo) => {
|
||||||
setSizeClassInfo(newInfo);
|
if (!isMounted) return;
|
||||||
|
cachedSizeClassInfo = info;
|
||||||
|
setSizeClassInfo(info);
|
||||||
console.debug(
|
console.debug(
|
||||||
`[SizeClass] Updated:`,
|
`[SizeClass] Updated:`,
|
||||||
`horizontal=${SizeClass[newInfo.horizontalSizeClass]}`,
|
`horizontal=${SizeClass[info.horizontalSizeClass]}`,
|
||||||
`vertical=${SizeClass[newInfo.verticalSizeClass]}`,
|
`vertical=${SizeClass[info.verticalSizeClass]}`,
|
||||||
`orientation=${newInfo.orientation}`,
|
`orientation=${info.orientation}`,
|
||||||
`isLargeScreen=${newInfo.isLargeScreen}`,
|
`isLargeScreen=${info.isLargeScreen}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dimensionSubscription = Dimensions.addEventListener('change', updateSizeClass);
|
const updateFromDimensions = () => {
|
||||||
|
const calculated = calculateFromDimensions();
|
||||||
|
applySizeClass(calculated);
|
||||||
|
};
|
||||||
|
|
||||||
// Also update when app becomes active
|
const requestNativeUpdate = async () => {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const nativeInfo = await fetchNativeSizeClass();
|
||||||
if (nextAppState === 'active') {
|
if (nativeInfo) {
|
||||||
updateSizeClass();
|
applySizeClass(nativeInfo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const appStateSubscription = AppState.addEventListener('change', handleAppStateChange);
|
const dimensionSubscription = Dimensions.addEventListener('change', () => {
|
||||||
|
updateFromDimensions();
|
||||||
|
requestNativeUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
requestNativeUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
// Clean up
|
|
||||||
return () => {
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
dimensionSubscription.remove();
|
dimensionSubscription.remove();
|
||||||
appStateSubscription.remove();
|
appStateSubscription.remove();
|
||||||
|
nativeSubscription?.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { BlueApp as BlueAppClass } from '../class/';
|
import { BlueApp as BlueAppClass } from '../class/blue-app';
|
||||||
import prompt from '../helpers/prompt';
|
import prompt from '../helpers/prompt';
|
||||||
import { showKeychainWipeAlert } from '../hooks/useBiometrics';
|
import { showKeychainWipeAlert } from '../hooks/useBiometrics';
|
||||||
import loc from '../loc';
|
import loc from '../loc';
|
||||||
@ -23,7 +23,7 @@ export const startAndDecrypt = async (retry?: boolean, passwordPrompt?: Password
|
|||||||
password = await passwordPrompt();
|
password = await passwordPrompt();
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false);
|
password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, { cancelable: false });
|
||||||
} while (!password);
|
} while (!password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
blue_modules/transactionDisplayState.ts
Normal file
41
blue_modules/transactionDisplayState.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// 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,7 +13,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import { Psbt } from 'bitcoinjs-lib';
|
import { Psbt } from 'bitcoinjs-lib';
|
||||||
import b58 from 'bs58check';
|
import b58 from 'bs58check';
|
||||||
|
|
||||||
import { MultisigCosigner, MultisigHDWallet } from '../../class';
|
import { MultisigCosigner } from '../../class/multisig-cosigner';
|
||||||
|
import { MultisigHDWallet } from '../../class/wallets/multisig-hd-wallet';
|
||||||
import { joinQRs } from '../bbqr/join';
|
import { joinQRs } from '../bbqr/join';
|
||||||
import {
|
import {
|
||||||
concatUint8Arrays,
|
concatUint8Arrays,
|
||||||
|
|||||||
@ -147,11 +147,10 @@ export class BlueApp {
|
|||||||
console.warn('error reading', key, error.message);
|
console.warn('error reading', key, error.message);
|
||||||
console.warn('fallback to realm');
|
console.warn('fallback to realm');
|
||||||
const realmKeyValue = await this.openRealmKeyValue();
|
const realmKeyValue = await this.openRealmKeyValue();
|
||||||
const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key
|
const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', key);
|
||||||
value = obj?.value;
|
value = obj?.value;
|
||||||
realmKeyValue.close();
|
realmKeyValue.close();
|
||||||
if (value) {
|
if (value) {
|
||||||
// @ts-ignore value.length
|
|
||||||
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
|
console.warn('successfully recovered', value.length, 'bytes from realm for key', key);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@ -547,10 +546,11 @@ export class BlueApp {
|
|||||||
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
|
(walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = [];
|
// Legacy single-address wallets - store under index 0
|
||||||
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || [];
|
walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || {};
|
||||||
|
walletToInflate._txs_by_external_index[0] = walletToInflate._txs_by_external_index[0] || [];
|
||||||
const transaction = JSON.parse(tx.tx);
|
const transaction = JSON.parse(tx.tx);
|
||||||
(walletToInflate._txs_by_external_index as Transaction[]).push(transaction);
|
walletToInflate._txs_by_external_index[0].push(transaction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -559,32 +559,6 @@ export class BlueApp {
|
|||||||
const id = wallet.getID();
|
const id = wallet.getID();
|
||||||
const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet;
|
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) {
|
if (walletToSave._txs_by_external_index) {
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
// cleanup all existing transactions for the wallet first
|
// cleanup all existing transactions for the wallet first
|
||||||
@ -592,16 +566,14 @@ export class BlueApp {
|
|||||||
realm.delete(walletTransactionsToDelete);
|
realm.delete(walletTransactionsToDelete);
|
||||||
|
|
||||||
// insert new ones:
|
// insert new ones:
|
||||||
for (const index of Object.keys(walletToSave._txs_by_external_index)) {
|
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) {
|
||||||
// @ts-ignore index is number
|
|
||||||
const txs = walletToSave._txs_by_external_index[index];
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
realm.create(
|
realm.create(
|
||||||
'WalletTransactions',
|
'WalletTransactions',
|
||||||
{
|
{
|
||||||
walletid: id,
|
walletid: id,
|
||||||
internal: false,
|
internal: false,
|
||||||
index: parseInt(index, 10),
|
index: parseInt(indexStr, 10),
|
||||||
tx: JSON.stringify(tx),
|
tx: JSON.stringify(tx),
|
||||||
},
|
},
|
||||||
Realm.UpdateMode.Modified,
|
Realm.UpdateMode.Modified,
|
||||||
@ -609,16 +581,14 @@ export class BlueApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const index of Object.keys(walletToSave._txs_by_internal_index)) {
|
for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) {
|
||||||
// @ts-ignore index is number
|
|
||||||
const txs = walletToSave._txs_by_internal_index[index];
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
realm.create(
|
realm.create(
|
||||||
'WalletTransactions',
|
'WalletTransactions',
|
||||||
{
|
{
|
||||||
walletid: id,
|
walletid: id,
|
||||||
internal: true,
|
internal: true,
|
||||||
index: parseInt(index, 10),
|
index: parseInt(indexStr, 10),
|
||||||
tx: JSON.stringify(tx),
|
tx: JSON.stringify(tx),
|
||||||
},
|
},
|
||||||
Realm.UpdateMode.Modified,
|
Realm.UpdateMode.Modified,
|
||||||
@ -756,10 +726,12 @@ export class BlueApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const wallet of this.wallets) {
|
await Promise.all(
|
||||||
console.log('fetching balance for', wallet.getLabel());
|
this.wallets.map(async wallet => {
|
||||||
await wallet.fetchBalance();
|
console.log('fetching balance for', wallet.getLabel());
|
||||||
}
|
await wallet.fetchBalance();
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -788,13 +760,15 @@ export class BlueApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const wallet of this.wallets) {
|
await Promise.all(
|
||||||
await wallet.fetchTransactions();
|
this.wallets.map(async wallet => {
|
||||||
if ('fetchPendingTransactions' in wallet) {
|
await wallet.fetchTransactions();
|
||||||
await wallet.fetchPendingTransactions();
|
if ('fetchPendingTransactions' in wallet) {
|
||||||
await wallet.fetchUserInvoices();
|
await wallet.fetchPendingTransactions();
|
||||||
}
|
await wallet.fetchUserInvoices();
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -809,14 +783,16 @@ export class BlueApp {
|
|||||||
console.error('Failed to fetch sender payment codes for wallet', index, error);
|
console.error('Failed to fetch sender payment codes for wallet', index, error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const wallet of this.wallets) {
|
await Promise.all(
|
||||||
try {
|
this.wallets.map(async wallet => {
|
||||||
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) continue;
|
try {
|
||||||
await wallet.fetchBIP47SenderPaymentCodes();
|
if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return;
|
||||||
} catch (error) {
|
await wallet.fetchBIP47SenderPaymentCodes();
|
||||||
console.error('Failed to fetch sender payment codes for wallet', wallet.label, error);
|
} 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 URL from 'url';
|
||||||
import { readFileOutsideSandbox } from '../blue_modules/fs';
|
import { readFileOutsideSandbox } from '../blue_modules/fs';
|
||||||
import { Chain } from '../models/bitcoinUnits';
|
import { Chain } from '../models/bitcoinUnits';
|
||||||
import { WatchOnlyWallet } from './';
|
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
|
||||||
import Azteco from './azteco';
|
import Azteco from './azteco';
|
||||||
import Lnurl from './lnurl';
|
import Lnurl from './lnurl';
|
||||||
import type { TWallet } from './wallets/types';
|
import type { TWallet } from './wallets/types';
|
||||||
|
|||||||
@ -390,7 +390,7 @@ export class HDSegwitBech32Transaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore stfu
|
// Non-null assertions are safe here because the while loop always runs at least once (add starts at 0)
|
||||||
return { tx, inputs, outputs, fee };
|
return { tx: tx!, inputs: inputs!, outputs: outputs!, fee: fee! };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
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 bolt11 from 'bolt11';
|
||||||
import { sha256 } from '@noble/hashes/sha256';
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
import { hmac } from '@noble/hashes/hmac';
|
import { hmac } from '@noble/hashes/hmac';
|
||||||
import CryptoJS from 'crypto-js';
|
import { cbc } from '@noble/ciphers/aes';
|
||||||
import ecc from '../blue_modules/noble_ecc';
|
import ecc from '../blue_modules/noble_ecc';
|
||||||
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
|
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
|
||||||
import { fetch } from '../util/fetch';
|
import { fetch } from '../util/fetch';
|
||||||
@ -321,13 +321,24 @@ export default class Lnurl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||||
const iv = CryptoJS.enc.Base64.parse(ivBase64);
|
// crypto-js's old implementation silently returned '' on malformed
|
||||||
const key = CryptoJS.enc.Hex.parse(preimageHex);
|
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
|
||||||
return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, {
|
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
|
||||||
iv,
|
// catch every throw and return '' — the call site at
|
||||||
mode: CryptoJS.mode.CBC,
|
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
|
||||||
format: CryptoJS.format.Hex,
|
// try/catch, so a misbehaving LNURL server should not crash the screen.
|
||||||
}).toString(CryptoJS.enc.Utf8);
|
// 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 '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommentAllowed(): number | false {
|
getCommentAllowed(): number | false {
|
||||||
|
|||||||
@ -106,23 +106,31 @@ export class MultisigCosigner {
|
|||||||
this._valid = false;
|
this._valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// is it coldcard json?
|
// is it coldcard / unchained json?
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
if (json.p2sh && json.p2sh_deriv && json.xfp) {
|
|
||||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
|
// 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));
|
||||||
this._valid = true;
|
this._valid = true;
|
||||||
this._cosigners.push(cc);
|
this._cosigners.push(cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) {
|
if (xpub && path && json.xfp) {
|
||||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv));
|
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
|
||||||
this._valid = true;
|
this._valid = true;
|
||||||
this._cosigners.push(cc);
|
this._cosigners.push(cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
|
if (json.p2wsh && p2wsh_deriv && json.xfp) {
|
||||||
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
|
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
|
||||||
this._valid = true;
|
this._valid = true;
|
||||||
this._cosigners.push(cc);
|
this._cosigners.push(cc);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
* @return {Promise.<Uint8Array>} The random bytes
|
* @return {Promise.<Uint8Array>} The random bytes
|
||||||
*/
|
*/
|
||||||
export async function randomBytes(size: number): Promise<Uint8Array> {
|
export async function randomBytes(size: number): Promise<Uint8Array> {
|
||||||
const g: any = globalThis as any;
|
const g = globalThis as any;
|
||||||
const rnCrypto = g && g.crypto;
|
const rnCrypto = g && g.crypto;
|
||||||
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
|
if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') {
|
||||||
throw new Error('crypto.getRandomValues is not available');
|
throw new Error('crypto.getRandomValues is not available');
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { useTheme } from '../components/themes';
|
|
||||||
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
|
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
|
||||||
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
|
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
|
||||||
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
|
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
|
||||||
@ -30,8 +29,7 @@ export default class WalletGradient {
|
|||||||
static aezeedWallet: string[] = ['#8584FF', '#5351FB'];
|
static aezeedWallet: string[] = ['#8584FF', '#5351FB'];
|
||||||
|
|
||||||
static createWallet = () => {
|
static createWallet = () => {
|
||||||
const { colors } = useTheme();
|
return WalletGradient.defaultGradients[0];
|
||||||
return colors.lightButton;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static gradientsFor(type: string): string[] {
|
static gradientsFor(type: string): string[] {
|
||||||
|
|||||||
@ -2,27 +2,23 @@ import bip38 from 'bip38';
|
|||||||
import wif from 'wif';
|
import wif from 'wif';
|
||||||
|
|
||||||
import loc from '../loc';
|
import loc from '../loc';
|
||||||
import {
|
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
|
||||||
HDAezeedWallet,
|
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
|
||||||
HDLegacyBreadwalletWallet,
|
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
|
||||||
HDLegacyElectrumSeedP2PKHWallet,
|
import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet';
|
||||||
HDLegacyP2PKHWallet,
|
import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet';
|
||||||
HDSegwitBech32Wallet,
|
import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
|
||||||
HDSegwitElectrumSeedP2WPKHWallet,
|
import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet';
|
||||||
HDSegwitP2SHWallet,
|
import { HDTaprootWallet } from './wallets/hd-taproot-wallet';
|
||||||
HDTaprootWallet,
|
import { LegacyWallet } from './wallets/legacy-wallet';
|
||||||
LegacyWallet,
|
import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet';
|
||||||
LightningCustodianWallet,
|
import { LightningArkWallet } from './wallets/lightning-ark-wallet';
|
||||||
LightningArkWallet,
|
import { MultisigHDWallet } from './wallets/multisig-hd-wallet';
|
||||||
MultisigHDWallet,
|
import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet';
|
||||||
SegwitBech32Wallet,
|
import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet';
|
||||||
SegwitP2SHWallet,
|
import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets';
|
||||||
SLIP39LegacyP2PKHWallet,
|
import { TaprootWallet } from './wallets/taproot-wallet';
|
||||||
SLIP39SegwitBech32Wallet,
|
import { WatchOnlyWallet } from './wallets/watch-only-wallet';
|
||||||
SLIP39SegwitP2SHWallet,
|
|
||||||
TaprootWallet,
|
|
||||||
WatchOnlyWallet,
|
|
||||||
} from '.';
|
|
||||||
import bip39WalletFormatsElectrum from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json
|
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 bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json';
|
||||||
import type { TWallet } from './wallets/types';
|
import type { TWallet } from './wallets/types';
|
||||||
@ -220,10 +216,35 @@ const startImport = (
|
|||||||
if (text.startsWith('arkade://')) {
|
if (text.startsWith('arkade://')) {
|
||||||
const ark = new LightningArkWallet();
|
const ark = new LightningArkWallet();
|
||||||
ark.setSecret(text);
|
ark.setSecret(text);
|
||||||
await ark.init();
|
// 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.
|
||||||
if (!offline) {
|
if (!offline) {
|
||||||
await ark.fetchBalance();
|
try {
|
||||||
await ark.fetchTransactions();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
yield { wallet: ark };
|
yield { wallet: ark };
|
||||||
}
|
}
|
||||||
@ -323,6 +344,7 @@ const startImport = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
yield { progress: 'wif' };
|
yield { progress: 'wif' };
|
||||||
|
|
||||||
const segwitWallet = new SegwitP2SHWallet();
|
const segwitWallet = new SegwitP2SHWallet();
|
||||||
segwitWallet.setSecret(text);
|
segwitWallet.setSecret(text);
|
||||||
if (segwitWallet.getAddress()) {
|
if (segwitWallet.getAddress()) {
|
||||||
@ -386,6 +408,89 @@ const startImport = (
|
|||||||
yield { wallet: legacyWallet };
|
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?
|
// maybe its a watch-only address?
|
||||||
yield { progress: 'watch only' };
|
yield { progress: 'watch only' };
|
||||||
const wo1 = new WatchOnlyWallet();
|
const wo1 = new WatchOnlyWallet();
|
||||||
|
|||||||
@ -45,9 +45,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
_balances_by_external_index: Record<number, BalanceByIndex>;
|
_balances_by_external_index: Record<number, BalanceByIndex>;
|
||||||
_balances_by_internal_index: Record<number, BalanceByIndex>;
|
_balances_by_internal_index: Record<number, BalanceByIndex>;
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
_txs_by_external_index: Record<number, Transaction[]>;
|
_txs_by_external_index: Record<number, Transaction[]>;
|
||||||
// @ts-ignore
|
|
||||||
_txs_by_internal_index: Record<number, Transaction[]>;
|
_txs_by_internal_index: Record<number, Transaction[]>;
|
||||||
|
|
||||||
_utxo: any[];
|
_utxo: any[];
|
||||||
@ -158,6 +156,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
return ret;
|
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() {
|
async generate() {
|
||||||
const buf = await randomBytes(16);
|
const buf = await randomBytes(16);
|
||||||
this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(buf));
|
this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(buf));
|
||||||
@ -195,70 +202,37 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
return child.toWIF();
|
return child.toWIF();
|
||||||
}
|
}
|
||||||
|
|
||||||
_getNodeAddressByIndex(node: number, index: number): string {
|
_getNodeByIndex(node: 0 | 1, index: number): BIP32Interface {
|
||||||
index = index * 1; // cast to int
|
const cachedNode = node === 0 ? this._node0 : this._node1;
|
||||||
|
if (cachedNode) {
|
||||||
|
return cachedNode.derive(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xpub = this._zpubToXpub(this.getXpub());
|
||||||
|
const hdNode = bip32.fromBase58(xpub).derive(node);
|
||||||
|
|
||||||
if (node === 0) {
|
if (node === 0) {
|
||||||
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
|
this._node0 = hdNode;
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
// tbh the only possible else is node === 1
|
this._node1 = hdNode;
|
||||||
// @ts-ignore
|
|
||||||
address = this._hdNodeToAddress(this._node1.derive(index));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node === 0) {
|
return hdNode.derive(index);
|
||||||
return (this.external_addresses_cache[index] = address);
|
|
||||||
} else {
|
|
||||||
// tbh the only possible else option is node === 1
|
|
||||||
return (this.internal_addresses_cache[index] = address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_getNodePubkeyByIndex(node: number, index: number) {
|
_getNodeAddressByIndex(node: 0 | 1, index: number): string {
|
||||||
index = index * 1; // cast to int
|
const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache;
|
||||||
|
|
||||||
if (node === 0 && !this._node0) {
|
if (cache[index]) return cache[index]; // cache hit
|
||||||
const xpub = this._zpubToXpub(this.getXpub());
|
|
||||||
const hdNode = bip32.fromBase58(xpub);
|
|
||||||
this._node0 = hdNode.derive(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node === 1 && !this._node1) {
|
const hdNode = this._getNodeByIndex(node, index);
|
||||||
const xpub = this._zpubToXpub(this.getXpub());
|
const address = this._hdNodeToAddress(hdNode);
|
||||||
const hdNode = bip32.fromBase58(xpub);
|
|
||||||
this._node1 = hdNode.derive(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node === 0 && this._node0) {
|
return (cache[index] = address);
|
||||||
return this._node0.derive(index).publicKey;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (node === 1 && this._node1) {
|
_getNodePubkeyByIndex(node: 0 | 1, index: number) {
|
||||||
return this._node1.derive(index).publicKey;
|
return this._getNodeByIndex(node, index).publicKey;
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Internal error: this._node0 or this._node1 is undefined');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_getExternalAddressByIndex(index: number): string {
|
_getExternalAddressByIndex(index: number): string {
|
||||||
@ -415,137 +389,95 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
// now, we need to put transactions in all relevant `cells` of internal hashmaps:
|
// 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
|
// 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++) {
|
for (let c = 0; c < next_free_address_index + this.gap_limit; c++) {
|
||||||
for (const tx of Object.values(txdatas)) {
|
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
|
||||||
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++) {
|
for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) {
|
||||||
for (const tx of Object.values(txdatas)) {
|
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
|
||||||
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 (const pc of this._receive_payment_codes) {
|
||||||
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
|
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
|
||||||
for (const tx of Object.values(txdatas)) {
|
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
|
||||||
// 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 */,
|
|
||||||
};
|
|
||||||
|
|
||||||
// trying to replace tx if it exists already (because it has lower confirmations, for example)
|
// per-cell txid -> position lookup, used to replace-or-push a transaction into a cell in constant time
|
||||||
let replaced = false;
|
const cellPositionsByTxid = new Map<Transaction[], Map<string, number>>();
|
||||||
for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) {
|
const getCellPositions = (cell: Transaction[]): Map<string, number> => {
|
||||||
if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) {
|
let positions = cellPositionsByTxid.get(cell);
|
||||||
replaced = true;
|
if (!positions) {
|
||||||
this._txs_by_payment_code_index[pc][c][cc] = clonedTx;
|
positions = new Map();
|
||||||
}
|
for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc);
|
||||||
}
|
cellPositionsByTxid.set(cell, positions);
|
||||||
if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx);
|
}
|
||||||
}
|
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);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -592,8 +524,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
|
|
||||||
const ret: Transaction[] = [];
|
const ret: Transaction[] = [];
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
tx.timestamp = tx.blocktime;
|
tx.timestamp = tx.blocktime || Math.floor(+new Date() / 1000) - 30; // fallback for unconfirmed
|
||||||
if (!tx.blocktime) tx.timestamp = Math.floor(+new Date() / 1000) - 30; // unconfirmed
|
|
||||||
tx.confirmations = tx.confirmations || 0; // unconfirmed
|
tx.confirmations = tx.confirmations || 0; // unconfirmed
|
||||||
tx.hash = tx.txid;
|
tx.hash = tx.txid;
|
||||||
tx.value = 0;
|
tx.value = 0;
|
||||||
@ -644,8 +575,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
let lastHistoriesWithUsedAddresses = null;
|
let lastHistoriesWithUsedAddresses = null;
|
||||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||||
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
||||||
// @ts-ignore
|
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
|
||||||
// in this particular chunk we have used addresses
|
// in this particular chunk we have used addresses
|
||||||
lastChunkWithUsedAddressesNum = c;
|
lastChunkWithUsedAddressesNum = c;
|
||||||
lastHistoriesWithUsedAddresses = histories;
|
lastHistoriesWithUsedAddresses = histories;
|
||||||
@ -687,8 +617,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
let lastHistoriesWithUsedAddresses = null;
|
let lastHistoriesWithUsedAddresses = null;
|
||||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||||
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c));
|
||||||
// @ts-ignore
|
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
|
||||||
// in this particular chunk we have used addresses
|
// in this particular chunk we have used addresses
|
||||||
lastChunkWithUsedAddressesNum = c;
|
lastChunkWithUsedAddressesNum = c;
|
||||||
lastHistoriesWithUsedAddresses = histories;
|
lastHistoriesWithUsedAddresses = histories;
|
||||||
@ -730,8 +659,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
|
|||||||
let lastHistoriesWithUsedAddresses = null;
|
let lastHistoriesWithUsedAddresses = null;
|
||||||
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
for (let c = 0; c < Math.round(index / this.gap_limit); c++) {
|
||||||
const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c));
|
const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c));
|
||||||
// @ts-ignore
|
if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) {
|
||||||
if (this.constructor._getTransactionsFromHistories(histories).length > 0) {
|
|
||||||
// in this particular chunk we have used addresses
|
// in this particular chunk we have used addresses
|
||||||
lastChunkWithUsedAddressesNum = c;
|
lastChunkWithUsedAddressesNum = c;
|
||||||
lastHistoriesWithUsedAddresses = histories;
|
lastHistoriesWithUsedAddresses = histories;
|
||||||
|
|||||||
@ -315,7 +315,7 @@ export class AbstractHDWallet extends LegacyWallet {
|
|||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
_getNodePubkeyByIndex(node: number, index: number): Uint8Array | undefined {
|
_getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -136,6 +136,14 @@ export class AbstractWallet {
|
|||||||
return BitcoinUnit.BTC;
|
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> {
|
async allowOnchainAddress(): Promise<boolean> {
|
||||||
throw new Error('allowOnchainAddress: Not implemented');
|
throw new Error('allowOnchainAddress: Not implemented');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { ECPairAPI, ECPairFactory, Signer } from 'ecpair';
|
|||||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||||
import ecc from '../../blue_modules/noble_ecc';
|
import ecc from '../../blue_modules/noble_ecc';
|
||||||
import { hexToUint8Array, concatUint8Arrays } from '../../blue_modules/uint8array-extras';
|
import { hexToUint8Array, concatUint8Arrays } from '../../blue_modules/uint8array-extras';
|
||||||
import { HDSegwitBech32Wallet } from '..';
|
import type { HDSegwitBech32Wallet as HDSegwitBech32WalletT } from './hd-segwit-bech32-wallet';
|
||||||
import { randomBytes } from '../rng';
|
import { randomBytes } from '../rng';
|
||||||
import { AbstractWallet } from './abstract-wallet';
|
import { AbstractWallet } from './abstract-wallet';
|
||||||
import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
|
import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
|
||||||
@ -21,14 +21,20 @@ bitcoin.initEccLib(ecc);
|
|||||||
*/
|
*/
|
||||||
export class LegacyWallet extends AbstractWallet {
|
export class LegacyWallet extends AbstractWallet {
|
||||||
static readonly type = 'legacy';
|
static readonly type = 'legacy';
|
||||||
static readonly typeReadable = 'Legacy (P2PKH)';
|
static readonly defaultTypeReadable = 'Legacy (P2PKH)';
|
||||||
// @ts-ignore: override
|
// @ts-ignore: override
|
||||||
public readonly type = LegacyWallet.type;
|
public readonly type = LegacyWallet.type;
|
||||||
// @ts-ignore: override
|
// @ts-ignore: override
|
||||||
public readonly typeReadable = LegacyWallet.typeReadable;
|
public readonly typeReadable: string;
|
||||||
|
|
||||||
_txs_by_external_index: Transaction[] = [];
|
_txs_by_external_index: Record<number, Transaction[]> = {};
|
||||||
_txs_by_internal_index: Transaction[] = [];
|
_txs_by_internal_index: Record<number, Transaction[]> = {};
|
||||||
|
|
||||||
|
constructor(typeReadable?: string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.typeReadable = typeReadable ?? LegacyWallet.defaultTypeReadable;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple function which says that we havent tried to fetch balance
|
* Simple function which says that we havent tried to fetch balance
|
||||||
@ -338,15 +344,18 @@ export class LegacyWallet extends AbstractWallet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._txs_by_external_index = _txsByExternalIndex;
|
this._txs_by_external_index = { 0: _txsByExternalIndex };
|
||||||
this._lastTxFetch = +new Date();
|
this._lastTxFetch = +new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTransactions(): Transaction[] {
|
getTransactions(): Transaction[] {
|
||||||
// a hacky code reuse from electrum HD wallet:
|
// a hacky code reuse from electrum HD wallet:
|
||||||
this._txs_by_external_index = this._txs_by_external_index || [];
|
this._txs_by_external_index = this._txs_by_external_index || {};
|
||||||
this._txs_by_internal_index = [];
|
this._txs_by_internal_index = {};
|
||||||
|
|
||||||
|
const { HDSegwitBech32Wallet } = require('./hd-segwit-bech32-wallet') as {
|
||||||
|
HDSegwitBech32Wallet: typeof HDSegwitBech32WalletT;
|
||||||
|
};
|
||||||
const hd = new HDSegwitBech32Wallet();
|
const hd = new HDSegwitBech32Wallet();
|
||||||
return hd.getTransactions.apply(this);
|
return hd.getTransactions.apply(this);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -104,6 +104,11 @@ export type LightningTransaction = {
|
|||||||
timestamp: number; // seconds, not milliseconds
|
timestamp: number; // seconds, not milliseconds
|
||||||
expire_time?: number;
|
expire_time?: number;
|
||||||
ispaid?: boolean;
|
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;
|
walletID?: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
amt?: number;
|
amt?: number;
|
||||||
@ -123,10 +128,11 @@ export type Transaction = {
|
|||||||
locktime: number;
|
locktime: number;
|
||||||
inputs: TransactionInput[];
|
inputs: TransactionInput[];
|
||||||
outputs: TransactionOutput[];
|
outputs: TransactionOutput[];
|
||||||
blockhash: string;
|
// Confirmation-only fields: absent on mempool (unconfirmed) responses.
|
||||||
confirmations: number;
|
blockhash?: string;
|
||||||
time: number;
|
confirmations?: number;
|
||||||
blocktime: number;
|
time?: number;
|
||||||
|
blocktime?: number;
|
||||||
timestamp: number; // seconds, not milliseconds
|
timestamp: number; // seconds, not milliseconds
|
||||||
value?: number;
|
value?: number;
|
||||||
|
|
||||||
|
|||||||
@ -197,12 +197,13 @@ export class WatchOnlyWallet extends LegacyWallet {
|
|||||||
|
|
||||||
async fetchUtxo() {
|
async fetchUtxo() {
|
||||||
if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo();
|
if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo();
|
||||||
throw new Error('Not initialized');
|
// Single-address watch-only uses LegacyWallet UTXO + derivation from txs (no HD instance).
|
||||||
|
return super.fetchUtxo();
|
||||||
}
|
}
|
||||||
|
|
||||||
getUtxo(...args: Parameters<THDWalletForWatchOnly['getUtxo']>) {
|
getUtxo(...args: Parameters<THDWalletForWatchOnly['getUtxo']>) {
|
||||||
if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args);
|
if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args);
|
||||||
throw new Error('Not initialized');
|
return super.getUtxo(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
combinePsbt(...args: Parameters<THDWalletForWatchOnly['combinePsbt']>) {
|
combinePsbt(...args: Parameters<THDWalletForWatchOnly['combinePsbt']>) {
|
||||||
|
|||||||
14
codegen/NativeEventEmitter.ts
Normal file
14
codegen/NativeEventEmitter.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
18
codegen/NativeMenuElementsEmitter.ts
Normal file
18
codegen/NativeMenuElementsEmitter.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
17
codegen/NativeSettingsModule.ts
Normal file
17
codegen/NativeSettingsModule.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
10
codegen/NativeWidgetHelper.ts
Normal file
10
codegen/NativeWidgetHelper.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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