Compare commits

..

31 Commits

Author SHA1 Message Date
Ojok Emmanuel Nsubuga
d2e8e490c6
Merge branch 'master' into reproducibility 2026-06-14 14:11:09 +03:00
Ojok Emmanuel Nsubuga
38117ef477 OPS: Update detox version 2026-06-04 11:46:34 +03:00
Ojok Emmanuel Nsubuga
3ec160983c
Merge branch 'master' into reproducibility 2026-06-04 10:26:14 +03:00
Ojok Emmanuel Nsubuga
fa8c181026 OPS: clean up the zip files after usage 2026-06-04 10:25:23 +03:00
Ojok Emmanuel Nsubuga
5a49b44f11
Merge branch 'master' into reproducibility 2026-06-01 08:15:09 +03:00
Ivan Vershigora
bc5f204f3d
fix: add ios/build to dockerignore 2026-05-27 14:14:03 +01:00
Ojok Emmanuel Nsubuga
724f589791 OPS: Update apksigner 2026-05-27 06:25:02 +03:00
Ojok Emmanuel Nsubuga
25c02c82f2 OPS: Handle password securely 2026-05-25 22:03:46 +03:00
Ojok Emmanuel Nsubuga
d0c1284a3c
Merge branch 'master' into reproducibility 2026-05-25 18:27:11 +03:00
Ojok Emmanuel Nsubuga
4d74329ac9 OPS: Add debian snapshot and update build script for aab on Mac 2026-05-25 18:17:52 +03:00
Ojok Emmanuel Nsubuga
8b48feba54 OPS: Review feedback 2026-05-24 16:35:07 +03:00
Ojok Emmanuel Nsubuga
4f240a6ecb
Merge branch 'master' into reproducibility 2026-05-22 11:46:17 +03:00
Ojok Emmanuel Nsubuga
ae905607e2
Merge branch 'master' into reproducibility 2026-05-21 19:39:28 +03:00
Ojok Emmanuel Nsubuga
e44b8e5f49
Merge branch 'master' into reproducibility 2026-05-21 16:31:46 +03:00
Ojok Emmanuel Nsubuga
d40e3ce87a OPS: Restore file to match master version 2026-05-14 10:58:02 +03:00
Ojok Emmanuel Nsubuga
6824bc753a
Merge branch 'master' into reproducibility 2026-05-14 10:43:23 +03:00
Ojok Emmanuel Nsubuga
ea3c33189b OPS: Fix build failing on Macbook 2026-05-13 07:46:59 +03:00
Ojok Emmanuel Nsubuga
a40238e383 OPS: Update detox and ndk versions 2026-05-05 12:46:57 +03:00
Ojok Emmanuel Nsubuga
5c3bfe5305
Merge branch 'master' into reproducibility 2026-05-05 10:30:35 +03:00
Ojok Emmanuel Nsubuga
c67284a2d1 OPS: Update to build reproducible apk 2026-04-20 11:56:38 +03:00
Ojok Emmanuel Nsubuga
822fe9316d OPS: Update detox version 2026-04-09 20:14:12 +03:00
Ojok Emmanuel Nsubuga
435cabbd41
Merge branch 'master' into reproducibility 2026-04-09 19:36:48 +03:00
Ojok Emmanuel Nsubuga
6bcbd02e41 OPS: Improve docker image building performance
Add npm config for timeout and retry
Create android sdk dir and update permission at the top before adding files

Co-authored-by: winterrdog <winterrdog@protonmail.ch>
2026-04-09 16:01:28 +03:00
Ojok Emmanuel Nsubuga
b0ba753ad1 OPS: Apply Bugsnag plugin 2026-04-08 16:59:54 +03:00
Ojok Emmanuel Nsubuga
403df62f2d OPS: Add script to compare apks 2026-04-08 16:53:31 +03:00
Ojok Emmanuel Nsubuga
7009cf30fe OPS: Update Dockerfile and add build scripts 2026-04-08 16:47:24 +03:00
Ojok Emmanuel Nsubuga
5d9b8c5ba7 FIX: Update detox version 2026-03-28 07:22:08 +03:00
Ojok Emmanuel Nsubuga
61d168de17
Merge branch 'master' into reproducibility 2026-03-28 05:42:50 +03:00
Ojok Emmanuel Nsubuga
8793b1e941 OPS: Update detox version 2026-02-17 20:25:15 +03:00
Ojok Emmanuel Nsubuga
3b9ab70c82 OPS: add Dockerfile and .dockerignore for containerized builds 2026-02-17 13:07:58 +03:00
Ojok Emmanuel Nsubuga
0d8b4c6bd6 OPS: Update gradle configurations for reproducibility 2026-02-17 13:01:43 +03:00
54 changed files with 2732 additions and 1872 deletions

57
.dockerignore Normal file
View File

@ -0,0 +1,57 @@
# Node modules
node_modules/
npm-debug.log
yarn-error.log
# Android build artifacts
android/.gradle/
android/build/
android/app/build/
android/.kotlin/
android/app/.cxx/
reproducible-builds/build
# Git
.git/
# IDE files
.idea/
*.iml
*.classpath
*.project
android/.settings/
android/app/.settings/
ios/BlueWallet.xcodeproj/xcuserdata/
.vscode/
.vs/
# Temporary / system files
.DS_Store
*.hprof
.metro-health-check*
artifacts/
# Fastlane outputs
fastlane/screenshots/
fastlane/test_output/
fastlane/report.xml
fastlane/Preview.html
fastlane/README.md
# BlueWallet specific
release-notes.json
release-notes.txt
current-branch.json
# iOS / Xcode
ios/build/
build/
DerivedData/
*.xcuserstate
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
.cxx/
*.ipa
*.hmap

View File

@ -64,7 +64,7 @@ jobs:
- name: Install Android SDK components
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;27.1.12297006"
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;28.2.13676358"
- name: Install node_modules (include dev deps for patch-package)
run: npm ci --yes

View File

@ -194,6 +194,9 @@ jobs:
mkdir -p ios/build/Build/Products/Release-iphonesimulator
tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator
- name: Disable simulator animations
run: defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
# Pre-boot simulator so first detox launchApp lands warm.
- name: Pre-boot iOS simulator
run: |
@ -207,13 +210,6 @@ jobs:
xcrun simctl bootstatus "$UDID" -b
xcrun simctl launch "$UDID" com.apple.springboard >/dev/null 2>&1 || true
# Cut animations so detox sync stays steady on slow CI VMs; Reduce Motion makes reanimated skip to final value.
- name: Disable simulator animations
run: |
defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO
xcrun simctl spawn booted defaults write com.apple.Accessibility ReduceMotionEnabled -bool true
xcrun simctl spawn booted notifyutil -p com.apple.Accessibility.ReduceMotionStatusDidChange
- name: Run detox tests
timeout-minutes: 360
run: |

1
.gitignore vendored
View File

@ -33,6 +33,7 @@ build/
.gradle
local.properties
*.iml
reproducible-builds/build
# testing
/coverage

View File

@ -7,7 +7,7 @@ gem "fastlane", "~> 2.234.0"
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.8'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 removed these from the standard library
gem 'bigdecimal'

View File

@ -87,7 +87,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.3)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
@ -337,7 +337,7 @@ DEPENDENCIES
benchmark
bigdecimal
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
concurrent-ruby (< 1.3.8)
concurrent-ruby (< 1.3.4)
fastlane (~> 2.234.0)
fastlane-plugin-browserstack
fastlane-plugin-bugsnag
@ -377,7 +377,7 @@ CHECKSUMS
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
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

View File

