REF: bump react native

This commit is contained in:
Overtorment 2026-03-16 21:13:23 +00:00 committed by GitHub
parent 1d208142e8
commit 9dc35cecb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
181 changed files with 10575 additions and 6928 deletions

View File

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

View File

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

View File

@ -12,7 +12,7 @@ on:
jobs:
build:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 180
outputs:
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
@ -463,7 +463,7 @@ jobs:
testflight-upload:
needs: build
runs-on: macos-15
runs-on: macos-26
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}

View File

@ -11,7 +11,7 @@ on:
jobs:
buildReleaseApk:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout project
@ -19,6 +19,23 @@ jobs:
with:
fetch-depth: "0"
- name: Free disk space (Android build)
shell: bash
run: |
df -h
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
docker system prune -af || true
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
rm -rf ~/.gradle/caches/build-cache || true
rm -rf ~/.npm/_cacache ~/.cache || true
sudo rm -rf /home/runner/work/_temp || true
df -h
- name: Specify node version
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
@ -33,8 +50,16 @@ jobs:
java-version: '17'
cache: 'gradle'
- name: Install node_modules
run: npm ci --omit=dev --yes
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- 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
uses: ruby/setup-ruby@v1
@ -55,31 +80,41 @@ jobs:
run: |
NEW_BUILD_NUMBER="$(date +%s)"
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Prepare Keystore
run: bundle exec fastlane android prepare_keystore
env:
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
- name: Update Version Code, Build, and Sign APK
- name: Build and sign APK
id: build_and_sign_apk
run: |
bundle exec fastlane android update_version_build_and_sign_apk
run: bundle exec fastlane android build_release_apk
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 }}
- name: Upload build logs on failure
if: failure()
uses: actions/upload-artifact@v4
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
id: determine_apk_path
run: |
BUILD_NUMBER=${{ steps.build_number.outputs.build_number }}
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk"
else
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk"
fi
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
@ -94,7 +129,7 @@ jobs:
if-no-files-found: error
browserstack:
runs-on: ubuntu-latest
runs-on: macos-latest
needs: buildReleaseApk
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
@ -127,4 +162,4 @@ jobs:
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec fastlane upload_to_browserstack_and_comment
run: bundle exec fastlane upload_to_browserstack_and_comment

View File

@ -7,7 +7,7 @@ permissions:
jobs:
ios:
runs-on: macos-15
runs-on: macos-26
env:
BUILD_CONFIGURATION: Release
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
@ -139,6 +139,7 @@ jobs:
brew install applesimutils
- name: Run detox tests
timeout-minutes: 360
run: |
npm run e2e:test:ios-release -- \
--record-videos failing \
@ -157,7 +158,7 @@ jobs:
android:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
env:
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
@ -168,32 +169,26 @@ jobs:
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
- name: Free disk space (Ubuntu)
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/
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: Create artifacts directory on /mnt
- name: Ensure artifacts directory
run: |
sudo mkdir -p /mnt/artifacts
sudo chown -R runner /mnt/artifacts
mkdir -p ${{ github.workspace }}/artifacts
- name: Specify node version
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
@ -236,16 +231,17 @@ jobs:
- name: Run tests
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2
with:
api-level: 31
api-level: 36
profile: pixel
avd-name: Pixel_API_29_AOSP
force-avd-creation: false
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 3 --reuse --artifacts-location /mnt/artifacts
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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: failure()
with:
name: e2e-android-videos
path: /mnt/artifacts/
path: ${{ github.workspace }}/artifacts

2
.gitignore vendored
View File

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

View File

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

View File

@ -90,13 +90,14 @@ export const BlueFormLabel: React.FC<TextProps> = props => {
export const BlueFormMultiInput: React.FC<TextInputProps> = props => {
const { colors } = useTheme();
const { style, editable, ...restProps } = props;
return (
<TextInput
multiline
underlineColorAndroid="transparent"
numberOfLines={4}
editable={!props.editable}
editable={editable}
style={[
styles.blueFormMultiInput,
{
@ -105,11 +106,12 @@ export const BlueFormMultiInput: React.FC<TextInputProps> = props => {
backgroundColor: colors.inputBackgroundColor,
color: colors.foregroundColor,
},
style,
]}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
{...props}
{...restProps}
selectTextOnFocus={false}
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
/>

View File

@ -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.
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).

View File

@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1220.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1222.0)
aws-sdk-core (3.243.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -35,8 +35,8 @@ GEM
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.215.0)
aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@ -88,7 +88,7 @@ GEM
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.3)
connection_pool (2.5.5)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
@ -98,9 +98,8 @@ GEM
drb (2.2.3)
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.18.0)
ethon (0.15.0)
ffi (>= 1.15.0)
logger
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
@ -194,10 +193,10 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
git (3.1.1)
git (4.3.1)
activesupport (>= 5.0)
addressable (~> 2.8)
process_executer (~> 1.3)
process_executer (~> 4.0)
rchardet (~> 1.9)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-core (>= 0.15.0, < 2.a)
@ -246,17 +245,19 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.18.1)
json (2.19.0)
jwt (2.10.2)
base64
logger (1.7.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0203)
mime-types-data (3.2026.0303)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.27.0)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
molinillo (0.8.0)
multi_json (1.19.1)
multipart-post (2.4.1)
@ -270,7 +271,9 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
process_executer (1.3.0)
prism (1.9.0)
process_executer (4.0.2)
track_open_instances (~> 0.1)
public_suffix (4.0.7)
rake (13.3.1)
rchardet (1.10.0)
@ -283,7 +286,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
retriable (3.2.1)
retriable (3.3.0)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
@ -303,13 +306,14 @@ GEM
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
track_open_instances (0.1.15)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.4.1)
ethon (>= 0.9.0)
typhoeus (1.5.0)
ethon (>= 0.9.0, < 0.16.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
@ -343,8 +347,146 @@ DEPENDENCIES
jwt
xcodeproj (< 1.26.0)
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
activesupport (7.2.3) sha256=5675c9770dac93e371412684249f9dc3c8cec104efd0624362a520ae685c7b10
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
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.1222.0) sha256=e86b1c65f5cedff52586f9f2b288448d5a069376cbfe1a8abdc29f5e06411bd5
aws-sdk-core (3.243.0) sha256=a014eef785124b71d28325783fa422a1512f8421ec9b6e3931c8b0ca3fbb0f1c
aws-sdk-kms (1.122.0) sha256=47ce3f51b26bd7d76f1270cfdfca17b40073ecd3219c8c9400788712abfb4eb8
aws-sdk-s3 (1.215.0) sha256=15e90b20d985e343cd2cd315c030226b4f8826dd2a031c237cfcd8e0893b01ce
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
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.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
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.15.0) sha256=0809805a035bc10f54162ca99f15ded49e428e0488bcfe1c08c821e18261a74d
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.3) sha256=add154f4f399243cbe070806ed41b96906942e7f5259bb1fe6daf2ec8f497194
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.0) sha256=5fce375e27d3bdbb46c18dbca6ba9af29d3304801ae1eb995771c4796c5ac7e8
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
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.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
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.96.0) sha256=9e27b03295fdd2c4a67b5e4d11f891492c89f73beff4a3f9323419165a56d01c
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.5.0) sha256=b56be28b8c10628125214dde571b925cfcebdbc58619e598250c37a2114f7b4b
google-cloud-storage (1.58.0) sha256=1bedc07a9c75af169e1ede1dd306b9f941f9ffa9e7095d0364c0803c468fdffd
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.0) sha256=bc5202f083618b3af7aba3184146ec9d820f8f6de261838b577173475e499d9a
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
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
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
retriable (3.3.0) sha256=b6a5a4000a0dc04fcbea0976d5af3bf62c142e8d2d5c85191369ed9ff8bdfe11
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
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
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.5.0) sha256=120b67ed1ef515e6c0e938176db880f15b0916f038e78ce2a66290f3f1de3e3b
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d
RUBY VERSION
ruby 3.4.8p72
ruby 3.4.8
BUNDLED WITH
2.3.27
4.0.7

View File

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

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-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="output" path="bin/default"/>
</classpath>

View File

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

View 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

View File

@ -90,7 +90,8 @@ android {
versionName "7.2.7"
testBuildType System.getProperty('testBuildType', 'debug')
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 {
@ -108,6 +109,7 @@ android {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
@ -123,6 +125,13 @@ task copyFiatUnits(type: Copy) {
preBuild.dependsOn(copyFiatUnits)
// Ensure fiat units are available before codegen scans JS sources
tasks.configureEach { task ->
if (task.name == 'generateCodegenSchemaFromJavaScript') {
task.dependsOn(copyFiatUnits)
}
}
dependencies {
androidTestImplementation('com.wix:detox:+')
// The version of react-native is set by the React Native Gradle Plugin

View File

@ -34,27 +34,18 @@
android:networkSecurityConfig="@xml/network_security_config"
android:configChanges="uiMode">
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_name"
android:value="BlueWallet notifications" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_channel_description"
android:value="Notifications about incoming payments" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_foreground"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.channel_create_default"
android:value="true" />
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_color"
android:resource="@color/white" />
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification_icon" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/foreground_color" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@ -64,16 +55,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationActions" />
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationPublisher" />
<receiver
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationBootEventReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".BitcoinPriceWidget" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
@ -130,14 +111,6 @@
</intent-filter>
</activity-alias>
<service
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationListenerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:label="@string/app_name"

View File

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

View File

@ -11,12 +11,9 @@ import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.facebook.react.modules.i18nmanager.I18nUtil
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
@ -66,9 +63,9 @@ class MainApplication : Application(), ReactApplication {
}
}
override val reactNativeHost: ReactNativeHost =
override val reactNativeHost: ReactNativeHost by lazy {
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
override fun getPackages() =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
@ -76,16 +73,15 @@ class MainApplication : Application(), ReactApplication {
add(SettingsPackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport() = BuildConfig.DEBUG
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
override fun getJSMainModuleName() = "index"
}
}
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override val reactHost: ReactHost by lazy {
getDefaultReactHost(applicationContext, reactNativeHost)
}
override fun onCreate() {
super.onCreate()
@ -101,11 +97,7 @@ class MainApplication : Application(), ReactApplication {
val sharedI18nUtilInstance = I18nUtil.getInstance()
sharedI18nUtilInstance.allowRTL(applicationContext, true)
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
loadReactNative(this)
initializeDeviceUID()
initializeBugsnag()

View File

@ -0,0 +1,210 @@
package io.bluewallet.bluewallet
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.util.Log
import com.bugsnag.android.Bugsnag
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.modules.i18nmanager.I18nUtil
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
class MainApplication : Application(), ReactApplication {
private lateinit var sharedPref: SharedPreferences
private val themeChangeReceiver = ThemeChangeReceiver()
private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
if (key == "preferredCurrency") {
prefs.edit().remove("previous_price").apply()
// Update BitcoinPrice widgets
WidgetUpdateWorker.scheduleWork(this)
// Immediately refresh Market widgets
MarketWidget.refreshAllWidgetsImmediately(this)
} else if (key == "force_dark_mode") {
// Theme setting changed, update all widgets
ThemeHelper.updateAllWidgets(this)
} else if (key == "donottrack") {
// Handle Do Not Track changes similar to iOS
val isEnabled = prefs.getString("donottrack", "0") == "1"
Log.d("MainApplication", "Do Not Track changed to: $isEnabled")
if (isEnabled) {
// Set deviceUIDCopy to "Disabled"
prefs.edit()
.putString("deviceUIDCopy", "Disabled")
.apply()
Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'")
} else {
// Re-initialize device UID
initializeDeviceUID()
}
} else if (key == "deviceUID") {
// When deviceUID changes, update deviceUIDCopy
val isDoNotTrackEnabled = prefs.getString("donottrack", "0") == "1"
if (!isDoNotTrackEnabled) {
val deviceUID = prefs.getString("deviceUID", null)
if (deviceUID != null) {
prefs.edit()
.putString("deviceUIDCopy", deviceUID)
.apply()
Log.d("MainApplication", "deviceUID changed, synced to deviceUIDCopy: $deviceUID")
}
}
}
}
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(CustomSegmentedControlPackage())
add(SettingsPackage())
},
useDevSupport = BuildConfig.DEBUG,
jsMainModulePath = "index",
)
}
override fun onCreate() {
super.onCreate()
sharedPref = getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE)
// Handle clearFilesOnLaunch before registering listeners
clearFilesIfNeeded()
sharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
// Register the theme change receiver
registerReceiver(themeChangeReceiver, IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED))
val sharedI18nUtilInstance = I18nUtil.getInstance()
sharedI18nUtilInstance.allowRTL(applicationContext, true)
loadReactNative(this)
initializeDeviceUID()
initializeBugsnag()
}
override fun onTerminate() {
super.onTerminate()
sharedPref.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
// Unregister the theme change receiver
try {
unregisterReceiver(themeChangeReceiver)
} catch (e: Exception) {
Log.e("MainApplication", "Error unregistering theme receiver", e)
}
}
private fun initializeBugsnag() {
val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0")
if (isDoNotTrackEnabled != "1") {
Bugsnag.start(this)
}
}
/**
* Initialize device UID similar to iOS implementation
* Uses the same Android ID as react-native-device-info's getUniqueId()
*/
private fun initializeDeviceUID() {
val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0") == "1"
if (isDoNotTrackEnabled) {
val currentCopy = sharedPref.getString("deviceUIDCopy", "")
if (currentCopy != "Disabled") {
sharedPref.edit()
.putString("deviceUIDCopy", "Disabled")
.apply()
Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'")
}
return
}
// Get the Android ID (same as react-native-device-info's getUniqueId())
val deviceUID = try {
android.provider.Settings.Secure.getString(
contentResolver,
android.provider.Settings.Secure.ANDROID_ID
) ?: "unknown"
} catch (e: Exception) {
Log.e("MainApplication", "Error getting Android ID", e)
"unknown"
}
// Store in deviceUID for consistency
sharedPref.edit()
.putString("deviceUID", deviceUID)
.apply()
// Copy deviceUID to deviceUIDCopy (for Settings compatibility)
val currentCopy = sharedPref.getString("deviceUIDCopy", "")
if (deviceUID != currentCopy) {
sharedPref.edit()
.putString("deviceUIDCopy", deviceUID)
.apply()
Log.d("MainApplication", "Synced deviceUID to deviceUIDCopy: $deviceUID")
}
}
/**
* Clear files if clearFilesOnLaunch is enabled
* Similar to iOS implementation
*/
private fun clearFilesIfNeeded() {
val shouldClear = sharedPref.getBoolean("clearFilesOnLaunch", false)
if (shouldClear) {
try {
// Clear cache directory
cacheDir?.let { clearDirectory(it) }
// Clear files directory
filesDir?.let { clearDirectory(it) }
// Clear external cache directory
externalCacheDir?.let { clearDirectory(it) }
// Reset the flag and set a flag to show alert
sharedPref.edit()
.putBoolean("clearFilesOnLaunch", false)
.putBoolean("shouldShowCacheClearedAlert", true)
.apply()
Log.d("MainApplication", "Cache and files cleared on launch")
} catch (e: Exception) {
Log.e("MainApplication", "Error clearing files", e)
}
}
}
/**
* Recursively clear all files in a directory
*/
private fun clearDirectory(dir: java.io.File) {
if (!dir.exists()) return
dir.listFiles()?.forEach { file ->
if (file.isDirectory) {
clearDirectory(file)
}
try {
file.delete()
Log.d("MainApplication", "Deleted: ${file.absolutePath}")
} catch (e: Exception) {
Log.e("MainApplication", "Error deleting file: ${file.absolutePath}", e)
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -23,7 +23,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
private val toggleGroup: MaterialButtonToggleGroup
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()
set(value) {
@ -55,6 +59,9 @@ class CustomSegmentedControl @JvmOverloads constructor(
if (newIndex != -1 && newIndex != currentSelectedIndex) {
currentSelectedIndex = newIndex
emitChangeEvent(newIndex)
if (momentaryProp) {
toggleGroup.clearChecked()
}
}
}
}
@ -79,6 +86,7 @@ class CustomSegmentedControl @JvmOverloads constructor(
isCheckable = true
strokeWidth = 2
applyEnabledState()
val cornerRadius = resources.getDimensionPixelSize(
com.google.android.material.R.dimen.mtrl_btn_corner_radius
@ -111,10 +119,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
val selectedBgColor = ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = ContextCompat.getColor(context, R.color.button_disabled_background_color)
val selectedTextColor = ContextCompat.getColor(context, R.color.button_text_color)
val unselectedTextColor = ContextCompat.getColor(context, R.color.button_disabled_text_color)
val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color)
val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color)
val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color)
val selectedTextColor = resolvedTextColor
val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color)
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
@ -155,9 +164,36 @@ class CustomSegmentedControl @JvmOverloads constructor(
button.setTextColor(textColorStateList)
button.strokeColor = strokeColorStateList
button.rippleColor = rippleColorStateList
button.isEnabled = isEnabledProp
}
}
fun setBackgroundColorProp(color: String?) {
backgroundColorProp = parseColor(color)
updateButtonColors()
}
fun setTintColorProp(color: String?) {
tintColorProp = parseColor(color)
updateButtonColors()
}
fun setTextColorProp(color: String?) {
textColorProp = parseColor(color)
updateButtonColors()
}
fun setMomentaryProp(momentary: Boolean) {
momentaryProp = momentary
toggleGroup.isSelectionRequired = !momentary
}
fun setEnabledProp(enabled: Boolean) {
isEnabledProp = enabled
toggleGroup.isEnabled = enabled
applyEnabledState()
}
private fun updateSelectedSegment() {
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
@ -196,18 +232,29 @@ class CustomSegmentedControl @JvmOverloads constructor(
eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event))
}
private fun applyEnabledState() {
for (i in 0 until toggleGroup.childCount) {
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
button.isEnabled = isEnabledProp
}
}
private fun parseColor(color: String?): Int? {
return try {
color?.let { Color.parseColor(it) }
} catch (_: IllegalArgumentException) {
null
}
}
private inner class ChangeEvent(
surfaceId: Int,
viewId: Int,
private val eventData: WritableMap
) : Event<ChangeEvent>(surfaceId, viewId) {
override fun getEventName(): String = "onChangeEvent"
override fun getEventName(): String = "topChange"
override fun getEventData(): WritableMap = eventData
}
fun setOnChangeEvent(callback: ((WritableMap) -> Unit)?) {
onChangeEvent = callback
}
}

View File