@ -63,14 +63,14 @@ def enableProguardInReleaseBuilds = false
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.0.1`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.0.1'
android {
androidResources {
@ -114,6 +114,11 @@ android {
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
task copyFiatUnits(type: Copy) {
@ -131,7 +136,7 @@ tasks.configureEach { task ->
}
dependencies {
androidTestImplementation('com.wix:detox:+')
androidTestImplementation('com.wix:detox:20.51.3')
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'androidx.core:core-ktx:1.18.0'
@ -146,8 +151,9 @@ dependencies {
} else {
implementation jscFlavor
}
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
apply plugin: 'com.google.gms.google-services' // Google Services plugin
apply plugin: "com.bugsnag.android.gradle"
apply plugin: "com.bugsnag.android.gradle"

View File

@ -3,10 +3,11 @@
buildscript {
ext {
minSdkVersion = 24
buildToolsVersion = "36.0.0"
compileSdkVersion = 36
targetSdkVersion = 36
googlePlayServicesVersion = "16.+"
googlePlayServicesVersion = "16.1.0"
googlePlayServicesIidVersion = "16.0.1"
firebaseVersion = "21.1.0"
ndkVersion = "28.2.13676358"
@ -17,7 +18,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.android.tools.build:gradle:8.8.0")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
@ -135,8 +136,19 @@ subprojects { project ->
defaultConfig {
minSdkVersion 24
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
}
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
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"]
@ -145,6 +157,7 @@ subprojects { project ->
} else {
kotlinOptions.jvmTarget = sourceCompatibility
}
kotlinOptions.jvmTarget = "17"
}
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {

View File

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

View File

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

View File

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

View File

@ -658,12 +658,6 @@ export class LightningArkWallet extends LightningCustodianWallet {
const paymentResult = await this._arkadeSwaps.sendLightningPayment({ invoice });
this.last_paid_invoice_result = {
payment_preimage: paymentResult.preimage,
payment_hash: invoiceDetails.paymentHash,
payment_request: invoice,
};
console.log('Payment successful!');
console.log('Amount:', paymentResult.amount);
console.log('Preimage:', paymentResult.preimage);

View File

@ -52,14 +52,7 @@ const useFloatButtonAnimation = (initialHeight: number) => {
};
};
const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale);
/** Scroll padding so list content clears float buttons (excludes safe-area inset). Default 70 at fontScale 1. */
const FLOAT_BUTTON_LIST_CLEARANCE = 18;
export const getFloatingButtonReservedHeight = (fontScale = 1): number => getScaledButtonHeight(fontScale) + FLOAT_BUTTON_LIST_CLEARANCE;
const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: number) => {
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
const lastVerticalDecision = useRef(false);
const shouldUseVerticalLayout = useCallback(
@ -159,19 +152,15 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: nu
[width, sizeClass, shouldUseVerticalLayout],
);
const calculateContainerHeight = useCallback(
(childrenCount: number, isVerticalLayout: boolean) => {
const buttonHeight = getScaledButtonHeight(fontScale);
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
const totalButtonsHeight = childrenCount * buttonHeight;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
return { height: calculatedHeight };
},
[fontScale],
);
return { height: calculatedHeight };
}, []);
const calculateButtonFontSize = useMemo(() => {
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
@ -278,7 +267,6 @@ interface FButtonProps {
isVertical?: boolean;
borderRadius?: number;
fontSize?: number;
buttonHeight?: number;
disabled?: boolean;
testID?: string;
onPress: () => void;
@ -289,14 +277,13 @@ interface ButtonContentProps {
icon: ReactNode;
text: string;
textStyle: StyleProp<TextStyle>;
buttonHeight: number;
}
const getScaledIconSize = (fontSize: number): number => {
return Math.max(Math.round(fontSize * 1.2), 16);
};
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
const computedStyle = StyleSheet.flatten(textStyle);
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
const iconSize = getScaledIconSize(Number(fontSize));
@ -320,14 +307,9 @@ const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentPro
}
return (
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
<View style={buttonContentStaticStyles.contentContainer}>
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
>
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
{text}
</Text>
</View>
@ -343,7 +325,6 @@ export const FButton = ({
isVertical,
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
buttonHeight = LAYOUT.BUTTON_HEIGHT,
testID,
...props
}: FButtonProps) => {
@ -366,8 +347,6 @@ export const FButton = ({
return {
root: {
...baseStyles,
height: buttonHeight,
minHeight: buttonHeight,
backgroundColor: colors.buttonBackgroundColor,
},
text: {
@ -381,7 +360,7 @@ export const FButton = ({
marginBottom: buttonContentStaticStyles.marginBottom,
textBase: buttonContentStaticStyles.textBase,
};
}, [colors, fontSize, buttonHeight]);
}, [colors, fontSize]);
const style: Record<string, any> = {};
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
@ -418,7 +397,7 @@ export const FButton = ({
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
{...props}
>
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
</TouchableOpacity>
</Animated.View>
);
@ -426,9 +405,8 @@ export const FButton = ({
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const insets = useSafeAreaInsets();
const { height, width, fontScale } = useWindowDimensions();
const { height, width } = useWindowDimensions();
const { sizeClass } = useSizeClass();
const scaledButtonHeight = getScaledButtonHeight(fontScale);
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
@ -441,7 +419,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
width,
sizeClass,
fontScale,
);
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
@ -531,7 +508,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
useEffect(() => {
debouncedCalculateLayout();
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
const { width: currentLayoutWidth } = event.nativeEvent.layout;
@ -568,7 +545,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
isVertical,
borderRadius: buttonBorderRadius,
fontSize: buttonFontSize,
buttonHeight: scaledButtonHeight,
});
};
@ -585,10 +561,10 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
bottomInsets,
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
isVertical ? containerHeight : null,
{ transform: [{ translateY: slideAnimation }] },
],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
);
return (

View File

@ -1,13 +1,10 @@
import React, { useMemo } from 'react';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, View, ViewStyle } from 'react-native';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
import { useLocale } from '@react-navigation/native';
import Icon from './Icon';
import { useTheme } from './themes';
/** Base row height for transaction list `getItemLayout` (padding + title + subtitle at fontScale 1). */
export const TX_ROW_BASE_HEIGHT = 64;
interface ListItemProps {
leftAvatar?: React.JSX.Element;
containerStyle?: StyleProp<ViewStyle>;
@ -58,20 +55,12 @@ const ListItem: React.FC<ListItemProps> = React.memo(
}: ListItemProps) => {
const { colors } = useTheme();
const { direction } = useLocale();
const { fontScale } = useWindowDimensions();
const isRtl = direction === 'rtl';
const contentRowStyle = useMemo(
() => ({
paddingVertical: Math.round(12 * fontScale),
}),
[fontScale],
);
const stylesHook = StyleSheet.create({
title: {
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
fontSize: 16,
fontWeight: '500',
lineHeight: Math.round(22 * fontScale),
writingDirection: direction,
},
rightMemoText: {
@ -83,7 +72,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
color: colors.alternativeTextColor,
fontWeight: '400',
paddingVertical: switchProps ? 8 : 0,
lineHeight: Math.round(20 * fontScale),
lineHeight: 20,
fontSize: 14,
marginTop: 2,
},
@ -104,7 +93,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
const enableFeedback = !noFeedback && !!onPress && !disabled;
const renderContent = () => (
<View style={[styles.contentRow, contentRowStyle]}>
<View style={styles.contentRow}>
{leftAvatar && (
<View style={styles.leftAvatarContainer}>
{leftAvatar}
@ -125,14 +114,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
{rightTitle || rightSubtitle ? (
<View style={styles.rightColumn}>
{rightTitle ? (
<Text
style={rightTitleStyle}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.75}
accessibilityRole="text"
selectable={rightTitleSelectable}
>
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text" selectable={rightTitleSelectable}>
{rightTitle}
</Text>
) : null}
@ -210,20 +192,16 @@ const styles = StyleSheet.create({
},
content: {
flex: 1,
flexShrink: 1,
minWidth: 0,
justifyContent: 'center',
},
leftAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
},
rightColumn: {
marginStart: 8,
flexShrink: 0,
minWidth: 0,
alignItems: 'flex-end',
alignSelf: 'center',
},
rightMemoWrapper: {
flexShrink: 1,

View File

@ -67,25 +67,26 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
}, []);
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
if (feeType === NetworkTransactionFeeType.CUSTOM) {
setSelectedFeeType(feeType);
return;
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
Keyboard.dismiss();
}
Keyboard.dismiss();
if (networkFees) {
let selectedFee: number;
switch (feeType) {
case NetworkTransactionFeeType.FAST:
onFeeSelected(networkFees.fastestFee);
selectedFee = networkFees.fastestFee;
break;
case NetworkTransactionFeeType.MEDIUM:
onFeeSelected(networkFees.mediumFee);
selectedFee = networkFees.mediumFee;
break;
case NetworkTransactionFeeType.SLOW:
onFeeSelected(networkFees.slowFee);
selectedFee = networkFees.slowFee;
break;
case NetworkTransactionFeeType.CUSTOM:
selectedFee = Number(customFeeValue);
break;
}
onFeeSelected(selectedFee);
setSelectedFeeType(feeType);
}
};
@ -93,8 +94,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
const handleCustomFeeChange = (customFee: string) => {
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
setCustomFeeValue(sanitizedFee);
onFeeSelected(Number(sanitizedFee));
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
};
return (
@ -156,10 +156,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
ref={customTextInput}
maxLength={9}
style={[styles.customFeeInput, stylesHook.customFeeInput]}
onFocus={() => {
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(customFeeValue));
}}
onFocus={() => handleCustomFeeChange(customFeeValue)}
placeholder={loc.send.fee_satvbyte}
placeholderTextColor="#81868e"
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}

View File

@ -7,18 +7,10 @@ import { useTheme } from './themes';
interface SafeAreaScrollViewProps extends ScrollViewProps {
floatingButtonHeight?: number;
headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true)
disableDefaultTopPadding?: boolean;
}
const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((props, ref) => {
const {
style,
contentContainerStyle,
floatingButtonHeight = 0,
headerHeight = 0,
disableDefaultTopPadding = false,
...otherProps
} = props;
const { style, contentContainerStyle, floatingButtonHeight = 0, headerHeight = 0, ...otherProps } = props;
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@ -40,10 +32,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
if (headerHeight > 0) {
return headerHeight;
}
if (disableDefaultTopPadding) {
return 0;
}
// Preserve legacy behavior for existing screens
// iOS safe area or no status bar
return insets.top > 0 ? 5 : 0;
})(),
};
@ -59,7 +48,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
// Now compose with contentContainerStyle to ensure passed styles override defaults
return StyleSheet.compose(basePadding, contentContainerStyle);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight]);
return (
<ScrollView

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from 'react';
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
import { useStorage } from '../hooks/context/useStorage';
import loc, { formatBalanceWithoutSuffix } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
@ -22,7 +22,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
setTotalBalancePreferredUnitStorage,
} = useSettings();
const { colors } = useTheme();
const { fontScale } = useWindowDimensions();
const totalBalanceFormatted = useMemo(() => {
const totalBalance = wallets.reduce((prev, curr) => {
@ -32,22 +31,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]);
const scaledStyles = useMemo(
() => ({
container: {
paddingVertical: Math.round(8 * fontScale),
},
label: {
lineHeight: Math.round(18 * fontScale),
marginBottom: Math.round(2 * fontScale),
},
balance: {
lineHeight: Math.round(38 * Math.max(1, fontScale)),
},
}),
[fontScale],
);
const toolTipActions = useMemo(
() => [
{
@ -109,20 +92,13 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
return (
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
<View style={[styles.container, scaledStyles.container]}>
<Text style={[styles.label, scaledStyles.label]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{loc.wallets.total_balance}
</Text>
<TouchableOpacity onPress={handleBalanceOnPress} style={styles.balanceTouchable}>
<Text
style={[styles.balance, scaledStyles.balance, { color: colors.foregroundColor }]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
>
{totalBalanceFormatted}
<View style={styles.container}>
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
<TouchableOpacity onPress={handleBalanceOnPress}>
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
{totalBalanceFormatted}{' '}
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
)}
</Text>
</TouchableOpacity>
@ -140,11 +116,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 8,
width: '100%',
},
balanceTouchable: {
alignSelf: 'stretch',
width: '100%',
},
label: {
fontSize: 14,
@ -154,7 +125,6 @@ const styles = StyleSheet.create({
balance: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 38,
},
currency: {
fontSize: 18,

View File

@ -1,7 +1,7 @@
import React, { memo, useCallback, useMemo, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Clipboard from '@react-native-clipboard/clipboard';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningTransaction, Transaction } from '../class/wallets/types';
@ -29,6 +29,9 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import ListItem from './ListItem';
const styles = StyleSheet.create({
dateLine: {
fontSize: 13,
},
fullWidthButton: {
width: '100%',
alignSelf: 'stretch',
@ -130,7 +133,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
const { language, selectedBlockExplorer } = useSettings();
const insets = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
const containerStyle = useMemo(
() => ({
backgroundColor: colors.background,
@ -246,7 +248,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
color,
fontSize: 14,
fontWeight: '600' as TextStyle['fontWeight'],
lineHeight: Math.round(20 * fontScale),
textAlign: 'right',
paddingRight: insets.right,
paddingLeft: insets.left,
@ -261,7 +262,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
item.ispaid,
insets.right,
insets.left,
fontScale,
]);
const determineTransactionTypeAndAvatar = () => {
@ -549,7 +549,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
<ListItem
leftAvatar={avatar}
title={listTitle}
subtitle={dateLine}
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}

View File

@ -1,8 +1,8 @@
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 { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { 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 { useTheme } from './themes';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
@ -14,39 +14,35 @@ import { FiatUnit } from '../models/fiatUnit';
import { BlurredBalanceView } from './BlurredBalanceView';
import { useSettings } from '../hooks/context/useSettings';
import ToolTipMenu from './TooltipMenu';
import useAnimateOnChange from '../hooks/useAnimateOnChange';
import { useLocale } from '@react-navigation/native';
import ActionSheet from '../screen/ActionSheet';
const HERO_BASE_BODY_MIN_HEIGHT = 120;
const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2);
const HERO_BOTTOM_PADDING = 32;
const WALLET_LABEL_TOP_GAP = 32;
interface TransactionsNavigationHeaderProps {
wallet: TWallet;
unit: BitcoinUnit;
headerOverlayHeight: number;
onWalletUnitChange: (unit: BitcoinUnit) => void;
onManageFundsPressed?: (id?: string) => void;
onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void;
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
unitSwitching?: boolean;
}
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
wallet,
headerOverlayHeight,
onWalletUnitChange,
onManageFundsPressed,
onWalletBalanceVisibilityChange,
unit = BitcoinUnit.BTC,
unitSwitching = false,
}) => {
const { colors } = useTheme();
const { hideBalance } = wallet;
const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type;
const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet);
const { preferredFiatCurrency } = useSettings();
const { direction } = useLocale();
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const previousBalance = useRef<string | undefined>(undefined);
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
if (isLightningWallet) {
@ -77,14 +73,13 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const handleBalanceVisibility = useCallback(() => {
onWalletBalanceVisibilityChange?.(!hideBalance);
}, [hideBalance, onWalletBalanceVisibilityChange]);
}, [onWalletBalanceVisibilityChange, hideBalance]);
const changeWalletBalanceUnit = () => {
if (hideBalance) {
return;
}
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) {
@ -93,6 +88,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
onWalletUnitChange(newWalletPreferredUnit);
};
@ -107,9 +103,9 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const onPressMenuItem = useCallback(
(id: string) => {
if (id === actionKeys.WalletBalanceVisibility) {
if (id === 'walletBalanceVisibility') {
handleBalanceVisibility();
} else if (id === actionKeys.CopyToClipboard) {
} else if (id === 'copyToClipboard') {
handleCopyPress();
}
},
@ -144,160 +140,148 @@ 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
? [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_show,
icon: actionIcons.Eye,
icon: {
iconValue: 'eye',
},
},
]
: [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_hide,
icon: actionIcons.EyeSlash,
icon: {
iconValue: 'eye.slash',
},
},
{
id: actionKeys.CopyToClipboard,
id: 'copyToClipboard',
text: loc.transactions.details_copy,
icon: actionIcons.Clipboard,
icon: {
iconValue: 'doc.on.doc',
},
},
];
}, [hideBalance]);
useEffect(() => {
console.debug('[UnitSwitch/UI] render state', {
walletID: wallet.getID?.(),
unit,
hideBalance,
preferredFiat: preferredFiatCurrency?.endPointKey,
switching: unitSwitching,
});
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
return (
<View
style={[
styles.lineaderGradient,
{
paddingTop: headerOverlayHeight,
minHeight: headerOverlayHeight + HERO_MIN_BODY_HEIGHT,
backgroundColor: WalletGradient.headerColorFor(wallet.type),
},
]}
>
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={StyleSheet.absoluteFill} />
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
<View style={styles.contentContainer}>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<View style={styles.balanceSection}>
<View style={styles.walletBalanceAndUnitContainer}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<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}
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Text>
)}
</View>
</ToolTipMenu>
{!hideBalance && (
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
)}
</View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</TouchableOpacity>
)}
</View>
</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 && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</TouchableOpacity>
)}
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.bottomBarSpacer}>
<View
style={[
styles.bottomBar,
{
backgroundColor: colors.background,
...Platform.select({
ios: { shadowColor: colors.shadowColor },
android: {},
}),
},
]}
/>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
lineaderGradient: {
minHeight: 140,
justifyContent: 'flex-start',
position: 'relative',
},
contentContainer: {
flex: 1,
paddingTop: WALLET_LABEL_TOP_GAP,
paddingHorizontal: 16,
paddingBottom: HERO_BOTTOM_PADDING,
},
bottomBarSpacer: {
position: 'relative',
height: 12,
marginBottom: 0,
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
height: 13,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
...Platform.select({
ios: {
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 0.5,
},
}),
padding: 15,
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
color: 'rgba(255, 255, 255, 0.7)',
marginBottom: 4,
color: '#fff',
marginBottom: 10,
},
walletBalance: {
flexShrink: 1,
marginRight: 6,
minHeight: 39,
justifyContent: 'center',
},
balanceSection: {
flexDirection: 'column',
alignItems: 'flex-start',
},
manageFundsButton: {
marginTop: 14,
@ -318,13 +302,13 @@ const styles = StyleSheet.create({
walletBalanceAndUnitContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: 10,
paddingRight: 10, // Ensure there's some padding to the right
},
walletBalanceText: {
color: '#fff',
fontWeight: 'bold',
fontSize: 36,
flexShrink: 1,
flexShrink: 1, // Allow the text to shrink if there's not enough space
},
walletPreferredUnitView: {
justifyContent: 'center',

View File

@ -30,7 +30,6 @@ import WalletGradient from '../class/wallet-gradient';
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
import { BlurredBalanceView } from './BlurredBalanceView';
import { withAlpha } from './color';
import { useTheme } from './themes';
import { Transaction, TWallet } from '../class/wallets/types';
import { BlueSpacing10 } from './BlueSpacing';
@ -38,30 +37,6 @@ import { useLocale } from '@react-navigation/native';
export const WALLET_CAROUSEL_HEADER_WIDTH = 16;
/** Base card body height at default Dynamic Type — grows with larger Dynamic Type, never shrinks below default. */
export const WALLET_CARD_BASE_MIN_HEIGHT = 164;
/** Top inset above wallet cards in the horizontal home carousel. */
export const WALLET_CAROUSEL_PADDING_TOP = 12;
/** Bottom inset so iOS card shadows (offset 4 + radius 8) are not clipped by the list row. */
export const WALLET_CAROUSEL_PADDING_BOTTOM = 20;
/** Scale layout metrics up for accessibility sizes; keep the design default when fontScale ≤ 1. */
const scaleLayoutUp = (base: number, fontScale: number): number => Math.round(base * Math.max(1, fontScale));
export const getWalletCardMinHeight = (fontScale = 1): number => scaleLayoutUp(WALLET_CARD_BASE_MIN_HEIGHT, fontScale);
export const getWalletCarouselHeight = (fontScale = 1): number =>
scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale) +
getWalletCardMinHeight(fontScale) +
scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale);
/** Default carousel row height at `fontScale` 1 — prefer `getWalletCarouselHeight(fontScale)` when layout depends on Dynamic Type. */
export const WALLET_CAROUSEL_HEIGHT = getWalletCarouselHeight(1);
/** Vertical gap between the wallet title/balance block and the latest-tx footer on carousel cards. */
const WALLET_CARD_SECTION_GAP = 12;
const WALLET_CARD_TEXT_OPACITY = 0.85;
export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82);
interface NewWalletPanelProps {
@ -185,28 +160,23 @@ const iStyles = StyleSheet.create({
borderRadius: 12,
minHeight: 164,
overflow: 'hidden',
justifyContent: 'flex-end',
},
gradCompact: {
borderRadius: 10,
minHeight: 132,
overflow: 'hidden',
justifyContent: 'flex-end',
},
gradContent: {
padding: 15,
width: '100%',
},
gradContentCompact: {
padding: 12,
},
balanceContainer: {
minHeight: 40,
justifyContent: 'center',
height: 40,
},
balanceContainerCompact: {
minHeight: 32,
justifyContent: 'center',
height: 32,
},
image: {
width: 99,
@ -219,6 +189,9 @@ const iStyles = StyleSheet.create({
width: 78,
height: 74,
},
br: {
backgroundColor: 'transparent',
},
label: {
backgroundColor: 'transparent',
fontSize: 19,
@ -233,6 +206,7 @@ const iStyles = StyleSheet.create({
},
balanceCompact: {
fontSize: 28,
lineHeight: 34,
},
latestTx: {
backgroundColor: 'transparent',
@ -308,32 +282,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const { colors } = useTheme();
const { width, fontScale } = useWindowDimensions();
const { width } = useWindowDimensions();
const itemWidth = getWalletCarouselItemWidth(width);
const { sizeClass } = useSizeClass();
const isCompact = sizeVariant === 'compact';
const { direction } = useLocale();
const scaledCardStyles = useMemo(
() => ({
grad: { minHeight: getWalletCardMinHeight(fontScale) },
gradContent: { padding: scaleLayoutUp(15, fontScale) },
balanceContainer: { minHeight: scaleLayoutUp(40, fontScale) },
textSpacer: { height: scaleLayoutUp(WALLET_CARD_SECTION_GAP, fontScale) },
label: { lineHeight: scaleLayoutUp(24, fontScale) },
balance: { lineHeight: scaleLayoutUp(38, fontScale) },
balanceCompact: { lineHeight: scaleLayoutUp(30, fontScale) },
latestTx: { lineHeight: scaleLayoutUp(18, fontScale) },
latestTxTime: { lineHeight: scaleLayoutUp(22, fontScale) },
}),
[fontScale],
);
const cardTextStyle = useMemo(
() => ({
color: withAlpha(colors.inverseForegroundColor, WALLET_CARD_TEXT_OPACITY),
writingDirection: direction,
}),
[colors.inverseForegroundColor, direction],
);
const previousBalance = useRef<string | undefined>(undefined);
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
const safeBalance = balance || undefined;
@ -478,23 +431,23 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
]}
>
<LinearGradient
colors={WalletGradient.gradientsFor(item.type)}
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
>
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
<Text style={iStyles.br} />
{!isPlaceHolder && (
<>
<Text
numberOfLines={1}
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
style={[
iStyles.label,
isCompact && iStyles.labelCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
</Text>
<View
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
>
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
{hideBalance ? (
<>
<BlueSpacing10 />
@ -504,13 +457,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
<Animated.Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
style={[
iStyles.balance,
isCompact && iStyles.balanceCompact,
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
cardTextStyle,
{ color: colors.inverseForegroundColor, writingDirection: direction },
animatedBalanceStyle,
]}
>
@ -518,20 +469,24 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
</Animated.Text>
)}
</View>
<View style={scaledCardStyles.textSpacer} />
<Text style={iStyles.br} />
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
style={[
iStyles.latestTx,
isCompact && iStyles.latestTxCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
style={[
iStyles.latestTxTime,
isCompact && iStyles.latestTxTimeCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
>
{latestTransactionText}
</Text>
@ -586,7 +541,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
animateChanges = false,
} = props;
const { width, fontScale } = useWindowDimensions();
const { width } = useWindowDimensions();
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
const snapOffsets = React.useMemo(() => {
@ -695,7 +650,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
// Fallback: try scrolling to offset
// Use different measurement based on orientation
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
const itemSize = horizontal ? itemWidth : 195; // 195 is the approximate height of wallet card
flatListRef.current.scrollToOffset({
offset: itemSize * walletIndex,
animated,
@ -817,7 +772,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
const sliderHeight = getWalletCarouselHeight(fontScale);
const sliderHeight = 195;
useEffect(() => {
return () => {
@ -900,8 +855,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const cStyles = StyleSheet.create({
content: {
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
paddingTop: 16,
},
contentLargeScreen: {
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
@ -932,7 +886,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
automaticallyAdjustContentInsets
automaticallyAdjustKeyboardInsets
automaticallyAdjustsScrollIndicatorInsets
style={{ minHeight: sliderHeight }}
style={{ minHeight: sliderHeight + 12 }}
onScrollToIndexFailed={onScrollToIndexFailed}
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
{...props}

View File

@ -31,8 +31,6 @@ export { platformColors } from '../themes';
export const isAndroid = Platform.OS === 'android';
const isIOS = Platform.OS === 'ios';
const iosMajorVersion = isIOS ? Number(String(Platform.Version).split('.')[0]) : 0;
export const isIOS26OrHigher = isIOS && Number.isFinite(iosMajorVersion) && iosMajorVersion >= 26;
export const platformSizing = {
horizontalPadding: isIOS ? 16 : 20,
@ -109,15 +107,6 @@ export const getSettingsHeaderOptions = (
const cardColor = colors.lightButton ?? colors.modal ?? colors.elevated ?? defaultBackgroundColor;
const headerBackgroundColor = isIOS ? (dark ? defaultBackgroundColor : cardColor) : defaultBackgroundColor;
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
return {
title,
headerLargeTitle: isIOS,
@ -203,7 +192,6 @@ export const SettingsScrollView = forwardRef<ScrollView, SettingsScrollViewProps
ref={ref}
style={[style, { backgroundColor: screenBackgroundColor }]}
headerHeight={resolvedHeaderHeight}
disableDefaultTopPadding={isIOS26OrHigher}
floatingButtonHeight={floatingButtonHeight}
contentContainerStyle={[staticStyles.contentContainer, contentContainerStyle]}
{...rest}

View File

@ -14,8 +14,6 @@ export const BlueDefaultTheme = {
foregroundColor: '#0c2550',
borderTopColor: 'rgba(0, 0, 0, 0.1)',
buttonBackgroundColor: '#ccddf9',
/** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */
headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)',
buttonTextColor: '#0c2550',
secondButtonTextColor: '#50555C',
buttonAlternativeTextColor: '#2f5fb3',
@ -103,7 +101,6 @@ export const BlueDarkTheme: Theme = {
foregroundColor: '#ffffff',
buttonDisabledBackgroundColor: '#3A3A3C',
buttonBackgroundColor: '#3A3A3C',
headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)',
buttonTextColor: '#ffffff',
lightButton: 'rgba(255,255,255,.1)',
buttonAlternativeTextColor: '#ffffff',

View File

@ -245,6 +245,8 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>

View File

@ -1,5 +1,5 @@
PODS:
- BugsnagReactNative (8.9.0):
- BugsnagReactNative (8.8.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -29,7 +29,7 @@ PODS:
- hermes-engine/Pre-built (= 250829098.0.10)
- hermes-engine/Pre-built (250829098.0.10)
- lottie-ios (4.6.0)
- lottie-react-native (7.3.8):
- lottie-react-native (7.3.7):
- hermes-engine
- lottie-ios (= 4.6.0)
- RCTRequired
@ -2018,8 +2018,6 @@ PODS:
- ReactNativeDependencies (0.85.3)
- RealmJS (20.2.0):
- React
- RNBackgroundFetch (4.2.9):
- React-Core
- RNCAsyncStorage (2.2.0):
- hermes-engine
- RCTRequired
@ -2545,7 +2543,6 @@ DEPENDENCIES:
- ReactNativeCameraKit (from `../node_modules/react-native-camera-kit-no-google`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- RealmJS (from `../node_modules/realm`)
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
- RNDefaultPreference (from `../node_modules/react-native-default-preference`)
@ -2765,8 +2762,6 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RealmJS:
:path: "../node_modules/realm"
RNBackgroundFetch:
:path: "../node_modules/react-native-background-fetch"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
@ -2807,13 +2802,13 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
BugsnagReactNative: 73ce58aac04585e7cba3081c0abba06d848d62fc
BugsnagReactNative: bee770e3f497a8571feb1579bdc083a070bee1f3
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
hermes-engine: 4ed74710a31e8e31f20356c641eab1d8f7d54595
hermes-engine: 86cdbf283775c54dc008895c3eacd24a1f2a40b4
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
lottie-react-native: ee142214581f3bb68fbda7efcf07b835a189eeda
lottie-react-native: 26b365c3d5615e87f4db048dcb151de3eb9a8e76
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
@ -2822,7 +2817,7 @@ SPEC CHECKSUMS:
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
React-Core-prebuilt: 3445f1028d9b206cd45c8bbb7e2427ee891f810e
React-Core-prebuilt: 9e875134f667c471ab68bf9edf1661fa11b86540
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
@ -2903,9 +2898,8 @@ SPEC CHECKSUMS:
ReactCodegen: 1bd7f2174582b0e142f8671735b5c906c08b72ea
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeCameraKit: 5974256fc608631c1c812710cd98abe95dae0f88
ReactNativeDependencies: 75299c281f422106c723e79dc1f6ce7ef03241be
ReactNativeDependencies: 0a5c93845772e4b1c5ad065c59a859518b13a6b7
RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1
RNBackgroundFetch: 64b1215fbb8ec58afba877ca0ce177e009ce12b7
RNCAsyncStorage: 2ad919e88b8bc2cd80e8697ce66d04d006743283
RNCClipboard: 715fa7c6c8366f17d00f05a439ee7488f390fa5f
RNDefaultPreference: 8a089ee8ce829a66c5453e3c5434f0785499d1c3

View File

@ -1,6 +1,6 @@
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, AppState, View, Platform, PlatformColor, Text, StyleSheet, Pressable } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
import { useTheme } from '../components/themes';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
@ -57,9 +57,6 @@ import { ConnectionPollContext } from './ConnectionPollContext';
import ManageWallets from '../screen/wallets/ManageWallets';
import ReceiveDetails from '../screen/receive/ReceiveDetails';
import ReceiveCustomAmountSheet from '../screen/receive/ReceiveCustomAmountSheet';
import { isIOS26OrHigher } from '../components/platform';
type HeaderRightItem = ReturnType<NonNullable<NativeStackNavigationOptions['unstable_headerRightItems']>>[number];
const PaymentCodesList = lazy(() => import('../screen/wallets/PaymentCodesList'));
const PaymentCodesListComponent = withLazySuspense(PaymentCodesList);
@ -153,15 +150,6 @@ const DetailViewStackScreensStack = () => {
navigation.navigate('AddWalletRoot');
}, [navigation]);
const navigateToSettings = useCallback(() => {
navigation.navigate('DrawerRoot', {
screen: 'DetailViewStackScreensStack',
params: {
screen: 'Settings',
},
});
}, [navigation]);
const RightBarButtons = useMemo(
() =>
sizeClass === SizeClass.Large ? (
@ -231,53 +219,6 @@ const DetailViewStackScreensStack = () => {
return null;
};
if (isIOS26OrHigher) {
// Status pills: `unstable_headerLeftItems` + `hidesSharedBackground` avoids the
// navigation bar's shared liquid-glass chrome on the pill (solid colors only).
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
headerTransparent: true,
unstable_headerLeftItems: (): NativeStackHeaderItem[] => {
const element = renderHeaderLeft();
if (element == null) {
return [];
}
return [{ type: 'custom', element, hidesSharedBackground: true }];
},
unstable_headerRightItems: () => {
if (isDesktop) {
return [];
}
const items: HeaderRightItem[] = [
{
type: 'button',
label: loc.wallets.add_title,
icon: { type: 'sfSymbol', name: 'plus' },
variant: 'prominent',
tintColor: theme.colors.headerProminentButtonBackgroundColor,
identifier: 'AddWalletButton',
accessibilityLabel: 'AddWalletButton',
sharesBackground: false,
onPress: navigateToAddWallet,
},
];
if (sizeClass !== SizeClass.Large) {
items.push({
type: 'button',
label: loc.settings.default_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'SettingsButton',
accessibilityLabel: 'SettingsButton',
sharesBackground: false,
onPress: navigateToSettings,
});
}
return items;
},
};
}
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
@ -292,7 +233,6 @@ const DetailViewStackScreensStack = () => {
RightBarButtons,
sizeClass,
theme.colors.customHeader,
theme.colors.headerProminentButtonBackgroundColor,
theme.colors.foregroundColor,
theme.colors.lightButton,
theme.colors.redBG,
@ -302,8 +242,6 @@ const DetailViewStackScreensStack = () => {
electrumConnected,
isElectrumDisabled,
navigateToElectrumSettings,
navigateToAddWallet,
navigateToSettings,
walletTransactionUpdateStatus,
]);
@ -313,14 +251,6 @@ const DetailViewStackScreensStack = () => {
// Consistent header configuration for all settings screens
const getSettingsHeaderOptions = (title: string) => {
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
// Use PlatformColor for iOS to match the Settings component, fallback to theme color
const titleColor = Platform.OS === 'ios' ? PlatformColor('label') : theme.colors.foregroundColor;
// Convert PlatformColor to string for TypeScript compatibility
@ -343,9 +273,6 @@ const DetailViewStackScreensStack = () => {
};
};
const settingsScreenOptions = (title: string) =>
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
return (
<ConnectionPollContext.Provider value={connectionPollContextValue}>
<DetailViewStack.Navigator
@ -412,14 +339,22 @@ const DetailViewStackScreensStack = () => {
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
/>
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
<DetailViewStack.Screen
name="Broadcast"
component={Broadcast}
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
/>
<DetailViewStack.Screen
name="IsItMyAddress"
component={IsItMyAddress}
initialParams={{ address: undefined }}
options={settingsScreenOptions(loc.is_it_my_address.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
/>
<DetailViewStack.Screen
name="GenerateWord"
component={GenerateWord}
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
/>
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
<DetailViewStack.Screen
name="LnurlPay"
component={LnurlPay}
@ -462,90 +397,115 @@ const DetailViewStackScreensStack = () => {
<DetailViewStack.Screen
name="Settings"
component={Settings}
options={
isIOS26OrHigher
? getSettingsHeaderOptions(loc.settings.header)
: navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
}
: undefined,
headerTitleStyle: {
options={navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)
}
}
: undefined,
headerTitleStyle: {
color: typeof theme.colors.foregroundColor === 'string' ? theme.colors.foregroundColor : String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)}
/>
<DetailViewStack.Screen
name="Currency"
component={Currency}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.currency))(theme)}
/>
<DetailViewStack.Screen
name="GeneralSettings"
component={GeneralSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.general))(theme)}
/>
<DetailViewStack.Screen name="Currency" component={Currency} options={settingsScreenOptions(loc.settings.currency)} />
<DetailViewStack.Screen name="GeneralSettings" component={GeneralSettings} options={settingsScreenOptions(loc.settings.general)} />
<DetailViewStack.Screen
name="PlausibleDeniability"
component={PlausibleDeniability}
options={settingsScreenOptions(loc.plausibledeniability.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.plausibledeniability.title))(theme)}
/>
<DetailViewStack.Screen
name="Licensing"
component={Licensing}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.license))(theme)}
/>
<DetailViewStack.Screen
name="NetworkSettings"
component={NetworkSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.network))(theme)}
/>
<DetailViewStack.Screen name="Licensing" component={Licensing} options={settingsScreenOptions(loc.settings.license)} />
<DetailViewStack.Screen name="NetworkSettings" component={NetworkSettings} options={settingsScreenOptions(loc.settings.network)} />
<DetailViewStack.Screen
name="SettingsBlockExplorer"
component={SettingsBlockExplorer}
options={settingsScreenOptions(loc.settings.block_explorer)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
/>
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
<DetailViewStack.Screen
name="About"
component={About}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
/>
{/* <DetailViewStack.Screen
name="DefaultView"
component={DefaultView}
options={settingsScreenOptions(loc.settings.default_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
/> */}
<DetailViewStack.Screen
name="ElectrumSettings"
component={ElectrumSettings}
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
initialParams={{ server: undefined }}
/>
<DetailViewStack.Screen
name="EncryptStorage"
component={EncryptStorage}
options={settingsScreenOptions(loc.settings.encrypt_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
/>
<DetailViewStack.Screen
name="Language"
component={Language}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
/>
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
<DetailViewStack.Screen
name="LightningSettings"
component={LightningSettings}
options={settingsScreenOptions(loc.settings.lightning_settings)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
/>
<DetailViewStack.Screen
name="NotificationSettings"
component={NotificationSettings}
options={settingsScreenOptions(loc.settings.notifications)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
/>
<DetailViewStack.Screen
name="SelfTest"
component={SelfTest}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
/>
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
<DetailViewStack.Screen
name="ReleaseNotes"
component={ReleaseNotes}
options={settingsScreenOptions(loc.settings.about_release_notes)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
/>
<DetailViewStack.Screen
name="SettingsTools"
component={SettingsTools}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
/>
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
<DetailViewStack.Screen
name="PromptPasswordConfirmationSheet"
component={PromptPasswordConfirmationSheet}

View File

@ -1,98 +1,44 @@
import React from 'react';
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { TouchableOpacity, StyleSheet } from 'react-native';
import Icon from '../../components/Icon';
import WalletGradient from '../../class/wallet-gradient';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../DetailViewStackParamList';
import { navigationRef } from '../../NavigationService';
import { RouteProp } from '@react-navigation/native';
import { isDesktop } from '../../blue_modules/environment';
import { isIOS26OrHigher } from '../../components/platform';
import loc from '../../loc';
export type WalletTransactionsRouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID, walletType } = route.params;
const navigateToWalletDetails = (walletID: string) => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
const onPress = () => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
/** Material "more" button for WalletTransactions header (preiOS 26 and Android). */
export const createWalletDetailsHeaderRight = ({
walletID,
isLoading = false,
iconColor = HERO_HEADER_ICON_COLOR,
}: {
walletID: string;
isLoading?: boolean;
iconColor?: string;
}): (() => React.ReactElement) => {
return () => (
<TouchableOpacity
accessibilityRole="button"
testID="WalletDetails"
disabled={isLoading}
style={styles.walletDetails}
onPress={() => navigateToWalletDetails(walletID)}
>
<Icon name="more-horiz" type="material" size={22} color={iconColor} />
const RightButton = (
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
</TouchableOpacity>
);
};
/** Native toolbar ellipsis for WalletTransactions on iOS 26+. */
export const createWalletDetailsHeaderRightItems = ({
isLoading = false,
walletID,
}: {
isLoading?: boolean;
walletID: string;
}): (() => NativeStackHeaderItem[]) => {
return () => [
{
type: 'button',
label: loc.wallets.details_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'WalletDetails',
accessibilityLabel: 'WalletDetails',
sharesBackground: false,
onPress: () => navigateToWalletDetails(walletID),
disabled: isLoading,
},
];
};
const backgroundColor = WalletGradient.headerColorFor(walletType);
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID } = route.params;
const base: NativeStackNavigationOptions = {
return {
title: '',
headerBackTitleStyle: { fontSize: 0 },
headerTransparent: true,
headerStyle: {
backgroundColor: 'transparent',
backgroundColor,
},
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerTintColor: HERO_HEADER_ICON_COLOR,
headerBlurEffect: undefined,
headerTintColor: '#FFFFFF',
statusBarStyle: 'light',
headerBackTitle: undefined,
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
headerRight: () => RightButton,
};
if (Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop) {
return {
...base,
headerRight: undefined,
experimental_userInterfaceStyle: 'dark' as const,
unstable_headerRightItems: createWalletDetailsHeaderRightItems({ isLoading, walletID }),
};
}
return base;
};
const styles = StyleSheet.create({

184
package-lock.json generated
View File

@ -10,15 +10,14 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@arkade-os/boltz-swap": "0.3.40",
"@arkade-os/sdk": "0.4.35",
"@arkade-os/boltz-swap": "0.3.38",
"@arkade-os/sdk": "0.4.33",
"@babel/preset-env": "7.29.5",
"@bugsnag/react-native": "8.9.0",
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/hashes": "1.3.3",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -26,7 +25,7 @@
"@react-native-community/cli-platform-android": "20.1.3",
"@react-native-community/cli-platform-ios": "20.1.3",
"@react-native-documents/picker": "12.0.1",
"@react-native-vector-icons/entypo": "13.1.2",
"@react-native-vector-icons/entypo": "13.1.1",
"@react-native-vector-icons/fontawesome": "13.1.2",
"@react-native-vector-icons/fontawesome6": "13.1.2",
"@react-native-vector-icons/ionicons": "13.1.2",
@ -36,10 +35,10 @@
"@react-native/codegen": "0.85.3",
"@react-native/gradle-plugin": "0.85.3",
"@react-native/metro-config": "0.85.3",
"@react-navigation/devtools": "7.0.62",
"@react-navigation/drawer": "7.12.0",
"@react-navigation/native": "7.3.1",
"@react-navigation/native-stack": "7.17.3",
"@react-navigation/devtools": "7.0.58",
"@react-navigation/drawer": "7.10.2",
"@react-navigation/native": "7.2.4",
"@react-navigation/native-stack": "7.15.1",
"@scure/base": "2.0.0",
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
"aezeed": "0.0.5",
@ -58,6 +57,7 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dayjs": "1.11.21",
"detox": "20.51.3",
"ecpair": "3.0.1",
@ -92,7 +92,7 @@
"react-native-linear-gradient": "2.8.3",
"react-native-localize": "3.7.0",
"react-native-notifications": "5.2.2",
"react-native-permissions": "5.5.3",
"react-native-permissions": "5.5.1",
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "4.3.1",
@ -119,13 +119,14 @@
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
"@react-native/jest-preset": "0.85.3",
"@react-native/js-polyfills": "^0.86.0",
"@react-native/js-polyfills": "^0.85.3",
"@react-native/metro-babel-transformer": "^0.85.3",
"@react-native/typescript-config": "^0.85.3",
"@testing-library/react-native": "^13.0.1",
"@types/bip38": "^3.1.2",
"@types/bs58check": "^2.1.0",
"@types/create-hash": "^1.2.2",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
@ -178,12 +179,12 @@
}
},
"node_modules/@arkade-os/boltz-swap": {
"version": "0.3.40",
"resolved": "https://registry.npmjs.org/@arkade-os/boltz-swap/-/boltz-swap-0.3.40.tgz",
"integrity": "sha512-Q1myKKXC5c44wzAD6eb4lrq3rro0qwyJqNqf0powjfbhSTzHfk5Do6DfZYrciueEK4agilynLNurWCYsoE8yEw==",
"version": "0.3.38",
"resolved": "https://registry.npmjs.org/@arkade-os/boltz-swap/-/boltz-swap-0.3.38.tgz",
"integrity": "sha512-BVbyw9Fj+1eQn771t0ZO9uW7E1BgViAPLFddb4pnW9p3rM9fCIdWEs2ZrjPnq70leDdhrUxRy++cJuK7zFThuA==",
"license": "MIT",
"dependencies": {
"@arkade-os/sdk": "0.4.35",
"@arkade-os/sdk": "0.4.33",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
@ -217,9 +218,9 @@
}
},
"node_modules/@arkade-os/sdk": {
"version": "0.4.35",
"resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.35.tgz",
"integrity": "sha512-gMARWDEgy5YL15vE4hBoUf4IGBi94tDRymtVwIehL+2MQylFm6cO1Qt50/aA6dwle5Ae+XMfF99Wf6k/Gc257A==",
"version": "0.4.33",
"resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.33.tgz",
"integrity": "sha512-EvfmDhSyAiZ7DW89o5D1N4woDEFMfZLHXi/zh9C1xKlPHB2PCezEkHpVe51lNF0Vx3rgkf6bx54QXoGOvg1p9A==",
"license": "MIT",
"dependencies": {
"@bitcoinerlab/descriptors-scure": "3.1.7",
@ -3496,18 +3497,6 @@
"eslint-scope": "5.1.1"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
@ -3536,12 +3525,10 @@
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"version": "1.3.3",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -3966,12 +3953,12 @@
}
},
"node_modules/@react-native-vector-icons/entypo": {
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/@react-native-vector-icons/entypo/-/entypo-13.1.2.tgz",
"integrity": "sha512-oxfKPz8amwmI/IiYadwgKlGBo4y68bwYVhx5N4dTffaIR4n73Lk6AUlNUcYzSoMzSAYZVfySGPq7YV8whrc8dw==",
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@react-native-vector-icons/entypo/-/entypo-13.1.1.tgz",
"integrity": "sha512-K3uZ/S0Nr0a/vuXw81tZDhKJaUfaGeTG+50vPHO60Ucl/L9b3O4KUtzMJa7zd0c400CO0vl5Lr97Wk266eXwLQ==",
"license": "MIT",
"dependencies": {
"@react-native-vector-icons/common": "^13.0.1"
"@react-native-vector-icons/common": "^13.0.0"
},
"engines": {
"node": ">= 18.0.0"
@ -4714,16 +4701,6 @@
"react": "^19.2.3"
}
},
"node_modules/@react-native/jest-preset/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/jest-preset/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@ -4732,10 +4709,9 @@
"license": "MIT"
},
"node_modules/@react-native/js-polyfills": {
"version": "0.86.0",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz",
"integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==",
"dev": true,
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
@ -4774,15 +4750,6 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz",
@ -4820,12 +4787,12 @@
}
},
"node_modules/@react-navigation/core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.20.0.tgz",
"integrity": "sha512-Lqw5cDQWWxiQnaWv6RhQV95Wr4fh+38/IFVNn1grssyLWV+wXGJjlucXOoU7EVh9jdtcLT8pGyzvsyrvSDywWA==",
"version": "7.17.4",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz",
"integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==",
"license": "MIT",
"dependencies": {
"@react-navigation/routers": "^7.6.0",
"@react-navigation/routers": "^7.5.5",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
@ -4845,9 +4812,9 @@
"license": "MIT"
},
"node_modules/@react-navigation/devtools": {
"version": "7.0.62",
"resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.62.tgz",
"integrity": "sha512-Xl+HhZmz0tzJCH13KCs19xYQWPfkQFfYd7Mxv5MnpFdYuxkmvedPJilwAhcTtJc+4PMtQ7sR0Jqv7Ssg4CPblg==",
"version": "7.0.58",
"resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.58.tgz",
"integrity": "sha512-WpADcM0n+QHP1RMMmKZPc4reuvwTyX41gnJCdipjNUG0+VBNOkDyJZpAkeJqOJg2BIjSwsKcTAph3xkmXBjXVA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -4859,18 +4826,18 @@
}
},
"node_modules/@react-navigation/drawer": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.12.0.tgz",
"integrity": "sha512-OP8ti/ESCPng79/UzafQxYYP/EVHmgSCnNL91RGnT3ghsIpjr8xut5Ax+5N5+vwfEWBbHaxPCeuVHwukcmdtQw==",
"version": "7.10.2",
"resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.10.2.tgz",
"integrity": "sha512-/ccYFvBPJNzOYioiMQsqjAR4dcQ+7+yjzcuMDTKgsMahLD7Jn7FdOFNtGwMaIQWhfK8KFVMH2KOXAlH/uAGZXw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^2.9.23",
"@react-navigation/elements": "^2.9.18",
"color": "^4.2.3",
"react-native-drawer-layout": "^4.2.5",
"react-native-drawer-layout": "^4.2.4",
"use-latest-callback": "^0.2.4"
},
"peerDependencies": {
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-gesture-handler": ">= 2.0.0",
@ -4880,9 +4847,9 @@
}
},
"node_modules/@react-navigation/elements": {
"version": "2.9.23",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.23.tgz",
"integrity": "sha512-sp+FgihDyMBoEXoCUsUCT/iibN/sg6LYGq/rciy6NjT8bnfv4Cu3el8SAaJ0bfRG3tdchHy6gweKmcaJs/BAYQ==",
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.18.tgz",
"integrity": "sha512-mKEvDr6CkCVYZSb8W9WubNseihL+1c8M7ktZJCTCbMk8rQgdQfkdRNwpSUQKspdGpUHCb9cyzvaiuzl1NtjVgw==",
"license": "MIT",
"dependencies": {
"color": "^4.2.3",
@ -4891,7 +4858,7 @@
},
"peerDependencies": {
"@react-native-masked-view/masked-view": ">= 0.2.0",
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0"
@ -4903,16 +4870,15 @@
}
},
"node_modules/@react-navigation/native": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.1.tgz",
"integrity": "sha512-g1o8jBm87WviR0Eq0wT0M43TSi+uBTz4x8YfHh4XRQ+FHqhNr+uGbuxtGu72QhHtOz0LWnb8UWyvd+M6xWkWHQ==",
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz",
"integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==",
"license": "MIT",
"dependencies": {
"@react-navigation/core": "^7.20.0",
"@react-navigation/core": "^7.17.4",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
"standard-navigation": "^0.0.7",
"use-latest-callback": "^0.2.4"
},
"peerDependencies": {
@ -4921,18 +4887,18 @@
}
},
"node_modules/@react-navigation/native-stack": {
"version": "7.17.3",
"resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.17.3.tgz",
"integrity": "sha512-8X9AxW0BACB62eCL+DAL+Nf5lFAxXi3w1qaj2D/i0axYjxUZbI5AwrfuHjRo0B231K5WWa6HKyscF07IDHcKHg==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.15.1.tgz",
"integrity": "sha512-kNrJggwoB/onC0MpZIuZ6qaqeAziFchz+W9txBzhd6qbWmB1OkPVUnu6fWgc6BQc7MeMf59djVmqgX+6kJU1Ug==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^2.9.23",
"@react-navigation/elements": "^2.9.18",
"color": "^4.2.3",
"sf-symbols-typescript": "^2.1.0",
"warn-once": "^0.1.1"
},
"peerDependencies": {
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0",
@ -4940,9 +4906,9 @@
}
},
"node_modules/@react-navigation/routers": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.0.tgz",
"integrity": "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz",
"integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11"
@ -5276,6 +5242,11 @@
"@types/node": "*"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"dev": true,
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"dev": true,
@ -7963,6 +7934,10 @@
"node": ">= 0.10"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.1.0",
"license": "BSD-2-Clause",
@ -16284,9 +16259,9 @@
}
},
"node_modules/react-native-drawer-layout": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.5.tgz",
"integrity": "sha512-Yl82uLkXjXuq7222hWGIDsq5A6R/bsCeCEgdIxQUxAEHf00oRdDnRByLx3Fsij3qwtmYNPGrHV1NH8G8hbCbLQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.4.tgz",
"integrity": "sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg==",
"license": "MIT",
"dependencies": {
"color": "^4.2.3",
@ -16439,9 +16414,9 @@
}
},
"node_modules/react-native-permissions": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.5.3.tgz",
"integrity": "sha512-ngvzzhSC96Wnkz6tslF2BZHJAzBTi1lmrjA4EC/1StAkpNVUssctgotyX+wj/Ti3el/gTCBPOCP3frULMMOepQ==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.5.1.tgz",
"integrity": "sha512-nTKFoj47b6EXNqbbg+8VFwBWMpxF1/UTbrNBLpXkWpt005pH4BeFv/NwpcC1iNhToKBrxQD+5kI0z6+kTYoYWA==",
"license": "MIT",
"peerDependencies": {
"react": "*",
@ -16631,15 +16606,6 @@
"node": ">=10"
}
},
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/react-native/node_modules/commander": {
"version": "12.1.0",
"license": "MIT",
@ -17890,12 +17856,6 @@
"node": ">=8"
}
},
"node_modules/standard-navigation": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz",
"integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",

View File

@ -20,13 +20,14 @@
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
"@react-native/jest-preset": "0.85.3",
"@react-native/js-polyfills": "^0.86.0",
"@react-native/js-polyfills": "^0.85.3",
"@react-native/metro-babel-transformer": "^0.85.3",
"@react-native/typescript-config": "^0.85.3",
"@testing-library/react-native": "^13.0.1",
"@types/bip38": "^3.1.2",
"@types/bs58check": "^2.1.0",
"@types/create-hash": "^1.2.2",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
@ -92,15 +93,14 @@
"unit": "jest -b tests/unit/*"
},
"dependencies": {
"@arkade-os/boltz-swap": "0.3.40",
"@arkade-os/sdk": "0.4.35",
"@arkade-os/boltz-swap": "0.3.38",
"@arkade-os/sdk": "0.4.33",
"@babel/preset-env": "7.29.5",
"@bugsnag/react-native": "8.9.0",
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/hashes": "1.3.3",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -108,7 +108,7 @@
"@react-native-community/cli-platform-android": "20.1.3",
"@react-native-community/cli-platform-ios": "20.1.3",
"@react-native-documents/picker": "12.0.1",
"@react-native-vector-icons/entypo": "13.1.2",
"@react-native-vector-icons/entypo": "13.1.1",
"@react-native-vector-icons/fontawesome": "13.1.2",
"@react-native-vector-icons/fontawesome6": "13.1.2",
"@react-native-vector-icons/ionicons": "13.1.2",
@ -118,10 +118,10 @@
"@react-native/codegen": "0.85.3",
"@react-native/gradle-plugin": "0.85.3",
"@react-native/metro-config": "0.85.3",
"@react-navigation/devtools": "7.0.62",
"@react-navigation/drawer": "7.12.0",
"@react-navigation/native": "7.3.1",
"@react-navigation/native-stack": "7.17.3",
"@react-navigation/devtools": "7.0.58",
"@react-navigation/drawer": "7.10.2",
"@react-navigation/native": "7.2.4",
"@react-navigation/native-stack": "7.15.1",
"@scure/base": "2.0.0",
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
"aezeed": "0.0.5",
@ -140,6 +140,7 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dayjs": "1.11.21",
"detox": "20.51.3",
"ecpair": "3.0.1",
@ -174,7 +175,7 @@
"react-native-linear-gradient": "2.8.3",
"react-native-localize": "3.7.0",
"react-native-notifications": "5.2.2",
"react-native-permissions": "5.5.3",
"react-native-permissions": "5.5.1",
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "4.3.1",

View File

@ -1,84 +0,0 @@
diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
index a42477a..3ff714c 100644
--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
+++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
@@ -159,7 +159,8 @@ export function useHeaderConfigProps({
route,
title,
unstable_headerLeftItems: headerLeftItems,
- unstable_headerRightItems: headerRightItems
+ unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption
}) {
const {
direction
@@ -365,7 +366,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light'
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')
};
}
//# sourceMappingURL=useHeaderConfigProps.js.map
\ No newline at end of file
diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
index 2f1351a..5742b66 100644
--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
+++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
@@ -302,6 +302,14 @@ export type NativeStackNavigationOptions = {
* @platform ios
*/
unstable_headerRightItems?: (props: NativeStackHeaderItemProps) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ */
+ experimental_userInterfaceStyle?: import('react-native-screens').ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
index 7488b1c..542333e 100644
--- a/node_modules/@react-navigation/native-stack/src/types.tsx
+++ b/node_modules/@react-navigation/native-stack/src/types.tsx
@@ -350,6 +350,15 @@ export type NativeStackNavigationOptions = {
unstable_headerRightItems?: (
props: NativeStackHeaderItemProps
) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ * @see {@link https://github.com/react-navigation/react-navigation/issues/13069}
+ */
+ experimental_userInterfaceStyle?: ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
index 6f74856..d12cf7d 100644
--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
+++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
@@ -217,6 +217,7 @@ export function useHeaderConfigProps({
title,
unstable_headerLeftItems: headerLeftItems,
unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption,
}: Props): ScreenStackHeaderConfigProps {
const { direction } = useLocale();
const { colors, fonts, dark } = useTheme();
@@ -527,6 +528,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light',
+ experimental_userInterfaceStyle:
+ experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light'),
} as const;
}

View File

@ -64,48 +64,3 @@ delivered.
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8424
during a React Native bump. Remove once `react-native-notifications`
ships New-Architecture-safe token delivery.
---
## `@react-navigation+native-stack+7.15.1.patch`
**What:** adds an `experimental_userInterfaceStyle` navigation option to
`NativeStackNavigationOptions` (typed in `src/types.tsx` and the built
`lib/typescript` d.ts) and threads it through `useHeaderConfigProps` so a
screen can override the header's `UIUserInterfaceStyle`. When omitted it
falls back to the previous behaviour via
`experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')`.
**Why:** on iOS 26 the navigation bar's liquid-glass material and tint are
resolved from `UIUserInterfaceStyle`. React Navigation hard-codes this from
the theme `dark` boolean, so a screen cannot force a light/dark header
independent of the active theme. The iOS 26 glass header
(`screen/wallets/WalletTransactions.tsx`) needs that per-screen override.
**Upstream:** https://github.com/react-navigation/react-navigation/issues/13069 (open)
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
Remove once `@react-navigation/native-stack` exposes a header
`UIUserInterfaceStyle` override upstream. When bumping the dependency,
rename this patch to the new version and re-confirm the hunks still apply
(`npx patch-package`).
---
## `react-native-screens+4.25.2.patch`
**What:** in `RNSBarButtonItem.mm`, also set `self.accessibilityIdentifier`
when the JS `identifier` is provided (one line, alongside the existing
`self.identifier = identifier`).
**Why:** the iOS 26 glass header builds nav-bar buttons through
`unstable_headerRightItems`. The native `identifier` is not exposed as an
accessibility identifier, so Detox/XCUITest could not target those bar
buttons. Mirroring it onto `accessibilityIdentifier` makes them reachable
from e2e tests.
**Upstream:** no issue filed yet — local accessibility enhancement.
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
When bumping `react-native-screens`, rename this patch to the new version
and re-confirm the hunk still applies (`npx patch-package`).

View File

@ -1,12 +0,0 @@
diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
index 0eb1f09..324b888 100644
--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
+++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
@@ -81,6 +81,7 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
NSString *identifier = dict[@"identifier"];
if (identifier != nil) {
self.identifier = identifier;
+ self.accessibilityIdentifier = identifier;
}
NSDictionary *badgeConfig = dict[@"badge"];
if (badgeConfig != nil) {

View File

@ -1,29 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices",
":disableMajorUpdates",
":preserveSemverRanges"
],
"ignoreDeps": ["react-native"],
"schedule": ["before 6am on monday"],
"prConcurrentLimit": 1,
"prHourlyLimit": 0,
"minimumReleaseAge": "3 days",
"semanticCommits": "disabled",
"commitMessagePrefix": "OPS:",
"packageRules": [
{
"matchPackageNames": ["*"],
"groupName": "all dependencies",
"groupSlug": "all"
}
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"vulnerabilityAlerts": {
"schedule": ["at any time"]
}
"ignoreDeps": ["react-native"]
}

View File

@ -0,0 +1,64 @@
# Base image with Digest pinning
FROM node:24-bullseye-slim@sha256:e27057f6adaf0b3f172345fb5cdca821e07203ca81bc35f7b0a9e9631b255340
# Environment Setup
ENV TZ=UTC \
LANG=C \
LC_ALL=C \
SOURCE_DATE_EPOCH=315532800 \
ANDROID_SDK_ROOT=/opt/android-sdk \
JAVA_HOME=/usr/lib/jvm/java-17-openjdk
# Freeze Debian repositories to snapshot on May 24 2026
RUN printf '%s\n' \
'deb http://snapshot.debian.org/archive/debian/20260524T000000Z bullseye main contrib non-free' \
'deb http://snapshot.debian.org/archive/debian-security/20260524T000000Z bullseye-security main contrib non-free' \
> /etc/apt/sources.list \
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99snapshot \
&& echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries
# Install system packages and create a stable JAVA_HOME symlink
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.74.0-1.3+deb11u16 \
git=1:2.30.2-1+deb11u5 \
unzip=6.0-26+deb11u1 \
zip=3.0-12 \
libglu1-mesa=9.0.1-1 \
wget=1.21-1+deb11u2 \
xxd=2:8.2.2434-3+deb11u3 \
build-essential=12.9 \
openjdk-17-jdk-headless=17.0.19+10-1~deb11u1 \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sfn "$(dirname $(dirname $(readlink -f $(which java))))" /usr/lib/jvm/java-17-openjdk
# Update Path to include all tool locations
ENV PATH=$ANDROID_SDK_ROOT/cmdline-tools/11.0/bin:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/build-tools/36.0.0:$JAVA_HOME/bin:$PATH
# Create SDK directory with correct ownership
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \
&& chown -R node:node $ANDROID_SDK_ROOT
# Download, Verify, and Install Android Command Line Tools
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/tools.zip \
&& echo "2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258 /tmp/tools.zip" | sha256sum -c - \
&& unzip -X -q /tmp/tools.zip -d $ANDROID_SDK_ROOT/cmdline-tools \
&& mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/11.0 \
&& rm /tmp/tools.zip
USER node
# Install SDK Components (Aligned with build.gradle)
RUN yes | sdkmanager --sdk_root=$ANDROID_SDK_ROOT --licenses \
&& sdkmanager --sdk_root=$ANDROID_SDK_ROOT \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;28.2.13676358" \
"cmake;3.22.1" \
"platform-tools" \
&& rm -rf $ANDROID_SDK_ROOT/cmdline-tools/tmp
COPY --chown=node:node reproducible-builds/inside-docker.sh /app/inside-docker.sh
RUN chmod +x /app/inside-docker.sh
WORKDIR /app
COPY --chown=node:node . /app