@ -5,12 +5,17 @@ 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
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.viewmanagers.CustomSegmentedControlManagerInterface
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>() {
@ReactModule(name = CustomSegmentedControlManager.REACT_CLASS)
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(),
CustomSegmentedControlManagerInterface<CustomSegmentedControl> {
companion object {
const val REACT_CLASS = "CustomSegmentedControl"
private const val ON_CHANGE_EVENT = "onChangeEvent"
private const val TOP_CHANGE = "topChange"
private const val REGISTRATION_NAME = "onChange"
}
override fun getName(): String = REACT_CLASS
@ -20,7 +25,7 @@ class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(
}
@ReactProp(name = "values")
fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
override fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
val valuesArray = values?.let { array ->
Array(array.size()) { index ->
array.getString(index) ?: ""
@ -31,13 +36,44 @@ class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(
}
@ReactProp(name = "selectedIndex", defaultInt = 0)
fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
override fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
view.selectedIndex = selectedIndex
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any>? {
@ReactProp(name = "backgroundColor")
override fun setBackgroundColor(view: CustomSegmentedControl, value: String?) {
view.setBackgroundColorProp(value)
}
@ReactProp(name = "tintColor")
override fun setTintColor(view: CustomSegmentedControl, value: String?) {
view.setTintColorProp(value)
}
@ReactProp(name = "textColor")
override fun setTextColor(view: CustomSegmentedControl, value: String?) {
view.setTextColorProp(value)
}
@ReactProp(name = "momentary", defaultBoolean = false)
override fun setMomentary(view: CustomSegmentedControl, value: Boolean) {
view.setMomentaryProp(value)
}
@ReactProp(name = "enabled", defaultBoolean = true)
override fun setEnabled(view: CustomSegmentedControl, value: Boolean) {
view.setEnabledProp(value)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any>? {
return MapBuilder.builder<String, Any>()
.put(ON_CHANGE_EVENT, MapBuilder.of("registrationName", ON_CHANGE_EVENT))
.put(
TOP_CHANGE,
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", REGISTRATION_NAME, "captured", "${REGISTRATION_NAME}Capture")
)
)
.build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -3,23 +3,22 @@
buildscript {
ext {
minSdkVersion = 24
buildToolsVersion = "35.0.0"
compileSdkVersion = 35
targetSdkVersion = 35
buildToolsVersion = "36.0.0"
compileSdkVersion = 36
targetSdkVersion = 36
googlePlayServicesVersion = "16.+"
googlePlayServicesIidVersion = "16.0.1"
firebaseVersion = "17.3.4"
firebaseMessagingVersion = "21.1.0"
firebaseVersion = "21.1.0"
ndkVersion = "27.1.12297006"
kotlin_version = '2.0.21'
kotlinVersion = '2.0.21'
kotlin_version = '2.1.20'
kotlinVersion = '2.1.20'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.android.tools.build:gradle:8.7.2")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
@ -27,6 +26,33 @@ buildscript {
}
}
// Gradle 9 removes jcenter(); add a shim that redirects any jcenter() call to mavenCentral()
def addJcenterShim = { repoContainer, logger ->
def mc = repoContainer.metaClass
if (!mc.respondsTo(repoContainer, 'jcenter')) {
mc.jcenter << { Closure config = null ->
def repo = repoContainer.mavenCentral()
if (config != null) {
config.delegate = repo
config.resolveStrategy = Closure.DELEGATE_FIRST
config.call(repo)
}
logger.lifecycle("Redirected jcenter() to mavenCentral()")
return repo
}
}
if (!mc.respondsTo(repoContainer, 'methodMissing')) {
mc.methodMissing = { String name, args ->
if (name == 'jcenter') {
def repo = repoContainer.mavenCentral()
logger.lifecycle("Redirected jcenter() (methodMissing) to mavenCentral()")
return repo
}
throw new MissingMethodException(name, repoContainer.class, args)
}
}
}
allprojects {
repositories {
maven {
@ -54,12 +80,50 @@ allprojects {
}
}
subprojects {
afterEvaluate {project ->
if (project.hasProperty("android")) {
// Apply jcenter shim very early for every project before its build.gradle is evaluated
gradle.beforeProject { project ->
addJcenterShim(project.repositories, project.logger)
if (project.buildscript != null) {
addJcenterShim(project.buildscript.repositories, project.logger)
}
}
// Apply to root as well
addJcenterShim(repositories, logger)
if (buildscript != null) {
addJcenterShim(buildscript.repositories, logger)
}
subprojects { project ->
// 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 {
buildToolsVersion "35.0.0"
compileSdkVersion 35
buildToolsVersion "36.0.0"
compileSdkVersion 36
defaultConfig {
minSdkVersion 24
}
@ -68,12 +132,30 @@ subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
if (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
} else {
kotlinOptions.jvmTarget = sourceCompatibility
}
}
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {
// Wire Reanimated's generated codegen sources so WorkletsModule spec is visible under new architecture
proj.android.sourceSets.main.java.srcDir("${proj.buildDir}/generated/source/codegen/java")
}
}
}
// Final guard: fail fast if any jcenter repository slips through
gradle.projectsEvaluated {
allprojects { proj ->
def offenders = proj.repositories.findAll { repo ->
repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')
}
if (!offenders.isEmpty()) {
throw new GradleException("jcenter() detected in ${proj.path}; remove or replace with mavenCentral()");
}
}
}

View File

@ -32,8 +32,16 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# 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

Binary file not shown.

View File

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

View File

@ -7,3 +7,29 @@ includeBuild('../node_modules/@react-native/gradle-plugin')
include ':detox'
project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox')
// Ensure any jcenter() repos declared by subprojects are removed before their build.gradle is evaluated
gradle.beforeProject { proj ->
def stripJcenter = { repoContainer ->
repoContainer.all { repo ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
proj.logger.lifecycle("Settings.gradle: removing jcenter() from ${proj.path}")
repoContainer.remove(repo)
}
}
repoContainer.whenObjectAdded { repo ->
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
repo.url?.toString()?.contains('jcenter')) {
proj.logger.lifecycle("Settings.gradle: blocking jcenter() from ${proj.path}")
repoContainer.remove(repo)
repoContainer.mavenCentral()
}
}
}
stripJcenter(proj.repositories)
if (proj.buildscript != null) {
stripJcenter(proj.buildscript.repositories)
}
}

View File

@ -1,4 +1,4 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/
plugins: ['react-native-worklets/plugin'],
};

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { NativeModules, Platform } from 'react-native';
import { Platform } from 'react-native';
import NativeSettingsModule from '../codegen/NativeSettingsModule';
interface SettingsModuleInterface {
/**
@ -46,6 +47,7 @@ interface SettingsModuleInterface {
}
// 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;

View File

@ -1,9 +1,13 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import PushNotificationIOS from '@react-native-community/push-notification-ios';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native';
import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info';
import {
Notification as RNNotification,
NotificationBackgroundFetchResult,
NotificationCompletion,
Notifications,
} from 'react-native-notifications';
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
import PushNotification, { ReceivedNotification } from 'react-native-push-notification';
import loc from '../loc';
import { groundControlUri } from './constants';
import { fetch } from '../util/fetch';
@ -11,37 +15,162 @@ import { fetch } from '../util/fetch';
const PUSH_TOKEN = 'PUSH_TOKEN';
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
let alreadyConfigured = false;
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 = {
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 = {
// inherited from `ReceivedNotification`:
subText?: string;
title?: string;
identifier?: string;
message?: string | object;
foreground: boolean;
userInteraction: boolean;
// hopefully stuffed in `data` and uwrapped when received:
address: string;
txid: string;
type: number;
hash: string;
[key: string]: any;
};
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
const createPushToken = (deviceToken: string): TPushToken => ({
token: deviceToken,
os: Platform.OS as TPushToken['os'],
});
const settlePendingRegistration = (value: boolean) => {
if (!pendingRegistrationResolve) return;
const resolve = pendingRegistrationResolve;
pendingRegistrationResolve = null;
pendingRegistrationPromise = null;
if (pendingRegistrationTimeout) {
clearTimeout(pendingRegistrationTimeout);
pendingRegistrationTimeout = undefined;
}
resolve(value);
};
const waitForRemoteRegistration = (timeoutMs = 10_000): Promise<boolean> => {
if (pendingRegistrationPromise) return pendingRegistrationPromise;
pendingRegistrationPromise = new Promise<boolean>(resolve => {
pendingRegistrationResolve = resolve;
pendingRegistrationTimeout = setTimeout(() => {
settlePendingRegistration(false);
}, timeoutMs);
});
Notifications.registerRemoteNotifications();
return pendingRegistrationPromise;
};
const ensureAndroidNotificationChannel = () => {
if (Platform.OS !== 'android') return;
Notifications.setNotificationChannel({
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
name: 'BlueWallet notifications',
description: 'Notifications about incoming payments',
importance: 4,
enableVibration: true,
showBadge: true,
});
};
const getNotificationKey = (payload: Partial<TPayload>, notification?: RNNotification) => {
return JSON.stringify({
identifier: notification?.identifier ?? payload.identifier ?? '',
type: payload.type ?? '',
hash: payload.hash ?? '',
txid: payload.txid ?? '',
address: payload.address ?? '',
message: payload.message ?? '',
});
};
const markNotificationHandled = (key: string) => {
handledNotificationKeys.add(key);
if (handledNotificationKeys.size > 100) {
const oldestKey = handledNotificationKeys.values().next().value;
if (oldestKey) handledNotificationKeys.delete(oldestKey);
}
};
const normalizeNotificationPayload = (notification: RNNotification, status: Pick<TPayload, 'foreground' | 'userInteraction'>): TPayload => {
const rawPayload =
notification.payload && typeof notification.payload === 'object' ? (deepClone(notification.payload) as Record<string, any>) : {};
const nestedPayload = rawPayload.data && typeof rawPayload.data === 'object' ? rawPayload.data : {};
const nestedData = nestedPayload.data && typeof nestedPayload.data === 'object' ? nestedPayload.data : {};
const payload: TPayload = {
...rawPayload,
...nestedPayload,
...nestedData,
title: notification.title ?? rawPayload.title,
subText: rawPayload.subText ?? rawPayload.subtitle ?? notification.title,
message: rawPayload.message ?? notification.body,
identifier: notification.identifier,
foreground: status.foreground,
userInteraction: status.userInteraction,
} as TPayload;
delete payload.data;
return payload;
};
const storeIncomingNotification = async (
notification: RNNotification,
status: Pick<TPayload, 'foreground' | 'userInteraction'>,
completion?: ((response: NotificationCompletion) => void) | ((response: NotificationBackgroundFetchResult) => void),
) => {
try {
const payload = normalizeNotificationPayload(notification, status);
const notificationKey = getNotificationKey(payload, notification);
if (handledNotificationKeys.has(notificationKey)) {
return;
}
markNotificationHandled(notificationKey);
if (!payload.subText && !payload.message) {
console.warn('Notification missing required fields:', payload);
return;
}
await addNotification(payload);
if (payload.foreground && onProcessNotificationsHandler) {
await onProcessNotificationsHandler();
}
} catch (error) {
console.error('Failed to store incoming notification:', error);
} finally {
if (completion) {
if (status.foreground) {
(completion as (response: NotificationCompletion) => void)({ alert: false, sound: false, badge: false });
} else {
(completion as (response: NotificationBackgroundFetchResult) => void)(NotificationBackgroundFetchResult.NO_DATA);
}
}
}
};
const checkAndroidNotificationPermission = async () => {
try {
const { status } = await checkNotifications();
console.debug('Notification permission check:', status);
console.log('Notification permission check:', status);
return status === RESULTS.GRANTED;
} catch (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.
* If we dont have a token it will show alert asking whether
* user wants to receive notifications, and if yes - will configure push notifications.
* FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask,
* we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box.
*
* @returns {Promise<boolean>} TRUE if permissions were obtained, FALSE otherwise
*/
/**
* Attempts to obtain permissions and configure notifications.
* Shows a rationale on Android if permissions are needed.
*
* @returns {Promise<boolean>}
*/
export const tryToObtainPermissions = async () => {
console.debug('tryToObtainPermissions: Starting user-triggered permission request');
export const tryToObtainPermissions = async (): Promise<boolean> => {
console.log('tryToObtainPermissions: Starting user-triggered permission request');
if (!isNotificationsCapable) {
console.debug('tryToObtainPermissions: Device not capable');
console.log('tryToObtainPermissions: Device not capable');
return false;
}
@ -122,7 +243,7 @@ export const tryToObtainPermissions = async () => {
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
);
if (status !== RESULTS.GRANTED) {
console.debug('tryToObtainPermissions: Permission denied');
console.log('tryToObtainPermissions: Permission denied');
return false;
}
return configureNotifications();
@ -141,7 +262,7 @@ export const tryToObtainPermissions = async () => {
* @returns {Promise<object>} Response object from API rest call
*/
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,
hashCount: hashes?.length,
txidCount: txids?.length,
@ -159,7 +280,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
const pushToken = await getPushToken();
console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken);
console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken);
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
@ -174,7 +295,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
let response;
try {
console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
method: 'POST',
headers: _getHeaders(),
@ -216,11 +337,18 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
*/
export const checkPermissions = async () => {
try {
return new Promise(function (resolve) {
PushNotification.checkPermissions((result: any) => {
resolve(result);
});
});
if (Platform.OS === 'ios') {
return Notifications.ios.checkPermissions();
}
const { status } = await checkNotifications();
const granted = status === RESULTS.GRANTED;
return {
alert: granted,
badge: granted,
sound: granted,
status,
};
} catch (error) {
console.error('Error checking permissions:', error);
throw error;
@ -255,12 +383,14 @@ export const setLevels = async (levelAll: boolean) => {
}
if (!levelAll) {
console.debug('Disabling notifications as user opted out...');
PushNotification.removeAllDeliveredNotifications();
PushNotification.setApplicationIconBadgeNumber(0);
PushNotification.cancelAllLocalNotifications();
console.log('Disabling notifications as user opted out...');
Notifications.removeAllDeliveredNotifications();
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(0);
Notifications.ios.cancelAllLocalNotifications();
}
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
console.debug('Notifications disabled successfully');
console.log('Notifications disabled successfully');
} else {
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
}
@ -286,19 +416,19 @@ export const addNotification = async (notification: TPayload) => {
};
const postTokenConfig = async () => {
console.debug('postTokenConfig: Starting token configuration');
console.log('postTokenConfig: Starting token configuration');
const pushToken = await getPushToken();
console.debug('postTokenConfig: Retrieved push token:', !!pushToken);
console.log('postTokenConfig: Retrieved push token:', !!pushToken);
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;
}
try {
const lang = (await AsyncStorage.getItem('lang')) || 'en';
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
console.debug('postTokenConfig: Posting configuration', { lang, appVersion });
console.log('postTokenConfig: Posting configuration', { lang, appVersion });
await fetch(`${baseURI}/setTokenConfiguration`, {
method: 'POST',
@ -329,87 +459,74 @@ const _setPushToken = async (token: TPushToken) => {
/**
* 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) => {
if (alreadyConfigured) {
console.debug('configureNotifications: Already configured, skipping');
return true;
const configureNotifications = async (onProcessNotifications?: () => void): Promise<boolean> => {
console.log('configureNotifications()');
if (onProcessNotifications) {
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);
};
try {
const { status } = await checkNotifications();
if (status !== RESULTS.GRANTED) {
console.log('configureNotifications: Permissions not granted');
return false;
}
// 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,
});
ensureAndroidNotificationChannel();
if (notification.data?.data) {
const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null));
Object.assign(payload, validData);
}
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();
}
}),
];
}
// @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);
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));
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();
});
// 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;
}
};
/**
@ -528,9 +645,15 @@ export const clearStoredNotifications = async () => {
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
try {
return new Promise(resolve => {
PushNotification.getDeliveredNotifications((notifications: Record<string, any>[]) => resolve(notifications));
});
if (Platform.OS !== 'ios') {
return Promise.resolve([]);
}
return Notifications.ios
.getDeliveredNotifications()
.then(notifications =>
notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })),
);
} catch (error) {
console.error('Error getting delivered notifications:', error);
throw error;
@ -538,15 +661,19 @@ export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = (
};
export const removeDeliveredNotifications = (identifiers = []) => {
PushNotification.removeDeliveredNotifications(identifiers);
if (Platform.OS === 'ios') {
Notifications.ios.removeDeliveredNotifications(identifiers);
}
};
export const setApplicationIconBadgeNumber = (badges: number) => {
PushNotification.setApplicationIconBadgeNumber(badges);
if (Platform.OS === 'ios') {
Notifications.ios.setBadgeCount(badges);
}
};
export const removeAllDeliveredNotifications = () => {
PushNotification.removeAllDeliveredNotifications();
Notifications.removeAllDeliveredNotifications();
};
export const getDefaultUri = () => {
@ -619,10 +746,11 @@ export const getStoredNotifications = async (): Promise<TPayload[]> => {
// on app launch (load module):
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
console.debug('initializeNotifications: Starting initialization');
console.log('initializeNotifications: Starting initialization');
try {
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') {
console.warn('User has opted out of notifications.');
@ -631,13 +759,13 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
baseURI = baseUriStored || groundControlUri;
console.debug('Base URI set to:', baseURI);
console.log('Base URI set to:', baseURI);
setApplicationIconBadgeNumber(0);
// Only check permissions, never request
currentPermissionStatus = await checkNotificationPermissionStatus();
console.debug('initializeNotifications: Permission status:', currentPermissionStatus);
console.log('initializeNotifications: Permission status:', currentPermissionStatus);
// Handle Android 13+ permissions differently
const canProceed =
@ -646,19 +774,10 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
: currentPermissionStatus === 'granted';
if (canProceed) {
console.debug('initializeNotifications: Can proceed with notification setup');
const token = await getPushToken();
if (token) {
console.debug('initializeNotifications: Existing token found, configuring');
await configureNotifications(onProcessNotifications);
await postTokenConfig();
} else {
console.debug('initializeNotifications: No token found, will request permissions');
await tryToObtainPermissions();
}
console.log('initializeNotifications: Can proceed with notification setup');
await configureNotifications(onProcessNotifications);
} else {
console.debug('Notifications require user action to enable');
console.log('Notifications require user action to enable');
}
} catch (error) {
console.error('Failed to initialize notifications:', error);

4
blue_modules/pako/dist/pako.esm.mjs vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View 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;

View 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;

View 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;

View File

@ -0,0 +1,16 @@
import type { ViewProps } from 'react-native';
import type { BubblingEventHandler, Int32 } from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
export interface NativeProps extends ViewProps {
values: string[];
selectedIndex: Int32;
enabled: boolean;
backgroundColor: string;
tintColor: string;
textColor: string;
momentary: boolean;
onChange?: BubblingEventHandler<Readonly<{ selectedIndex: Int32 }>>;
}
export default codegenNativeComponent<NativeProps>('CustomSegmentedControl');

View File

@ -16,8 +16,8 @@ const styles = StyleSheet.create({
width: 30,
height: 30,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center',
justifyContent: 'center',
},
pressed: {
opacity: 0.6,

View File

@ -127,17 +127,18 @@ export const AddressInputScanButton = ({
[onChangeText],
);
const buttonStyle = useMemo(() => [styles.scan, stylesHook.scan], [stylesHook.scan]);
const menuButtonStyle = useMemo(() => (type === 'default' ? [styles.scan, stylesHook.scan] : undefined), [stylesHook.scan, type]);
return (
<ToolTipMenu
actions={actions}
isButton
onPressMenuItem={onMenuItemPressed}
testID={testID}
shouldOpenOnLongPress
disabled={isLoading}
onPress={toolTipOnPress}
buttonStyle={type === 'default' ? buttonStyle : undefined}
testID={type === 'default' ? testID : undefined}
buttonStyle={menuButtonStyle}
accessibilityLabel={loc.send.details_scan}
accessibilityHint={loc.send.details_scan_hint}
>
@ -149,7 +150,11 @@ export const AddressInputScanButton = ({
</Text>
</View>
) : (
<Text style={[styles.linkText, { color: colors.foregroundColor }]}>{loc.wallets.import_scan_qr}</Text>
<View testID={testID} style={styles.contentRow}>
<Text style={[styles.linkText, { color: colors.foregroundColor }]} numberOfLines={1} ellipsizeMode="tail">
{loc.wallets.import_scan_qr}
</Text>
</View>
)}
</ToolTipMenu>
);
@ -164,22 +169,35 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 6,
minWidth: 82,
flexShrink: 0,
borderRadius: 4,
paddingVertical: 4,
paddingHorizontal: 8,
marginHorizontal: 4,
alignSelf: 'center',
},
scanText: {
marginLeft: 4,
flexShrink: 0,
textAlignVertical: 'center',
},
scanContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 1,
flexShrink: 0,
},
linkText: {
textAlign: 'center',
fontSize: 16,
flexShrink: 1,
textAlignVertical: 'center',
},
contentRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
},
});

View File

@ -1,272 +0,0 @@
import React, { forwardRef, useImperativeHandle, useRef, ReactElement, ComponentType } from 'react';
import { SheetSize, SizeChangeEvent, TrueSheet, TrueSheetProps } from '@lodev09/react-native-true-sheet';
import { Keyboard, Image, StyleSheet, View, Pressable, Platform, GestureResponderEvent, Text } from 'react-native';
import SaveFileButton from './SaveFileButton';
import { useTheme } from './themes';
import Icon from './Icon';
interface BottomModalProps extends TrueSheetProps {
children?: React.ReactNode;
onClose?: () => void;
onCloseModalPressed?: () => Promise<void>;
isGrabberVisible?: boolean;
sizes?: SheetSize[] | undefined;
footer?: ReactElement | ComponentType<any> | null;
footerDefaultMargins?: boolean | number;
onPresent?: () => void;
onSizeChange?: (event: SizeChangeEvent) => void;
showCloseButton?: boolean;
shareContent?: BottomModalShareContent;
shareButtonOnPress?: (event: GestureResponderEvent) => void;
header?: ReactElement | ComponentType<any> | null;
headerTitle?: string;
headerSubtitle?: string;
}
type BottomModalShareContent = {
fileName: string;
fileContent: string;
};
export interface BottomModalHandle {
present: () => Promise<void>;
dismiss: () => Promise<void>;
}
const BottomModal = forwardRef<BottomModalHandle, BottomModalProps>(
(
{
onClose,
onCloseModalPressed,
onPresent,
onSizeChange,
showCloseButton = true,
isGrabberVisible = true,
shareContent,
shareButtonOnPress,
sizes = ['auto'],
footer,
footerDefaultMargins,
header,
headerTitle,
headerSubtitle,
children,
...props
},
ref,
) => {
const trueSheetRef = useRef<TrueSheet>(null);
const { colors, closeImage } = useTheme();
const stylesHook = StyleSheet.create({
barButton: {
backgroundColor: colors.lightButton,
},
headerTitle: {
color: colors.foregroundColor,
},
});
useImperativeHandle(ref, () => ({
present: async () => {
Keyboard.dismiss();
if (trueSheetRef.current?.present) {
await trueSheetRef.current.present();
} else {
return Promise.resolve();
}
},
dismiss: async () => {
Keyboard.dismiss();
if (trueSheetRef.current?.dismiss) {
await trueSheetRef.current.dismiss();
} else {
return Promise.resolve();
}
},
}));
const dismiss = async () => {
try {
await onCloseModalPressed?.();
await trueSheetRef.current?.dismiss();
} catch (error) {
console.error('Error during dismiss:', error);
}
};
const renderTopRightButton = () => {
const buttons = [];
if (shareContent || shareButtonOnPress) {
if (shareContent) {
buttons.push(
<SaveFileButton
style={[styles.topRightButton, stylesHook.barButton]}
fileContent={shareContent.fileContent}
fileName={shareContent.fileName}
testID="ModalShareButton"
key="ModalShareButton"
>
<Icon
name={Platform.OS === 'android' ? 'share' : 'file-arrow-up'}
type="font-awesome-6"
size={20}
color={colors.buttonTextColor}
/>
</SaveFileButton>,
);
} else if (shareButtonOnPress) {
buttons.push(
<Pressable
testID="ModalShareButton"
key="ModalShareButton"
style={({ pressed }) => [pressed && styles.pressed, styles.topRightButton, stylesHook.barButton]}
onPress={shareButtonOnPress}
>
<Icon
name={Platform.OS === 'android' ? 'share' : 'file-arrow-up'}
type="font-awesome-6"
size={20}
color={colors.buttonTextColor}
/>
</Pressable>,
);
}
}
if (showCloseButton) {
buttons.push(
<Pressable
style={({ pressed }) => [pressed && styles.pressed, styles.topRightButton, stylesHook.barButton]}
onPress={dismiss}
key="ModalDoneButton"
testID="ModalDoneButton"
>
<Image source={closeImage} />
</Pressable>,
);
}
return <View style={styles.topRightButtonContainer}>{buttons}</View>;
};
const renderHeader = () => {
if (headerTitle || headerSubtitle) {
return (
<View style={styles.headerContainer}>
<View style={styles.headerContent}>
{headerTitle && <Text style={[styles.headerTitle, stylesHook.headerTitle]}>{headerTitle}</Text>}
{headerSubtitle && <Text style={[styles.headerSubtitle, stylesHook.headerTitle]}>{headerSubtitle}</Text>}
</View>
{renderTopRightButton()}
</View>
);
}
if (showCloseButton || shareContent)
return (
<View style={styles.headerContainer}>
<View style={styles.headerContent}>{typeof header === 'function' ? <header /> : header}</View>
{renderTopRightButton()}
</View>
);
if (React.isValidElement(header)) {
return (
<View style={styles.headerContainerWithCloseButton}>
{header}
{renderTopRightButton()}
</View>
);
}
return null;
};
const renderFooter = (): ReactElement | undefined => {
if (React.isValidElement(footer)) {
return footerDefaultMargins ? <View style={styles.footerContainer}>{footer}</View> : footer;
} else if (typeof footer === 'function') {
const ModalFooterComponent = footer as ComponentType<any>;
return <ModalFooterComponent />;
}
return undefined;
};
const FooterComponent = renderFooter();
return (
<TrueSheet
ref={trueSheetRef}
sizes={sizes}
onDismiss={onClose}
onPresent={onPresent}
onSizeChange={onSizeChange}
grabber={isGrabberVisible}
FooterComponent={FooterComponent as ReactElement}
{...props}
>
<View style={styles.childrenContainer}>{children}</View>
{renderHeader()}
</TrueSheet>
);
},
);
export default BottomModal;
const styles = StyleSheet.create({
footerContainer: {
alignItems: 'center',
justifyContent: 'center',
},
headerContainer: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
minHeight: 22,
right: 16,
top: 16,
},
headerContainerWithCloseButton: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
minHeight: 22,
width: '100%',
top: 16,
left: 0,
justifyContent: 'space-between',
},
headerContent: {
flex: 1,
justifyContent: 'center',
minHeight: 22,
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
},
headerSubtitle: {
fontSize: 14,
},
topRightButton: {
justifyContent: 'center',
alignItems: 'center',
width: 30,
height: 30,
borderRadius: 15,
marginLeft: 22,
},
topRightButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
},
childrenContainer: {
paddingTop: 66,
paddingHorizontal: 16,
width: '100%',
},
pressed: {
opacity: 0.6,
},
});

View File

@ -1,5 +1,5 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { InteractionManager, LayoutAnimation } from 'react-native';
import { LayoutAnimation } from 'react-native';
import { BlueApp as BlueAppClass, LegacyWallet, TCounterpartyMetadata, TTXMetadata, WatchOnlyWallet } from '../../class';
import type { TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
@ -155,13 +155,11 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
console.debug('Not saving empty wallets array');
return;
}
await InteractionManager.runAfterInteractions(async () => {
BlueApp.tx_metadata = txMetadata.current;
BlueApp.counterparty_metadata = counterpartyMetadata.current;
await BlueApp.saveToDisk();
const w: TWallet[] = [...BlueApp.getWallets()];
setWallets(w);
});
BlueApp.tx_metadata = txMetadata.current;
BlueApp.counterparty_metadata = counterpartyMetadata.current;
await BlueApp.saveToDisk();
const w: TWallet[] = [...BlueApp.getWallets()];
setWallets(w);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[txMetadata.current, counterpartyMetadata.current],
@ -324,8 +322,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
console.debug('[refreshAllWalletTransactions] Starting refresh');
refreshingRef.current = true;
await new Promise<void>(resolve => InteractionManager.runAfterInteractions(() => resolve()));
const TIMEOUT_DURATION = 30000;
let refreshTimeout;
const timeoutPromise = new Promise<never>(
@ -398,36 +394,34 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
const fetchAndSaveWalletTransactions = useCallback(
async (walletID: string) => {
await InteractionManager.runAfterInteractions(async () => {
const index = wallets.findIndex(wallet => wallet.getID() === walletID);
let noErr = true;
try {
if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) {
console.debug('[fetchAndSaveWalletTransactions] Re-fetch wallet happens too fast; NOP');
return;
}
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
await BlueElectrum.waitTillConnected();
setWalletTransactionUpdateStatus(walletID);
const balanceStart = Date.now();
await BlueApp.fetchWalletBalances(index);
const balanceEnd = Date.now();
console.debug('[fetchAndSaveWalletTransactions] fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec');
const txStart = Date.now();
await BlueApp.fetchWalletTransactions(index);
const txEnd = Date.now();
console.debug('[fetchAndSaveWalletTransactions] fetch tx took', (txEnd - txStart) / 1000, 'sec');
} catch (err) {
noErr = false;
console.error('[fetchAndSaveWalletTransactions] Error:', err);
} finally {
setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE);
const index = wallets.findIndex(wallet => wallet.getID() === walletID);
let noErr = true;
try {
if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) {
console.debug('[fetchAndSaveWalletTransactions] Re-fetch wallet happens too fast; NOP');
return;
}
if (noErr) await saveToDisk();
});
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
await BlueElectrum.waitTillConnected();
setWalletTransactionUpdateStatus(walletID);
const balanceStart = Date.now();
await BlueApp.fetchWalletBalances(index);
const balanceEnd = Date.now();
console.debug('[fetchAndSaveWalletTransactions] fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec');
const txStart = Date.now();
await BlueApp.fetchWalletTransactions(index);
const txEnd = Date.now();
console.debug('[fetchAndSaveWalletTransactions] fetch tx took', (txEnd - txStart) / 1000, 'sec');
} catch (err) {
noErr = false;
console.error('[fetchAndSaveWalletTransactions] Error:', err);
} finally {
setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE);
}
if (noErr) await saveToDisk();
},
[saveToDisk, wallets],
);

View File

@ -41,6 +41,10 @@ export class DynamicQRCode extends Component<DynamicQRCodeProps, DynamicQRCodeSt
fragments: string[] = [];
componentWillUnmount() {
this.stopAutoMove();
}
componentDidMount() {
const { value, capacity = 175, hideControls = true, walletID } = this.props;
try {

View File

@ -417,12 +417,15 @@ const ButtonContent = ({ icon, text, textStyle, iconStyle }: ButtonContentProps)
if (React.isValidElement(icon)) {
const iconElement = icon as React.ReactElement;
scaledIcon = React.cloneElement(iconElement, {
...iconElement.props,
size: iconSize,
width: iconSize,
height: iconSize,
});
scaledIcon = React.cloneElement(
iconElement as React.ReactElement<any>,
{
...(iconElement.props as Record<string, unknown>),
size: iconSize,
width: iconSize,
height: iconSize,
} as any,
);
} else {
scaledIcon = icon;
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Pressable, Platform } from 'react-native';
import { Pressable, Platform, StyleSheet } from 'react-native';
import ToolTipMenu from './TooltipMenu';
import { useTheme } from './themes';
import Icon from './Icon';
@ -22,7 +22,8 @@ const HeaderMenuButton: React.FC<HeaderMenuButtonProps> = ({ onPressMenuItem, ac
testID="HeaderMenuButton"
disabled={disabled}
android_ripple={{ color: colors.lightButton }}
style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }]}
hitSlop={8}
style={({ pressed }) => [styles.buttonCenter, pressed && styles.pressed]}
>
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} {...styleProps} />
</Pressable>
@ -36,14 +37,27 @@ const HeaderMenuButton: React.FC<HeaderMenuButtonProps> = ({ onPressMenuItem, ac
testID="HeaderMenuButton"
disabled={disabled}
isButton
isMenuPrimaryAction
shouldOpenOnLongPress={false}
onPressMenuItem={onPressMenuItem}
actions={menuActions}
title={title}
buttonStyle={styles.buttonCenter}
>
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} {...styleProps} />
</ToolTipMenu>
);
};
const styles = StyleSheet.create({
buttonCenter: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
minWidth: 44,
minHeight: 44,
},
pressed: { opacity: 0.5 },
});
export default HeaderMenuButton;

View File

@ -30,7 +30,10 @@ const styles = StyleSheet.create({
save: {
alignItems: 'center',
justifyContent: 'center',
width: 80,
alignSelf: 'center',
flexDirection: 'row',
minWidth: 80,
paddingHorizontal: 12,
borderRadius: 8,
height: 34,
},

View File

@ -15,6 +15,7 @@ export type MaterialDesignIconName = React.ComponentProps<typeof MaterialDesignI
export type EntypoIconName = React.ComponentProps<typeof Entypo>['name'];
type IconType = 'font-awesome' | 'font-awesome-6' | 'ionicons' | 'material' | 'material-community' | 'entypo';
type IconComponentType = React.ComponentType<any>;
type IconNameFor<T extends IconType> = T extends 'font-awesome'
? FontAwesomeIconName
@ -46,22 +47,13 @@ export interface IconProps<T extends IconType = IconType> {
testID?: string;
}
const resolveIconComponent = (type?: IconType): React.ComponentType<any> => {
switch (type) {
case 'font-awesome-6':
return FontAwesome6;
case 'ionicons':
return Ionicons;
case 'material':
return MaterialIcons;
case 'material-community':
return MaterialDesignIcons;
case 'entypo':
return Entypo;
case 'font-awesome':
default:
return FontAwesome;
}
const ICON_COMPONENTS: Record<IconType, IconComponentType> = {
'font-awesome': FontAwesome,
'font-awesome-6': FontAwesome6,
ionicons: Ionicons,
material: MaterialIcons,
'material-community': MaterialDesignIcons,
entypo: Entypo,
};
const Icon = <T extends IconType = 'font-awesome'>({
@ -76,7 +68,7 @@ const Icon = <T extends IconType = 'font-awesome'>({
accessibilityLabel,
testID,
}: IconProps<T>): React.ReactElement | null => {
const IconComponent = resolveIconComponent(type);
const IconComponent = ICON_COMPONENTS[type ?? 'font-awesome'] as React.ComponentType<any>;
const isFa6 = type === 'font-awesome-6';
const fa6IconStyle = isFa6 ? (typeof iconStyle === 'string' ? iconStyle : 'solid') : undefined;
const mergedStyle = isFa6 ? style : [style, iconStyle];

View File

@ -23,6 +23,7 @@ interface ListItemProps {
rightSubtitleStyle?: StyleProp<TextStyle>;
chevron?: boolean;
checkmark?: boolean;
isLoading?: boolean;
}
const ListItem: React.FC<ListItemProps> = React.memo(
@ -44,6 +45,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
rightSubtitleStyle,
chevron,
checkmark,
isLoading,
}: ListItemProps) => {
const { colors } = useTheme();
const { direction } = useLocale();

View File

@ -40,7 +40,7 @@ interface ManageWalletsListItemProps {
state: { wallets: TWallet[]; searchQuery: string; isSearchFocused?: boolean };
navigateToWallet: (wallet: TWallet) => void;
navigateToAddress?: (address: string, walletID: string) => void;
renderHighlightedText: (text: string, query: string) => JSX.Element;
renderHighlightedText: (text: string, query: string) => React.ReactElement;
handleToggleHideBalance: (wallet: TWallet) => void;
isActive?: boolean;
style?: ViewStyle;
@ -89,31 +89,24 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
const resetFunctionRef = useRef<(() => void) | null>(null);
const swipeableRef = useRef<Swipeable | null>(null);
const CARD_SORT_ACTIVE = 1.06;
const INACTIVE_SCALE_WHEN_ACTIVE = 0.9;
const SCALE_DURATION = 200;
const CARD_SORT_ACTIVE = 1.0;
const scaleValue = useRef(new Animated.Value(1)).current;
const handleOpacity = useRef(new Animated.Value(1)).current;
const prevIsActive = useRef(isActive);
const DEFAULT_VERTICAL_MARGIN = -10;
const REDUCED_VERTICAL_MARGIN = -50;
const DEFAULT_VERTICAL_MARGIN = 0;
const searchLocked = state.searchQuery.length > 0 || state.isSearchFocused === true;
const swipeDisabled = item.type === ItemType.WalletSection ? isActive || globalDragActive || searchLocked : true;
const hideHandle = item.type === ItemType.WalletSection ? swipeDisabled || isDraggingDisabled : true;
const animateItemIn = useCallback(() => {
if (Platform.OS === 'ios') {
Animated.spring(scaleValue, {
toValue: isActive ? CARD_SORT_ACTIVE : globalDragActive ? INACTIVE_SCALE_WHEN_ACTIVE : 1,
friction: 8,
tension: 40,
useNativeDriver: true,
}).start();
} else {
Animated.timing(scaleValue, {
toValue: isActive ? CARD_SORT_ACTIVE : globalDragActive ? INACTIVE_SCALE_WHEN_ACTIVE : 1,
duration: SCALE_DURATION,
useNativeDriver: true,
}).start();
}
}, [isActive, globalDragActive, scaleValue, CARD_SORT_ACTIVE, INACTIVE_SCALE_WHEN_ACTIVE, SCALE_DURATION]);
Animated.spring(scaleValue, {
toValue: isActive ? CARD_SORT_ACTIVE : 1,
friction: 7,
tension: 80,
useNativeDriver: true,
}).start();
}, [isActive, scaleValue, CARD_SORT_ACTIVE]);
useEffect(() => {
if (isActive !== prevIsActive.current) {
@ -124,6 +117,14 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
animateItemIn();
}, [isActive, globalDragActive, animateItemIn]);
useEffect(() => {
Animated.timing(handleOpacity, {
toValue: hideHandle ? 0 : 1,
duration: 140,
useNativeDriver: true,
}).start();
}, [hideHandle, handleOpacity]);
const onPress = useCallback(() => {
if (item.type === ItemType.WalletSection) {
setIsLoading(true);
@ -153,12 +154,11 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
resetFunctionRef.current();
}
scaleValue.setValue(CARD_SORT_ACTIVE);
triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium);
if (drag) {
drag();
}
}, [CARD_SORT_ACTIVE, drag, scaleValue, isSwipeActive]);
}, [drag, isSwipeActive]);
if (isLoading) {
return <ActivityIndicator size="large" color={colors.brandingColor} />;
@ -166,15 +166,13 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
if (item.type === ItemType.WalletSection) {
const animatedStyle = {
marginVertical: DEFAULT_VERTICAL_MARGIN,
minHeight: 110,
paddingHorizontal: 6,
transform: [{ scale: scaleValue }],
marginVertical: globalDragActive && !isActive ? REDUCED_VERTICAL_MARGIN : DEFAULT_VERTICAL_MARGIN,
};
const backgroundColor = isActive || globalDragActive ? colors.brandingColor : colors.background;
// Disable swiping only when search bar is focused or during active dragging
const swipeDisabled = isActive || globalDragActive || state.isSearchFocused === true;
const backgroundColor = isActive ? colors.brandingColor : colors.background;
const content = (
<View style={[style, { backgroundColor }, swipeDisabled ? styles.transparentBackground : {}]}>
<WalletCarouselItem
@ -293,7 +291,7 @@ interface WalletGroupProps {
state: { wallets: TWallet[]; searchQuery: string };
navigateToWallet: (wallet: TWallet) => void;
navigateToAddress?: (address: string, walletID: string) => void;
renderHighlightedText: (text: string, query: string) => JSX.Element;
renderHighlightedText: (text: string, query: string) => React.ReactElement;
isSearching: boolean;
}
@ -323,8 +321,8 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
const primaryColor = walletGradientColors[0];
const containerStyle: ViewStyle = {
marginHorizontal: 10,
marginVertical: 16,
marginHorizontal: 8,
marginVertical: 10,
borderRadius: 10,
overflow: 'hidden' as const,
backgroundColor: colors.elevated,
@ -398,6 +396,7 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
isPlaceHolder={false}
renderHighlightedText={renderHighlightedText}
customStyle={styles.carouselItem}
sizeVariant="compact"
/>
</View>
@ -486,9 +485,12 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
carouselItem: {
width: '100%',
paddingHorizontal: 12,
paddingVertical: 8,
flex: 1,
flexBasis: 0,
flexShrink: 1,
maxWidth: '100%',
alignSelf: 'stretch',
overflow: 'hidden',
},
transparentBackground: {
backgroundColor: 'transparent',

View File

@ -1,451 +0,0 @@
import React, { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
import { View, Text, TextInput, StyleSheet, Animated, Easing, ViewStyle, Keyboard, Platform, UIManager } from 'react-native';
import BottomModal, { BottomModalHandle } from './BottomModal';
import { useTheme } from '../components/themes';
import loc from '../loc';
import { SecondButton } from './SecondButton';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import { useKeyboard } from '../hooks/useKeyboard';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
export const MODAL_TYPES = {
ENTER_PASSWORD: 'ENTER_PASSWORD',
CREATE_PASSWORD: 'CREATE_PASSWORD',
CREATE_FAKE_STORAGE: 'CREATE_FAKE_STORAGE',
SUCCESS: 'SUCCESS',
} as const;
export type ModalType = (typeof MODAL_TYPES)[keyof typeof MODAL_TYPES];
interface PromptPasswordConfirmationModalProps {
modalType: ModalType;
onConfirmationSuccess: (password: string) => Promise<boolean>;
onConfirmationFailure: () => void;
}
export interface PromptPasswordConfirmationModalHandle {
present: () => Promise<void>;
dismiss: () => Promise<void>;
}
const PromptPasswordConfirmationModal = forwardRef<PromptPasswordConfirmationModalHandle, PromptPasswordConfirmationModalProps>(
({ modalType, onConfirmationSuccess, onConfirmationFailure }, ref) => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [showExplanation, setShowExplanation] = useState(false); // State to toggle between explanation and password input for CREATE_PASSWORD and CREATE_FAKE_STORAGE
const modalRef = useRef<BottomModalHandle>(null);
const fadeOutAnimation = useRef(new Animated.Value(1)).current;
const fadeInAnimation = useRef(new Animated.Value(0)).current;
const scaleAnimation = useRef(new Animated.Value(1)).current;
const shakeAnimation = useRef(new Animated.Value(0)).current;
const explanationOpacity = useRef(new Animated.Value(1)).current;
const { colors } = useTheme();
const passwordInputRef = useRef<TextInput>(null);
const confirmPasswordInputRef = useRef<TextInput>(null);
const { isVisible } = useKeyboard();
const stylesHook = StyleSheet.create({
modalContent: {
backgroundColor: colors.elevated,
width: '100%',
},
input: {
backgroundColor: colors.inputBackgroundColor,
borderColor: colors.formBorder,
color: colors.foregroundColor,
width: '100%',
},
feeModalCustomText: {
color: colors.buttonAlternativeTextColor,
},
feeModalLabel: {
color: colors.successColor,
},
});
useImperativeHandle(ref, () => ({
present: async () => {
resetState();
modalRef.current?.present();
if (modalType === MODAL_TYPES.CREATE_PASSWORD || (modalType === MODAL_TYPES.CREATE_FAKE_STORAGE && !showExplanation)) {
passwordInputRef.current?.focus();
} else if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
passwordInputRef.current?.focus();
}
},
dismiss: async () => {
await modalRef.current?.dismiss();
resetState();
},
}));
const resetState = () => {
setPassword('');
setConfirmPassword('');
setIsSuccess(false);
setIsLoading(false);
fadeOutAnimation.setValue(1);
fadeInAnimation.setValue(0);
scaleAnimation.setValue(1);
shakeAnimation.setValue(0);
explanationOpacity.setValue(1);
setShowExplanation(modalType === MODAL_TYPES.CREATE_PASSWORD);
};
useEffect(() => {
resetState();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalType]);
const performShake = (shakeAnimRef: Animated.Value) => {
Animated.sequence([
Animated.timing(shakeAnimRef, {
toValue: 10,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: -10,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: 5,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: -5,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: 0,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]).start();
};
const handleShakeAnimation = () => {
performShake(shakeAnimation);
};
const handleSuccessAnimation = () => {
// Step 1: Cross-fade current content out and success content in
Animated.timing(fadeOutAnimation, {
toValue: 0, // Fade out current content
duration: 300,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}).start(() => {
setIsSuccess(true);
Animated.timing(fadeInAnimation, {
toValue: 1, // Fade in success content
duration: 300,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}).start(() => {
// Step 2: Perform any additional animations like scaling if necessary
Animated.timing(scaleAnimation, {
toValue: 1.1,
duration: 300,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}).start(() => {
Animated.timing(scaleAnimation, {
toValue: 1, // Return scale to normal size
duration: 300,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}).start(() => {
// Optional delay before dismissing the modal
setTimeout(async () => {
await modalRef.current?.dismiss();
}, 1000);
});
});
});
});
};
const handleConfirmationFailure = () => {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
if (!isSuccess) handleShakeAnimation();
onConfirmationFailure();
};
const handleConfirmSuccess = () => {
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
handleSuccessAnimation();
};
const handleSubmit = async () => {
Keyboard.dismiss();
setIsLoading(true);
let success = false;
try {
if (modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) {
if (password === confirmPassword && password) {
success = await onConfirmationSuccess(password);
success ? handleConfirmSuccess() : handleConfirmationFailure();
} else {
handleConfirmationFailure();
}
} else if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
success = await onConfirmationSuccess(password);
success ? handleConfirmSuccess() : handleConfirmationFailure();
}
} finally {
setIsLoading(false); // Ensure loading state is reset
if (success) {
// Ensure shake animation is reset before starting the success animation
shakeAnimation.setValue(0);
}
}
};
const handleTransitionToCreatePassword = () => {
Animated.timing(explanationOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(() => {
setShowExplanation(false);
explanationOpacity.setValue(1); // Reset opacity for when transitioning back
passwordInputRef.current?.focus();
});
};
const handleCancel = async () => {
onConfirmationFailure();
await modalRef.current?.dismiss();
};
const animatedViewStyle: Animated.WithAnimatedObject<ViewStyle> = {
opacity: fadeOutAnimation,
transform: [{ scale: scaleAnimation }],
width: '100%',
};
const onModalDismiss = () => {
resetState();
onConfirmationFailure();
};
const opacity = isVisible ? 0 : 1;
return (
<BottomModal
ref={modalRef}
onClose={onModalDismiss}
grabber={false}
showCloseButton={!isSuccess}
onCloseModalPressed={handleCancel}
backgroundColor={colors.modal}
isGrabberVisible={!isSuccess}
dismissible={false}
sizes={Platform.OS === 'ios' ? ['auto'] : [460, '90%']}
footer={
!isSuccess ? (
showExplanation && modalType === MODAL_TYPES.CREATE_PASSWORD ? (
<Animated.View style={[{ opacity: explanationOpacity }, styles.feeModalFooterSpacing]}>
<SecondButton
title={loc.settings.i_understand}
onPress={handleTransitionToCreatePassword}
disabled={isLoading}
testID="IUnderstandButton"
/>
</Animated.View>
) : (
<Animated.View
style={[
{ opacity: isVisible ? opacity : fadeOutAnimation, transform: [{ scale: scaleAnimation }] },
styles.feeModalFooterSpacing,
]}
>
<SecondButton
title={isLoading ? '' : loc._.ok}
onPress={handleSubmit}
testID="OKButton"
loading={isLoading}
disabled={isLoading || !password || (modalType === MODAL_TYPES.CREATE_PASSWORD && !confirmPassword)}
/>
</Animated.View>
)
) : null
}
>
{!isSuccess && (
<Animated.View style={[animatedViewStyle, styles.minHeight]}>
{modalType === MODAL_TYPES.CREATE_PASSWORD && showExplanation && (
<Animated.View style={{ opacity: explanationOpacity }}>
<Text style={[styles.textLabel, stylesHook.feeModalLabel]}>{loc.settings.encrypt_storage_explanation_headline}</Text>
<Animated.View>
<Text style={[styles.description, stylesHook.feeModalCustomText]} maxFontSizeMultiplier={1.2}>
{loc.settings.encrypt_storage_explanation_description_line1}
</Text>
<Text style={[styles.description, stylesHook.feeModalCustomText]} maxFontSizeMultiplier={1.2}>
{loc.settings.encrypt_storage_explanation_description_line2}
</Text>
</Animated.View>
<View style={styles.feeModalFooter} />
</Animated.View>
)}
{(modalType === MODAL_TYPES.ENTER_PASSWORD ||
((modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && !showExplanation)) && (
<>
<Text adjustsFontSizeToFit style={[styles.textLabel, stylesHook.feeModalLabel]}>
{modalType === MODAL_TYPES.CREATE_PASSWORD
? loc.settings.password_explain
: modalType === MODAL_TYPES.CREATE_FAKE_STORAGE
? `${loc.settings.password_explain} ${loc.plausibledeniability.create_password_explanation}`
: loc._.enter_password}
</Text>
<View style={styles.inputContainer}>
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
<TextInput
testID="PasswordInput"
ref={passwordInputRef}
secureTextEntry
placeholder="Password"
value={password}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={setPassword}
style={[styles.input, stylesHook.input]}
clearTextOnFocus
clearButtonMode="while-editing"
autoFocus
/>
</Animated.View>
{(modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && (
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
<TextInput
testID="ConfirmPasswordInput"
ref={confirmPasswordInputRef}
secureTextEntry
placeholder="Confirm Password"
value={confirmPassword}
clearTextOnFocus
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
clearButtonMode="while-editing"
onChangeText={setConfirmPassword}
style={[styles.input, stylesHook.input]}
/>
</Animated.View>
)}
</View>
</>
)}
</Animated.View>
)}
{isSuccess && (
<Animated.View
style={{
opacity: fadeInAnimation,
transform: [{ scale: scaleAnimation }],
}}
>
<View style={styles.successContainer}>
<View style={styles.circle}>
<Animated.Text
style={[
styles.checkmark,
{
transform: [
{
scale: scaleAnimation.interpolate({
inputRange: [0.8, 1],
outputRange: [0.8, 1],
}),
},
],
},
]}
>
</Animated.Text>
</View>
</View>
</Animated.View>
)}
</BottomModal>
);
},
);
export default PromptPasswordConfirmationModal;
const styles = StyleSheet.create({
modalContent: {
padding: 22,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
},
minHeight: {
minHeight: 420,
},
feeModalFooter: {
padding: 16,
},
feeModalFooterSpacing: {
padding: 16,
marginVertical: 24,
},
inputContainer: {
marginBottom: 10,
width: '100%',
},
input: {
borderRadius: 4,
padding: 8,
marginVertical: 8,
fontSize: 16,
width: '100%',
},
textLabel: {
fontSize: 20,
fontWeight: '600',
marginBottom: 16,
textAlign: 'center',
},
description: {
fontSize: 16,
marginBottom: 12,
textAlign: 'center',
},
successContainer: {
justifyContent: 'center',
alignItems: 'center',
margin: 24,
marginBottom: 48,
},
circle: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'green',
justifyContent: 'center',
alignItems: 'center',
},
checkmark: {
color: 'white',
fontSize: 30,
},
});

View File

@ -1,6 +1,6 @@
import Clipboard from '@react-native-clipboard/clipboard';
import React, { useCallback, useRef } from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
import QRCode from 'react-native-qrcode-svg';
import Share from 'react-native-share';
@ -63,7 +63,7 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
ecl = 'H',
onError = () => {},
}) => {
const qrCode = useRef<any>();
const qrCode = useRef<any>(null);
const { colors, dark } = useTheme();
const handleShareQRCode = () => {
@ -90,6 +90,13 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
container: { borderWidth: dark ? BORDER_WIDTH : 0 },
});
const qrButtonStyle: ViewStyle = {
width: newSize,
height: newSize,
justifyContent: 'center',
alignItems: 'center',
};
const renderQRCode = (
<QRCode
value={value}
@ -102,20 +109,27 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
ecl={ecl}
getRef={(c: any) => (qrCode.current = c)}
onError={onError}
testID="BitcoinAddressQRCode"
/>
);
return (
<View
style={[styles.container, stylesHook.container]}
testID="BitcoinAddressQRCodeContainer"
accessibilityIgnoresInvertColors
importantForAccessibility="no-hide-descendants"
accessibilityRole="image"
accessibilityLabel={loc.receive.qrcode_for_the_address}
>
{isMenuAvailable ? (
<ToolTipMenu actions={menuActions} onPressMenuItem={onPressMenuItem}>
<ToolTipMenu
actions={menuActions}
onPressMenuItem={onPressMenuItem}
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={qrButtonStyle}
>
{renderQRCode}
</ToolTipMenu>
) : (
@ -128,5 +142,5 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
export default QRCodeComponent;
const styles = StyleSheet.create({
container: { borderColor: '#FFFFFF' },
container: { borderColor: '#FFFFFF', alignItems: 'center', justifyContent: 'center' },
});

View File

@ -53,7 +53,7 @@ const SaveFileButton: React.FC<SaveFileButtonProps> = ({
onMenuWillHide={onMenuWillHide}
onMenuWillShow={onMenuWillShow}
isButton
isMenuPrimaryAction
shouldOpenOnLongPress={false}
actions={actions}
onPressMenuItem={handlePressMenuItem}
buttonStyle={style as ViewStyle} // Type assertion to match ViewStyle

View File

@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { requireNativeComponent, View, StyleSheet, NativeSyntheticEvent } from 'react-native';
import { View, StyleSheet, NativeSyntheticEvent } from 'react-native';
import NativeSegmentedControl from '../codegen/SegmentControlNativeComponent';
interface SegmentedControlProps {
values: string[];
@ -11,15 +12,6 @@ interface SegmentedControlEvent {
selectedIndex: number;
}
interface NativeSegmentedControlProps {
values: string[];
selectedIndex: number;
onChangeEvent: (event: NativeSyntheticEvent<SegmentedControlEvent>) => void;
style?: object;
}
const NativeSegmentedControl = requireNativeComponent<NativeSegmentedControlProps>('CustomSegmentedControl');
const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedIndex, onChange }) => {
const handleChange = useMemo(
() => (event: NativeSyntheticEvent<SegmentedControlEvent>) => {
@ -36,7 +28,17 @@ const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedInd
return (
<View style={styles.container}>
<NativeSegmentedControl values={values} selectedIndex={selectedIndex} style={styles.segmentedControl} onChangeEvent={handleChange} />
<NativeSegmentedControl
values={values}
selectedIndex={selectedIndex}
enabled
backgroundColor="transparent"
tintColor="#007AFF"
textColor="#007AFF"
momentary={false}
style={styles.segmentedControl}
onChange={handleChange}
/>
</View>
);
};
@ -44,7 +46,7 @@ const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedInd
const styles = StyleSheet.create({
container: {
width: '100%',
marginHorizontal: 18,
marginHorizontal: 0,
marginBottom: 18,
minHeight: 40,
},

View File

@ -10,7 +10,7 @@ interface SettingsBlockExplorerCustomUrlItemProps {
customUrl: string;
onCustomUrlChange: (url: string) => void;
onSubmitCustomUrl: () => void;
inputRef?: React.RefObject<TextInput>;
inputRef?: React.RefObject<TextInput | null>;
}
const SettingsBlockExplorerCustomUrlItem: React.FC<SettingsBlockExplorerCustomUrlItemProps> = ({

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { Animated, Platform, TouchableOpacity } from 'react-native';
import { Platform, Pressable, StyleSheet, ViewStyle } from 'react-native';
import { MenuView, MenuAction, NativeActionEvent } from '@react-native-menu/menu';
import { ToolTipMenuProps, Action } from './types';
import { useSettings } from '../hooks/context/useSettings';
@ -7,153 +7,183 @@ import { useSettings } from '../hooks/context/useSettings';
const ToolTipMenu = (props: ToolTipMenuProps) => {
const {
title = '',
isMenuPrimaryAction = false,
shouldOpenOnLongPress = true,
disabled = false,
onPress,
buttonStyle,
onPressMenuItem,
children,
isButton = false,
...restProps
actions,
accessibilityLabel,
accessibilityHint,
accessibilityRole,
accessibilityState,
testID,
onMenuWillShow,
onMenuWillHide,
enableAndroidRipple = true,
} = props;
const { language } = useSettings();
const scaleAnim = useRef(new Animated.Value(1)).current;
const openedRef = useRef(false);
const menuRef = useRef<any>(null);
const handlePressIn = useCallback(() => {
Animated.spring(scaleAnim, {
toValue: 0.98,
useNativeDriver: true,
}).start();
}, [scaleAnim]);
const handlePressOut = useCallback(() => {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
}).start();
}, [scaleAnim]);
// Map Menu Items for RN Menu (supports subactions and displayInline)
const mapMenuItemForMenuView = useCallback((action: Action): MenuAction | null => {
if (!action.id) return null;
// Check for subactions
const subactions =
action.subactions?.map(subaction => {
const subMenuItem: MenuAction = {
id: subaction.id.toString(),
title: subaction.text,
subtitle: subaction.subtitle,
image: subaction.icon?.iconValue ? subaction.icon.iconValue : undefined,
attributes: { disabled: subaction.disabled, destructive: subaction.destructive, hidden: subaction.hidden },
};
if ('menuState' in subaction) {
subMenuItem.state = subaction.menuState ? 'on' : 'off';
}
if (subaction.subactions && subaction.subactions.length > 0) {
const deepSubactions = subaction.subactions.map(deepSub => {
const deepMenuItem: MenuAction = {
id: deepSub.id.toString(),
title: deepSub.text,
subtitle: deepSub.subtitle,
image: deepSub.icon?.iconValue ? deepSub.icon.iconValue : undefined,
attributes: { disabled: deepSub.disabled, destructive: deepSub.destructive, hidden: deepSub.hidden },
};
if ('menuState' in deepSub) {
deepMenuItem.state = deepSub.menuState ? 'on' : 'off';
}
return deepMenuItem;
});
subMenuItem.subactions = deepSubactions;
}
return subMenuItem;
}) || [];
const menuItem: MenuAction = {
id: action.id.toString(),
title: action.text,
subtitle: action.subtitle,
image: action.icon?.iconValue ? action.icon.iconValue : undefined,
attributes: { disabled: action.disabled, destructive: action.destructive, hidden: action.hidden },
displayInline: action.displayInline || false,
};
if ('menuState' in action) {
menuItem.state = action.menuState ? 'on' : 'off';
const normalizeMenuState = useCallback((menuState?: Action['menuState']): MenuAction['state'] | undefined => {
if (menuState === undefined) {
return undefined;
}
if (subactions.length > 0) {
menuItem.subactions = subactions;
if (menuState === 'mixed') {
return 'mixed';
}
return menuItem;
return menuState ? 'on' : 'off';
}, []);
const menuViewItemsIOS = useMemo(() => {
return props.actions
.map(actionGroup => {
if (Array.isArray(actionGroup) && actionGroup.length > 0) {
return {
id: actionGroup[0].id.toString(),
title: '',
subactions: actionGroup
.filter(action => action.id)
.map(mapMenuItemForMenuView)
.filter(item => item !== null) as MenuAction[],
displayInline: true,
};
} else if (!Array.isArray(actionGroup) && actionGroup.id) {
return mapMenuItemForMenuView(actionGroup);
}
return null;
})
.filter(item => item !== null) as MenuAction[];
}, [props.actions, mapMenuItemForMenuView]);
const buildAttributes = useCallback((action: Action): MenuAction['attributes'] | undefined => {
const attributes = {
destructive: Boolean(action.destructive),
disabled: Boolean(action.disabled),
hidden: Boolean(action.hidden),
};
const menuViewItemsAndroid = useMemo(() => {
const mergedActions = props.actions.flat().filter(action => action.id);
return mergedActions.map(mapMenuItemForMenuView).filter(item => item !== null) as MenuAction[];
}, [props.actions, mapMenuItemForMenuView]);
if (!attributes.destructive && !attributes.disabled && !attributes.hidden) {
return undefined;
}
const handlePressMenuItemForMenuView = useCallback(
({ nativeEvent }: NativeActionEvent) => {
onPressMenuItem(nativeEvent.event);
return attributes;
}, []);
const mapMenuItemForMenuView = useCallback(
(action: Action): MenuAction | null => {
if (!action?.id) return null;
const mappedSubactions = (action.subactions || [])
.map(subaction => mapMenuItemForMenuView(subaction))
.filter((item): item is MenuAction => item !== null);
const menuItem: MenuAction = {
id: action.id.toString(),
title: action.text,
subtitle: action.subtitle,
image: action.icon?.iconValue ?? action.image,
imageColor: action.imageColor,
attributes: buildAttributes(action),
displayInline: Platform.OS === 'ios' ? action.displayInline : undefined,
};
const state = normalizeMenuState(action.menuState);
if (state) {
menuItem.state = state;
}
if (mappedSubactions.length > 0) {
menuItem.subactions = mappedSubactions;
}
return menuItem;
},
[onPressMenuItem],
[buildAttributes, normalizeMenuState],
);
const menuViewItemsIOS = useMemo(() => {
return actions
.map(actionGroup => {
if (Array.isArray(actionGroup) && actionGroup.length > 0) {
const inlineActions = actionGroup.map(mapMenuItemForMenuView).filter((item): item is MenuAction => item !== null);
if (inlineActions.length === 0) return null;
const group: MenuAction = {
id: inlineActions[0].id,
title: '',
subactions: inlineActions,
displayInline: true,
};
return group;
}
if (!Array.isArray(actionGroup)) {
return mapMenuItemForMenuView(actionGroup);
}
return null;
})
.filter((item): item is MenuAction => item !== null);
}, [actions, mapMenuItemForMenuView]);
const menuViewItemsAndroid = useMemo(() => {
const mergedActions = actions.flat().filter(action => action.id);
return mergedActions.map(mapMenuItemForMenuView).filter((item): item is MenuAction => item !== null);
}, [actions, mapMenuItemForMenuView]);
const handlePressMenuItemForMenuView = ({ nativeEvent }: NativeActionEvent) => {
if (nativeEvent?.event) {
onPressMenuItem(nativeEvent.event);
}
};
const renderMenuView = () => {
if (disabled || (!isButton && !onPress)) {
return null;
}
return (
<MenuView
title={title}
isAnchoredToRight
onPressAction={handlePressMenuItemForMenuView}
actions={Platform.OS === 'ios' ? menuViewItemsIOS : menuViewItemsAndroid}
shouldOpenOnLongPress={!isMenuPrimaryAction}
// @ts-ignore: Not exposed in types
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.accessibilityRole}
<Pressable
android_ripple={enableAndroidRipple ? { color: '#d9d9d9', foreground: true } : undefined}
style={({ pressed }) => {
const base: ViewStyle[] = [styles.pressable];
if (buttonStyle) {
if (Array.isArray(buttonStyle)) {
base.push(...buttonStyle);
} else {
base.push(buttonStyle);
}
}
if (pressed && enableAndroidRipple) base.push(styles.pressed);
return base;
}}
disabled={disabled}
onPress={onPress}
onLongPress={shouldOpenOnLongPress ? () => {} : undefined}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityRole={accessibilityRole}
accessibilityState={accessibilityState}
accessibilityLanguage={language}
testID={testID}
hitSlop={8}
>
{isMenuPrimaryAction || isButton ? (
<TouchableOpacity
style={buttonStyle}
disabled={disabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
{...restProps}
>
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>{children}</Animated.View>
</TouchableOpacity>
) : (
children
)}
</MenuView>
<MenuView
ref={menuRef}
title={title}
isAnchoredToRight
onOpenMenu={() => {
openedRef.current = true;
onMenuWillShow?.();
}}
onCloseMenu={() => {
if (!openedRef.current) {
return;
}
openedRef.current = false;
onMenuWillHide?.();
}}
onPressAction={handlePressMenuItemForMenuView}
actions={Platform.OS === 'ios' ? menuViewItemsIOS : menuViewItemsAndroid}
shouldOpenOnLongPress={shouldOpenOnLongPress}
style={buttonStyle ? styles.menuViewFlex : undefined}
>
{children}
</MenuView>
</Pressable>
);
};
return props.actions.length > 0 ? renderMenuView() : null;
return actions.length > 0 ? renderMenuView() : null;
};
export default ToolTipMenu;
const styles = StyleSheet.create({
menuViewFlex: { flex: 1 },
pressable: { alignSelf: 'center' },
pressed: { opacity: 0.6 },
});

View File

@ -90,10 +90,10 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
await setTotalBalancePreferredUnitStorage(nextUnit);
}, [totalBalancePreferredUnit, setTotalBalancePreferredUnitStorage]);
if (wallets.length <= 1 || !isTotalBalanceEnabled) return null;
if (!isTotalBalanceEnabled) return null;
return (
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem}>
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress>
<View style={styles.container}>
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
<TouchableOpacity onPress={handleBalanceOnPress}>

View File

@ -1,8 +1,7 @@
import React, { useCallback, useMemo, useRef, memo } from 'react';
import React, { useCallback, useMemo, memo } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Clipboard from '@react-native-clipboard/clipboard';
import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import { Linking, Text, TextStyle, ViewStyle, StyleSheet } from 'react-native';
import { Linking, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningTransaction, Transaction } from '../class/wallets/types';
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
@ -15,9 +14,8 @@ import TransactionPendingIcon from '../components/icons/TransactionPendingIcon';
import loc, { formatBalanceWithoutSuffix, formatTransactionListDate, transactionTimeToReadable } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { useSettings } from '../hooks/context/useSettings';
import ListItem from './ListItem';
import { useTheme } from './themes';
import { Action, ToolTipMenuProps } from './types';
import { Action } from './types';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList';
@ -26,16 +24,51 @@ import ToolTipMenu from './TooltipMenu';
import { CommonToolTipActions } from '../typings/CommonToolTipActions';
import { pop } from '../NavigationService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import ListItem from './ListItem';
const styles = StyleSheet.create({
pressable: {
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
width: '100%',
},
dateLine: {
fontSize: 13,
},
highlight: {
backgroundColor: '#FFF5C0',
color: '#000000',
fontSize: 13,
fontWeight: '600',
fullWidthButton: {
width: '100%',
alignSelf: 'stretch',
},
row: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
},
avatarContainer: {
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
},
textContainer: {
flex: 1,
paddingRight: 8,
},
title: {
fontSize: 16,
fontWeight: '500',
},
subtitle: {
fontSize: 14,
lineHeight: 20,
},
rightColumn: {
marginLeft: 8,
alignItems: 'flex-end',
justifyContent: 'center',
},
rightTitle: {
textAlign: 'right',
},
});
@ -45,7 +78,7 @@ interface TransactionListItemProps {
item: Transaction & LightningTransaction; // using type intersection to have less issues with ts
searchQuery?: string;
style?: ViewStyle;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
onPress?: () => void;
disableNavigation?: boolean;
}
@ -65,7 +98,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
}: TransactionListItemProps) => {
const { colors } = useTheme();
const { navigate } = useExtendedNavigation<NavigationProps>();
const menuRef = useRef<ToolTipMenuProps>();
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
const { language, selectedBlockExplorer } = useSettings();
const insets = useSafeAreaInsets();
@ -118,8 +150,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending, item.timestamp, language]);
const dateLineStyle = useMemo(() => [styles.dateLine, { color: colors.alternativeTextColor }], [colors.alternativeTextColor]);
const formattedAmount = useMemo(() => {
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
}, [item.value, itemPriceUnit]);
@ -138,12 +168,7 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
return formattedAmount;
}, [item, formattedAmount]);
const rightMemoStyle = useMemo(
() => [styles.dateLine, { color: colors.alternativeTextColor, textAlign: 'right' as const }],
[colors.alternativeTextColor],
);
const rowTitleStyle = useMemo(() => {
const rowTitleStyle = useMemo<TextStyle>(() => {
let color = colors.successColor;
if (item.type === 'user_invoice' || item.type === 'payment_request') {
@ -254,7 +279,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
}, [formattedAmount, itemPriceUnit]);
const onPress = useCallback(async () => {
menuRef?.current?.dismissMenu?.();
// If a custom onPress handler was provided, use it and return
if (customOnPress) {
customOnPress();
@ -272,16 +296,16 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
try {
// is it a successful lnurl-pay?
const LN = new Lnurl(false, AsyncStorage);
let paymentHash = item.payment_hash!;
if (typeof paymentHash === 'object') {
paymentHash = uint8ArrayToHex(new Uint8Array((paymentHash as any).data));
}
const loaded = await LN.loadSuccessfulPayment(paymentHash);
const rawPaymentHash = item.payment_hash;
if (!rawPaymentHash) throw new Error('Missing payment hash');
const normalizedPaymentHash =
typeof rawPaymentHash === 'string' ? rawPaymentHash : uint8ArrayToHex(new Uint8Array((rawPaymentHash as any).data));
const loaded = await LN.loadSuccessfulPayment(normalizedPaymentHash);
if (loaded) {
navigate('ScanLNDInvoiceRoot', {
screen: 'LnurlPaySuccess',
params: {
paymentHash,
paymentHash: normalizedPaymentHash,
justPaid: false,
fromWalletID: lightningWallet[0].getID(),
},
@ -380,28 +404,84 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
return actions as Action[];
}, [rowTitle, noteForCopy, item.hash]);
const title = listTitle;
const subtitle = dateLine;
const subtitleNumberOfLines: number = 1;
const titleStyle = useMemo(() => ({ color: colors.foregroundColor }), [colors.foregroundColor]);
const subtitleStyle = useMemo(() => ({ color: colors.alternativeTextColor }), [colors.alternativeTextColor]);
const subtitleContent = useMemo(() => {
if (!subtitle) return null;
const maxLines = subtitleNumberOfLines === 0 ? undefined : subtitleNumberOfLines;
if (renderHighlightedText && searchQuery) {
const highlighted = renderHighlightedText(subtitle, searchQuery);
if (React.isValidElement(highlighted)) {
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
const existingStyle = highlightedElement.props?.style;
const mergedStyle: TextStyle[] = (
Array.isArray(existingStyle)
? [styles.subtitle, subtitleStyle, ...existingStyle]
: [styles.subtitle, subtitleStyle, existingStyle]
).filter(Boolean) as TextStyle[];
return React.cloneElement(highlightedElement, {
numberOfLines: maxLines,
style: mergedStyle,
});
}
return highlighted;
}
return (
<Text style={[styles.subtitle, subtitleStyle]} numberOfLines={maxLines}>
{subtitle}
</Text>
);
}, [subtitle, subtitleNumberOfLines, renderHighlightedText, searchQuery, subtitleStyle]);
return (
<ToolTipMenu
isButton
actions={toolTipActions}
onPressMenuItem={onToolTipPress}
onPress={onPress}
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${dateLine}`}
shouldOpenOnLongPress
buttonStyle={styles.fullWidthButton}
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${subtitle ?? title}`}
accessibilityRole="button"
>
{/* @ts-ignore - MenuView types can be overly strict about child element props */}
<ListItem
leftAvatar={avatar}
title={listTitle}
subtitle={<Text style={dateLineStyle}>{dateLine}</Text>}
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}
rightSubtitle={noteForCopy}
rightSubtitleStyle={rightMemoStyle}
rightSubtitleStyle={styles.rightColumn}
containerStyle={combinedStyle}
testID="TransactionListItem"
/>
accessibilityRole="button"
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${subtitle ?? title}`}
>
<View style={styles.row}>
<View style={styles.avatarContainer}>{avatar}</View>
<View style={styles.textContainer}>
<Text style={[styles.title, titleStyle]} numberOfLines={1}>
{title}
</Text>
{subtitleContent}
</View>
<View style={styles.rightColumn}>
<Text style={[styles.rightTitle, rowTitleStyle]} numberOfLines={1}>
{rowTitle}
</Text>
</View>
</View>
</ListItem>
</ToolTipMenu>
);
},

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { LightningArkWallet, LightningCustodianWallet, MultisigHDWallet } from '../class';
import WalletGradient from '../class/wallet-gradient';
@ -20,6 +21,7 @@ interface TransactionsNavigationHeaderProps {
onWalletUnitChange: (unit: BitcoinUnit) => void;
onManageFundsPressed?: (id?: string) => void;
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
unitSwitching?: boolean;
}
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
@ -28,11 +30,15 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
onManageFundsPressed,
onWalletBalanceVisibilityChange,
unit = BitcoinUnit.BTC,
unitSwitching = false,
}) => {
const { hideBalance } = wallet;
const [allowOnchainAddress, setAllowOnchainAddress] = useState(false);
const { preferredFiatCurrency } = useSettings();
const { direction } = useLocale();
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const previousBalance = useRef<string | undefined>(undefined);
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
if (wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) {
@ -64,6 +70,8 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const changeWalletBalanceUnit = () => {
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
newWalletPreferredUnit = BitcoinUnit.SATS;
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
@ -72,6 +80,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
onWalletUnitChange(newWalletPreferredUnit);
};
@ -118,6 +127,36 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
}, [unit, currentBalance]);
const balance = !wallet.hideBalance && formattedBalance;
const safeBalance = balance ? String(balance) : undefined;
useEffect(() => {
if (hideBalance) {
previousBalance.current = undefined;
balanceOpacity.value = 1;
balanceTranslateY.value = 0;
return;
}
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
balanceOpacity.value = 0;
balanceTranslateY.value = 6;
balanceOpacity.value = withTiming(1, { duration: 180 });
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
}
previousBalance.current = safeBalance;
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
const balanceAnimationKey = useMemo(
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
[safeBalance, hideBalance, unit, wallet],
);
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
opacity: balanceOpacity.value,
transform: [{ translateY: balanceTranslateY.value }],
}));
const toolTipWalletBalanceActions = useMemo(() => {
return hideBalance
@ -160,78 +199,87 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
}
}, [direction, wallet.type]);
useAnimateOnChange(balance);
useAnimateOnChange(hideBalance);
useAnimateOnChange(unit);
useAnimateOnChange(wallet.getID?.());
useEffect(() => {
console.debug('[UnitSwitch/UI] render state', {
walletID: wallet.getID?.(),
unit,
hideBalance,
preferredFiat: preferredFiatCurrency?.endPointKey,
switching: unitSwitching,
});
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
return (
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
<ImageBackground source={imageSource} style={styles.chainIcon} />
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<View style={styles.walletBalanceAndUnitContainer}>
<ToolTipMenu
isMenuPrimaryAction
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<View>
<Text
key={String(balance)} // force component recreation on balance change. To fix right-to-left languages, like Farsis
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={styles.walletBalanceText}
>
{balance}
</Text>
</View>
)}
</View>
</ToolTipMenu>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
<View style={styles.contentContainer}>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
<Animated.Text
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Animated.Text>
</View>
)}
</View>
</ToolTipMenu>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
</Animated.View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<ToolTipMenu
shouldOpenOnLongPress
isButton
onPressMenuItem={handleManageFundsPressed}
actions={toolTipActions}
buttonStyle={styles.manageFundsButton}
>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</ToolTipMenu>
)}
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<ToolTipMenu
isMenuPrimaryAction
isButton
onPressMenuItem={handleManageFundsPressed}
actions={toolTipActions}
buttonStyle={styles.manageFundsButton}
>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</ToolTipMenu>
)}
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</LinearGradient>
);
};
const styles = StyleSheet.create({
lineaderGradient: {
padding: 15,
minHeight: 140,
justifyContent: 'center',
},
contentContainer: {
padding: 15,
},
chainIcon: {
width: 99,
height: 94,

View File

@ -1,20 +1,27 @@
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useEffect, createRef } from 'react';
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useEffect } from 'react';
import {
Animated,
FlatList,
ImageBackground,
Platform,
Pressable,
StyleSheet,
Text,
useWindowDimensions,
View,
useWindowDimensions,
FlatListProps,
ListRenderItemInfo,
ViewStyle,
LayoutAnimation,
UIManager,
} from 'react-native';
import Animated, {
Easing,
FadeIn,
FadeOut,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { LightningArkWallet, LightningCustodianWallet, MultisigHDWallet } from '../class';
import WalletGradient from '../class/wallet-gradient';
@ -25,13 +32,11 @@ import { useTheme } from './themes';
import { useStorage } from '../hooks/context/useStorage';
import { WalletTransactionsStatus } from './Context/StorageProvider';
import { Transaction, TWallet } from '../class/wallets/types';
import HighlightedText from './HighlightedText';
import { BlueSpacing10 } from './BlueSpacing';
import { useLocale } from '@react-navigation/native';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
// Horizontal carousel shows a small peek of the next card; adjust overlap to control that spacing.
const CARD_OVERLAP = 24;
interface NewWalletPanelProps {
onPress: () => void;
@ -78,22 +83,18 @@ const NewWalletPanel: React.FC<NewWalletPanelProps> = ({ onPress }) => {
: { paddingVertical: 16, paddingHorizontal: 24 },
});
const scale = useRef(new Animated.Value(1)).current;
const scale = useSharedValue(1);
const animatedScaleStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
toValue: 0.97,
useNativeDriver: true,
friction: 4,
}).start();
scale.value = withSpring(0.97, { damping: 14, stiffness: 180 });
}, [scale]);
const handlePressOut = useCallback(() => {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
friction: 4,
}).start();
scale.value = withSpring(1, { damping: 14, stiffness: 180 });
}, [scale]);
return (
@ -115,9 +116,9 @@ const NewWalletPanel: React.FC<NewWalletPanelProps> = ({ onPress }) => {
style={[
nStyles.container,
nStylesHooks.container,
{ backgroundColor: WalletGradient.createWallet() },
{ backgroundColor: colors.borderTopColor },
isLarge ? {} : { width: itemWidth },
{ transform: [{ scale }] },
animatedScaleStyle,
]}
>
<Text style={[nStyles.addAWAllet, { color: colors.foregroundColor }]}>{loc.wallets.list_create_a_wallet}</Text>
@ -139,25 +140,40 @@ interface WalletCarouselItemProps {
horizontal?: boolean;
isPlaceHolder?: boolean;
searchQuery?: string;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
animationsEnabled?: boolean;
onPressIn?: () => void;
onPressOut?: () => void;
isNewWallet?: boolean;
isExiting?: boolean;
isDraggingActive?: boolean;
dragActiveScale?: number;
sizeVariant?: 'default' | 'compact';
}
const iStyles = StyleSheet.create({
root: { paddingRight: 20 },
rootLargeDevice: { marginVertical: 20 },
grad: {
padding: 15,
borderRadius: 12,
minHeight: 164,
},
gradCompact: {
borderRadius: 10,
minHeight: 132,
},
gradContent: {
padding: 15,
},
gradContentCompact: {
padding: 12,
},
balanceContainer: {
height: 40,
},
balanceContainerCompact: {
height: 32,
},
image: {
width: 99,
height: 94,
@ -165,6 +181,12 @@ const iStyles = StyleSheet.create({
bottom: 0,
right: 0,
},
imageCompact: {
width: 78,
height: 74,
right: 4,
bottom: 4,
},
br: {
backgroundColor: 'transparent',
},
@ -172,20 +194,33 @@ const iStyles = StyleSheet.create({
backgroundColor: 'transparent',
fontSize: 19,
},
labelCompact: {
fontSize: 16,
},
balance: {
backgroundColor: 'transparent',
fontWeight: 'bold',
fontSize: 36,
},
balanceCompact: {
fontSize: 28,
lineHeight: 34,
},
latestTx: {
backgroundColor: 'transparent',
fontSize: 13,
},
latestTxCompact: {
fontSize: 12,
},
latestTxTime: {
backgroundColor: 'transparent',
fontWeight: 'bold',
fontSize: 16,
},
latestTxTimeCompact: {
fontSize: 14,
},
shadowContainer: {
...Platform.select({
ios: {
@ -200,6 +235,20 @@ const iStyles = StyleSheet.create({
},
}),
},
shadowContainerCompact: {
...Platform.select({
ios: {
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 20 / 100,
shadowRadius: 6,
borderRadius: 10,
},
android: {
elevation: 6,
borderRadius: 10,
},
}),
},
});
export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
@ -218,51 +267,59 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
onPressOut,
isNewWallet = false,
isExiting = false,
isDraggingActive = false,
dragActiveScale = 1.02,
sizeVariant = 'default',
}: WalletCarouselItemProps) => {
const scaleValue = useRef(new Animated.Value(1.0)).current;
const opacityValue = useRef(new Animated.Value(isSelectedWallet === false ? 0.5 : 1.0)).current;
const translateYValue = useRef(new Animated.Value(isNewWallet ? 20 : 0)).current;
const walletLabel = item.getLabel ? item.getLabel() : '';
const pressScale = useSharedValue(1.0);
const dragScale = useSharedValue(isDraggingActive ? dragActiveScale : 1.0);
const opacityValue = useSharedValue(isSelectedWallet === false ? 0.5 : 1.0);
const translateYValue = useSharedValue(isNewWallet ? 20 : 0);
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const { colors } = useTheme();
const { walletTransactionUpdateStatus } = useStorage();
const { width } = useWindowDimensions();
const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82;
const { sizeClass } = useSizeClass();
const isCompact = sizeVariant === 'compact';
const { direction } = useLocale();
const previousBalance = useRef<string | undefined>(undefined);
const balance = !item.hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
const safeBalance = balance || undefined;
const springConfig = useMemo(() => ({ useNativeDriver: true, tension: 100 }), []);
const animateScale = useCallback(
(toValue: number, callback?: () => void) => {
Animated.spring(scaleValue, { toValue, ...springConfig }).start(callback);
const animatePressScale = useCallback(
(toValue: number) => {
pressScale.value = withSpring(toValue, { damping: 13, stiffness: 180, mass: 0.9 });
},
[scaleValue, springConfig],
[pressScale],
);
useEffect(() => {
dragScale.value = withSpring(isDraggingActive ? dragActiveScale : 1, { damping: 16, stiffness: 200, mass: 1 });
}, [isDraggingActive, dragActiveScale, dragScale]);
useEffect(() => {
if (!animationsEnabled) return;
const targetOpacity = isSelectedWallet === false ? 0.5 : 1.0;
Animated.spring(opacityValue, {
toValue: targetOpacity,
useNativeDriver: true,
tension: 30,
friction: 7,
velocity: 0.1,
}).start();
opacityValue.value = withSpring(targetOpacity, { damping: 18, stiffness: 240 });
}, [isSelectedWallet, opacityValue, animationsEnabled]);
const onPressedIn = useCallback(() => {
if (animationsEnabled) {
animateScale(0.95);
animatePressScale(0.97);
}
if (onPressIn) onPressIn();
}, [animateScale, animationsEnabled, onPressIn]);
}, [animatePressScale, animationsEnabled, onPressIn]);
const onPressedOut = useCallback(() => {
if (animationsEnabled) {
animateScale(1.0);
animatePressScale(1.0);
}
if (onPressOut) onPressOut();
}, [animateScale, animationsEnabled, onPressOut]);
}, [animatePressScale, animationsEnabled, onPressOut]);
const handlePress = useCallback(() => {
onPress(item);
@ -270,38 +327,45 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
useEffect(() => {
if (isNewWallet && animationsEnabled) {
Animated.parallel([
Animated.timing(translateYValue, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(opacityValue, {
toValue: isSelectedWallet === false ? 0.5 : 1.0,
useNativeDriver: true,
friction: 7,
}),
]).start();
translateYValue.value = withTiming(0, { duration: 300 });
opacityValue.value = withSpring(isSelectedWallet === false ? 0.5 : 1.0, { damping: 18, stiffness: 240 });
}
}, [isNewWallet, animationsEnabled, translateYValue, opacityValue, isSelectedWallet]);
useEffect(() => {
if (!animationsEnabled) {
previousBalance.current = safeBalance;
return;
}
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
// Subtle currency-like transition on balance updates.
balanceOpacity.value = 0;
balanceTranslateY.value = 6;
balanceOpacity.value = withTiming(1, { duration: 180 });
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
}
previousBalance.current = safeBalance;
}, [safeBalance, animationsEnabled, balanceOpacity, balanceTranslateY]);
useEffect(() => {
if (isExiting && animationsEnabled) {
Animated.parallel([
Animated.timing(translateYValue, {
toValue: -20,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(opacityValue, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
translateYValue.value = withTiming(-20, { duration: 200 });
opacityValue.value = withTiming(0, { duration: 200 });
}
}, [isExiting, animationsEnabled, translateYValue, opacityValue]);
const animatedCardStyle = useAnimatedStyle(() => ({
opacity: opacityValue.value,
transform: [{ scale: pressScale.value * dragScale.value }, { translateY: translateYValue.value }],
}));
const animatedBalanceStyle = useAnimatedStyle(() => ({
opacity: balanceOpacity.value,
transform: [{ translateY: balanceTranslateY.value }],
}));
let image;
switch (item.type) {
case LightningCustodianWallet.type:
@ -327,23 +391,18 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());
}
const balance = !item.hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
return (
<Animated.View
style={[
sizeClass === SizeClass.Large || !horizontal
? [iStyles.rootLargeDevice, customStyle]
: (customStyle ?? { ...iStyles.root, width: itemWidth }),
{
opacity: opacityValue,
transform: [{ scale: scaleValue }, { translateY: translateYValue }],
},
: [iStyles.root, { width: itemWidth }, customStyle],
animatedCardStyle,
]}
>
<Pressable
accessibilityRole="button"
testID={item.getLabel()}
testID={walletLabel}
onPressIn={onPressedIn}
onPressOut={onPressedOut}
onLongPress={() => {
@ -353,52 +412,75 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
delayHoverIn={0}
delayHoverOut={0}
>
<View style={[iStyles.shadowContainer, { backgroundColor: colors.background, shadowColor: colors.shadowColor }]}>
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={iStyles.grad}>
<ImageBackground source={image} style={iStyles.image} />
<Text style={iStyles.br} />
{!isPlaceHolder && (
<>
<Text numberOfLines={1} style={[iStyles.label, { color: colors.inverseForegroundColor, writingDirection: direction }]}>
{renderHighlightedText && searchQuery ? (
<HighlightedText
text={item.getLabel()}
query={searchQuery}
style={[iStyles.label, { color: colors.inverseForegroundColor, writingDirection: direction }]}
/>
) : (
item.getLabel()
)}
</Text>
<View style={iStyles.balanceContainer}>
{item.hideBalance ? (
<>
<BlueSpacing10 />
<BlurredBalanceView />
</>
) : (
<Text
numberOfLines={1}
adjustsFontSizeToFit
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
style={[iStyles.balance, { color: colors.inverseForegroundColor, writingDirection: direction }]}
>
{`${balance} `}
</Text>
)}
</View>
<Text style={iStyles.br} />
<Text numberOfLines={1} style={[iStyles.latestTx, { color: colors.inverseForegroundColor, writingDirection: direction }]}>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
style={[iStyles.latestTxTime, { color: colors.inverseForegroundColor, writingDirection: direction }]}
>
{latestTransactionText}
</Text>
</>
)}
<View
style={[
iStyles.shadowContainer,
isCompact && iStyles.shadowContainerCompact,
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
]}
>
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
<Text style={iStyles.br} />
{!isPlaceHolder && (
<>
<Text
numberOfLines={1}
style={[
iStyles.label,
isCompact && iStyles.labelCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
</Text>
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
{item.hideBalance ? (
<>
<BlueSpacing10 />
<BlurredBalanceView />
</>
) : (
<Animated.Text
numberOfLines={1}
adjustsFontSizeToFit
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
style={[
iStyles.balance,
isCompact && iStyles.balanceCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
animatedBalanceStyle,
]}
>
{`${balance} `}
</Animated.Text>
)}
</View>
<Text style={iStyles.br} />
<Text
numberOfLines={1}
style={[
iStyles.latestTx,
isCompact && iStyles.latestTxCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
style={[
iStyles.latestTxTime,
isCompact && iStyles.latestTxTimeCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{latestTransactionText}
</Text>
</>
)}
</View>
</LinearGradient>
</View>
</Pressable>
@ -417,7 +499,7 @@ interface WalletsCarouselProps extends Partial<FlatListProps<any>> {
data: TWallet[];
scrollEnabled?: boolean;
searchQuery?: string;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
animateChanges?: boolean;
}
@ -457,8 +539,10 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
const { width } = useWindowDimensions();
const itemWidth = React.useMemo(() => (width * 0.82 > 375 ? 375 : width * 0.82), [width]);
const layoutTransition = useMemo(() => LinearTransition.duration(240).easing(Easing.inOut(Easing.quad)), []);
const enteringTransition = useMemo(() => FadeIn.duration(180), []);
const exitingTransition = useMemo(() => FadeOut.duration(150), []);
const prevDataLength = useRef(data.length);
const prevWalletIds = useRef<string[]>([]);
const newWalletsMap = useRef<Record<string, boolean>>({});
const lastAddedWalletId = useRef<string | null>(null);
@ -467,7 +551,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
const isInitialMount = useRef(true);
const flatListRef = useRef<FlatList<any>>(null);
const walletRefs = useRef<Record<string, React.RefObject<View>>>({});
const walletRefs = useRef<Record<string, React.MutableRefObject<View | null>>>({});
const { sizeClass } = useSizeClass();
@ -508,7 +592,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
const walletRef = walletRefs.current[walletId];
if (walletRef?.current) {
return new Promise<{ x: number; y: number; width: number; height: number }>(resolve => {
walletRef.current?.measure((x, y, widthVal, heightVal, pageX, pageY) => {
walletRef.current?.measure((x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => {
resolve({ x: pageX, y: pageY, width: widthVal, height: heightVal });
});
});
@ -530,7 +614,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
useEffect(() => {
data.forEach(wallet => {
if (!walletRefs.current[wallet.getID()]) {
walletRefs.current[wallet.getID()] = createRef<View>();
walletRefs.current[wallet.getID()] = { current: null };
}
});
}, [data]);
@ -584,7 +668,6 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
if (isInitialMount.current) {
isInitialMount.current = false;
prevWalletIds.current = currentWalletIds;
prevDataLength.current = data.length;
return;
}
@ -598,9 +681,6 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
newWalletsMap.current[id] = true;
});
// Always animate layout changes
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
// Auto-scroll to new wallet after mount (no condition, always scroll)
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
@ -614,14 +694,8 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
}, 300);
}
// Handle wallet removals
if (prevDataLength.current > data.length) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}
// Update refs for next comparison
prevWalletIds.current = currentWalletIds;
prevDataLength.current = data.length;
// Clear animation states
if (addedWallets.length > 0) {
@ -646,8 +720,9 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
};
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<TWallet>) =>
item ? (
({ item }: ListRenderItemInfo<TWallet>) => {
if (!item) return null;
const content = (
<WalletCarouselItem
isSelectedWallet={!horizontal && selectedWallet ? selectedWallet === item.getID() : undefined}
item={item}
@ -659,8 +734,28 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
isNewWallet={animateChanges && newWalletsMap.current[item.getID()]}
animationsEnabled={animateChanges}
/>
) : null,
[horizontal, selectedWallet, handleLongPress, onPress, searchQuery, renderHighlightedText, animateChanges],
);
if (!animateChanges) return content;
return (
<Animated.View layout={layoutTransition} entering={enteringTransition} exiting={exitingTransition}>
{content}
</Animated.View>
);
},
[
horizontal,
selectedWallet,
handleLongPress,
onPress,
searchQuery,
renderHighlightedText,
animateChanges,
layoutTransition,
enteringTransition,
exitingTransition,
],
);
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
@ -674,16 +769,24 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
}, []);
const renderNonFlatListWallets = useCallback(() => {
return data.map((item, index) =>
item ? (
return data.map(item => {
if (!item) return null;
const content = (
<View
key={item.getID()}
ref={walletRefs.current[item.getID()]}
key={!animateChanges ? item.getID() : undefined}
ref={(node: View | null) => {
// Keep existing ref object in map
walletRefs.current[item.getID()] ??= { current: null };
walletRefs.current[item.getID()].current = node;
}}
onLayout={() => {
if (walletRefs.current[item.getID()]?.current && newWalletsMap.current[item.getID()]) {
walletRefs.current[item.getID()].current?.measure((x, y, widthVal, heightVal, pageX, pageY) => {
console.debug(`[WalletsCarousel] New wallet ${item.getID()} positioned at y=${y}, pageY=${pageY}`);
});
walletRefs.current[item.getID()].current?.measure(
(x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => {
console.debug(`[WalletsCarousel] New wallet ${item.getID()} positioned at y=${y}, pageY=${pageY}`);
},
);
}
}}
>
@ -698,9 +801,29 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
animationsEnabled={animateChanges}
/>
</View>
) : null,
);
}, [data, horizontal, selectedWallet, handleLongPress, onPress, props.searchQuery, props.renderHighlightedText, animateChanges]);
);
if (!animateChanges) return content;
return (
<Animated.View key={item.getID()} layout={layoutTransition} entering={enteringTransition} exiting={exitingTransition}>
{content}
</Animated.View>
);
});
}, [
data,
horizontal,
selectedWallet,
handleLongPress,
onPress,
props.searchQuery,
props.renderHighlightedText,
animateChanges,
layoutTransition,
enteringTransition,
exitingTransition,
]);
useEffect(() => {
// We check the current values inside the effect, but don't include them as dependencies
@ -735,7 +858,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
showsVerticalScrollIndicator={false}
pagingEnabled={horizontal}
disableIntervalMomentum={horizontal}
snapToInterval={itemWidth}
snapToInterval={horizontal ? itemWidth - CARD_OVERLAP : undefined}
decelerationRate="fast"
contentContainerStyle={cStyles.content}
directionalLockEnabled

View File

@ -1,4 +1,4 @@
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { StyleSheet, Text, View } from 'react-native';
import Share from 'react-native-share';
@ -18,6 +18,7 @@ import ToolTipMenu from '../TooltipMenu';
import { CommonToolTipActions } from '../../typings/CommonToolTipActions';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import HighlightedText from '../HighlightedText';
import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
interface AddressItemProps {
item: any;
@ -26,7 +27,7 @@ interface AddressItemProps {
allowSignVerifyMessage: boolean;
onPress?: () => void; // example: ManageWallets uses this
searchQuery?: string;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
}
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
@ -43,6 +44,9 @@ const AddressItem = ({
const { wallets } = useStorage();
const { colors } = useTheme();
const { isBiometricUseCapableAndEnabled } = useBiometrics();
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const previousBalance = useRef<string | undefined>(undefined);
const hasTransactions = item.transactions > 0;
@ -50,6 +54,7 @@ const AddressItem = ({
container: {
borderBottomColor: colors.lightBorder,
backgroundColor: colors.elevated,
borderBottomWidth: StyleSheet.hairlineWidth,
},
index: {
@ -104,6 +109,17 @@ const AddressItem = ({
const balance = formatBalance(item.balance, balanceUnit, true);
useEffect(() => {
if (previousBalance.current !== undefined && previousBalance.current !== balance) {
balanceOpacity.value = 0;
balanceTranslateY.value = 6;
balanceOpacity.value = withTiming(1, { duration: 180 });
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
}
previousBalance.current = balance;
}, [balance, balanceOpacity, balanceTranslateY]);
const handleCopyPress = useCallback(() => {
Clipboard.setString(item.address);
}, [item.address]);
@ -189,6 +205,8 @@ const AddressItem = ({
renderPreview={renderPreview}
onPress={navigateToReceive}
isButton
buttonStyle={styles.tooltipButton}
shouldOpenOnLongPress
>
<View key={item.key} style={[styles.container, stylesHook.container]}>
<View style={styles.row}>
@ -223,26 +241,36 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
marginHorizontal: 4,
},
tooltipButton: {
width: '100%',
alignSelf: 'stretch',
},
index: {
fontSize: 15,
fontWeight: '600',
},
balance: {
marginTop: 4,
marginTop: 6,
fontWeight: '600',
},
row: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
leftSection: {
marginRight: 8,
marginRight: 10,
paddingTop: 2,
},
middleSection: {
flex: 1,
paddingRight: 12,
},
rightContainer: {
justifyContent: 'center',
alignItems: 'flex-end',
minWidth: 96,
paddingLeft: 8,
},
});

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { StyleSheet, View } from 'react-native';
import Icon from '../Icon';
import { useTheme } from '../themes';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
@ -29,16 +29,20 @@ const SettingsButton = () => {
const actions = useMemo(() => [CommonToolTipActions.ManageWallet], []);
return (
<ToolTipMenu onPressMenuItem={onPressMenuItem} actions={actions}>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc.settings.default_title}
testID="SettingsButton"
style={[style.buttonStyle, { backgroundColor: colors.lightButton }]}
onPress={onPress}
>
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} />
</TouchableOpacity>
<ToolTipMenu
isButton
onPress={onPress}
buttonStyle={[style.buttonStyle, { backgroundColor: colors.lightButton }]}
accessibilityRole="button"
accessibilityLabel={loc.settings.default_title}
testID="SettingsButton"
onPressMenuItem={onPressMenuItem}
actions={actions}
shouldOpenOnLongPress
>
<View style={style.iconContainer}>
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} iconStyle={style.icon} />
</View>
</ToolTipMenu>
);
};
@ -53,4 +57,9 @@ const style = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
},
icon: {},
});

View File

@ -124,12 +124,17 @@ const navigationStyle = (
const leftCloseButtonStyle = isLeftCloseButtonAndroid ? { headerBackImageSource: theme.closeImage } : { headerLeft };
// statusBarStyle: auto is not supported on Android, so we get it based on the theme.barStyle
const statusBarStyle: NativeStackNavigationOptions['statusBarStyle'] =
opts.statusBarStyle && opts.statusBarStyle !== 'auto' ? opts.statusBarStyle : theme.barStyle === 'light-content' ? 'light' : 'dark';
let options: NativeStackNavigationOptions = {
...baseHeaderStyle,
...leftCloseButtonStyle,
headerBackButtonDisplayMode: 'minimal',
headerRight,
...opts,
statusBarStyle,
};
if (formatter) {

View File

@ -69,7 +69,6 @@ export const BlueDefaultTheme = {
changeText: '#F38C47',
receiveBackground: '#D1F9D6',
receiveText: '#37C0A1',
navigationBarColor: '#FFFFFF',
androidRippleColor: '#CCCCCC',
},
};
@ -127,7 +126,6 @@ export const BlueDarkTheme: Theme = {
changeText: '#F38C47',
receiveBackground: 'rgba(210,248,214,.2)',
receiveText: '#37C0A1',
navigationBarColor: '#3A3A3C',
androidRippleColor: '#444444',
},
};

View File

@ -1,4 +1,4 @@
import { AccessibilityRole, ViewStyle, ColorValue } from 'react-native';
import { AccessibilityRole, ViewStyle, ColorValue, GestureResponderEvent } from 'react-native';
export interface Action {
id: string | number;
@ -25,10 +25,10 @@ export interface ToolTipMenuProps {
dismissMenu?: () => void;
onPressMenuItem: (id: string) => void;
title?: string;
isMenuPrimaryAction?: boolean;
shouldOpenOnLongPress?: boolean;
isButton?: boolean;
renderPreview?: () => React.ReactNode;
onPress?: () => void;
onPress?: (event: GestureResponderEvent) => void;
previewValue?: string;
accessibilityRole?: AccessibilityRole;
disabled?: boolean;

View File

@ -24,8 +24,125 @@ def app_store_state_readable(state)
states[state] || state
end
require 'securerandom'
default_platform(:android)
project_root = File.expand_path("..", __dir__)
PROJECT_ROOT = File.expand_path("..", __dir__)
project_root = PROJECT_ROOT
module AndroidHelpers
module_function
def require_env!(keys)
missing = Array(keys).select { |key| ENV[key].nil? || ENV[key].empty? }
UI.user_error!("Missing required env vars: #{missing.join(', ')}") unless missing.empty?
end
def project_root
PROJECT_ROOT
end
def keystore_paths
{
hex: File.join(project_root, 'bluewallet-release-key.keystore.hex'),
file: File.join(project_root, 'android', 'bluewallet-release-key.keystore'),
}
end
def write_keystore_from_hex!(hex_value, paths)
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if hex_value.nil? || hex_value.empty?
File.write(paths[:hex], hex_value)
Actions.sh("xxd -plain -revert #{paths[:hex]} > #{paths[:file]}") do |status|
UI.user_error!("Error reverting hex to keystore") unless status.success?
end
File.delete(paths[:hex])
end
def version_name_and_update_code!(build_gradle_path, build_number)
gradle_contents = File.read(build_gradle_path)
File.write(build_gradle_path, gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}"))
version_name = File.read(build_gradle_path)[/versionName\s+"([^"]+)"/, 1]
UI.user_error!("Failed to extract versionName from #{build_gradle_path}") if version_name.nil? || version_name.empty?
version_name
end
def branch_name
raw = ENV['GITHUB_HEAD_REF'] || ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip
sanitized = raw.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
sanitized.empty? ? 'master' : sanitized
end
def apk_name(version_name, build_number, branch)
branch != 'master' ? "BlueWallet-#{version_name}-#{build_number}-#{branch}.apk" : "BlueWallet-#{version_name}-#{build_number}.apk"
end
def apksigner_path
sdk_root = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']
Dir.glob(File.join(sdk_root.to_s, 'build-tools', '*', 'apksigner')).sort.last
end
def gradle_log_path
path = File.join(project_root, 'fastlane', 'logs', 'gradle-build.log')
FileUtils.mkdir_p(File.dirname(path))
path
end
def assemble_release!(log_path:)
Actions.sh("cd android && ./gradlew assembleRelease --no-daemon --stacktrace --console=plain | tee #{log_path}") do |status|
UI.user_error!("Gradle assembleRelease failed") unless status.success?
end
end
def resolve_apk_paths(version_name:, build_number:, branch_name:)
apk_dir = File.join(project_root, 'android', 'app', 'build', 'outputs', 'apk', 'release')
unsigned = File.join(apk_dir, 'app-release-unsigned.apk')
fallback = File.join(apk_dir, 'app-release.apk')
signed = File.join(apk_dir, apk_name(version_name, build_number, branch_name))
{ unsigned: unsigned, fallback: fallback, signed: signed }
end
def finalize_apk!(paths)
candidate = File.exist?(paths[:unsigned]) ? paths[:unsigned] : paths[:fallback]
UI.user_error!("Unsigned APK not found at path: #{paths[:unsigned]} or #{paths[:fallback]}") unless File.exist?(candidate)
FileUtils.mv(candidate, paths[:signed])
paths[:signed]
end
def sign_apk!(apk_path, keystore_path, keystore_password)
signer = apksigner_path
UI.user_error!("apksigner not found in Android build-tools") if signer.nil? || signer.empty?
Actions.sh("#{signer} sign --ks #{keystore_path} --ks-pass=pass:#{keystore_password} #{apk_path}")
end
def write_github_output(hash)
return unless ENV['GITHUB_OUTPUT'] && !ENV['GITHUB_OUTPUT'].empty?
File.open(ENV['GITHUB_OUTPUT'], 'a') do |f|
hash.each { |key, value| f.puts("#{key}=#{value}") }
end
end
def ensure_temp_credentials!(auto: false)
return unless auto
temp_dir = Dir.mktmpdir('bw-temp-keystore')
keystore_path = File.join(temp_dir, 'temp.keystore')
password = "temp#{SecureRandom.hex(6)}"
alias_name = "bluewallet-temp"
Actions.sh(
"keytool -genkeypair -v -keystore #{keystore_path} -storepass #{password} -keypass #{password} " \
"-alias #{alias_name} -keyalg RSA -keysize 2048 -validity 10000 " \
"-dname \"CN=Temp,O=BlueWallet,OU=CI,L=NY,ST=NY,C=US\""
) do |status|
UI.user_error!("Failed to create temporary keystore with keytool") unless status.success?
end
keystore_hex = File.binread(keystore_path).unpack1('H*')
ENV['KEYSTORE_FILE_HEX'] = keystore_hex
ENV['KEYSTORE_PASSWORD'] = password
end
end
# Add session caching for App Store Connect
def cached_app_store_connect_login
@ -112,82 +229,87 @@ platform :android do
desc "Prepare the keystore file"
lane :prepare_keystore do
Dir.chdir(project_root) do
keystore_file_hex = ENV['KEYSTORE_FILE_HEX']
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if keystore_file_hex.nil?
UI.message("Creating keystore from HEX...")
File.write("bluewallet-release-key.keystore.hex", keystore_file_hex)
sh("xxd -plain -revert bluewallet-release-key.keystore.hex > bluewallet-release-key.keystore") do |status|
UI.user_error!("Error reverting hex to keystore") unless status.success?
end
Dir.chdir(PROJECT_ROOT) do
paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], paths)
UI.message("Keystore created successfully.")
File.delete("bluewallet-release-key.keystore.hex")
end
end
desc "Update version, build number, and sign APK"
lane :update_version_build_and_sign_apk do
Dir.chdir(project_root) do
build_number = ENV['BUILD_NUMBER']
UI.user_error!("BUILD_NUMBER environment variable is missing") if build_number.nil?
# Extract versionName from build.gradle
version_name = sh("grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '\"'").strip
UI.user_error!("Failed to extract versionName from build.gradle") if version_name.nil? || version_name.empty?
# Update versionCode in build.gradle
UI.message("Updating versionCode in build.gradle to #{build_number}...")
build_gradle_path = "android/app/build.gradle"
build_gradle_contents = File.read(build_gradle_path)
new_build_gradle_contents = build_gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}")
File.write(build_gradle_path, new_build_gradle_contents)
# Determine branch name and sanitize it
branch_name = ENV['GITHUB_HEAD_REF'] || `git rev-parse --abbrev-ref HEAD`.strip
branch_name = branch_name.gsub(/[^a-zA-Z0-9_-]/, '_') # Replace non-alphanumeric characters with underscore
branch_name = 'master' if branch_name.nil? || branch_name.empty?
# Define APK name based on branch
signed_apk_name = branch_name != 'master' ?
"BlueWallet-#{version_name}-#{build_number}-#{branch_name}.apk" :
"BlueWallet-#{version_name}-#{build_number}.apk"
# Define paths
unsigned_apk_path = "android/app/build/outputs/apk/release/app-release-unsigned.apk"
signed_apk_path = "android/app/build/outputs/apk/release/#{signed_apk_name}"
# Build APK
lane :update_version_build_and_sign_apk do |options|
Dir.chdir(PROJECT_ROOT) do
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
AndroidHelpers.require_env!(%w[BUILD_NUMBER KEYSTORE_PASSWORD KEYSTORE_FILE_HEX])
keystore_paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
build_gradle_path = File.join('android', 'app', 'build.gradle')
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, ENV['BUILD_NUMBER'])
branch = AndroidHelpers.branch_name
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: ENV['BUILD_NUMBER'], branch_name: branch)
UI.message("Building APK...")
sh("cd android && ./gradlew assembleRelease --no-daemon")
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
UI.message("APK build completed.")
# Rename APK
if File.exist?(unsigned_apk_path)
UI.message("Renaming APK to #{signed_apk_name}...")
FileUtils.mv(unsigned_apk_path, signed_apk_path)
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
else
UI.error("Unsigned APK not found at path: #{unsigned_apk_path}")
next
end
# Sign APK
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
UI.message("Signing APK with apksigner...")
apksigner_path = Dir.glob("#{ENV['ANDROID_HOME']}/build-tools/*/apksigner").sort.last
UI.user_error!("apksigner not found in Android build-tools") if apksigner_path.nil? || apksigner_path.empty?
sh("#{apksigner_path} sign --ks #{project_root}/bluewallet-release-key.keystore --ks-pass=pass:#{ENV['KEYSTORE_PASSWORD']} #{signed_apk_path}")
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
UI.message("APK signed successfully: #{signed_apk_path}")
FileUtils.rm_f(keystore_paths[:file])
end
end
desc "Build and sign release APK"
lane :build_release_apk do |options|
Dir.chdir(PROJECT_ROOT) do
# Allow caller to pass a build_number; otherwise fall back to env or timestamp
build_number = options[:build_number] || ENV['BUILD_NUMBER'] || Time.now.to_i.to_s
ENV['BUILD_NUMBER'] = build_number
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
AndroidHelpers.require_env!(%w[KEYSTORE_FILE_HEX KEYSTORE_PASSWORD])
keystore_paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
build_gradle_path = File.join('android', 'app', 'build.gradle')
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, build_number)
branch = AndroidHelpers.branch_name
UI.message("Building release APK...")
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: build_number, branch_name: branch)
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
UI.message("Signing APK with apksigner...")
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
UI.success("APK signed successfully: #{signed_apk_path}")
FileUtils.rm_f(keystore_paths[:file])
apk_absolute_path = File.expand_path(signed_apk_path)
ENV['APK_OUTPUT_PATH'] = apk_absolute_path
ENV['APK_VERSION_NAME'] = version_name
ENV['APK_VERSION_CODE'] = build_number
AndroidHelpers.write_github_output(
apk_output_path: apk_absolute_path,
apk_version_name: version_name,
apk_version_code: build_number,
)
end
end
end
desc "Upload APK to BrowserStack and post result as PR comment"
lane :upload_to_browserstack_and_comment do
Dir.chdir(project_root) do
# Determine APK path
Dir.chdir(PROJECT_ROOT) do
apk_path = ENV['APK_PATH']
if apk_path.nil? || apk_path.empty?
UI.message("No APK path provided, searching for APK...")
@ -195,7 +317,6 @@ end
UI.user_error!("No APK file found") if apk_path.nil? || apk_path.empty?
end
# Upload to BrowserStack
UI.message("Uploading APK to BrowserStack: #{apk_path}...")
upload_to_browserstack_app_live(
file_path: apk_path,
@ -203,13 +324,11 @@ end
browserstack_access_key: ENV['BROWSERSTACK_ACCESS_KEY']
)
# Extract BrowserStack URL
app_url = ENV['BROWSERSTACK_LIVE_APP_ID']
UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty?
# Prepare PR comment
apk_filename = File.basename(apk_path)
apk_download_url = ENV['APK_OUTPUT_PATH'] # Ensure this path is accessible
apk_download_url = ENV['APK_OUTPUT_PATH']
browserstack_hashed_id = app_url.gsub('bs://', '')
pr_number = ENV['GITHUB_PR_NUMBER']
@ -236,10 +355,9 @@ end
**BrowserStack App URL**: #{app_url}
COMMENT
# Delete Previous BrowserStack Comments
if pr_number
begin
repo = ENV['GITHUB_REPOSITORY'] # Format: "owner/repo"
repo = ENV['GITHUB_REPOSITORY']
repo_owner, repo_name = repo.split('/')
UI.message("Fetching existing comments for PR ##{pr_number}...")
@ -263,7 +381,6 @@ end
UI.important("No PR number found. Skipping deletion of previous comments.")
end
# Post New Comment to PR
if pr_number
begin
escaped_comment = comment.gsub("'", "'\\''")
@ -276,6 +393,7 @@ end
UI.important("No PR number found. Skipping PR comment.")
end
end
end
end

View File

@ -1,13 +1,37 @@
import { useEffect, useRef } from 'react';
import { LayoutAnimation } from 'react-native';
import { useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, Easing, interpolate } from 'react-native-reanimated';
const useAnimateOnChange = <T>(value: T) => {
const prevValue = useRef<T | undefined>(undefined);
useEffect(() => {
if (prevValue.current !== undefined && prevValue.current !== value) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}
prevValue.current = value;
}, [value]);
const progress = useSharedValue(1);
useAnimatedReaction(
() => {
return value;
},
(current, previous) => {
if (previous === null || previous === undefined) {
return;
}
if (current !== previous) {
progress.value = 0;
progress.value = withTiming(1, { duration: 220, easing: Easing.out(Easing.quad) });
}
},
[value],
);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(progress.value, [0, 1], [0.85, 1]),
transform: [
{
scale: interpolate(progress.value, [0, 1], [0.98, 1]),
},
],
};
});
return animatedStyle;
};
export default useAnimateOnChange;

View File

@ -47,7 +47,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
walletsInitialized,
} = useStorage();
const appState = useRef<AppStateStatus>(AppState.currentState);
const clipboardContent = useRef<undefined | string>();
const clipboardContent = useRef<undefined | string>(undefined);
const navigation = useExtendedNavigation();
// We need to call hooks unconditionally before any conditional logic

View File

@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { NativeEventEmitter } from 'react-native';
import EventEmitterModule from '../blue_modules/NativeEventEmitter';
import { useStorage } from '../hooks/context/useStorage';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
import { HandOffActivityType } from '../components/types';
@ -13,8 +14,7 @@ interface UserActivityData {
};
}
const EventEmitter = NativeModules.EventEmitter;
const eventEmitter = EventEmitter ? new NativeEventEmitter(EventEmitter) : null;
const eventEmitter = EventEmitterModule ? new NativeEventEmitter(EventEmitterModule as any) : null;
const useHandoffListener = () => {
const { walletsInitialized } = useStorage();
@ -50,8 +50,9 @@ const useHandoffListener = () => {
const activitySubscription = eventEmitter?.addListener('onUserActivityOpen', handleUserActivity);
if (EventEmitter && EventEmitter.getMostRecentUserActivity) {
EventEmitter.getMostRecentUserActivity()
if (EventEmitterModule && (EventEmitterModule as any).getMostRecentUserActivity) {
(EventEmitterModule as any)
.getMostRecentUserActivity()
.then(handleUserActivity)
.catch(() => console.debug('No valid user activity object received'));
} else {

View File

@ -1,18 +1,18 @@
import { useEffect, useCallback, useRef } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { NativeEventEmitter, Platform } from 'react-native';
import MenuElementsEmitter from '../blue_modules/NativeMenuElementsEmitter';
import { navigationRef } from '../NavigationService';
type MenuActionHandler = () => void;
const { MenuElementsEmitter } = NativeModules;
let eventEmitter: NativeEventEmitter | null = null;
const handlerRegistry = new Map<string, MenuActionHandler>();
try {
if (Platform.OS === 'ios' && MenuElementsEmitter) {
eventEmitter = new NativeEventEmitter(MenuElementsEmitter);
if (typeof MenuElementsEmitter.sharedInstance === 'function') {
MenuElementsEmitter.sharedInstance();
eventEmitter = new NativeEventEmitter(MenuElementsEmitter as any);
if (typeof (MenuElementsEmitter as any).sharedInstance === 'function') {
(MenuElementsEmitter as any).sharedInstance();
}
}
} catch (error) {

View File

@ -9,7 +9,7 @@ const useWalletSubscribe = (walletID: string): TWallet => {
const { wallets } = useStorage();
// get wallet by ID or used cached wallet
const previousWallet = useRef<TWallet | undefined>();
const previousWallet = useRef<TWallet | undefined>(undefined);
const origWallet = wallets.find(w => w.getID() === walletID) ?? previousWallet.current;
if (!origWallet) {
throw new Error(`Wallet with ID ${walletID} not found`);

View File

@ -6,5 +6,8 @@
// Copyright © 2025 BlueWallet. All rights reserved.
//
#import <RNCPushNotificationIOS.h>
#import "RNNotifications.h"
#import "RNQuickActionManager.h"
#import "NativeEventEmitterSpec.h"
#import "NativeMenuElementsEmitterSpec.h"
#import "NativeWidgetHelperSpec.h"

View File

@ -163,6 +163,8 @@
B4D899942DCAE67700B959AA /* CustomSegmentedControl.m in Sources */ = {isa = PBXBuildFile; fileRef = B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */; };
B4EE583C226703320003363C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; };
B4EFF73B2C3F6C5E0095D655 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73A2C3F6C5E0095D655 /* MockData.swift */; };
B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */; };
B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */; };
C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
/* End PBXBuildFile section */
@ -361,6 +363,7 @@
B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITest.swift; sourceTree = "<group>"; };
B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPriceView.swift; sourceTree = "<group>"; };
B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiatUnitEnum.swift; sourceTree = "<group>"; };
B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeWidgetHelperSpec.h; sourceTree = "<group>"; };
B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MenuElementsEmitter.m; sourceTree = "<group>"; };
B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLParserDelegate.swift; sourceTree = "<group>"; };
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHelper.swift; sourceTree = "<group>"; };
@ -376,6 +379,10 @@
B4D59C262D8C5D6E00B7025B /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CustomSegmentedControl.m; sourceTree = "<group>"; };
B4EFF73A2C3F6C5E0095D655 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = "<group>"; };
B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = WidgetHelper.mm; sourceTree = "<group>"; };
B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = EventEmitter.mm; sourceTree = "<group>"; };
B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeEventEmitterSpec.h; sourceTree = "<group>"; };
B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeMenuElementsEmitterSpec.h; sourceTree = "<group>"; };
B642AFB13483418CAB6FF25E /* libRCTQRCodeLocalImage.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTQRCodeLocalImage.a; sourceTree = "<group>"; };
B9D9B3A7B2CB4255876B67AF /* libz.tbd */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
BBA99996E6FA4B49ACE0BEFA /* libRNRate.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNRate.a; sourceTree = "<group>"; };
@ -593,6 +600,9 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */,
B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */,
B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */,
B45010A12C1504E900619044 /* Components */,
B44033C82BCC34AC00162242 /* Shared */,
B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */,
@ -742,9 +752,11 @@
B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */,
B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */,
B4B3EC232D69FF8700327F3D /* EventEmitter.swift */,
B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */,
B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */,
B4B3EC202D69FF6C00327F3D /* CustomSegmentedControl.swift */,
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */,
B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */,
);
path = Components;
sourceTree = "<group>";
@ -1140,6 +1152,7 @@
B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */,
B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */,
B4B3EC252D69FF8700327F3D /* EventEmitter.swift in Sources */,
B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */,
B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */,
B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */,
B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */,
@ -1157,6 +1170,7 @@
B4793DBB2CEDACBD00C92C2E /* Chain.swift in Sources */,
B4B3EC222D69FF6C00327F3D /* CustomSegmentedControl.swift in Sources */,
B4B1A4622BFA73110072E3BB /* WidgetHelper.swift in Sources */,
B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */,
B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */,
B44033DA2BCC369A00162242 /* Colors.swift in Sources */,
B44033D32BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */,
@ -1965,8 +1979,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/bugsnag/bugsnag-cocoa";
requirement = {
kind = exactVersion;
version = 6.28.1;
kind = upToNextMajorVersion;
minimumVersion = 6.28.1;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@ -12,6 +12,14 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
private var userDefaultsGroup: UserDefaults?
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
#if RCT_NEW_ARCH_ENABLED
let turboModuleEnabled = true
#else
let turboModuleEnabled = false
#endif
RCTAppSetupPrepareApp(application, turboModuleEnabled)
clearFilesIfNeeded()
// Fix app group UserDefaults initialization
@ -50,8 +58,8 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
RCTI18nUtil.sharedInstance().allowRTL(true)
let center = UNUserNotificationCenter.current()
center.delegate = self
RNNotifications.startMonitorNotifications()
RNNotifications.addNativeDelegate(self)
setupUserDefaultsListener()
registerNotificationCategories()
@ -60,7 +68,9 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
_ = MenuElementsEmitter.sharedInstance()
NSLog("[MenuElements] AppDelegate: Initialized emitter singleton")
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
return result
}
override func sourceURL(for bridge: RCTBridge) -> URL? {
@ -286,7 +296,7 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
]
if keys.contains(keyPath) {
WidgetHelper.reloadAllWidgets()
WidgetHelper().reloadAllWidgets()
}
}
@ -321,9 +331,26 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
return RCTLinkingManager.application(app, open: url, options: options)
}
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
RNNotifications.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
}
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
RNNotifications.didFailToRegisterForRemoteNotificationsWithError(error)
}
override func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler)
}
override func applicationWillTerminate(_ application: UIApplication) {
userDefaultsGroup?.removeObject(forKey: "onUserActivityOpen")
RNNotifications.removeNativeDelegate(self)
UserDefaults.standard.removeObserver(self, forKeyPath: "deviceUID")
}
@ -351,7 +378,6 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
}
}
RNCPushNotificationIOS.didReceive(response)
completionHandler()
}

View File

@ -195,6 +195,8 @@
<string>io.bluewallet.bluewallet.receiveonchain</string>
<string>io.bluewallet.bluewallet.xpub</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>Entypo.ttf</string>

View File

@ -41,6 +41,8 @@
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@ -16,6 +16,6 @@
RCT_EXPORT_VIEW_PROPERTY(values, NSArray)
RCT_EXPORT_VIEW_PROPERTY(selectedIndex, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(onChangeEvent, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
@end

View File

@ -0,0 +1,9 @@
#import <React/RCTEventEmitter.h>
#import "NativeEventEmitterSpec.h"
@interface RCT_EXTERN_REMAP_MODULE(EventEmitter, EventEmitter, RCTEventEmitter<NativeEventEmitterSpec>)
RCT_EXTERN_METHOD(addListener:(NSString *)eventName)
RCT_EXTERN_METHOD(removeListeners:(double)count)
RCT_EXTERN_METHOD(getMostRecentUserActivity:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@ -2,13 +2,9 @@ import Foundation
import React
@objc(EventEmitter)
class EventEmitter: RCTEventEmitter {
class EventEmitter: RCTEventEmitter, NativeEventEmitterSpec {
static let sharedInstance = EventEmitter()
override class func requiresMainQueueSetup() -> Bool {
return true
}
@objc static func shared() -> EventEmitter {
return sharedInstance
}
@ -17,6 +13,14 @@ class EventEmitter: RCTEventEmitter {
return ["onUserActivityOpen"]
}
override func addListener(_ eventName: String!) {
// Required for TurboModule event emitters; no-op handled by JS side
}
override func removeListeners(_ count: Double) {
// Required for TurboModule event emitters; no-op handled by JS side
}
@objc func sendUserActivity(_ userInfo: [String: Any]) {
sendEvent(withName: "onUserActivityOpen", body: userInfo)
}

View File

@ -1,20 +1,13 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import "NativeMenuElementsEmitterSpec.h"
// This macro exposes the Swift class to Objective-C
@interface RCT_EXTERN_MODULE(MenuElementsEmitter, RCTEventEmitter)
// Expose the Swift method to JS
RCT_EXTERN_METHOD(shared)
@interface RCT_EXTERN_REMAP_MODULE(MenuElementsEmitter, MenuElementsEmitter, RCTEventEmitter<NativeMenuElementsEmitterSpec>)
RCT_EXTERN_METHOD(addListener:(NSString *)eventName)
RCT_EXTERN_METHOD(removeListeners:(double)count)
RCT_EXTERN_METHOD(openSettings)
RCT_EXTERN_METHOD(addWalletMenuAction)
RCT_EXTERN_METHOD(importWalletMenuAction)
RCT_EXTERN_METHOD(reloadTransactionsMenuAction)
RCT_EXTERN_METHOD(checkListenerStatus)
// Make sure we share the same instance between native UI and JS
+ (BOOL)requiresMainQueueSetup {
return YES;
}
RCT_EXTERN_METHOD(sharedInstance)
@end

View File

@ -3,7 +3,7 @@ import React
@objc(CustomSegmentedControl)
class CustomSegmentedControl: UISegmentedControl {
@objc var onChangeEvent: RCTDirectEventBlock?
@objc var onChange: RCTBubblingEventBlock?
@objc var values: [String] = [] {
didSet {
@ -31,7 +31,7 @@ class CustomSegmentedControl: UISegmentedControl {
}
@objc func onChange(_ sender: UISegmentedControl) {
onChangeEvent?(["selectedIndex": sender.selectedSegmentIndex])
onChange?(["selectedIndex": sender.selectedSegmentIndex])
}
}

View File

@ -0,0 +1,6 @@
#import <React/RCTBridgeModule.h>
#import "NativeWidgetHelperSpec.h"
@interface RCT_EXTERN_REMAP_MODULE(WidgetHelper, WidgetHelperModule, NSObject<NativeWidgetHelperSpec>)
RCT_EXTERN_METHOD(reloadAllWidgets)
@end

View File

@ -1,11 +1,40 @@
import Foundation
import WidgetKit
#if canImport(React_Codegen)
import React
#endif
@objc class WidgetHelper: NSObject {
@objc static func reloadAllWidgets() {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadAllTimelines()
} else {
// Fallback on earlier versions
}
// Lightweight helper used by the app target to refresh widget timelines from native code.
class WidgetHelper {
func reloadAllWidgets() {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
#if canImport(React_Codegen)
@objc(WidgetHelperModule)
class WidgetHelperModule: NSObject, NativeWidgetHelperSpec {
static func moduleName() -> String! { "WidgetHelper" }
static func requiresMainQueueSetup() -> Bool { false }
@objc
func reloadAllWidgets() {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
#else
// Fallback for targets (e.g., widget extension) that do not pull in React codegen modules.
@objc(WidgetHelperModule)
class WidgetHelperModule: NSObject {
func reloadAllWidgets() {
// WidgetsExtension does not link the app's WidgetHelper; invoke WidgetKit directly.
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
#endif

View File

@ -1,12 +1 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_MODULE(MenuElementsEmitter, RCTEventEmitter)
RCT_EXTERN_METHOD(openSettings)
RCT_EXTERN_METHOD(addWalletMenuAction)
RCT_EXTERN_METHOD(importWalletMenuAction)
RCT_EXTERN_METHOD(reloadTransactionsMenuAction)
RCT_EXTERN_METHOD(sharedInstance)
@end
// Intentionally left empty after TurboModule migration.

View File

@ -2,8 +2,7 @@ import Foundation
import React
@objc(MenuElementsEmitter)
class MenuElementsEmitter: RCTEventEmitter {
class MenuElementsEmitter: RCTEventEmitter, NativeMenuElementsEmitterSpec {
private static var instance: MenuElementsEmitter?
private var hasListeners = false
@ -19,13 +18,23 @@ class MenuElementsEmitter: RCTEventEmitter {
}
return instance!
}
// NativeMenuElementsEmitterSpec expects an instance method; bridge it to the singleton above.
@objc
func sharedInstance() {
_ = MenuElementsEmitter.sharedInstance()
}
override func supportedEvents() -> [String]! {
return ["openSettings", "addWalletMenuAction", "importWalletMenuAction", "reloadTransactionsMenuAction"]
}
override class func requiresMainQueueSetup() -> Bool {
return true
override func addListener(_ eventName: String!) {
// Required for TurboModule event emitters; JS handles bookkeeping
}
override func removeListeners(_ count: Double) {
// Required for TurboModule event emitters; JS handles bookkeeping
}
override func startObserving() {

View File

@ -0,0 +1,9 @@
#import <React/RCTBridgeModule.h>
// Keep this spec light for Swift bridging; avoid TurboModule includes to prevent C++ STL lookup issues during Swift dependency scanning.
@protocol NativeEventEmitterSpec <RCTBridgeModule>
- (void)addListener:(NSString *)eventName;
- (void)removeListeners:(double)count;
- (void)getMostRecentUserActivity:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject;
@end

View File

@ -0,0 +1,11 @@
#import <React/RCTBridgeModule.h>
@protocol NativeMenuElementsEmitterSpec <RCTBridgeModule>
- (void)addListener:(NSString *)eventName;
- (void)removeListeners:(double)count;
- (void)openSettings;
- (void)addWalletMenuAction;
- (void)importWalletMenuAction;
- (void)reloadTransactionsMenuAction;
- (void)sharedInstance;
@end

View File

@ -0,0 +1,5 @@
#import <React/RCTBridgeModule.h>
@protocol NativeWidgetHelperSpec <RCTBridgeModule>
- (void)reloadAllWidgets;
@end

View File

@ -6,7 +6,7 @@ def node_require(script)
{paths: [process.argv[1]]},
)", __dir__]).strip
end
ENV['RCT_NEW_ARCH_ENABLED'] = '0'
ENV['RCT_NEW_ARCH_ENABLED'] = '1'
min_ios_version_supported = '15.1'
node_require('react-native/scripts/react_native_pods.rb')
node_require('react-native-permissions/scripts/setup.rb')
@ -52,6 +52,24 @@ post_install do |installer|
:mac_catalyst_enabled => true,
# :ccache_enabled => true
)
# Fix RCTDeprecation module map error with Xcode 16+/26+ (required for react-native-notifications).
# The bridging header imports RCTBridgeModule.h which does
# #import <RCTDeprecation/RCTDeprecation.h>. With -fmodules, Clang treats this
# as a module import and builds a PCM, but PCH verification then fails with
# "module 'RCTDeprecation' is not defined in any loaded module map file".
# Fix: remove RCTDeprecation -fmodule-map-file from the consumer xcconfigs
# so the import resolves as a plain textual include via HEADER_SEARCH_PATHS.
# No code uses @import RCTDeprecation, so this is safe.
installer.generated_aggregate_targets.each do |aggregate_target|
aggregate_target.xcconfigs.each do |config_name, config|
xcconfig_path = aggregate_target.xcconfig_path(config_name)
content = File.read(xcconfig_path)
# Order matters: match -Xcc variant first to avoid leaving a dangling -Xcc
content.gsub!('-Xcc -fmodule-map-file="${PODS_ROOT}/Headers/Public/RCTDeprecation/RCTDeprecation.modulemap" ', '')
content.gsub!('-fmodule-map-file="${PODS_ROOT}/Headers/Public/RCTDeprecation/RCTDeprecation.modulemap" ', '')
File.write(xcconfig_path, content)
end
end
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_MODULE(SizeClassEmitter, RCTEventEmitter)
RCT_EXTERN_METHOD(getCurrentSizeClass:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(emitSizeClassChange:(UIWindow *)window)
RCT_EXTERN_METHOD(sharedInstance)
@end

View File

@ -0,0 +1,161 @@
import Foundation
import UIKit
import React
@objc(SizeClassEmitter)
class SizeClassEmitter: RCTEventEmitter {
private enum SizeClassValue: Int {
case compact = 0
case regular = 1
case large = 2
}
private static var sharedEmitter = SizeClassEmitter()
private var hasListeners = false
override init() {
super.init()
SizeClassEmitter.sharedEmitter = self
}
override class func requiresMainQueueSetup() -> Bool {
true
}
override func supportedEvents() -> [String]! {
["sizeClassDidChange"]
}
// MARK: - Singleton access used by ObjC bridge
@objc func sharedInstance() -> SizeClassEmitter {
SizeClassEmitter.sharedEmitter
}
// MARK: - Public API exposed to JS
@objc func getCurrentSizeClass(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
guard let payload = self.buildPayload(window: nil) else {
reject("size_class_unavailable", "Unable to read current size class", nil)
return
}
resolve(payload)
}
}
@objc func emitSizeClassChange(_ window: UIWindow?) {
DispatchQueue.main.async {
self.sendUpdate(window: window, reason: "manual")
}
}
// MARK: - Listener lifecycle
override func startObserving() {
hasListeners = true
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePotentialTraitChange),
name: UIDevice.orientationDidChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePotentialTraitChange),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePotentialTraitChange),
name: UIWindow.didBecomeKeyNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePotentialTraitChange),
name: UIWindow.didResignKeyNotification,
object: nil
)
}
override func stopObserving() {
hasListeners = false
NotificationCenter.default.removeObserver(self)
}
// MARK: - Notification handling
@objc private func handlePotentialTraitChange() {
sendUpdate(window: nil, reason: "notification")
}
private func sendUpdate(window: UIWindow?, reason: String) {
guard hasListeners else { return }
guard let payload = buildPayload(window: window) else { return }
#if DEBUG
NSLog("[SizeClassEmitter] Emitting update (%@): %@", reason, payload.description)
#endif
sendEvent(withName: "sizeClassDidChange", body: payload)
}
// MARK: - Payload construction
private func buildPayload(window: UIWindow?) -> [String: Any]? {
guard let activeWindow = resolveWindow(window) else {
return nil
}
let traits = activeWindow.traitCollection
let bounds = activeWindow.bounds
let horizontalClass = map(sizeClass: traits.horizontalSizeClass)
let verticalClass = map(sizeClass: traits.verticalSizeClass)
// Preserve previous JS behavior: any non-Compact width is considered Large overall.
let overallClass: SizeClassValue = horizontalClass == .compact ? .compact : .large
let orientation: String = bounds.width > bounds.height ? "landscape" : "portrait"
let isLargeScreen = horizontalClass != .compact
return [
"horizontal": horizontalClass.rawValue,
"vertical": verticalClass.rawValue,
"sizeClass": overallClass.rawValue,
"orientation": orientation,
"isLargeScreen": isLargeScreen,
"width": Double(bounds.width),
"height": Double(bounds.height),
]
}
private func map(sizeClass: UIUserInterfaceSizeClass) -> SizeClassValue {
switch sizeClass {
case .compact:
return .compact
case .regular:
return .regular
default:
return .regular
}
}
private func resolveWindow(_ providedWindow: UIWindow?) -> UIWindow? {
if let providedWindow {
return providedWindow
}
if let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) {
return keyWindow
}
return UIApplication.shared.windows.first
}
}

View File

@ -27,5 +27,7 @@
<key>NSExtensionPrincipalClass</key>
<string>StickerBrowserViewController</string>
</dict>
<key>RCTNewArchEnabled</key>
<true/>
</dict>
</plist>

View File

@ -20,16 +20,6 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>bugsnag</key>
<dict>
<key>apiKey</key>
<string>17ba9059f676f1cc4f45d98182388b01</string>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@ -64,5 +54,17 @@
</dict>
</dict>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>RCTNewArchEnabled</key>
<true/>
<key>bugsnag</key>
<dict>
<key>apiKey</key>
<string>17ba9059f676f1cc4f45d98182388b01</string>
</dict>
</dict>
</plist>

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