View File

@ -0,0 +1,3 @@
__pycache__
.venv
mismatches

View File

@ -0,0 +1 @@
3.12

View File

@ -0,0 +1,408 @@
#! /usr/bin/env python3
# script laregly taken from
# https://github.com/signalapp/Signal-Android/tree/0010386b9e558e0f3d43d61180c818246226b1f9/reproducible-builds/apkdiff
import sys
import os
import re
import logging
from xml.etree.ElementTree import Element
from zipfile import ZipFile, BadZipFile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
from collections import defaultdict
from androguard.core import axml
from loguru import logger
from util import deep_compare, format_differences
from tqdm import tqdm
logging.getLogger("deepdiff").setLevel(logging.ERROR)
logger.disable("androguard")
@dataclass
class XmlDifference:
"""Represents a difference between two XML elements."""
diff_type: str # "tag", "attribute", "text", "child_count"
path: str
attribute_name: Optional[str] = None
first_value: Optional[str] = None
second_value: Optional[str] = None
child_tag: Optional[str] = None
IGNORE_FILES = [
# Related to app signing. Not expected to be present in unsigned builds. Doesn"t affect app code.
"META-INF/MANIFEST.MF",
"META-INF/TEMP-KEY.SF",
"META-INF/TEMP-KEY.RSA",
"META-INF/MBLUEWAL.SF",
"META-INF/MBLUEWAL.RSA",
"META-INF/TEXTSECU.SF",
"META-INF/TEXTSECU.RSA",
"META-INF/BNDLTOOL.SF",
"META-INF/BNDLTOOL.RSA",
"META-INF/code_transparency_signed.jwt",
"stamp-cert-sha256",
]
ALLOWED_ARSC_DIFF_PATHS = [".res1"]
def open_apk(path: str) -> ZipFile:
if not os.path.exists(path):
print(f"ERROR: File not found: {path}")
sys.exit(2)
try:
return ZipFile(path, "r")
except BadZipFile:
print(f"ERROR: Invalid or corrupted APK (not a valid zip archive): {path}")
sys.exit(2)
def compare(apk1, apk2) -> bool:
print(f"Comparing: \n\t{apk1}\n\t{apk2}\n")
print("Unzipping...")
with open_apk(apk1) as zip1, open_apk(apk2) as zip2:
entry_names = compare_entry_names(zip1, zip2)
entry_contents = compare_entry_contents(zip1, zip2)
return entry_names and entry_contents
def compare_entry_names(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry names...")
name_list_sorted_1 = sorted(zip1.namelist())
name_list_sorted_2 = sorted(zip2.namelist())
for ignoreFile in IGNORE_FILES:
while ignoreFile in name_list_sorted_1:
name_list_sorted_1.remove(ignoreFile)
while ignoreFile in name_list_sorted_2:
name_list_sorted_2.remove(ignoreFile)
success = True
if len(name_list_sorted_1) != len(name_list_sorted_2):
print(f"Manifest lengths differ! {len(name_list_sorted_1)} vs {len(name_list_sorted_2)}")
success = False
only_in_first = sorted(list(set(name_list_sorted_1) - set(name_list_sorted_2)))
only_in_second = sorted(list(set(name_list_sorted_2) - set(name_list_sorted_1)))
if only_in_first:
print(f"Files present only in {zip1.filename}:")
for name in only_in_first:
print(f" - {name}")
success = False
if only_in_second:
print(f"Files present only in {zip2.filename}:")
for name in only_in_second:
print(f" - {name}")
success = False
# If sets are identical but ordering differs, still report ordering mismatches
if success:
for entry_name_1, entry_name_2 in zip(name_list_sorted_1, name_list_sorted_2):
if entry_name_1 != entry_name_2:
print(f"Sorted manifests don't match: {entry_name_1} vs {entry_name_2}")
success = False
return success
def compare_entry_contents(zip1: ZipFile, zip2: ZipFile) -> bool:
print("Comparing zip entry contents...")
info_list_1 = list(filter(lambda info: info.filename not in IGNORE_FILES, zip1.infolist()))
info_list_2 = list(filter(lambda info: info.filename not in IGNORE_FILES, zip2.infolist()))
success = True
if len(info_list_1) != len(info_list_2):
print(f"APK info lists of different length! {len(info_list_1)} vs {len(info_list_2)}")
success = False
for entry_info_1 in info_list_1:
for entry_info_2 in list(info_list_2):
if entry_info_1.filename == entry_info_2.filename:
entry_bytes_1 = zip1.read(entry_info_1.filename)
entry_bytes_2 = zip2.read(entry_info_2.filename)
if entry_bytes_1 != entry_bytes_2 and not handle_special_cases(entry_info_1.filename, entry_bytes_1, entry_bytes_2):
zip1.extract(entry_info_1, "mismatches/first")
zip2.extract(entry_info_2, "mismatches/second")
print(f"APKs differ on file {entry_info_1.filename}! Files extracted to the mismatches/ directory.")
success = False
info_list_2.remove(entry_info_2)
break
return success
def handle_special_cases(filename: str, bytes1: bytes, bytes2: bytes):
"""
There are some specific files that expect will not be byte-for-byte identical. We want to ensure that the files
are matching except these expected differences. The differences are all related to extra XML attributes that the
Play Store may add as part of the bundle process. These differences do not affect the behavior of the app and are
unfortunately unavoidable given the modern realities of the Play Store.
"""
if filename == "AndroidManifest.xml":
print("Comparing AndroidManifest.xml...")
return compare_android_xml(bytes1, bytes2)
elif filename == "resources.arsc":
print("Comparing resources.arsc (may take a while)...")
return compare_resources_arsc(bytes1, bytes2)
elif re.match("res/xml/splits[0-9]+\\.xml", filename):
print(f"Comparing {filename}...")
return compare_split_xml(bytes1, bytes2)
return False
def compare_android_xml(bytes1: bytes, bytes2: bytes) -> bool:
all_differences = compare_xml(bytes1, bytes2)
bad_differences = []
for diff in all_differences:
is_split_attr = diff.diff_type == "attribute" and diff.path in ["manifest", "manifest/application"] and diff.attribute_name is not None and "split" in diff.attribute_name.lower()
is_meta_attr = diff.diff_type == "attribute" and diff.path == "manifest/application/meta-data"
is_meta_child_count = diff.diff_type == "child_count" and diff.child_tag == "meta-data"
is_bugsnag_build_uuid = (
diff.diff_type == "attribute"
and diff.path == "manifest/application/meta-data"
and diff.attribute_name == "android:value"
and "BUILD_UUID" in (diff.first_value or diff.second_value or "")
)
if not is_split_attr and not is_meta_attr and not is_meta_child_count and not is_bugsnag_build_uuid:
bad_differences.append(diff)
if bad_differences:
print(bad_differences)
return False
return True
def compare_split_xml(bytes1: bytes, bytes2: bytes) -> bool:
all_differences = compare_xml(bytes1, bytes2)
bad_differences = []
for diff in all_differences:
is_language = diff.diff_type == "attribute" and diff.path == "splits/module/language/entry"
if not is_language:
bad_differences.append(diff)
if bad_differences:
print(bad_differences)
return False
return True
def compare_resources_arsc(first_entry_bytes: bytes, second_entry_bytes: bytes) -> bool:
"""
Compares two resources.arsc files.
Largely taken from https://github.com/TheTechZone/reproducible-tests/blob/d8c73772b87fbe337eb852e338238c95703d59d6/comparators/arsc_compare.py
"""
first_arsc = axml.ARSCParser(first_entry_bytes)
second_arsc = axml.ARSCParser(second_entry_bytes)
all_package_names = sorted(set(first_arsc.packages.keys()) | set(second_arsc.packages.keys()))
total_diffs = defaultdict(list)
success = True
for package_name in all_package_names:
# Check if package exists in both files
if package_name not in first_arsc.packages:
print(f"Package only in source file: {package_name}")
success = False
continue
if package_name not in second_arsc.packages:
print(f"Package only in target file: {package_name}")
success = False
continue
packages1 = first_arsc.packages[package_name]
packages2 = second_arsc.packages[package_name]
# Check package length
if len(packages1) != len(packages2):
print(f"Package length mismatch: {len(packages1)} vs {len(packages2)}")
success = False
continue
# Compare each package element
for i in tqdm(range(len(packages1))):
pkg1 = packages1[i]
pkg2 = packages2[i]
if type(pkg1) is not type(pkg2):
print(f"Element type mismatch at index {i}: {type(pkg1).__name__} vs {type(pkg2).__name__}")
success = False
continue
# Different comparison strategies based on type
if isinstance(pkg1, axml.ARSCResTablePackage):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCResTablePackage at index {i}:")
total_diffs["ARSCResTablePackage"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.StringBlock):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in StringBlock at index {i}:")
total_diffs["StringBlock"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCHeader):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCHeader at index {i}:")
total_diffs["ARSCHeader"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCResTypeSpec):
diffs = deep_compare(pkg1, pkg2)
if diffs and not all(path in ALLOWED_ARSC_DIFF_PATHS for path in diffs.keys()):
print(f"Disallowed differences in ARSCResTypeSpec at index {i}:")
print(format_differences(diffs))
total_diffs["ARSCResTypeSpec"].append((i, diffs))
success = False
elif isinstance(pkg1, axml.ARSCResTableEntry):
# Use string representation for comparison
if pkg1.__repr__() != pkg2.__repr__():
print(f"Differences in ARSCResTableEntry at index {i}")
print(f"Target: {pkg1.__repr__()}", 3)
print(f"Source: {pkg2.__repr__()}", 3)
total_diffs["ARSCResTableEntry"].append((i, {"representation": f"{pkg1.__repr__()} vs {pkg2.__repr__()}"}))
success = False
elif isinstance(pkg1, list):
if pkg1 != pkg2:
print(f"List difference at index {i}")
total_diffs["list"].append((i, {"diff": "Lists differ"}))
success = False
elif isinstance(pkg1, axml.ARSCResType):
diffs = deep_compare(pkg1, pkg2)
if diffs:
print(f"Differences in ARSCResType at index {i}:")
total_diffs["ARSCResType"].append((i, diffs))
success = False
else:
# Other types
print(f"Unhandled type: {type(pkg1).__name__} at index {i}")
diffs = deep_compare(pkg1, pkg2)
if diffs:
total_diffs[type(pkg1).__name__].append((i, diffs))
success = False
for type_name, diffs in total_diffs.items():
if diffs:
print(f" {type_name}: {len(diffs)}", 1)
if not success:
print("Files have differences beyond the allowed .res1 differences.")
return success
def compare_xml(bytes1: bytes, bytes2: bytes) -> list[XmlDifference]:
printer = axml.AXMLPrinter(bytes1)
entry_text_1 = printer.get_xml().decode("utf-8")
printer = axml.AXMLPrinter(bytes2)
entry_text_2 = printer.get_xml().decode("utf-8")
if entry_text_1 == entry_text_2:
return []
root1 = ET.fromstring(entry_text_1)
root2 = ET.fromstring(entry_text_2)
return compare_xml_elements(root1, root2)
def compare_xml_elements(elem1: Element, elem2: Element, path: str = "") -> list[XmlDifference]:
"""Recursively compare two XML elements and return list of XmlDifference objects."""
differences: list[XmlDifference] = []
# Build current path
current_path = f"{path}/{elem1.tag}" if path else elem1.tag
# Compare tags
if elem1.tag != elem2.tag:
differences.append(XmlDifference(diff_type="tag", path=path, first_value=elem1.tag, second_value=elem2.tag))
return differences
# Compare attributes
attrs1 = elem1.attrib
attrs2 = elem2.attrib
all_keys = set(attrs1.keys()) | set(attrs2.keys())
for key in sorted(all_keys):
val1 = attrs1.get(key)
val2 = attrs2.get(key)
if val1 != val2:
differences.append(XmlDifference(diff_type="attribute", path=current_path, attribute_name=key, first_value=val1, second_value=val2))
# Compare text content
text1 = (elem1.text or "").strip()
text2 = (elem2.text or "").strip()
if text1 != text2:
differences.append(XmlDifference(diff_type="text", path=current_path, first_value=text1, second_value=text2))
# Compare children
children1 = list(elem1)
children2 = list(elem2)
# Try to match children by tag name for comparison
children1_by_tag: dict[str, list[Element]] = {}
for child in children1:
children1_by_tag.setdefault(child.tag, []).append(child)
children2_by_tag: dict[str, list[Element]] = {}
for child in children2:
children2_by_tag.setdefault(child.tag, []).append(child)
# Compare children with matching tags
all_child_tags = set(children1_by_tag.keys()) | set(children2_by_tag.keys())
for tag in sorted(all_child_tags):
list1 = children1_by_tag.get(tag, [])
list2 = children2_by_tag.get(tag, [])
if len(list1) != len(list2):
differences.append(XmlDifference(diff_type="child_count", path=current_path, child_tag=tag, first_value=str(len(list1)), second_value=str(len(list2))))
# Compare matching elements recursively
for child1, child2 in zip(list1, list2):
differences.extend(compare_xml_elements(child1, child2, current_path))
return differences
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: apkdiff <pathToFirstApk> <pathToSecondApk>")
sys.exit(1)
if compare(sys.argv[1], sys.argv[2]):
print("APKs match!")
sys.exit(0)
else:
print("APKs don't match!")
sys.exit(1)

View File

@ -0,0 +1,10 @@
[project]
name = "apkdiff"
version = "0.1.0"
description = "APK comparison utility"
requires-python = ">=3.12"
dependencies = [
"androguard>=4.1.3",
"tqdm>=4.67.1",
"loguru>=0.7.3",
]

View File

@ -0,0 +1,198 @@
# Script below taken from https://github.com/signalapp/Signal-Android/blob/main/reproducible-builds/apkdiff/util.py
# Utility functions taken from https://github.com/TheTechZone/reproducible-tests/blob/d8c73772b87fbe337eb852e338238c95703d59d6/comparators/arsc_compare.py
def format_differences(diffs, indent=0):
"""Format differences in a human-readable form"""
output = []
indent_str = " " * indent
for path, diff in sorted(diffs.items()):
if isinstance(diff, dict):
output.append(f"{indent_str}{path}:")
output.append(format_differences(diff, indent + 2))
elif isinstance(diff, list):
output.append(f"{indent_str}{path}: [{', '.join(map(str, diff))}]")
else:
output.append(f"{indent_str}{path}: {diff}")
return "\n".join(output)
def deep_compare(
obj1,
obj2,
path="",
max_depth=10,
current_depth=0,
exclude_attrs=None,
include_callable=False,
):
"""
Generic deep comparison of two Python objects.
Args:
obj1: First object to compare
obj2: Second object to compare
path: Current attribute path (for nested comparisons)
max_depth: Maximum recursion depth
current_depth: Current recursion depth
exclude_attrs: List of attribute names to exclude from comparison
include_callable: Whether to include callable attributes in comparison
Returns:
A dictionary mapping paths to differences, empty if objects are identical
"""
if exclude_attrs is None:
exclude_attrs = set()
else:
exclude_attrs = set(exclude_attrs)
# Add common attributes to exclude
exclude_attrs.update(["__dict__", "__weakref__", "__module__", "__doc__"])
differences = {}
# Check the recursion limit
if current_depth > max_depth:
return {f"{path} [max depth reached]": "Recursion limit reached"}
# Basic identity/equality check
if obj1 is obj2: # Same object (identity)
return {}
if obj1 == obj2: # Equal values
return {}
# Check for different types
if type(obj1) != type(obj2):
return {path: f"Type mismatch: {type(obj1).__name__} vs {type(obj2).__name__}"}
# Handle None
if obj1 is None or obj2 is None:
return {path: f"{obj1} vs {obj2}"}
# Handle primitive types
if isinstance(obj1, (int, float, str, bool, bytes, complex)):
return {path: f"{obj1} vs {obj2}"}
# Handle sequences (list, tuple)
if isinstance(obj1, (list, tuple)):
if len(obj1) != len(obj2):
differences[f"{path}.length"] = f"{len(obj1)} vs {len(obj2)}"
# Compare elements
for i in range(min(len(obj1), len(obj2))):
item_path = f"{path}[{i}]"
item_diffs = deep_compare(
obj1[i],
obj2[i],
item_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(item_diffs)
# Report extra elements
if len(obj1) > len(obj2):
for i in range(len(obj2), len(obj1)):
differences[f"{path}[{i}]"] = f"{obj1[i]} vs [missing]"
elif len(obj2) > len(obj1):
for i in range(len(obj1), len(obj2)):
differences[f"{path}[{i}]"] = f"[missing] vs {obj2[i]}"
return differences
# Handle dictionaries
if isinstance(obj1, dict):
keys1 = set(obj1.keys())
keys2 = set(obj2.keys())
# Check for different keys
if keys1 != keys2:
only_in_1 = keys1 - keys2
only_in_2 = keys2 - keys1
if only_in_1:
differences[f"{path}.keys_only_in_first"] = sorted(only_in_1)
if only_in_2:
differences[f"{path}.keys_only_in_second"] = sorted(only_in_2)
# Compare common keys
for key in keys1 & keys2:
key_path = f"{path}[{repr(key)}]"
key_diffs = deep_compare(
obj1[key],
obj2[key],
key_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(key_diffs)
return differences
# Handle sets
if isinstance(obj1, set):
only_in_1 = obj1 - obj2
only_in_2 = obj2 - obj1
if only_in_1:
differences[f"{path}.items_only_in_first"] = sorted(only_in_1)
if only_in_2:
differences[f"{path}.items_only_in_second"] = sorted(only_in_2)
return differences
# Handle custom objects and classes
try:
# Try to get all attributes
attrs1 = dir(obj1)
# Filter attributes
filtered_attrs = [attr for attr in attrs1 if not attr.startswith("__") and attr not in exclude_attrs and (include_callable or not callable(getattr(obj1, attr, None)))]
# Compare each attribute
for attr in filtered_attrs:
try:
# Skip unintended attributes
if attr in exclude_attrs:
continue
# Get attribute values
val1 = getattr(obj1, attr)
# Skip callables unless explicitly included
if callable(val1) and not include_callable:
continue
# Check if attr exists in obj2
if not hasattr(obj2, attr):
differences[f"{path}.{attr}"] = f"{val1} vs [attribute missing]"
continue
val2 = getattr(obj2, attr)
# Compare values
attr_path = f"{path}.{attr}"
attr_diffs = deep_compare(
val1,
val2,
attr_path,
max_depth,
current_depth + 1,
exclude_attrs,
include_callable,
)
differences.update(attr_diffs)
except Exception as e:
differences[f"{path}.{attr}"] = f"Error comparing: {str(e)}"
except Exception as e:
differences[path] = f"Error accessing attributes: {str(e)}"
return differences

1178
reproducible-builds/apkdiff/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="android-build-env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$REPO_ROOT/reproducible-builds/build"
log() {
printf "\n[%s] %s\n" "$(date +'%H:%M:%S')" "$*" >&2
}
rm -rf "$OUT"
mkdir -p "$OUT"
chmod 775 "$OUT"
log "Building Docker image..."
docker build --platform linux/amd64 -f "$SCRIPT_DIR/Dockerfile" -t "$IMAGE_NAME" "$REPO_ROOT"
log "Running build inside container..."
docker run --platform linux/amd64 --rm \
-v "$OUT":/build \
"$IMAGE_NAME" \
bash -c "
set -e
umask 022
npm config set fetch-timeout 600000 \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm ci --verbose
cd android
./gradlew --no-daemon --no-build-cache bundleRelease
cp app/build/outputs/bundle/release/app-release.aab /build/Bluewallet-latest.aab
"
log "App bundle saved in $OUT"

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="android-build-env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$REPO_ROOT/reproducible-builds/build"
log() {
printf "\n[%s] %s\n" "$(date +'%H:%M:%S')" "$*" >&2
}
rm -rf "$OUT"
mkdir -p "$OUT"
chmod 775 "$OUT"
log "Building Docker image..."
docker build --platform linux/amd64 -f "$SCRIPT_DIR/Dockerfile" -t "$IMAGE_NAME" "$REPO_ROOT"
log "Running build inside container..."
docker run --platform linux/amd64 --rm \
-e KEYSTORE_FILE_HEX \
-e KEYSTORE_PASSWORD \
-v "$OUT":/build \
"$IMAGE_NAME" \
bash /app/inside-docker.sh
log "Signed APK saved in $OUT"

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
umask 022
npm config set fetch-timeout 600000
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm ci --verbose
cd android
./gradlew --no-daemon --no-build-cache assembleRelease
APK_UNSIGNED="app/build/outputs/apk/release/app-release-unsigned.apk"
APK_SIGNED="/tmp/app-release-signed.apk"
KEYSTORE="/tmp/keystore.jks"
if [ -n "${KEYSTORE_FILE_HEX:-}" ] && [ -n "${KEYSTORE_PASSWORD:-}" ]; then
printf "%s" "$KEYSTORE_FILE_HEX" | xxd -r -p > "$KEYSTORE"
apksigner sign \
--ks "$KEYSTORE" \
--ks-pass env:KEYSTORE_PASSWORD \
--key-pass env:KEYSTORE_PASSWORD \
--deterministic-dsa-signing \
--out "$APK_SIGNED" \
"$APK_UNSIGNED"
else
keytool -genkeypair \
-keystore "$KEYSTORE" \
-storepass password \
-keypass password \
-alias temp-key \
-keyalg RSA \
-keysize 2048 \
-validity 1 \
-dname "CN=Temporary,O=Build,C=US"
apksigner sign \
--ks "$KEYSTORE" \
--ks-key-alias temp-key \
--ks-pass pass:password \
--key-pass pass:password \
--deterministic-dsa-signing \
--out "$APK_SIGNED" \
"$APK_UNSIGNED"
fi
apksigner verify --verbose "$APK_SIGNED"
cp "$APK_SIGNED" /build/Bluewallet-latest.apk

View File

@ -2,13 +2,7 @@ import React, { useMemo, useLayoutEffect, useCallback } from 'react';
import { View, StyleSheet, Linking, Image, Platform } from 'react-native';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import {
SettingsScrollView,
SettingsSection,
SettingsListItem,
getSettingsHeaderOptions,
isIOS26OrHigher,
} from '../../components/platform';
import { SettingsScrollView, SettingsSection, SettingsListItem, getSettingsHeaderOptions } from '../../components/platform';
import { useSettings } from '../../hooks/context/useSettings';
import { useTheme } from '../../components/themes';
@ -21,9 +15,6 @@ const Settings = () => {
const settingsScreenBackgroundColor = isIOSLightMode ? settingsCardColor : colors.background;
const settingsListItemBackgroundColor = isIOSLightMode ? colors.background : undefined;
useLayoutEffect(() => {
if (isIOS26OrHigher) {
return;
}
setOptions(getSettingsHeaderOptions(loc.settings.header, { ...colors, background: settingsScreenBackgroundColor }, dark));
}, [setOptions, language, colors, settingsScreenBackgroundColor, dark]); // Include language to trigger re-render when language changes

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
import { ActivityIndicator, BackHandler, Linking, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { sha256 } from '@noble/hashes/sha256';
import { RouteProp, useRoute } from '@react-navigation/native';
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Icon from '../../components/Icon';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -63,10 +63,6 @@ enum ButtonStatus {
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
headerTitleContainerStyle?: { flex: number; maxWidth: number };
};
enum ActionType {
SetCPFPPossible,
SetRBFBumpFeePossible,
@ -140,12 +136,8 @@ type TransactionDetailHeaderTitleProps = {
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
<View style={styles.headerTitleContainer}>
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{direction}
</BlueText>
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
{date}
</BlueText>
<BlueText style={directionStyle}>{direction}</BlueText>
<BlueText style={dateStyle}>{date}</BlueText>
</View>
);
@ -161,57 +153,10 @@ const TransactionStatus: React.FC = () => {
const subscribedWallet = useWalletSubscribe(walletID);
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
const { colors } = useTheme();
const { width: windowWidth, fontScale } = useWindowDimensions();
const { width: windowWidth } = useWindowDimensions();
const { selectedBlockExplorer } = useSettings();
const fetchTxInterval = useRef<NodeJS.Timeout | undefined>(undefined);
const scaledStyles = useMemo(() => {
const valueLineHeight = Math.round(48 * fontScale);
const valuePaddingTop = Math.round(8 * fontScale);
return {
value: {
lineHeight: valueLineHeight,
paddingTop: valuePaddingTop,
minHeight: valueLineHeight + valuePaddingTop,
},
localCurrency: {
lineHeight: Math.round(20 * fontScale),
marginTop: Math.round(6 * fontScale),
},
headerTitleDirection: {
lineHeight: Math.round(22 * fontScale),
},
headerTitleDate: {
lineHeight: Math.round(18 * fontScale),
},
stateLabel: {
lineHeight: Math.round(22 * fontScale),
},
stateValue: {
lineHeight: Math.round(18 * fontScale),
},
advancedHeader: {
minHeight: Math.round(44 * fontScale),
},
explorerButton: {
paddingVertical: Math.round(6 * fontScale),
paddingHorizontal: Math.round(12 * fontScale),
},
addButton: {
paddingVertical: Math.round(4 * fontScale),
paddingHorizontal: Math.round(12 * fontScale),
},
detailRow: {
minHeight: Math.round(24 * fontScale),
paddingVertical: Math.round(12 * fontScale),
},
sectionTitle: {
paddingVertical: Math.round(16 * fontScale),
},
};
}, [fontScale]);
// Explicit width for To/ID text so Android StaticLayout can apply ellipsis (flex alone often fails on Android)
const detailValueMaxWidth = useMemo(() => Math.max(0, Math.floor((windowWidth - 48) / 2)), [windowWidth]);
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
@ -976,20 +921,15 @@ const TransactionStatus: React.FC = () => {
<TransactionDetailHeaderTitle
direction={transactionDirection}
date={transactionDate}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
/>
),
headerTitleAlign: 'left',
headerTitleContainerStyle: {
flex: 1,
maxWidth: Math.max(0, windowWidth - 96),
},
} as TransactionStatusHeaderOptions);
});
}
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
}, [tx, transactionDirection, transactionDate, setOptions, colors]);
if (loadingError) {
return (
@ -1022,20 +962,15 @@ const TransactionStatus: React.FC = () => {
{/* Value Section */}
<View style={styles.valueCard}>
<View style={styles.valueContent}>
<Text
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
selectable
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.55}
>
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
{` `}
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{` ${preferredBalanceUnit}`}</Text>
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{preferredBalanceUnit}</Text>
)}
</Text>
{txValue !== null && (
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
: satoshiToLocalCurrency(Math.abs(txValue))}
@ -1061,10 +996,8 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionPendingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
{loc.transactions.pending}
</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
{eta || loc.transactions.details_eta_analyzing}
</BlueText>
</View>
@ -1096,11 +1029,9 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
{loc.transactions.details_sent}
</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1112,11 +1043,9 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
{loc.transactions.details_received}
</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1151,29 +1080,20 @@ const TransactionStatus: React.FC = () => {
{/* Details Section */}
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
{/* Details Title */}
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
{loc.transactions.details_section}
</BlueText>
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
{tx?.hash && (
<TouchableOpacity
onPress={handleOpenBlockExplorer}
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
style={[styles.explorerButton, stylesHook.explorerButton]}
activeOpacity={0.7}
>
<BlueText
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
>
{loc.transactions.details_explorer}
</BlueText>
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
</TouchableOpacity>
)}
</View>
{/* Network Fee */}
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1197,7 +1117,7 @@ const TransactionStatus: React.FC = () => {
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
const copyText = externalAddresses.join(', ');
return (
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1223,7 +1143,7 @@ const TransactionStatus: React.FC = () => {
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
{tx.hash && (
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1250,7 +1170,7 @@ const TransactionStatus: React.FC = () => {
)}
{/* Note/Memo */}
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
<View style={styles.detailValueContainer}>
{memo ? (
@ -1260,19 +1180,8 @@ const TransactionStatus: React.FC = () => {
</BlueText>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={handleNotePress}
style={[styles.addButton, stylesHook.addButton, scaledStyles.addButton]}
activeOpacity={0.7}
>
<BlueText
style={[styles.addButtonText, stylesHook.addButtonText]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
>
{loc.transactions.details_add_note}
</BlueText>
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
</TouchableOpacity>
)}
</View>
@ -1283,13 +1192,11 @@ const TransactionStatus: React.FC = () => {
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<TouchableOpacity
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
style={[styles.advancedHeader, stylesHook.advancedHeader]}
activeOpacity={0.85}
>
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow, scaledStyles.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]} numberOfLines={2}>
{loc.transactions.details_advanced}
</BlueText>
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
<Icon
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
type="font-awesome"
@ -1302,7 +1209,7 @@ const TransactionStatus: React.FC = () => {
{isAdvancedExpanded && (
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
{/* Fee Rate */}
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1314,7 +1221,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Size */}
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1326,7 +1233,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Virtual Size */}
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1338,7 +1245,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Transaction Hex */}
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
<View style={styles.detailValueContainer}>
{txHex ? (
@ -1403,7 +1310,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'center',
flex: 1,
minWidth: 0,
},
headerTitleDirection: {
fontSize: 17,
@ -1451,20 +1357,15 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'flex-start',
overflow: 'visible',
width: '100%',
},
value: {
fontSize: 40,
fontWeight: '700',
letterSpacing: -0.5,
lineHeight: 48,
lineHeight: 32,
paddingTop: 8,
minHeight: 38,
},
valueFullWidth: {
width: '100%',
flexShrink: 1,
},
valueUnit: {
fontSize: 18,
fontWeight: '600',
@ -1482,6 +1383,7 @@ const styles = StyleSheet.create({
borderRadius: 12,
marginHorizontal: 24,
marginBottom: 42,
overflow: 'hidden',
},
stateSection: {
alignItems: 'flex-start',
@ -1499,7 +1401,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
marginLeft: 8,
flex: 1,
minWidth: 0,
},
stateLabel: {
fontSize: 16,
@ -1585,23 +1486,17 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
sectionTitleText: {
fontSize: 17,
fontWeight: '600',
},
sectionTitleTextFlexible: {
flex: 1,
flexShrink: 1,
minWidth: 0,
},
explorerButton: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
flexShrink: 0,
minWidth: 50,
alignItems: 'center',
justifyContent: 'center',
},
@ -1612,7 +1507,7 @@ const styles = StyleSheet.create({
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
alignItems: 'center',
marginBottom: 0,
minHeight: 24,
paddingVertical: 12,
@ -1636,8 +1531,6 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '500',
flex: 1,
flexShrink: 1,
minWidth: 0,
lineHeight: 22,
paddingRight: 12,
},
@ -1651,12 +1544,11 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
maxWidth: '100%',
flexWrap: 'wrap',
alignItems: 'flex-end',
flexWrap: 'nowrap',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
flexShrink: 0,
},
detailValueCopyContainer: {
flex: 1,
@ -1704,7 +1596,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
flexShrink: 0,
minWidth: 50,
alignItems: 'center',
justifyContent: 'center',
},
@ -1722,6 +1614,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: 'hidden',
},
advancedContent: {
marginTop: 0,

View File

@ -11,15 +11,9 @@ import {
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
RefreshControl,
NativeScrollEvent,
NativeSyntheticEvent,
StyleProp,
ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Icon from '../../components/Icon';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import { isDesktop } from '../../blue_modules/environment';
@ -33,7 +27,6 @@ import presentAlert, { AlertType } from '../../components/Alert';
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
import { useTheme } from '../../components/themes';
import { TransactionListItem } from '../../components/TransactionListItem';
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
@ -42,14 +35,10 @@ import { Chain } from '../../models/bitcoinUnits';
import ActionSheet from '../ActionSheet';
import { useStorage } from '../../hooks/context/useStorage';
import WatchOnlyWarning from '../../components/WatchOnlyWarning';
import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
import { Transaction, TWallet } from '../../class/wallets/types';
import getWalletTransactionsOptions, {
WalletTransactionsRouteProps,
createWalletDetailsHeaderRight,
createWalletDetailsHeaderRightItems,
} from '../../navigation/helpers/getWalletTransactionsOptions';
import getWalletTransactionsOptions, { WalletTransactionsRouteProps } from '../../navigation/helpers/getWalletTransactionsOptions';
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
import selectWallet from '../../helpers/select-wallet';
import assert from 'assert';
@ -60,8 +49,6 @@ import { getClipboardContent } from '../../blue_modules/clipboard';
import HandOffComponent from '../../components/HandOffComponent';
import { HandOffActivityType } from '../../components/types';
import WalletGradient from '../../class/wallet-gradient';
import { isIOS26OrHigher } from '../../components/platform';
import Animated, { SharedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
const buttonFontSize =
PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22
@ -72,109 +59,7 @@ type RouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
type WalletTransactionsProps = NativeStackScreenProps<DetailViewStackParamList, 'WalletTransactions'>;
/** Scroll offset after which the compact wallet name + balance header is shown. */
const SCROLLED_HEADER_SHOW_OFFSET = 180;
const SCROLLED_HEADER_FADE_IN_MS = 180;
const SCROLLED_HEADER_FADE_OUT_MS = 150;
const usesIos26AnimatedScrolledHeader = Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop;
/** Native stack options used when scrolled; includes props missing from the published TS types. */
type WalletTransactionsScrolledHeaderOptions = NativeStackNavigationOptions & {
headerTitleContainerStyle?: StyleProp<ViewStyle>;
};
/** Horizontal space reserved so the scrolled title does not run under back / header-right actions. */
const getScrolledHeaderTitleLayout = (screenWidth: number) => {
const titleInsetLeft = Platform.OS === 'ios' ? (isIOS26OrHigher ? 40 : 56) : 72;
const titleInsetRight = Platform.OS === 'ios' ? (isIOS26OrHigher ? 96 : 84) : 84;
return {
maxWidth: Math.max(0, screenWidth - titleInsetLeft - titleInsetRight),
titleInsetLeft,
titleInsetRight,
};
};
const buildIos26HeaderTitleLayoutOptions = (
screenWidth: number,
): Pick<WalletTransactionsScrolledHeaderOptions, 'headerTitleAlign' | 'headerTitleContainerStyle'> => ({
headerTitleAlign: 'left',
headerTitleContainerStyle: {
width: screenWidth,
maxWidth: screenWidth,
alignSelf: 'flex-start',
alignItems: 'flex-start',
left: 0,
flexShrink: 1,
minWidth: 0,
},
});
type WalletTransactionsScrolledHeaderTitleProps = {
walletLabel: string;
balance: string;
};
type WalletTransactionsScrolledHeaderTitleAnimatedProps = WalletTransactionsScrolledHeaderTitleProps & {
opacity: SharedValue<number>;
};
const WalletTransactionsScrolledHeaderTitleAnimated: React.FC<WalletTransactionsScrolledHeaderTitleAnimatedProps> = ({
opacity,
walletLabel,
balance,
}) => {
const { width: screenWidth } = useWindowDimensions();
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[scrolledHeaderTitleStyles.animatedTitleWrapper, { width: screenWidth }, animatedStyle]} pointerEvents="box-none">
<WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={balance} />
</Animated.View>
);
};
const WalletTransactionsScrolledHeaderTitle: React.FC<WalletTransactionsScrolledHeaderTitleProps> = ({ walletLabel, balance }) => {
const { width: screenWidth } = useWindowDimensions();
const { colors } = useTheme();
const { maxWidth, titleInsetLeft, titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
const titleColor = Platform.OS === 'ios' ? colors.foregroundColor : '#FFFFFF';
const titleContent = (
<>
<Text style={[scrolledHeaderTitleStyles.walletLabel, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
{walletLabel}
</Text>
{balance.length > 0 ? (
<Text style={[scrolledHeaderTitleStyles.balance, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
{balance}
</Text>
) : null}
</>
);
if (Platform.OS === 'ios') {
return (
<View style={[scrolledHeaderTitleStyles.iosHeaderRoot, { width: screenWidth }]}>
<View
style={[
scrolledHeaderTitleStyles.container,
scrolledHeaderTitleStyles.iosTitleArea,
{ left: titleInsetLeft, right: titleInsetRight },
]}
>
{titleContent}
</View>
</View>
);
}
return <View style={[scrolledHeaderTitleStyles.container, { maxWidth }]}>{titleContent}</View>;
};
type TransactionListItem = Transaction & { type: 'transaction' | 'header' };
const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { route: WalletTransactionsRouteProps }) => {
const { wallets, saveToDisk } = useStorage();
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
@ -188,11 +73,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const [pageSize] = useState(20);
const navigation = useExtendedNavigation();
const { setOptions, navigate } = navigation;
const { colors, dark } = useTheme();
const { colors } = useTheme();
const { isElectrumDisabled } = useSettings();
const insets = useSafeAreaInsets();
const navBarHeight = Platform.select({ ios: 44, android: 56, default: 44 }) ?? 44;
const headerOverlayHeight = insets.top + navBarHeight;
const walletActionButtonsRef = useRef<View>(null);
const [lastFetchTimestamp, setLastFetchTimestamp] = useState(() => wallet._lastTxFetch || 0);
const [fetchFailures, setFetchFailures] = useState(0);
@ -205,8 +87,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const MAX_FAILURES = 3;
const flatListRef = useRef<FlatList<Transaction>>(null);
const headerRef = useRef<View>(null);
const headerScrolledRef = useRef(false);
const scrolledHeaderOpacity = useSharedValue(0);
const [headerHeight, setHeaderHeight] = useState(0);
const stylesHook = StyleSheet.create({
listHeaderText: {
@ -219,17 +100,44 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
backgroundContainer: {
backgroundColor: colors.background,
},
gradientBackground: {
backgroundColor: WalletGradient.headerColorFor(wallet.type),
height: headerHeight > 0 ? headerHeight : '30%',
},
activityIndicatorStyle: {
backgroundColor: colors.background,
},
sendIcon: {
transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }],
},
receiveIcon: {
transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }],
sendIcon: { transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }] },
receiveIcon: { transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }] },
headerBottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 12,
height: 12,
backgroundColor: colors.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
...Platform.select({
ios: {
shadowColor: colors.shadowColor,
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 0.5,
},
}),
},
});
useFocusEffect(
useCallback(() => {
setOptions(getWalletTransactionsOptions({ route }));
}, [route, setOptions]),
);
const onBarCodeRead = useCallback(
(ret?: { data?: any }) => {
if (!isLoading) {
@ -239,15 +147,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
uri: ret?.data ? ret.data : ret,
};
if (wallet.chain === Chain.ONCHAIN) {
navigate('SendDetailsRoot', {
screen: 'SendDetails',
params: parameters,
});
navigate('SendDetailsRoot', { screen: 'SendDetails', params: parameters });
} else {
navigate('ScanLNDInvoiceRoot', {
screen: 'ScanLNDInvoice',
params: parameters,
});
navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: parameters });
}
setIsLoading(false);
}
@ -265,6 +167,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
useEffect(() => {
// keep local display unit in sync when wallet changes (e.g., switching wallets)
console.debug('[UnitSwitch] sync from wallet preferred unit', { walletID, preferred: wallet.preferredBalanceUnit });
setDisplayUnit(wallet.preferredBalanceUnit);
}, [wallet, walletID]);
@ -273,6 +176,10 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
useEffect(() => {
console.debug('[UnitSwitch] display unit state changed', { walletID, displayUnit, switching: isUnitSwitching });
}, [walletID, displayUnit, isUnitSwitching]);
const sortedTransactions = useMemo(() => {
const txs = wallet.getTransactions();
txs.sort((a, b) => b.timestamp - a.timestamp);
@ -396,10 +303,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
await wallet.fetchBtcAddress();
toAddress = wallet.refill_addressess[0];
} catch (Err) {
return presentAlert({
message: (Err as Error).message,
type: AlertType.Toast,
});
return presentAlert({ message: (Err as Error).message, type: AlertType.Toast });
}
}
@ -438,17 +342,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
[name, navigate, navigation, onWalletSelect, walletID, wallets],
);
const { fontScale } = useWindowDimensions();
const txRowHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
const getItemLayout = useCallback(
(_: any, index: number) => ({
length: txRowHeight,
offset: txRowHeight * index,
index,
}),
[txRowHeight],
);
const getItemLayout = (_: any, index: number) => ({
length: 64,
offset: 64 * index,
index,
});
const renderItem = useCallback(
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
@ -493,10 +391,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const sendButtonPress = () => {
if (wallet.chain === Chain.OFFCHAIN) {
return navigate('ScanLNDInvoiceRoot', {
screen: 'ScanLNDInvoice',
params: { walletID },
});
return navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: { walletID } });
}
if (wallet.type === WatchOnlyWallet.type && wallet.isHd() && !wallet.useWithHardwareWalletEnabled()) {
@ -598,136 +493,55 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet, wallet.hideBalance, displayUnit, balance]);
const walletLabel = wallet.getLabel();
const scrolledHeaderTitle = useCallback(() => {
if (usesIos26AnimatedScrolledHeader) {
return (
<WalletTransactionsScrolledHeaderTitleAnimated opacity={scrolledHeaderOpacity} walletLabel={walletLabel} balance={walletBalance} />
);
}
return <WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={walletBalance} />;
}, [walletLabel, walletBalance, scrolledHeaderOpacity]);
const { width: screenWidth } = useWindowDimensions();
const getScrolledHeaderOptions = useCallback((): WalletTransactionsScrolledHeaderOptions => {
const { titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
const routeIsLoading = route.params.isLoading ?? false;
const scrolledHeaderIconColor = colors.foregroundColor;
return {
headerTitle: scrolledHeaderTitle,
// iOS ignores 'left'; title is positioned manually in WalletTransactionsScrolledHeaderTitle.
...(Platform.OS === 'ios'
? buildIos26HeaderTitleLayoutOptions(screenWidth)
: {
headerTitleAlign: 'left' as const,
headerTitleContainerStyle: {
paddingRight: titleInsetRight,
flexShrink: 1,
minWidth: 0,
alignItems: 'flex-start',
},
headerStyle: {
backgroundColor: WalletGradient.headerColorFor(wallet.type),
},
headerTintColor: '#ffffff',
}),
...(Platform.OS === 'ios'
? {
headerTintColor: scrolledHeaderIconColor,
statusBarStyle: 'light',
...(isIOS26OrHigher && !isDesktop
? {
headerRight: undefined,
unstable_headerRightItems: createWalletDetailsHeaderRightItems({
isLoading: routeIsLoading,
walletID,
}),
experimental_userInterfaceStyle: dark ? ('dark' as const) : ('light' as const),
}
: {
headerBlurEffect: dark ? ('dark' as const) : ('light' as const),
headerRight: createWalletDetailsHeaderRight({
walletID,
isLoading: routeIsLoading,
iconColor: scrolledHeaderIconColor,
}),
}),
}
: {}),
};
}, [scrolledHeaderTitle, screenWidth, colors.foregroundColor, dark, route.params.isLoading, walletID, wallet.type]);
useEffect(() => {
if (!headerScrolledRef.current) return;
setOptions(getScrolledHeaderOptions());
}, [walletBalance, getScrolledHeaderOptions, setOptions]);
useFocusEffect(
useCallback(() => {
if (usesIos26AnimatedScrolledHeader) {
headerScrolledRef.current = false;
scrolledHeaderOpacity.value = 0;
setOptions({
...getWalletTransactionsOptions({ route }),
...buildIos26HeaderTitleLayoutOptions(screenWidth),
headerTitle: scrolledHeaderTitle,
});
return;
}
setOptions(getWalletTransactionsOptions({ route }));
}, [route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity, setOptions]),
);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
(event: any) => {
const offsetY = event.nativeEvent.contentOffset.y;
const scrolled = offsetY >= SCROLLED_HEADER_SHOW_OFFSET;
if (usesIos26AnimatedScrolledHeader) {
if (scrolled === headerScrolledRef.current) return;
headerScrolledRef.current = scrolled;
scrolledHeaderOpacity.value = withTiming(scrolled ? 1 : 0, {
duration: scrolled ? SCROLLED_HEADER_FADE_IN_MS : SCROLLED_HEADER_FADE_OUT_MS,
});
if (scrolled) {
setOptions(getScrolledHeaderOptions());
} else {
setOptions({
...getWalletTransactionsOptions({ route }),
...buildIos26HeaderTitleLayoutOptions(screenWidth),
headerTitle: scrolledHeaderTitle,
});
}
return;
}
if (scrolled === headerScrolledRef.current) return;
headerScrolledRef.current = scrolled;
if (!scrolled) {
setOptions({
...getWalletTransactionsOptions({ route }),
headerTitle: undefined,
headerTitleAlign: undefined,
headerTitleContainerStyle: undefined,
headerBlurEffect: undefined,
});
const combinedHeight = 180;
if (offsetY < combinedHeight) {
setOptions({ ...getWalletTransactionsOptions({ route }), headerTitle: undefined });
} else {
setOptions(getScrolledHeaderOptions());
navigation.setOptions({
headerTitle: `${wallet.getLabel()} ${walletBalance}`,
});
}
},
[getScrolledHeaderOptions, setOptions, route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity],
[navigation, wallet, walletBalance, setOptions, route],
);
const ListHeaderComponent = useCallback(
const measureHeaderHeight = useCallback(() => {
if (!headerRef.current) {
// If header ref is not available, use default background
setHeaderHeight(0);
return;
}
headerRef.current.measure((x, y, width, height, pageX, pageY) => {
// Check if the header is actually visible
if (height === 0 || pageY < 0) {
// Header is not visible, use default background
setHeaderHeight(0);
return;
}
const fullHeight = pageY + height;
if (fullHeight > 0) {
setHeaderHeight(fullHeight);
}
});
}, []);
useEffect(() => {
const timer = setTimeout(measureHeaderHeight, 100);
return () => clearTimeout(timer);
}, [walletID, measureHeaderHeight]);
const ListHeaderComponent = useMemo(
() => (
<View ref={headerRef}>
<View ref={headerRef} onLayout={measureHeaderHeight}>
<TransactionsNavigationHeader
headerOverlayHeight={headerOverlayHeight}
wallet={wallet}
onWalletUnitChange={async selectedUnit => {
console.debug('[UnitSwitch] requested', { walletID, from: displayUnit, to: selectedUnit });
setIsUnitSwitching(true);
setDisplayUnit(selectedUnit);
if ('setPreferredBalanceUnit' in wallet) {
@ -736,25 +550,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
(wallet as TWallet).preferredBalanceUnit = selectedUnit;
}
await saveToDisk();
console.debug('[UnitSwitch] persisted preferred unit', { walletID, unit: selectedUnit });
setTimeout(() => {
setIsUnitSwitching(false);
console.debug('[UnitSwitch] complete', { walletID, unit: selectedUnit });
}, 50);
}}
unit={displayUnit}
unitSwitching={isUnitSwitching}
onWalletBalanceVisibilityChange={async shouldHideBalance => {
try {
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (wallet.hideBalance && !shouldHideBalance && isBiometricsEnabled) {
if (!(await unlockWithBiometrics())) {
return;
}
}
wallet.hideBalance = shouldHideBalance;
await saveToDisk();
} catch (error) {
console.error('Failed to toggle balance visibility:', error);
onWalletBalanceVisibilityChange={async isShouldBeVisible => {
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (wallet.hideBalance && isBiometricsEnabled) {
const unlocked = await unlockWithBiometrics();
if (!unlocked) throw new Error('Biometrics failed');
}
wallet.hideBalance = isShouldBeVisible;
await saveToDisk();
}}
onManageFundsPressed={id => {
if (wallet.type === MultisigHDWallet.type) {
@ -780,30 +591,36 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
}}
/>
<View style={[styles.flex, styles.transactionsSection, stylesHook.backgroundContainer]}>
<View style={styles.listHeaderTextRow}>
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
<View style={styles.headerBottomBarSpacer}>
<View style={stylesHook.headerBottomBar} />
</View>
<>
<View style={[styles.flex, stylesHook.backgroundContainer]}>
<View style={styles.listHeaderTextRow}>
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
</View>
</View>
</View>
<View style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</View>
<View style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</View>
</>
</View>
),
[
wallet,
displayUnit,
isUnitSwitching,
headerOverlayHeight,
measureHeaderHeight,
stylesHook.backgroundContainer,
stylesHook.headerBottomBar,
stylesHook.listHeaderText,
saveToDisk,
isBiometricUseCapableAndEnabled,
@ -816,18 +633,16 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
);
useEffect(() => {
headerScrolledRef.current = false;
scrolledHeaderOpacity.value = 0;
if (flatListRef.current) {
flatListRef.current.scrollToOffset({ offset: 0, animated: true });
}
}, [walletID, scrolledHeaderOpacity]);
}, [walletID]);
return (
<View style={[styles.flex, { backgroundColor: WalletGradient.headerColorFor(wallet.type) }]} testID="TransactionsListView">
<View style={[styles.flex, stylesHook.backgroundContainer]}>
<View style={[styles.refreshIndicatorBackground, stylesHook.gradientBackground]} testID="TransactionsListView" />
<FlatList<Transaction>
ref={flatListRef}
style={styles.flatList}
getItemLayout={getItemLayout}
updateCellsBatchingPeriod={50}
onEndReachedThreshold={0.3}
@ -838,9 +653,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
keyExtractor={_keyExtractor}
renderItem={renderItem}
initialNumToRender={10}
removeClippedSubviews={false}
contentContainerStyle={[styles.contentContainer, stylesHook.backgroundContainer]}
contentInsetAdjustmentBehavior="never"
removeClippedSubviews
contentContainerStyle={stylesHook.backgroundContainer}
contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }}
maxToRenderPerBatch={10}
onScroll={handleScroll}
@ -857,25 +671,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
refreshControl={
!isDesktop && !isElectrumDisabled ? (
<RefreshControl
refreshing={isLoading}
onRefresh={() => refreshTransactions(true)}
tintColor={Platform.OS === 'ios' ? 'transparent' : colors.msSuccessCheck}
progressViewOffset={headerOverlayHeight}
/>
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
) : undefined
}
/>
{isLoading && Platform.OS === 'ios' && (
<ActivityIndicator
style={[styles.refreshSpinner, { top: headerOverlayHeight + 12, transform: [{ scale: 1.4 }] }]}
color="#ffffff"
size="small"
pointerEvents="none"
/>
)}
<FloatButtonsBottomFade />
<FContainer ref={walletActionButtonsRef}>
{wallet.allowReceive() && (
@ -884,10 +684,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
text={loc.receive.header}
onPress={() => {
if (wallet.chain === Chain.OFFCHAIN) {
navigate('LNDCreateInvoiceRoot', {
screen: 'LNDCreateInvoice',
params: { walletID },
});
navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID } });
} else {
navigate('ReceiveDetails', { walletID });
}
@ -938,81 +735,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
export default WalletTransactions;
const scrolledHeaderTitleStyles = StyleSheet.create({
animatedTitleWrapper: {
alignSelf: 'flex-start',
},
iosHeaderRoot: {
height: 44,
justifyContent: 'center',
},
iosTitleArea: {
position: 'absolute',
top: 0,
bottom: 0,
minWidth: 0,
},
container: {
minWidth: 0,
alignItems: 'flex-start',
justifyContent: 'center',
overflow: 'hidden',
},
walletLabel: {
fontSize: 17,
fontWeight: '600',
letterSpacing: 0.15,
alignSelf: 'stretch',
flexShrink: 1,
},
balance: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
marginTop: 1,
alignSelf: 'stretch',
flexShrink: 1,
},
});
const styles = StyleSheet.create({
flex: { flex: 1 },
flatList: { flex: 1, backgroundColor: 'transparent' },
transactionsSection: { marginTop: -1 },
scrollViewContent: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 500,
},
headerBottomBarSpacer: { position: 'relative', height: 12 },
scrollViewContent: { flex: 1, justifyContent: 'center', paddingHorizontal: 16, paddingBottom: 500 },
activityIndicator: { marginVertical: 20 },
listHeaderTextRow: {
flex: 1,
marginHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
listHeaderTextRow: { flex: 1, marginHorizontal: 16, flexDirection: 'row', justifyContent: 'space-between' },
listHeaderText: { marginTop: 0, marginBottom: 16, fontWeight: 'bold', fontSize: 24 },
refreshIndicatorBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
listHeaderText: {
marginTop: 16,
marginBottom: 16,
fontWeight: 'bold',
fontSize: 24,
},
contentContainer: { flexGrow: 1 },
refreshSpinner: { position: 'absolute', alignSelf: 'center', zIndex: 10 },
emptyTxsContainer: { height: '10%', minHeight: '10%', flex: 1 },
emptyTxs: {
fontSize: 18,
color: '#9aa0aa',
textAlign: 'center',
marginVertical: 16,
},
emptyTxsLightning: {
fontSize: 18,
color: '#9aa0aa',
textAlign: 'center',
fontWeight: '600',
},
emptyTxs: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', marginVertical: 16 },
emptyTxsLightning: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', fontWeight: '600' },
iconContainer: {
justifyContent: 'center',
alignItems: 'center',

View File

@ -8,15 +8,10 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
import { useTheme } from '../../components/themes';
import { TransactionListItem } from '../../components/TransactionListItem';
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
import WalletsCarousel, {
getWalletCarouselItemWidth,
CarouselListRefType,
getWalletCarouselHeight,
} from '../../components/WalletsCarousel';
import WalletsCarousel, { getWalletCarouselItemWidth, CarouselListRefType } from '../../components/WalletsCarousel';
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
import loc from '../../loc';
import ActionSheet from '../ActionSheet';
@ -30,10 +25,8 @@ import { useSettings } from '../../hooks/context/useSettings';
import useMenuElements from '../../hooks/useMenuElements';
import SafeAreaSectionList from '../../components/SafeAreaSectionList';
import { scanQrHelper } from '../../helpers/scan-qr';
import { isIOS26OrHigher } from '../../components/platform';
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
const SECTION_HEADER_BASE_HEIGHT = 56;
/** Electrum `ping` while the list is visible; detects mid-session drops without polling when user is elsewhere. */
const ELECTRUM_HEALTH_POLL_WHILE_WALLETS_LIST_FOCUSED_MS = 30_000;
@ -114,11 +107,7 @@ const WalletsList: React.FC = () => {
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
const { width, fontScale } = useWindowDimensions();
const carouselHeight = getWalletCarouselHeight(fontScale);
const transactionItemHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
const sectionHeaderHeight = Math.round(SECTION_HEADER_BASE_HEIGHT * fontScale);
const floatingButtonHeight = getFloatingButtonReservedHeight(fontScale);
const { width } = useWindowDimensions();
const { colors, scanImage } = useTheme();
const navigation = useExtendedNavigation<NavigationProps>();
const isFocused = useIsFocused();
@ -134,11 +123,9 @@ const WalletsList: React.FC = () => {
listHeaderBack: {
backgroundColor: colors.background,
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
minHeight: sectionHeaderHeight,
},
listHeaderText: {
color: colors.foregroundColor,
marginVertical: Math.round(16 * fontScale),
},
});
@ -484,9 +471,7 @@ const WalletsList: React.FC = () => {
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
if (typeof item === 'string') return item;
const txKey = item?.hash || item?.txid;
if (txKey && item?.walletID) return `${txKey}_${item.walletID}`;
return txKey || `${item}${index}`;
return item?.hash || item?.txid || `${item}${index}`;
}, []);
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };
@ -505,9 +490,14 @@ const WalletsList: React.FC = () => {
}, [sizeClass, dataSource]);
// Constants for layout calculations
const TRANSACTION_ITEM_HEIGHT = 80;
const CAROUSEL_HEIGHT = 195;
const SECTION_HEADER_HEIGHT = 56; // Base height
const LARGE_TITLE_EXTRA_HEIGHT = 20; // Additional height for large titles
const getSectionHeaderHeight = useCallback(() => {
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
}, [sizeClass, sectionHeaderHeight, fontScale]);
return SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
}, [sizeClass]);
const getItemLayout = useCallback(
(data: any, index: number) => {
@ -516,8 +506,8 @@ const WalletsList: React.FC = () => {
if (sizeClass === SizeClass.Large) {
// On large screens: only transaction items, no carousel
return {
length: transactionItemHeight,
offset: transactionItemHeight * index,
length: TRANSACTION_ITEM_HEIGHT,
offset: TRANSACTION_ITEM_HEIGHT * index,
index,
};
} else {
@ -525,7 +515,7 @@ const WalletsList: React.FC = () => {
// First section: Carousel
if (index === 0) {
return {
length: carouselHeight,
length: CAROUSEL_HEIGHT,
offset: 0,
index,
};
@ -538,13 +528,13 @@ const WalletsList: React.FC = () => {
// 3. Transaction items
const transactionIndex = index - 1; // Adjust index to account for carousel
return {
length: transactionItemHeight,
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
length: TRANSACTION_ITEM_HEIGHT,
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
index,
};
}
},
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
[sizeClass, getSectionHeaderHeight],
);
return (
@ -557,13 +547,11 @@ const WalletsList: React.FC = () => {
initialNumToRender={10}
renderSectionFooter={renderSectionFooter}
sections={sections}
floatingButtonHeight={floatingButtonHeight}
floatingButtonHeight={70}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}
ignoreTopInset={true} // Ignore top inset as the screen header already handles it
// On iOS 26+, let the section headers scroll naturally with the content rather than sticking
stickySectionHeadersEnabled={!isIOS26OrHigher}
{...refreshProps}
/>
{renderScanButton()}

View File

@ -4,7 +4,6 @@ import { element, waitFor } from 'detox';
import {
confirmPasswordDialog,
dismissAlertByText,
expectToBeVisible,
extractTextFromElementById,
goBack,
@ -194,36 +193,31 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitFor(element(by.id('NotificationsSwitch')))
.toBeVisible()
.withTimeout(10000);
await element(by.id('NotificationsSwitch')).tap();
// Toggle notifications on/off. On iOS 26 simulators notifications are always
// denied, triggering a native UIAlertController whose buttons liquid glass
// can make un-tappable by Detox. If the alert cannot be dismissed, relaunch
// the app to recover instead of failing the entire settings test.
let notifDialogStuck = false;
// If notifications are not enabled on the device, an alert will appear
try {
await element(by.id('NotificationsSwitch')).tap();
const dismissed1 = await dismissAlertByText('OK', 10000);
if (dismissed1) {
await sleep(500);
await element(by.id('NotificationsSwitch')).tap();
await dismissAlertByText('OK', 10000);
} else {
notifDialogStuck = true;
}
} catch (e) {
console.warn('Notifications toggle skipped due to alert interaction issue:', e.message);
notifDialogStuck = true;
await waitFor(element(by.text('OK')))
.toBeVisible()
.withTimeout(3000);
await element(by.text('OK')).tap();
} catch (_) {
// Alert not shown, which is fine - notifications might be enabled
}
await element(by.id('NotificationsSwitch')).tap();
// If notifications are not enabled on the device, an alert will appear
try {
await waitFor(element(by.text('OK')))
.toBeVisible()
.withTimeout(3000);
await element(by.text('OK')).tap();
} catch (_) {
// Alert not shown, which is fine - notifications might be enabled
}
if (notifDialogStuck) {
// Dialog blocks all interaction; relaunch the app to clear it
await device.launchApp({ newInstance: true });
await waitForId('WalletsList');
await element(by.id('SettingsButton')).tap();
} else {
await goBack();
await goBack();
}
await goBack();
await goBack();
} else {
await goBack();
}

View File

@ -58,20 +58,8 @@ export async function waitForText(text, timeout = 33000) {
await waitFor(element(by.text(text)))
.toBeVisible()
.withTimeout(timeout / 2);
return true;
} catch (err) {
// iOS 26 liquid glass: text rendered inside/over the glass header (e.g. the wallet name on
// the transactions hero) can fail Detox's 75%-pixel toBeVisible check while still being
// present and on-screen — same root cause as the goBack() back-button workaround. Fall back
// to existence in the hierarchy so a glass false-negative does not fail an otherwise valid run.
try {
await waitFor(element(by.text(text)))
.toExist()
.withTimeout(3000);
return true;
} catch (_) {
rethrowWithCallsite(err, callsite);
}
rethrowWithCallsite(err, callsite);
}
}
@ -185,7 +173,7 @@ export async function helperDeleteWallet(label, remainingBalanceSat = false) {
await waitForId('WalletDetails');
await element(by.id('WalletDetails')).tap();
await element(by.id('WalletDetailsScroll')).swipe('up', 'fast', 1);
await sleep(1000);
await sleep(200);
await element(by.id('DeleteWallet')).tap();
await waitForText('Yes, delete');
await element(by.text('Yes, delete')).tap();
@ -228,44 +216,15 @@ export async function helperCreateWallet(walletName) {
await element(by.id('ActivateBitcoinButton')).tap();
await element(by.id('ActivateBitcoinButton')).tap();
// why tf we need 2 taps for it to work..? mystery
await tapAndTapAgainIfElementIsNotVisible('Create', 'PleaseBackupScrollView');
// iOS 26 liquid glass: the navigation transition after tapping "Create" triggers
// glass animations that never fully settle, keeping the app in a "busy" state.
// Detox synchronization waits for idle before proceeding, causing an infinite hang.
// Disable sync for the remainder of wallet creation and re-enable once we're back
// on the home screen where the glass animations have settled.
const isIOS = device.getPlatform() === 'ios';
if (isIOS) {
await device.disableSynchronization();
}
try {
await element(by.id('Create')).tap();
await sleep(500);
try {
await waitFor(element(by.id('PleaseBackupScrollView')))
.toBeVisible()
.withTimeout(15000);
} catch (_) {
await element(by.id('Create')).tap();
await sleep(500);
await waitFor(element(by.id('PleaseBackupScrollView')))
.toBeVisible()
.withTimeout(15000);
}
await waitFor(element(by.id('PleasebackupOk')))
.toBeVisible()
.whileElement(by.id('PleaseBackupScrollView'))
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
await waitFor(element(by.id('PleasebackupOk')))
.toBeVisible()
.whileElement(by.id('PleaseBackupScrollView'))
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
await element(by.id('PleasebackupOk')).tap();
await sleep(1000);
await scrollUpOnHomeScreen();
} finally {
if (isIOS) {
await device.enableSynchronization();
}
}
await element(by.id('PleasebackupOk')).tap();
await scrollUpOnHomeScreen();
await expect(element(by.id('WalletsList'))).toBeVisible();
await element(by.id('WalletsList')).swipe('right', 'fast', 1); // in case emu screen is small and it doesnt fit
await sleep(200);
@ -338,46 +297,6 @@ export async function tapIfTextPresent(text) {
// no need to check for visibility, just silently ignore exception if such testID is not present
}
/**
* Dismisses a native UIAlertController by tapping a button with the given text.
* On iOS 26 liquid glass, `waitFor().toBeVisible()` never resolves for alert
* buttons because the glass material fails Detox's pixel visibility check.
* This helper disables Detox synchronization (which can also hang on glass
* animations) and polls with direct tap attempts and label fallbacks.
*
* @returns true if the alert was dismissed, false if no alert was found
*/
export async function dismissAlertByText(text, timeoutMs = 10000) {
const isIOS = device.getPlatform() === 'ios';
if (isIOS) {
await device.disableSynchronization();
}
const deadline = Date.now() + timeoutMs;
let dismissed = false;
try {
while (Date.now() < deadline) {
// by.text — works on preiOS 26 and some iOS 26 alerts
try {
await element(by.text(text)).atIndex(0).tap();
dismissed = true;
break;
} catch (_) {}
// by.label — accessibility label, works when text matching differs
try {
await element(by.label(text)).atIndex(0).tap();
dismissed = true;
break;
} catch (_) {}
await sleep(500);
}
} finally {
if (isIOS) {
await device.enableSynchronization();
}
}
return dismissed;
}
/**
* Confirms password dialogs in a platform-safe way.
* Android must tap a visible confirmation to keep test flow deterministic.
@ -449,62 +368,19 @@ export async function goBack() {
// Try each back/close affordance in order; retry the full set up to 10 times.
const candidates = [by.id('BackButton'), by.id('NavigationCloseButton'), by.label('Back'), by.text('Close')];
// A matcher can hit several elements across stacked screens: each nav back
// button exists twice (_UIButtonBarButton wrapper + UIAccessibilityBackButtonElement),
// and when a modal covers a stack that also has a back button, the covered
// one can precede the visible one in match order (seen with Reduce Motion on).
// Probe attributes and only tap an element detox reports as visible & hittable.
//
// iOS 26 liquid glass: the native back button reports visible=false because
// the glass material fails Detox's 75%-pixel visibility check, yet the button
// IS functionally hittable. We first try (visible && hittable), then fall back
// to (hittable only) for the glass case.
let lastErr;
for (let attempt = 0; attempt < 10; attempt++) {
// Pass 1: prefer visible + hittable elements
for (const matcher of candidates) {
for (let idx = 0; idx < 6; idx++) {
let attrs;
try {
attrs = await element(matcher).atIndex(idx).getAttributes();
} catch (err) {
lastErr = err;
break; // no element at this index — try next candidate
}
if (!attrs.visible || attrs.hittable === false) continue;
try {
await element(matcher).atIndex(idx).tap();
return;
} catch (err) {
lastErr = err;
}
}
}
// Pass 2: accept hittable-only elements (iOS 26 liquid glass back button)
for (const matcher of candidates) {
for (let idx = 0; idx < 6; idx++) {
let attrs;
try {
attrs = await element(matcher).atIndex(idx).getAttributes();
} catch (err) {
lastErr = err;
break;
}
if (attrs.hittable === false) continue;
try {
await element(matcher).atIndex(idx).tap();
return;
} catch (err) {
lastErr = err;
}
try {
await element(matcher).atIndex(0).tap();
return;
} catch (_) {
/* try next */
}
}
await sleep(500);
}
const wrapped = new Error('goBack: no back/close affordance tappable after 10 attempts.');
if (lastErr) wrapped.cause = lastErr;
rethrowWithCallsite(wrapped, callsite);
rethrowWithCallsite(new Error('goBack: no back/close affordance tappable after 10 attempts.'), callsite);
}
export async function typeTextIntoAlertInput(text) {
@ -529,7 +405,7 @@ export async function scrollUpOnHomeScreen() {
// if no wallets there will be just one scroll
await element(by.type('RCTEnhancedScrollView')).swipe('down', 'slow', 0.5);
}
await sleep(1000); // bounce animation
await sleep(200); // bounce animation
}
// We really only need this function when running tests locally.

View File

@ -43,19 +43,4 @@ describe('unit - encryption', function () {
const decrypted = c.decrypt(crypted, 'password');
assert.deepEqual(data2decrypt, decrypted);
});
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
//
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
// printf 'hello world this is plaintext' \
// | openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5; \
// } | base64
//
// OpenSSL's `enc` only emits the `Salted__` envelope when it picks the salt itself;
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
// on-disk format against an independent reference beyond crypto-js.
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
});
});

View File

@ -1,51 +0,0 @@
import assert from 'assert';
import { evpBytesToKeyMd5 } from '../../blue_modules/encryption';
import { hexToUint8Array, stringToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
describe('evpBytesToKeyMd5', () => {
// Vectors computed against the OpenSSL EVP_BytesToKey reference algorithm
// (MD5, 1 iteration). The KDF is purely deterministic, so a single fixed
// (password, salt) pair pins the bytes our wallet store relies on.
it('matches the OpenSSL CLI reference for password="mypassword"', () => {
// openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5 -p
const out = evpBytesToKeyMd5(stringToUint8Array('mypassword'), hexToUint8Array('0102030405060708'), 48);
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), '20814c3ad75ac1d26c61a8e4702b5ff4d7baaee00c595bab71592aaf45bf41e4');
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '43269499cb6d59f4e3b9dda68098b673');
});
it('matches a Node-crypto reference vector for a multi-word password', () => {
const out = evpBytesToKeyMd5(stringToUint8Array('correct horse'), hexToUint8Array('0102030405060708'), 48);
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), 'bcf8d941d9291141709c9d56360eb7148e3960ab3dc44d832c4028568545c91d');
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '5a7a1d12207f801d2f6f4cf578e8708c');
});
it('returns exactly the requested number of bytes', () => {
const pwd = stringToUint8Array('pw');
const salt = hexToUint8Array('00000000000000ff');
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 1).length, 1);
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 15).length, 15);
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 16).length, 16); // one MD5 block exactly
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 17).length, 17); // one block + 1
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 48).length, 48); // key + iv default
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 65).length, 65); // multi-block + spillover
});
it('is a prefix-stable stream (same first N bytes regardless of total length)', () => {
const pwd = stringToUint8Array('xyz');
const salt = hexToUint8Array('cafebabedeadbeef');
const long = evpBytesToKeyMd5(pwd, salt, 64);
for (const n of [1, 16, 17, 32, 48]) {
assert.strictEqual(uint8ArrayToHex(evpBytesToKeyMd5(pwd, salt, n)), uint8ArrayToHex(long.subarray(0, n)));
}
});
it('rejects non-integer or negative byteLength', () => {
const pwd = stringToUint8Array('pw');
const salt = hexToUint8Array('0102030405060708');
assert.throws(() => evpBytesToKeyMd5(pwd, salt, -1));
assert.throws(() => evpBytesToKeyMd5(pwd, salt, 1.5));
assert.throws(() => evpBytesToKeyMd5(pwd, salt, NaN));
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 0).length, 0);
});
});

View File

@ -1,10 +0,0 @@
{
"xfp": "B68AF6E4",
"account": 0,
"p2wsh_deriv": "m/48h/0h/0h/2h",
"p2wsh": "Zpub74w9dfoeurKrKXE3SPRpFquLPTkiCuSwGuhDzBgbE42w5ShB2FxMjmJyjZpSJ6WhLt8y1PeFHQELGgq2GmktviFDH8yFWYRWg4xQiw3v335",
"p2sh_deriv": "m/45h",
"p2sh": "xpub69EKPNo9Jkd6v2h7xNKw5RdbFBoaHEcstXcRNfcQ2jg71iFpobCwcxfJjaV2ycGy218f2jM1znqs1SDkqMiR7fbyBVJwzacg2QarGt1gtJg",
"p2sh_p2wsh_deriv": "m/48h/0h/0h/1h",
"p2sh_p2wsh": "Ypub6k6tL18jmAnNRGZpk4u3WPGDmWMkdZNmx3MySYdQywCwMMHqNoKHeqLAgU6pFokHKQFdi88vAW4g3TEsCAymoq5LnFXd54RkQ8m3AD9f81J"
}

View File

@ -1160,7 +1160,6 @@ describe('LightningArkWallet — addInvoice + payInvoice (mocked SDK runtime)',
// Real BOLT11 with amount = 0.0001 BTC (10000 sat) so it passes the limits assertion.
const invoice =
'lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m';
const expectedPaymentHash = w.decodeInvoice(invoice).payment_hash;
fakeArkadeSwaps.sendLightningPayment.mockResolvedValue({ amount: 10_000, preimage: 'pre', txid: 'tx' });
await w.payInvoice(invoice);
@ -1168,11 +1167,6 @@ describe('LightningArkWallet — addInvoice + payInvoice (mocked SDK runtime)',
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls.length, 1);
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls[0][0].invoice, invoice);
assert.strictEqual(fakeWallet.sendBitcoin.mock.calls.length, 0, 'Ark sendBitcoin must not run for BOLT11');
assert.deepStrictEqual(w.last_paid_invoice_result, {
payment_preimage: 'pre',
payment_hash: expectedPaymentHash,
payment_request: invoice,
});
});
it('payInvoice routes a valid Ark address through Wallet.sendBitcoin', async () => {

View File

@ -208,17 +208,6 @@ describe('LNURL', function () {
assert.strictEqual(Lnurl.decipherAES(ciphertext, preimage, iv), '1234');
});
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
// Non-block-aligned ciphertext — would throw under raw @noble/ciphers
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
// Empty ciphertext
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
});
});
describe('lightning address', function () {

View File

@ -2161,31 +2161,6 @@ describe('multisig-cosigner', () => {
assert.strictEqual(c3.getPath(), "m/48'/0'/0'/2'");
});
it('can parse unchained json', () => {
const unchainedJson = require('./fixtures/unchained.json');
const cosigner = new MultisigCosigner(JSON.stringify(unchainedJson));
assert.ok(cosigner.isValid());
assert.strictEqual(cosigner.howManyCosignersWeHave(), 3);
assert.strictEqual(cosigner.getFp(), '');
assert.strictEqual(cosigner.getXpub(), '');
assert.strictEqual(cosigner.getPath(), '');
const [c1, c2, c3] = cosigner.getAllCosigners();
assert.strictEqual(c1.getXpub(), unchainedJson.p2sh);
assert.strictEqual(c1.getFp(), 'B68AF6E4');
assert.strictEqual(c1.getPath(), "m/45'");
assert.strictEqual(c2.getXpub(), unchainedJson.p2sh_p2wsh);
assert.strictEqual(c2.getFp(), 'B68AF6E4');
assert.strictEqual(c2.getPath(), "m/48'/0'/0'/1'");
assert.strictEqual(c3.getXpub(), unchainedJson.p2wsh);
assert.strictEqual(c3.getFp(), 'B68AF6E4');
assert.strictEqual(c3.getPath(), "m/48'/0'/0'/2'");
});
it('can parse plain Zpub', () => {
const cosigner = new MultisigCosigner(Zpub1);
assert.ok(cosigner.isValid());