Merge branch 'master' into bottommodalheader

This commit is contained in:
Marcos Rodriguez Velez 2024-09-15 12:50:11 -04:00
commit c1433caba9
372 changed files with 13354 additions and 4106 deletions

View file

@ -19,6 +19,7 @@
"react/display-name": "off",
"react-native/no-inline-styles": "error",
"react-native/no-unused-styles": "error",
"react/no-is-mounted": "off",
"react-native/no-single-element-style-arrays": "error",
"prettier/prettier": [
"warn",

View file

@ -12,7 +12,7 @@ on:
jobs:
build:
runs-on: macos-14
runs-on: macos-latest
timeout-minutes: 180
outputs:
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
@ -25,29 +25,36 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetches all history
- name: Specify node version
uses: actions/setup-node@v4
with:
node-version: 20
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 15.4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true
- name: Install dependencies with Bundler
run: bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Install node_modules
run: npm install
run: npm install --omit=dev --yes
- name: Install CocoaPods Dependencies
run: |
gem install cocoapods
bundle exec pod install
working-directory: ./ios
bundle exec fastlane ios install_pods
- name: Cache CocoaPods Pods
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
@ -67,7 +74,6 @@ jobs:
git config --global http.https://github.com/.extraheader "AUTHORIZATION: basic $(echo -n x-access-token:${ACCESS_TOKEN} | base64)"
- name: Create Temporary Keychain
run: bundle exec fastlane ios create_temp_keychain
working-directory: ./ios
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Setup Provisioning Profiles
@ -80,10 +86,9 @@ jobs:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
bundle exec fastlane ios setup_provisioning_profiles
working-directory: ./ios
- name: Cache Provisioning Profiles
id: cache_provisioning_profiles
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ~/Library/MobileDevice/Provisioning Profiles
key: ${{ runner.os }}-provisioning-profiles-${{ github.sha }}
@ -112,28 +117,28 @@ jobs:
echo "::set-output name=build_number::$NEW_BUILD_NUMBER"
- name: Set Build Number
run: bundle exec fastlane ios increment_build_number_lane
working-directory: ./ios
- name: Determine Marketing Version
id: determine_marketing_version
run: |
MARKETING_VERSION=$(grep MARKETING_VERSION ios/BlueWallet.xcodeproj/project.pbxproj | awk -F '= ' '{print $2}' | tr -d ' ;' | head -1)
MARKETING_VERSION=$(grep MARKETING_VERSION BlueWallet.xcodeproj/project.pbxproj | awk -F '= ' '{print $2}' | tr -d ' ;' | head -1)
echo "PROJECT_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV
echo "::set-output name=project_version::$MARKETING_VERSION"
working-directory: ios
- name: Expected IPA file name
run: |
echo "IPA file name: BlueWallet.${{env.PROJECT_VERSION}}(${{env.NEW_BUILD_NUMBER}}).ipa"
- name: Build App
run: bundle exec fastlane ios build_app_lane
working-directory: ./ios
- name: Upload IPA as Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: BlueWallet.${{env.PROJECT_VERSION}}(${{env.NEW_BUILD_NUMBER}}).ipa
path: ./ios/build/BlueWallet.${{env.PROJECT_VERSION}}(${{env.NEW_BUILD_NUMBER}}).ipa
path: ./build/BlueWallet.${{env.PROJECT_VERSION}}(${{env.NEW_BUILD_NUMBER}}).ipa
testflight-upload:
needs: build
runs-on: macos-14
runs-on: macos-latest
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
@ -149,7 +154,7 @@ jobs:
ruby-version: 3.1.6
bundler-cache: true
- name: Cache Ruby Gems
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
@ -160,15 +165,15 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --quiet
- name: Download IPA from Artifact
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: BlueWallet.${{ needs.build.outputs.project_version }}(${{ needs.build.outputs.new_build_number }}).ipa
path: ./ios/build
path: ./
- name: Create App Store Connect API Key JSON
run: echo '${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}' > ./ios/appstore_api_key.json
run: echo '${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}' > ./appstore_api_key.json
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_PATH: $(pwd)/ios/appstore_api_key.p8
APP_STORE_CONNECT_API_KEY_PATH: $(pwd)/appstore_api_key.p8
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }}
GIT_URL: ${{ secrets.GIT_URL }}
@ -178,7 +183,6 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: bundle exec fastlane ios upload_to_testflight_lane
working-directory: ./ios
- name: Post PR Comment
if: success() && github.event_name == 'pull_request'
uses: actions/github-script@v6

View file

@ -1,19 +1,21 @@
name: BuildReleaseApk
on:
pull_request:
branches:
- master
types: [opened, synchronize, reopened]
push:
branches:
- master
jobs:
buildReleaseApk:
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: "0"
@ -23,7 +25,7 @@ jobs:
node-version: 20
- name: Use npm caches
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
@ -31,37 +33,103 @@ jobs:
${{ runner.os }}-npm-
- name: Use specific Java version for sdkmanager to work
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Install node_modules
run: npm install --production
run: npm install --omit=dev --yes
- name: Extract Version Name
id: version_name
run: |
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "::set-output name=version_name::$VERSION_NAME"
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true
- name: Cache Ruby Gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Generate Build Number based on timestamp
id: build_number
run: |
NEW_BUILD_NUMBER=$(date +%s)
NEW_BUILD_NUMBER="$(date +%s)"
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
echo "::set-output name=build_number::$NEW_BUILD_NUMBER"
- name: Build
- name: Prepare Keystore
run: bundle exec fastlane android prepare_keystore
env:
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
run: ./scripts/build-release-apk.sh
- uses: actions/upload-artifact@v2
if: success()
- name: Update Version Code, Build, and Sign APK
id: build_and_sign_apk
run: |
bundle exec fastlane android update_version_build_and_sign_apk
env:
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Determine APK Filename and Path
id: determine_apk_path
run: |
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/-/g')
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
else
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
fi
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
echo "EXPECTED_FILENAME=${EXPECTED_FILENAME}" >> $GITHUB_ENV
echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with:
name: BlueWallet-${{ env.VERSION_NAME }}(${{ env.NEW_BUILD_NUMBER }}).apk
path: ./android/app/build/outputs/apk/release/BlueWallet-${{ env.VERSION_NAME }}(${{ env.NEW_BUILD_NUMBER }}).apk
name: signed-apk
path: ${{ env.APK_PATH }}
browserstack:
runs-on: ubuntu-latest
needs: buildReleaseApk
if: ${{ github.event_name == 'pull_request' }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true
- name: Install dependencies with Bundler
run: bundle install --jobs 4 --retry 3
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: signed-apk
- name: Set APK Path
run: |
APK_PATH=$(find ${{ github.workspace }} -name '*.apk')
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
- name: Upload APK to BrowserStack and Post PR Comment
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec fastlane upload_to_browserstack_and_comment

View file

@ -30,16 +30,9 @@ jobs:
restore-keys: |
${{ runner.os }}-npm-
- name: Use node_modules caches
id: cache-nm
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-nm-${{ hashFiles('package-lock.json') }}
- name: Install node_modules
if: steps.cache-nm.outputs.cache-hit != 'true'
run: npm install
run: npm ci
- name: Run tests
run: npm test || npm test || npm test || npm test
@ -55,6 +48,9 @@ jobs:
MNEMONICS_COLDCARD: ${{ secrets.MNEMONICS_COLDCARD }}
RETRY: 1
- name: Prune devDependencies
run: npm prune --omit=dev
e2e:
runs-on: ubuntu-latest
steps:

97
.github/workflows/lockfiles_update.yml vendored Normal file
View file

@ -0,0 +1,97 @@
name: Lock Files Update
on:
workflow_dispatch:
push:
branches:
- master
jobs:
pod-update:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout master branch
uses: actions/checkout@v4
with:
ref: master # Ensures we're checking out the master branch
fetch-depth: 0 # Ensures full history to enable branch deletion and recreation
- name: Delete existing branch
run: |
git push origin --delete pod-update-branch || echo "Branch does not exist, continuing..."
git branch -D pod-update-branch || echo "Local branch does not exist, continuing..."
- name: Create new branch from master
run: git checkout -b pod-update-branch # Create a new branch from the master branch
- name: Specify node version
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install node modules
run: npm install
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true
- name: Install and update Ruby Gems
run: |
bundle install
- name: Install CocoaPods Dependencies
run: |
cd ios
pod install
pod update
- name: Check for changes
id: check-changes
run: |
git diff --quiet package-lock.json ios/Podfile.lock || echo "Changes detected"
continue-on-error: true
- name: Stop job if no changes
if: steps.check-changes.outcome == 'success'
run: |
echo "No changes detected in package-lock.json or Podfile.lock. Stopping the job."
exit 0
- name: Commit changes
if: steps.check-changes.outcome != 'success'
run: |
git add package-lock.json ios/Podfile.lock
git commit -m "Update lock files"
# Step 10: Get the list of changed files for PR description
- name: Get changed files for PR description
id: get-changes
if: steps.check-changes.outcome != 'success'
run: |
git diff --name-only HEAD^ HEAD > changed_files.txt
echo "CHANGES=$(cat changed_files.txt)" >> $GITHUB_ENV
# Step 11: Push the changes and create the PR using the LockFiles PAT
- name: Push and create PR
if: steps.check-changes.outcome != 'success'
run: |
git push origin pod-update-branch
gh pr create --title "Lock Files Updates" --body "The following lock files were updated:\n\n${{ env.CHANGES }}" --base master
env:
GITHUB_TOKEN: ${{ secrets.LOCKFILES_WORKFLOW }} # Use the LockFiles PAT for PR creation
cleanup:
runs-on: macos-latest
if: github.event.pull_request.merged == true || github.event.pull_request.state == 'closed'
needs: pod-update
steps:
- name: Delete branch after PR merge/close
run: |
git push origin --delete pod-update-branch

View file

@ -1,22 +1,8 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import Clipboard from '@react-native-clipboard/clipboard';
import React, { forwardRef } from 'react';
import {
ActivityIndicator,
Dimensions,
I18nManager,
InputAccessoryView,
Keyboard,
Platform,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { ActivityIndicator, Dimensions, I18nManager, Platform, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native';
import { Icon, Text } from '@rneui/themed';
import { useTheme } from './components/themes';
import loc from './loc';
const { height, width } = Dimensions.get('window');
const aspectRatio = height / width;
@ -38,8 +24,8 @@ export const BlueButtonLink = forwardRef((props, ref) => {
<TouchableOpacity
accessibilityRole="button"
style={{
minHeight: 60,
minWidth: 100,
minHeight: 36,
justifyContent: 'center',
}}
{...props}
@ -137,60 +123,6 @@ export const BlueSpacing10 = props => {
return <View {...props} style={{ height: 10, opacity: 0 }} />;
};
export const BlueDismissKeyboardInputAccessory = () => {
const { colors } = useTheme();
BlueDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDismissKeyboardInputAccessory';
return Platform.OS !== 'ios' ? null : (
<InputAccessoryView nativeID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}>
<View
style={{
backgroundColor: colors.inputBackgroundColor,
height: 44,
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<BlueButtonLink title={loc.send.input_done} onPress={Keyboard.dismiss} />
</View>
</InputAccessoryView>
);
};
export const BlueDoneAndDismissKeyboardInputAccessory = props => {
const { colors } = useTheme();
BlueDoneAndDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDoneAndDismissKeyboardInputAccessory';
const onPasteTapped = async () => {
const clipboard = await Clipboard.getString();
props.onPasteTapped(clipboard);
};
const inputView = (
<View
style={{
backgroundColor: colors.inputBackgroundColor,
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
maxHeight: 44,
}}
>
<BlueButtonLink title={loc.send.input_clear} onPress={props.onClearTapped} />
<BlueButtonLink title={loc.send.input_paste} onPress={onPasteTapped} />
<BlueButtonLink title={loc.send.input_done} onPress={Keyboard.dismiss} />
</View>
);
if (Platform.OS === 'ios') {
return <InputAccessoryView nativeID={BlueDoneAndDismissKeyboardInputAccessory.InputAccessoryViewID}>{inputView}</InputAccessoryView>;
} else {
return inputView;
}
};
export const BlueLoading = props => {
return (
<View style={{ flex: 1, justifyContent: 'center' }} {...props}>

View file

@ -6,3 +6,5 @@ gem 'rubyzip', '2.3.2'
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem "fastlane"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View file

@ -24,17 +24,17 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.970.0)
aws-sdk-core (3.202.2)
aws-partitions (1.973.0)
aws-sdk-core (3.204.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (1.90.0)
aws-sdk-core (~> 3, >= 3.203.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.159.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-s3 (1.161.0)
aws-sdk-core (~> 3, >= 3.203.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
@ -167,6 +167,8 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-browserstack (0.3.3)
rest-client (~> 2.0, >= 2.0.2)
ffi (1.17.0)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
@ -208,6 +210,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-accept (1.7.0)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
@ -218,6 +221,9 @@ GEM
jwt (2.8.2)
base64
logger (1.6.1)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2024.0903)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.1)
@ -238,9 +244,13 @@ GEM
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
retriable (3.1.2)
rexml (3.3.6)
strscan
rexml (3.3.7)
rouge (2.0.7)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
@ -255,7 +265,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -290,6 +299,7 @@ DEPENDENCIES
activesupport (>= 6.1.7.5, != 7.1.0)
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
fastlane
fastlane-plugin-browserstack
rubyzip (= 2.3.2)
RUBY VERSION

View file

@ -83,7 +83,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "7.0.4"
versionName "7.0.5"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -52,6 +52,12 @@
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_color"
android:resource="@color/white" />
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationActions" />
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationPublisher" />

View file

@ -28,8 +28,8 @@ class BitcoinPriceWidget : AppWidgetProvider() {
}
private fun clearCache(context: Context) {
val sharedPref = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
sharedPref.edit().clear().apply()
Log.d("BitcoinPriceWidget", "Cache cleared")
val sharedPref = context.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE)
sharedPref.edit().clear().apply() // Clear all preferences in the group
Log.d("BitcoinPriceWidget", "Cache cleared from group.io.bluewallet.bluewallet")
}
}

View file

@ -8,6 +8,7 @@ import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.work.*
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.*
@ -33,17 +34,22 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
}
}
private lateinit var sharedPref: SharedPreferences
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
override fun doWork(): Result {
Log.d(TAG, "Widget update worker running")
sharedPref = applicationContext.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE)
registerPreferenceChangeListener()
val appWidgetManager = AppWidgetManager.getInstance(applicationContext)
val thisWidget = ComponentName(applicationContext, BitcoinPriceWidget::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout)
val sharedPref = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", "en-US")
val preferredCurrency = sharedPref.getString("preferredCurrency", null) ?: "USD"
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) ?: "en-US"
val previousPrice = sharedPref.getString("previous_price", null)
val currentTime = SimpleDateFormat("hh:mm a", Locale.getDefault()).format(Date())
@ -51,13 +57,47 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
fetchPrice(preferredCurrency) { fetchedPrice, error ->
handlePriceResult(
appWidgetManager, appWidgetIds, views, sharedPref,
fetchedPrice, previousPrice, currentTime, preferredCurrencyLocale, error
fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale, error
)
}
return Result.success()
}
private fun registerPreferenceChangeListener() {
preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
if (key == "preferredCurrency" || key == "preferredCurrencyLocale" || key == "previous_price") {
Log.d(TAG, "Preference changed: $key")
updateWidgetOnPreferenceChange()
}
}
sharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
}
override fun onStopped() {
super.onStopped()
sharedPref.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
}
private fun updateWidgetOnPreferenceChange() {
val appWidgetManager = AppWidgetManager.getInstance(applicationContext)
val thisWidget = ComponentName(applicationContext, BitcoinPriceWidget::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout)
val preferredCurrency = sharedPref.getString("preferredCurrency", null) ?: "USD"
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) ?: "en-US"
val previousPrice = sharedPref.getString("previous_price", null)
val currentTime = SimpleDateFormat("hh:mm a", Locale.getDefault()).format(Date())
fetchPrice(preferredCurrency) { fetchedPrice, error ->
handlePriceResult(
appWidgetManager, appWidgetIds, views, sharedPref,
fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale, error
)
}
}
private fun handlePriceResult(
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
@ -66,6 +106,7 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
fetchedPrice: String?,
previousPrice: String?,
currentTime: String,
preferredCurrency: String?,
preferredCurrencyLocale: String?,
error: String?
) {
@ -77,11 +118,11 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
if (!isPriceCached) {
showLoadingError(views)
} else {
displayCachedPrice(views, previousPrice, currentTime, preferredCurrencyLocale)
displayCachedPrice(views, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale)
}
} else {
displayFetchedPrice(
views, fetchedPrice!!, previousPrice, currentTime, preferredCurrencyLocale
views, fetchedPrice!!, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale
)
savePrice(sharedPref, fetchedPrice)
}
@ -103,11 +144,10 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
views: RemoteViews,
previousPrice: String?,
currentTime: String,
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.forLanguageTag(preferredCurrencyLocale!!)).apply {
maximumFractionDigits = 0
}
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
@ -125,12 +165,11 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
fetchedPrice: String,
previousPrice: String?,
currentTime: String,
preferredCurrency: String?,
preferredCurrencyLocale: String?
) {
val currentPrice = fetchedPrice.toDouble().let { it.toInt() } // Remove cents
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.forLanguageTag(preferredCurrencyLocale!!)).apply {
maximumFractionDigits = 0
}
val currentPrice = fetchedPrice.toDouble().toInt() // Remove cents
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
views.apply {
setViewVisibility(R.id.loading_indicator, View.GONE)
@ -153,6 +192,30 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Wor
}
}
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
val localeParts = localeString?.split("-") ?: listOf("en", "US")
val locale = if (localeParts.size == 2) {
Locale(localeParts[0], localeParts[1])
} else {
Locale.getDefault()
}
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
val currency = try {
Currency.getInstance(currencyCode ?: "USD")
} catch (e: IllegalArgumentException) {
Currency.getInstance("USD") // Default to USD if an invalid code is provided
}
currencyFormat.currency = currency
currencyFormat.maximumFractionDigits = 0 // No cents
// Remove the ISO country code and keep only the symbol
val decimalFormatSymbols = (currencyFormat as java.text.DecimalFormat).decimalFormatSymbols
decimalFormatSymbols.currencySymbol = currency.symbol
currencyFormat.decimalFormatSymbols = decimalFormatSymbols
return currencyFormat
}
private fun fetchPrice(currency: String?, callback: (String?, String?) -> Unit) {
val price = MarketAPI.fetchPrice(applicationContext, currency ?: "USD")
if (price == null) {

View file

@ -2,7 +2,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_layout"
android:minWidth="160dp"
android:minHeight="80dp"
android:minHeight="100dp"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen"
android:previewImage="@drawable/widget_preview"

View file

@ -93,7 +93,10 @@ export const writeFileAndExport = async function (fileName: string, contents: st
export const openSignedTransaction = async function (): Promise<string | false> {
try {
const res = await DocumentPicker.pickSingle({
type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles],
type:
Platform.OS === 'ios'
? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn', DocumentPicker.types.json]
: [DocumentPicker.types.allFiles],
});
return await _readPsbtFileIntoBase64(res.uri);
@ -160,7 +163,7 @@ export const showFilePickerAndReadFile = async function (): Promise<{ data: stri
'io.bluewallet.psbt.txn',
'io.bluewallet.backup',
DocumentPicker.types.plainText,
'public.json',
DocumentPicker.types.json,
DocumentPicker.types.images,
]
: [DocumentPicker.types.allFiles],

View file

@ -109,7 +109,7 @@ function Notifications(props) {
* - if you are not using remote notification or do not have Firebase installed, use this:
* requestPermissions: Platform.OS === 'ios'
*/
requestPermissions: true,
requestPermissions: Platform.OS === 'ios',
});
}
});

View file

@ -67,13 +67,12 @@ const isReactNative = typeof navigator !== 'undefined' && navigator?.product ===
export class BlueApp {
static FLAG_ENCRYPTED = 'data_encrypted';
static LNDHUB = 'lndhub';
static ADVANCED_MODE_ENABLED = 'advancedmodeenabled';
static DO_NOT_TRACK = 'donottrack';
static HANDOFF_STORAGE_KEY = 'HandOff';
private static _instance: BlueApp | null = null;
static keys2migrate = [BlueApp.HANDOFF_STORAGE_KEY, BlueApp.DO_NOT_TRACK, BlueApp.ADVANCED_MODE_ENABLED];
static keys2migrate = [BlueApp.HANDOFF_STORAGE_KEY, BlueApp.DO_NOT_TRACK];
public cachedPassword?: false | string;
public tx_metadata: TTXMetadata;
@ -882,17 +881,6 @@ export class BlueApp {
return finalBalance;
};
isAdvancedModeEnabled = async (): Promise<boolean> => {
try {
return !!(await AsyncStorage.getItem(BlueApp.ADVANCED_MODE_ENABLED));
} catch (_) {}
return false;
};
setIsAdvancedModeEnabled = async (value: boolean) => {
await AsyncStorage.setItem(BlueApp.ADVANCED_MODE_ENABLED, value ? '1' : '');
};
isHandoffEnabled = async (): Promise<boolean> => {
try {
return !!(await AsyncStorage.getItem(BlueApp.HANDOFF_STORAGE_KEY));

View file

@ -3,11 +3,51 @@ import bolt11 from 'bolt11';
import createHash from 'create-hash';
import { createHmac } from 'crypto';
import CryptoJS from 'crypto-js';
// @ts-ignore theres no types for secp256k1
import secp256k1 from 'secp256k1';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex for onion URL
interface LnurlPayServicePayload {
callback: string;
fixed: boolean;
min: number;
max: number;
domain: string;
metadata: string;
description?: string;
image?: string;
amount: number;
commentAllowed?: number;
}
interface LnurlPayServiceBolt11Payload {
pr: string;
successAction?: any;
disposable?: boolean;
tag: string;
metadata: any;
minSendable: number;
maxSendable: number;
callback: string;
commentAllowed: number;
}
interface DecodedInvoice {
destination: string;
num_satoshis: string;
num_millisatoshis: string;
timestamp: string;
fallback_addr: string;
route_hints: any[];
payment_hash?: string;
description_hash?: string;
cltv_expiry?: string;
expiry?: string;
description?: string;
}
/**
* @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md
*/
@ -16,15 +56,21 @@ export default class Lnurl {
static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL
static TAG_LOGIN_REQUEST = 'login'; // type of LNURL
constructor(url, AsyncStorage) {
this._lnurl = url;
private _lnurl: string;
private _lnurlPayServiceBolt11Payload: LnurlPayServiceBolt11Payload | false;
private _lnurlPayServicePayload: LnurlPayServicePayload | false;
private _AsyncStorage: any;
private _preimage: string | false;
constructor(url: string | false, AsyncStorage?: any) {
this._lnurl = url || '';
this._lnurlPayServiceBolt11Payload = false;
this._lnurlPayServicePayload = false;
this._AsyncStorage = AsyncStorage;
this._preimage = false;
}
static findlnurl(bodyOfText) {
static findlnurl(bodyOfText: string): string | null {
const res = /^(?:http.*[&?]lightning=|lightning:)?(lnurl1[02-9ac-hj-np-z]+)/.exec(bodyOfText.toLowerCase());
if (res) {
return res[1];
@ -32,7 +78,7 @@ export default class Lnurl {
return null;
}
static getUrlFromLnurl(lnurlExample) {
static getUrlFromLnurl(lnurlExample: string): string | false {
const found = Lnurl.findlnurl(lnurlExample);
if (!found) {
if (Lnurl.isLightningAddress(lnurlExample)) {
@ -49,22 +95,22 @@ export default class Lnurl {
return Buffer.from(bech32.fromWords(decoded.words)).toString();
}
static isLnurl(url) {
static isLnurl(url: string): boolean {
return Lnurl.findlnurl(url) !== null;
}
static isOnionUrl(url) {
static isOnionUrl(url: string): boolean {
return Lnurl.parseOnionUrl(url) !== null;
}
static parseOnionUrl(url) {
static parseOnionUrl(url: string): [string, string] | null {
const match = url.match(ONION_REGEX);
if (match === null) return null;
const [, baseURI, path] = match;
return [baseURI, path];
}
async fetchGet(url) {
async fetchGet(url: string): Promise<any> {
const resp = await fetch(url, { method: 'GET' });
if (resp.status >= 300) {
throw new Error('Bad response from server');
@ -76,14 +122,14 @@ export default class Lnurl {
return reply;
}
decodeInvoice(invoice) {
decodeInvoice(invoice: string): DecodedInvoice {
const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice);
const decoded = {
destination: payeeNodeKey,
const decoded: DecodedInvoice = {
destination: payeeNodeKey ?? '',
num_satoshis: satoshis ? satoshis.toString() : '0',
num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0',
timestamp: timestamp.toString(),
timestamp: timestamp?.toString() ?? '',
fallback_addr: '',
route_hints: [],
};
@ -92,10 +138,10 @@ export default class Lnurl {
const { tagName, data } = tags[i];
switch (tagName) {
case 'payment_hash':
decoded.payment_hash = data;
decoded.payment_hash = String(data);
break;
case 'purpose_commit_hash':
decoded.description_hash = data;
decoded.description_hash = String(data);
break;
case 'min_final_cltv_expiry':
decoded.cltv_expiry = data.toString();
@ -104,21 +150,21 @@ export default class Lnurl {
decoded.expiry = data.toString();
break;
case 'description':
decoded.description = data;
decoded.description = String(data);
break;
}
}
if (!decoded.expiry) decoded.expiry = '3600'; // default
if (parseInt(decoded.num_satoshis, 10) === 0 && decoded.num_millisatoshis > 0) {
decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString();
if (parseInt(decoded.num_satoshis, 10) === 0 && parseInt(decoded.num_millisatoshis, 10) > 0) {
decoded.num_satoshis = (parseInt(decoded.num_millisatoshis, 10) / 1000).toString();
}
return decoded;
}
async requestBolt11FromLnurlPayService(amountSat, comment = '') {
async requestBolt11FromLnurlPayService(amountSat: number, comment: string = ''): Promise<LnurlPayServiceBolt11Payload> {
if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set');
if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set');
if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max)
@ -132,15 +178,13 @@ export default class Lnurl {
);
const nonce = Math.floor(Math.random() * 2e16).toString(16);
const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&';
if (this.getCommentAllowed() && comment && comment.length > this.getCommentAllowed()) {
comment = comment.substr(0, this.getCommentAllowed());
if (this.getCommentAllowed() && comment && comment.length > (this.getCommentAllowed() as number)) {
comment = comment.substr(0, this.getCommentAllowed() as number);
}
if (comment) comment = `&comment=${encodeURIComponent(comment)}`;
const urlToFetch =
this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce + comment;
this._lnurlPayServiceBolt11Payload = await this.fetchGet(urlToFetch);
if (this._lnurlPayServiceBolt11Payload.status === 'ERROR')
throw new Error(this._lnurlPayServiceBolt11Payload.reason || 'requestBolt11FromLnurlPayService() error');
this._lnurlPayServiceBolt11Payload = (await this.fetchGet(urlToFetch)) as LnurlPayServiceBolt11Payload;
// check pr description_hash, amount etc:
const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr);
@ -155,11 +199,12 @@ export default class Lnurl {
return this._lnurlPayServiceBolt11Payload;
}
async callLnurlPayService() {
async callLnurlPayService(): Promise<LnurlPayServicePayload> {
if (!this._lnurl) throw new Error('this._lnurl is not set');
const url = Lnurl.getUrlFromLnurl(this._lnurl);
if (!url) throw new Error('Invalid LNURL');
// calling the url
const reply = await this.fetchGet(url);
const reply = (await this.fetchGet(url)) as LnurlPayServiceBolt11Payload;
if (reply.tag !== Lnurl.TAG_PAY_REQUEST) {
throw new Error('lnurl-pay expected, found tag ' + reply.tag);
@ -168,8 +213,8 @@ export default class Lnurl {
const data = reply;
// parse metadata and extract things from it
let image;
let description;
let image: string | undefined;
let description: string | undefined;
const kvs = JSON.parse(data.metadata);
for (let i = 0; i < kvs.length; i++) {
const [k, v] = kvs[i];
@ -185,14 +230,15 @@ export default class Lnurl {
}
// setting the payment screen with the parameters
const min = Math.ceil((data.minSendable || 0) / 1000);
const max = Math.floor(data.maxSendable / 1000);
const min = Math.ceil((data.minSendable ?? 0) / 1000);
const max = Math.floor((data.maxSendable ?? 0) / 1000);
this._lnurlPayServicePayload = {
callback: data.callback,
fixed: min === max,
min,
max,
// @ts-ignore idk
domain: data.callback.match(/^(https|http):\/\/([^/]+)\//)[2],
metadata: data.metadata,
description,
@ -204,7 +250,7 @@ export default class Lnurl {
return this._lnurlPayServicePayload;
}
async loadSuccessfulPayment(paymentHash) {
async loadSuccessfulPayment(paymentHash: string): Promise<boolean> {
if (!paymentHash) throw new Error('No paymentHash provided');
let data;
try {
@ -224,7 +270,7 @@ export default class Lnurl {
return true;
}
async storeSuccess(paymentHash, preimage) {
async storeSuccess(paymentHash: string, preimage: string | { data: Buffer }): Promise<void> {
if (typeof preimage === 'object') {
preimage = Buffer.from(preimage.data).toString('hex');
}
@ -241,35 +287,39 @@ export default class Lnurl {
);
}
getSuccessAction() {
return this._lnurlPayServiceBolt11Payload.successAction;
getSuccessAction(): any | undefined {
return this._lnurlPayServiceBolt11Payload && 'successAction' in this._lnurlPayServiceBolt11Payload
? this._lnurlPayServiceBolt11Payload.successAction
: undefined;
}
getDomain() {
return this._lnurlPayServicePayload.domain;
getDomain(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.domain : undefined;
}
getDescription() {
return this._lnurlPayServicePayload.description;
getDescription(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.description : undefined;
}
getImage() {
return this._lnurlPayServicePayload.image;
getImage(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.image : undefined;
}
getLnurl() {
getLnurl(): string {
return this._lnurl;
}
getDisposable() {
return this._lnurlPayServiceBolt11Payload.disposable;
getDisposable(): boolean | undefined {
return this._lnurlPayServiceBolt11Payload && 'disposable' in this._lnurlPayServiceBolt11Payload
? this._lnurlPayServiceBolt11Payload.disposable
: undefined;
}
getPreimage() {
getPreimage(): string | false {
return this._preimage;
}
static decipherAES(ciphertextBase64, preimageHex, ivBase64) {
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
const iv = CryptoJS.enc.Base64.parse(ivBase64);
const key = CryptoJS.enc.Hex.parse(preimageHex);
return CryptoJS.AES.decrypt(Buffer.from(ciphertextBase64, 'base64').toString('hex'), key, {
@ -279,27 +329,30 @@ export default class Lnurl {
}).toString(CryptoJS.enc.Utf8);
}
getCommentAllowed() {
return this?._lnurlPayServicePayload?.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed, 10) : false;
getCommentAllowed(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed.toString(), 10) : false;
}
getMin() {
return this?._lnurlPayServicePayload?.min ? parseInt(this._lnurlPayServicePayload.min, 10) : false;
getMin(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.min ? parseInt(this._lnurlPayServicePayload.min.toString(), 10) : false;
}
getMax() {
return this?._lnurlPayServicePayload?.max ? parseInt(this._lnurlPayServicePayload.max, 10) : false;
getMax(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.max ? parseInt(this._lnurlPayServicePayload.max.toString(), 10) : false;
}
getAmount() {
getAmount(): number | false {
return this.getMin();
}
authenticate(secret) {
authenticate(secret: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!this._lnurl) throw new Error('this._lnurl is not set');
const url = parse(Lnurl.getUrlFromLnurl(this._lnurl), true);
const url = parse(Lnurl.getUrlFromLnurl(this._lnurl) || '', true);
const hmac = createHmac('sha256', secret);
hmac.on('readable', async () => {
@ -308,7 +361,7 @@ export default class Lnurl {
if (!privateKey) return;
const privateKeyBuf = Buffer.from(privateKey, 'hex');
const publicKey = secp256k1.publicKeyCreate(privateKeyBuf);
const signatureObj = secp256k1.sign(Buffer.from(url.query.k1, 'hex'), privateKeyBuf);
const signatureObj = secp256k1.sign(Buffer.from(url.query.k1 as string, 'hex'), privateKeyBuf);
const derSignature = secp256k1.signatureExport(signatureObj.signature);
const reply = await this.fetchGet(`${url.href}&sig=${derSignature.toString('hex')}&key=${publicKey.toString('hex')}`);
@ -326,7 +379,7 @@ export default class Lnurl {
});
}
static isLightningAddress(address) {
static isLightningAddress(address: string) {
// ensure only 1 `@` present:
if (address.split('@').length !== 2) return false;
const splitted = address.split('@');

View file

@ -5,6 +5,7 @@ import { LegacyWallet } from './legacy-wallet';
export class LightningCustodianWallet extends LegacyWallet {
static readonly type = 'lightningCustodianWallet';
static readonly typeReadable = 'Lightning';
static readonly subtitleReadable = 'LNDhub';
// @ts-ignore: override
public readonly type = LightningCustodianWallet.type;
// @ts-ignore: override

View file

@ -0,0 +1,53 @@
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, TouchableOpacity, GestureResponderEvent } from 'react-native';
import { Icon } from '@rneui/themed';
import { useTheme } from './themes';
import ToolTipMenu from './TooltipMenu';
import { CommonToolTipActions } from '../typings/CommonToolTipActions';
import loc from '../loc';
import { navigationRef } from '../NavigationService';
type AddWalletButtonProps = {
onPress?: (event: GestureResponderEvent) => void;
};
const styles = StyleSheet.create({
ball: {
width: 30,
height: 30,
borderRadius: 15,
justifyContent: 'center',
alignContent: 'center',
},
});
const AddWalletButton: React.FC<AddWalletButtonProps> = ({ onPress }) => {
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
ball: {
backgroundColor: colors.buttonBackgroundColor,
},
});
const onPressMenuItem = useCallback((action: string) => {
switch (action) {
case CommonToolTipActions.ImportWallet.id:
navigationRef.current?.navigate('AddWalletRoot', { screen: 'ImportWallet' });
break;
default:
break;
}
}, []);
const actions = useMemo(() => [CommonToolTipActions.ImportWallet], []);
return (
<ToolTipMenu accessibilityRole="button" accessibilityLabel={loc.wallets.add_title} onPressMenuItem={onPressMenuItem} actions={actions}>
<TouchableOpacity style={[styles.ball, stylesHook.ball]} onPress={onPress}>
<Icon name="add" size={22} type="ionicons" color={colors.foregroundColor} />
</TouchableOpacity>
</ToolTipMenu>
);
};
export default AddWalletButton;

View file

@ -264,6 +264,7 @@ class AmountInput extends Component {
{amount !== BitcoinUnit.MAX ? (
<TextInput
{...this.props}
caretHidden
testID="BitcoinAmountInput"
keyboardType="numeric"
adjustsFontSizeToFit

View file

@ -3,8 +3,11 @@ import { Dimensions } from 'react-native';
import { isDesktop, isTablet } from '../../blue_modules/environment';
type ScreenSize = 'Handheld' | 'LargeScreen' | undefined;
interface ILargeScreenContext {
isLargeScreen: boolean;
setLargeScreenValue: (value: ScreenSize) => void;
}
export const LargeScreenContext = createContext<ILargeScreenContext | undefined>(undefined);
@ -15,7 +18,7 @@ interface LargeScreenProviderProps {
export const LargeScreenProvider: React.FC<LargeScreenProviderProps> = ({ children }) => {
const [windowWidth, setWindowWidth] = useState<number>(Dimensions.get('window').width);
const screenWidth: number = useMemo(() => Dimensions.get('screen').width, []);
const [largeScreenValue, setLargeScreenValue] = useState<ScreenSize>(undefined);
useEffect(() => {
const updateScreenUsage = (): void => {
@ -30,13 +33,23 @@ export const LargeScreenProvider: React.FC<LargeScreenProviderProps> = ({ childr
}, [windowWidth]);
const isLargeScreen: boolean = useMemo(() => {
if (largeScreenValue === 'LargeScreen') {
return true;
} else if (largeScreenValue === 'Handheld') {
return false;
}
const screenWidth: number = Dimensions.get('screen').width;
const halfScreenWidth = windowWidth >= screenWidth / 2;
const condition = (isTablet && halfScreenWidth) || isDesktop;
console.debug(
`LargeScreenProvider.isLargeScreen: width: ${windowWidth}, Screen width: ${screenWidth}, Is tablet: ${isTablet}, Is large screen: ${condition}, isDesktkop: ${isDesktop}`,
);
return condition;
}, [windowWidth, screenWidth]);
return (isTablet && halfScreenWidth) || isDesktop;
}, [windowWidth, largeScreenValue]);
return <LargeScreenContext.Provider value={{ isLargeScreen }}>{children}</LargeScreenContext.Provider>;
const contextValue = useMemo(
() => ({
isLargeScreen,
setLargeScreenValue,
}),
[isLargeScreen, setLargeScreenValue],
);
return <LargeScreenContext.Provider value={contextValue}>{children}</LargeScreenContext.Provider>;
};

View file

@ -69,8 +69,6 @@ interface SettingsContextType {
setIsHandOffUseEnabledAsyncStorage: (value: boolean) => Promise<void>;
isPrivacyBlurEnabled: boolean;
setIsPrivacyBlurEnabledState: (value: boolean) => void;
isAdvancedModeEnabled: boolean;
setIsAdvancedModeEnabledStorage: (value: boolean) => Promise<void>;
isDoNotTrackEnabled: boolean;
setDoNotTrackStorage: (value: boolean) => Promise<void>;
isWidgetBalanceDisplayAllowed: boolean;
@ -96,8 +94,6 @@ const defaultSettingsContext: SettingsContextType = {
setIsHandOffUseEnabledAsyncStorage: async () => {},
isPrivacyBlurEnabled: true,
setIsPrivacyBlurEnabledState: () => {},
isAdvancedModeEnabled: false,
setIsAdvancedModeEnabledStorage: async () => {},
isDoNotTrackEnabled: false,
setDoNotTrackStorage: async () => {},
isWidgetBalanceDisplayAllowed: true,
@ -125,8 +121,6 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [isHandOffUseEnabled, setHandOffUseEnabled] = useState<boolean>(false);
// PrivacyBlur
const [isPrivacyBlurEnabled, setIsPrivacyBlurEnabled] = useState<boolean>(true);
// AdvancedMode
const [isAdvancedModeEnabled, setIsAdvancedModeEnabled] = useState<boolean>(false);
// DoNotTrack
const [isDoNotTrackEnabled, setIsDoNotTrackEnabled] = useState<boolean>(false);
// WidgetCommunication
@ -141,19 +135,10 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [isTotalBalanceEnabled, setIsTotalBalanceEnabled] = useState<boolean>(true);
const [totalBalancePreferredUnit, setTotalBalancePreferredUnitState] = useState<BitcoinUnit>(BitcoinUnit.BTC);
const advancedModeStorage = useAsyncStorage(BlueApp.ADVANCED_MODE_ENABLED);
const languageStorage = useAsyncStorage(STORAGE_KEY);
const { walletsInitialized } = useStorage();
useEffect(() => {
advancedModeStorage
.getItem()
.then(advMode => {
console.debug('SettingsContext advMode:', advMode);
setIsAdvancedModeEnabled(advMode ? JSON.parse(advMode) : false);
})
.catch(error => console.error('Error fetching advanced mode settings:', error));
getIsHandOffUseEnabled()
.then(handOff => {
console.debug('SettingsContext handOff:', handOff);
@ -216,7 +201,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
getTotalBalancePreferredUnit()
.then(unit => {
console.debug('SettingsContext totalBalancePreferredUnit:', unit);
setTotalBalancePreferredUnit(unit);
setTotalBalancePreferredUnitState(unit);
})
.catch(error => console.error('Error fetching total balance preferred unit:', error));
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -243,14 +228,6 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setLanguage(newLanguage);
}, []);
const setIsAdvancedModeEnabledStorage = useCallback(
async (value: boolean) => {
await advancedModeStorage.setItem(JSON.stringify(value));
setIsAdvancedModeEnabled(value);
},
[advancedModeStorage],
);
const setDoNotTrackStorage = useCallback(async (value: boolean) => {
await DefaultPreference.setName(GROUP_IO_BLUEWALLET);
if (value) {
@ -321,8 +298,6 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setIsHandOffUseEnabledAsyncStorage,
isPrivacyBlurEnabled,
setIsPrivacyBlurEnabledState,
isAdvancedModeEnabled,
setIsAdvancedModeEnabledStorage,
isDoNotTrackEnabled,
setDoNotTrackStorage,
isWidgetBalanceDisplayAllowed,
@ -347,8 +322,6 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setIsHandOffUseEnabledAsyncStorage,
isPrivacyBlurEnabled,
setIsPrivacyBlurEnabledState,
isAdvancedModeEnabled,
setIsAdvancedModeEnabledStorage,
isDoNotTrackEnabled,
setDoNotTrackStorage,
isWidgetBalanceDisplayAllowed,

187
components/DevMenu.tsx Normal file
View file

@ -0,0 +1,187 @@
import React, { useEffect } from 'react';
import { DevSettings, Alert, Platform, AlertButton } from 'react-native';
import { useStorage } from '../hooks/context/useStorage';
import { HDSegwitBech32Wallet } from '../class';
import Clipboard from '@react-native-clipboard/clipboard';
import { useIsLargeScreen } from '../hooks/useIsLargeScreen';
import { TWallet } from '../class/wallets/types';
const getRandomLabelFromSecret = (secret: string): string => {
const words = secret.split(' ');
const firstWord = words[0];
const lastWord = words[words.length - 1];
return `[Developer] ${firstWord} ${lastWord}`;
};
const showAlertWithWalletOptions = (
wallets: TWallet[],
title: string,
message: string,
onWalletSelected: (wallet: TWallet) => void,
filterFn?: (wallet: TWallet) => boolean,
) => {
const filteredWallets = filterFn ? wallets.filter(filterFn) : wallets;
const showWallet = (index: number) => {
if (index >= filteredWallets.length) return;
const wallet = filteredWallets[index];
if (Platform.OS === 'android') {
// Android: Use a limited number of buttons since the alert dialog has a limit
Alert.alert(
`${title}: ${wallet.getLabel()}`,
`${message}\n\nSelected Wallet: ${wallet.getLabel()}\n\nWould you like to select this wallet or see the next one?`,
[
{
text: 'Select This Wallet',
onPress: () => onWalletSelected(wallet),
},
{
text: 'Show Next Wallet',
onPress: () => showWallet(index + 1),
},
{
text: 'Cancel',
style: 'cancel',
},
],
{ cancelable: true },
);
} else {
const options: AlertButton[] = filteredWallets.map(w => ({
text: w.getLabel(),
onPress: () => onWalletSelected(w),
}));
options.push({
text: 'Cancel',
style: 'cancel',
});
Alert.alert(title, message, options, { cancelable: true });
}
};
if (filteredWallets.length > 0) {
showWallet(0);
} else {
Alert.alert('No wallets available');
}
};
const DevMenu: React.FC = () => {
const { wallets, addWallet } = useStorage();
const { setLargeScreenValue } = useIsLargeScreen();
useEffect(() => {
if (__DEV__) {
// Clear existing Dev Menu items to prevent duplication
DevSettings.addMenuItem('Reset Dev Menu', () => {
DevSettings.reload();
});
DevSettings.addMenuItem('Add New Wallet', async () => {
const wallet = new HDSegwitBech32Wallet();
await wallet.generate();
const label = getRandomLabelFromSecret(wallet.getSecret());
wallet.setLabel(label);
addWallet(wallet);
Clipboard.setString(wallet.getSecret());
Alert.alert('New Wallet created!', `Wallet secret copied to clipboard.\nLabel: ${label}`);
});
DevSettings.addMenuItem('Copy Wallet Secret', () => {
if (wallets.length === 0) {
Alert.alert('No wallets available');
return;
}
showAlertWithWalletOptions(wallets, 'Copy Wallet Secret', 'Select the wallet to copy the secret', wallet => {
Clipboard.setString(wallet.getSecret());
Alert.alert('Wallet Secret copied to clipboard!');
});
});
DevSettings.addMenuItem('Copy Wallet ID', () => {
if (wallets.length === 0) {
Alert.alert('No wallets available');
return;
}
showAlertWithWalletOptions(wallets, 'Copy Wallet ID', 'Select the wallet to copy the ID', wallet => {
Clipboard.setString(wallet.getID());
Alert.alert('Wallet ID copied to clipboard!');
});
});
DevSettings.addMenuItem('Copy Wallet Xpub', () => {
if (wallets.length === 0) {
Alert.alert('No wallets available');
return;
}
showAlertWithWalletOptions(
wallets,
'Copy Wallet Xpub',
'Select the wallet to copy the Xpub',
wallet => {
const xpub = wallet.getXpub();
if (xpub) {
Clipboard.setString(xpub);
Alert.alert('Wallet Xpub copied to clipboard!');
} else {
Alert.alert('This wallet does not have an Xpub.');
}
},
wallet => typeof wallet.getXpub === 'function',
);
});
DevSettings.addMenuItem('Purge Wallet Transactions', () => {
if (wallets.length === 0) {
Alert.alert('No wallets available');
return;
}
showAlertWithWalletOptions(wallets, 'Purge Wallet Transactions', 'Select the wallet to purge transactions', wallet => {
const msg = 'Transactions purged successfully!';
if (wallet.type === HDSegwitBech32Wallet.type) {
wallet._txs_by_external_index = {};
wallet._txs_by_internal_index = {};
}
// @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help
if (wallet._hdWalletInstance) {
// @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help
wallet._hdWalletInstance._txs_by_external_index = {};
// @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help
wallet._hdWalletInstance._txs_by_internal_index = {};
}
Alert.alert(msg);
});
});
DevSettings.addMenuItem('Force Large Screen Interface', () => {
setLargeScreenValue('LargeScreen');
Alert.alert('Large Screen Interface forced.');
});
DevSettings.addMenuItem('Force Handheld Interface', () => {
setLargeScreenValue('Handheld');
Alert.alert('Handheld Interface forced.');
});
DevSettings.addMenuItem('Reset Screen Interface', () => {
setLargeScreenValue(undefined);
Alert.alert('Screen Interface reset to default.');
});
}
}, [wallets, addWallet, setLargeScreenValue]);
return null;
};
export default DevMenu;

View file

@ -0,0 +1,36 @@
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native';
import { useTheme } from './themes';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
export const DismissKeyboardInputAccessoryViewID = 'DismissKeyboardInputAccessory';
export const DismissKeyboardInputAccessory: React.FC = () => {
const { colors } = useTheme();
const styleHooks = StyleSheet.create({
container: {
backgroundColor: colors.inputBackgroundColor,
},
});
if (Platform.OS !== 'ios') {
return null;
}
return (
<InputAccessoryView nativeID={DismissKeyboardInputAccessoryViewID}>
<View style={[styles.container, styleHooks.container]}>
<BlueButtonLink title={loc.send.input_done} onPress={Keyboard.dismiss} />
</View>
</InputAccessoryView>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
maxHeight: 44,
},
});

View file

@ -0,0 +1,49 @@
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
import { useTheme } from './themes';
import Clipboard from '@react-native-clipboard/clipboard';
interface DoneAndDismissKeyboardInputAccessoryProps {
onPasteTapped: (clipboard: string) => void;
onClearTapped: () => void;
}
export const DoneAndDismissKeyboardInputAccessoryViewID = 'DoneAndDismissKeyboardInputAccessory';
export const DoneAndDismissKeyboardInputAccessory: React.FC<DoneAndDismissKeyboardInputAccessoryProps> = props => {
const { colors } = useTheme();
const styleHooks = StyleSheet.create({
container: {
backgroundColor: colors.inputBackgroundColor,
},
});
const onPasteTapped = async () => {
const clipboard = await Clipboard.getString();
props.onPasteTapped(clipboard);
};
const inputView = (
<View style={[styles.container, styleHooks.container]}>
<BlueButtonLink title={loc.send.input_clear} onPress={props.onClearTapped} />
<BlueButtonLink title={loc.send.input_paste} onPress={onPasteTapped} />
<BlueButtonLink title={loc.send.input_done} onPress={Keyboard.dismiss} />
</View>
);
if (Platform.OS === 'ios') {
return <InputAccessoryView nativeID={DoneAndDismissKeyboardInputAccessoryViewID}>{inputView}</InputAccessoryView>;
} else {
return inputView;
}
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
maxHeight: 44,
},
});

View file

@ -1,7 +1,7 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import PlusIcon from './icons/PlusIcon';
import { useTheme } from './themes';
import AddWalletButton from './AddWalletButton';
interface HeaderProps {
leftText: string;
@ -25,7 +25,7 @@ export const Header: React.FC<HeaderProps> = ({ leftText, isDrawerList, onNewWal
return (
<View style={[styles.root, styleWithProps.root]}>
<Text style={[styles.text, styleWithProps.text]}>{leftText}</Text>
{onNewWalletPress && <PlusIcon onPress={onNewWalletPress} />}
{onNewWalletPress && <AddWalletButton onPress={onNewWalletPress} />}
</View>
);
};

View file

@ -1,14 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react';
import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native';
import { Text } from '@rneui/themed';
import { BlueButtonLink } from '../BlueComponents';
import loc from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { useTheme } from './themes';
const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => {
interface InputAccessoryAllFundsProps {
balance: string;
canUseAll: boolean;
onUseAllPressed: () => void;
}
const InputAccessoryAllFunds: React.FC<InputAccessoryAllFundsProps> = ({ balance, canUseAll, onUseAllPressed }) => {
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
@ -42,7 +46,7 @@ const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => {
);
if (Platform.OS === 'ios') {
return <InputAccessoryView nativeID={InputAccessoryAllFunds.InputAccessoryViewID}>{inputView}</InputAccessoryView>;
return <InputAccessoryView nativeID={InputAccessoryAllFundsAccessoryViewID}>{inputView}</InputAccessoryView>;
}
// androidPlaceholder View is needed to force shrink screen (KeyboardAvoidingView) where this component is used
@ -54,13 +58,7 @@ const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => {
);
};
InputAccessoryAllFunds.InputAccessoryViewID = 'useMaxInputAccessoryViewID';
InputAccessoryAllFunds.propTypes = {
balance: PropTypes.string.isRequired,
canUseAll: PropTypes.bool.isRequired,
onUseAllPressed: PropTypes.func.isRequired,
};
export const InputAccessoryAllFundsAccessoryViewID = 'useMaxInputAccessoryViewID';
const styles = StyleSheet.create({
root: {

View file

@ -45,11 +45,10 @@ const MenuElements = () => {
if (reloadTransactionsMenuActionFunction && typeof reloadTransactionsMenuActionFunction === 'function') {
reloadTransactionsMenuActionFunction();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [reloadTransactionsMenuActionFunction]);
useEffect(() => {
console.log('MenuElements: useEffect');
console.debug('MenuElements: useEffect');
if (walletsInitialized) {
eventEmitter?.addListener('openSettings', openSettings);
eventEmitter?.addListener('addWalletMenuAction', addWalletMenuAction);
@ -62,8 +61,7 @@ const MenuElements = () => {
eventEmitter?.removeAllListeners('importWalletMenuAction');
eventEmitter?.removeAllListeners('reloadTransactionsMenuAction');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletsInitialized]);
}, [addWalletMenuAction, importWalletMenuAction, openSettings, reloadTransactionsMenuElementsFunction, walletsInitialized]);
return null;
};

View file

@ -1,5 +1,6 @@
import React, { Ref, useCallback, useMemo } from 'react';
import { Platform, Pressable, TouchableOpacity } from 'react-native';
import { MenuView, MenuAction, NativeActionEvent } from '@react-native-menu/menu';
import {
ContextMenuView,
RenderItem,
@ -8,7 +9,6 @@ import {
IconConfig,
MenuElementConfig,
} from 'react-native-ios-context-menu';
import { MenuView, MenuAction, NativeActionEvent } from '@react-native-menu/menu';
import { ToolTipMenuProps, Action } from './types';
import { useSettings } from '../hooks/context/useSettings';
@ -30,6 +30,7 @@ const ToolTipMenu = React.memo((props: ToolTipMenuProps, ref?: Ref<any>) => {
const { language } = useSettings();
// Map Menu Items for iOS Context Menu
const mapMenuItemForContextMenuView = useCallback((action: Action) => {
if (!action.id) return null;
return {
@ -41,14 +42,30 @@ const ToolTipMenu = React.memo((props: ToolTipMenuProps, ref?: Ref<any>) => {
};
}, []);
// Map Menu Items for RN Menu (supports subactions and displayInline)
const mapMenuItemForMenuView = useCallback((action: Action): MenuAction | null => {
if (!action.id) return null;
// Check for subactions
const subactions =
action.subactions?.map(subaction => ({
id: subaction.id.toString(),
title: subaction.text,
subtitle: subaction.subtitle,
image: subaction.icon?.iconValue ? subaction.icon.iconValue : undefined,
state: subaction.menuState === undefined ? undefined : ((subaction.menuState ? 'on' : 'off') as MenuState),
attributes: { disabled: subaction.disabled, destructive: subaction.destructive, hidden: subaction.hidden },
})) || [];
return {
id: action.id.toString(),
title: action.text,
subtitle: action.subtitle,
image: action.icon?.iconValue ? action.icon.iconValue : undefined,
state: action.menuState === undefined ? undefined : ((action.menuState ? 'on' : 'off') as MenuState),
attributes: { disabled: action.disabled },
attributes: { disabled: action.disabled, destructive: action.destructive, hidden: action.hidden },
subactions: subactions.length > 0 ? subactions : undefined,
displayInline: action.displayInline || false,
};
}, []);
@ -98,7 +115,6 @@ const ToolTipMenu = React.memo((props: ToolTipMenuProps, ref?: Ref<any>) => {
);
const renderContextMenuView = () => {
console.debug('ToolTipMenu.tsx rendering: renderContextMenuView');
return (
<ContextMenuView
lazyPreview
@ -139,7 +155,6 @@ const ToolTipMenu = React.memo((props: ToolTipMenuProps, ref?: Ref<any>) => {
};
const renderMenuView = () => {
console.debug('ToolTipMenu.tsx rendering: renderMenuView');
return (
<MenuView
title={title}
@ -147,7 +162,7 @@ const ToolTipMenu = React.memo((props: ToolTipMenuProps, ref?: Ref<any>) => {
onPressAction={handlePressMenuItemForMenuView}
actions={Platform.OS === 'ios' ? menuViewItemsIOS : menuViewItemsAndroid}
shouldOpenOnLongPress={!isMenuPrimaryAction}
// @ts-ignore: its not in the types but it works
// @ts-ignore: Not exposed in types
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.accessibilityRole}

View file

@ -289,7 +289,7 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = React.mem
handleOnViewOnBlockExplorer,
],
);
const toolTipActions = useMemo((): Action[] | Action[][] => {
const toolTipActions = useMemo((): Action[] => {
const actions: (Action | Action[])[] = [];
if (rowTitle !== loc.lnd.expired) {
@ -308,7 +308,7 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = React.mem
actions.push([CommonToolTipActions.ExpandNote]);
}
return actions as Action[] | Action[][];
return actions as Action[];
}, [item.hash, subtitle, rowTitle, subtitleNumberOfLines]);
const accessibilityState = useMemo(() => {

View file

@ -38,13 +38,15 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
.allowOnchainAddress()
.then((value: boolean) => setAllowOnchainAddress(value))
.catch((e: Error) => {
console.log('This Lndhub wallet does not have an onchain address API.');
console.log('This LNDhub wallet does not have an onchain address API.');
setAllowOnchainAddress(false);
});
}
}, [wallet]);
useEffect(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setWallet(initialWallet);
}, [initialWallet]);
@ -82,9 +84,9 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
const updatedWallet = updateWalletWithNewUnit(wallet, newWalletPreferredUnit);
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setWallet(updatedWallet);
onWalletUnitChange?.(updatedWallet);
};
@ -132,8 +134,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
? formatBalance(wallet.getBalance(), balanceUnit, true)
: formatBalanceWithoutSuffix(wallet.getBalance(), balanceUnit, true);
return !hideBalance && balanceFormatted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet.hideBalance, wallet.getPreferredBalanceUnit()]);
}, [wallet]);
const toolTipWalletBalanceActions = useMemo(() => {
return wallet.hideBalance
@ -181,7 +182,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
style={styles.lineaderGradient}
{...WalletGradient.linearGradientProps(wallet.type)}
>
<Image source={imageSource} defaultSource={imageSource} style={styles.chainIcon} />
<Image source={imageSource} style={styles.chainIcon} />
<Text testID="WalletLabel" numberOfLines={1} style={styles.walletLabel} selectable>
{wallet.getLabel()}
@ -218,7 +219,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit}>
<Text style={styles.walletPreferredUnitText}>
{wallet.getPreferredBalanceUnit() === BitcoinUnit.LOCAL_CURRENCY
? preferredFiatCurrency?.endPointKey ?? FiatUnit.USD
? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD)
: wallet.getPreferredBalanceUnit()}
</Text>
</TouchableOpacity>

View file

@ -61,7 +61,7 @@ const NewWalletPanel: React.FC<NewWalletPanelProps> = ({ onPress }) => {
const { colors } = useTheme();
const { width } = useWindowDimensions();
const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82;
const isLargeScreen = useIsLargeScreen();
const { isLargeScreen } = useIsLargeScreen();
const nStylesHooks = StyleSheet.create({
container: isLargeScreen
? {
@ -192,7 +192,7 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
const { walletTransactionUpdateStatus } = useStorage();
const { width } = useWindowDimensions();
const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82;
const isLargeScreen = useIsLargeScreen();
const { isLargeScreen } = useIsLargeScreen();
const onPressedIn = useCallback(() => {
if (animationsEnabled) {
@ -248,7 +248,7 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
return (
<Animated.View
style={[
isLargeScreen || !horizontal ? [iStyles.rootLargeDevice, customStyle] : customStyle ?? { ...iStyles.root, width: itemWidth },
isLargeScreen || !horizontal ? [iStyles.rootLargeDevice, customStyle] : (customStyle ?? { ...iStyles.root, width: itemWidth }),
{ opacity, transform: [{ scale: scaleValue }] },
]}
>
@ -264,7 +264,7 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
>
<View style={[iStyles.shadowContainer, { backgroundColor: colors.background, shadowColor: colors.shadowColor }]}>
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={iStyles.grad}>
<Image defaultSource={image} source={image} style={iStyles.image} />
<Image source={image} style={iStyles.image} />
<Text style={iStyles.br} />
{!isPlaceHolder && (
<>
@ -374,9 +374,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
const flatListRef = useRef<FlatList<any>>(null);
useImperativeHandle(
ref,
(): any => {
useImperativeHandle(ref, (): any => {
return {
scrollToEnd: (params: { animated?: boolean | null | undefined } | undefined) => flatListRef.current?.scrollToEnd(params),
scrollToIndex: (params: {
@ -396,9 +394,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
flashScrollIndicators: () => flatListRef.current?.flashScrollIndicators(),
getNativeScrollRef: () => flatListRef.current?.getNativeScrollRef(),
};
},
[],
);
}, []);
const onScrollToIndexFailed = (error: { averageItemLength: number; index: number }): void => {
console.debug('onScrollToIndexFailed');

View file

@ -209,7 +209,7 @@ const styles = StyleSheet.create({
},
});
const getAvailableActions = ({ allowSignVerifyMessage }: { allowSignVerifyMessage: boolean }): Action[] | Action[][] => {
const getAvailableActions = ({ allowSignVerifyMessage }: { allowSignVerifyMessage: boolean }): Action[] => {
const actions = [
{
id: actionKeys.CopyToClipboard,

View file

@ -1,42 +0,0 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, ViewStyle } from 'react-native';
import { Icon } from '@rneui/themed';
import { useTheme } from '../themes';
import loc from '../../loc';
type PlusIconProps = {
onPress: () => void;
};
const styles = StyleSheet.create({
ball: {
width: 30,
height: 30,
borderRadius: 15,
justifyContent: 'center',
alignContent: 'center',
} as ViewStyle,
});
const PlusIcon: React.FC<PlusIconProps> = ({ onPress }) => {
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
ball: {
backgroundColor: colors.buttonBackgroundColor,
},
});
return (
<TouchableOpacity
style={[styles.ball, stylesHook.ball]}
accessibilityLabel={loc.wallets.add_title}
onPress={onPress}
accessibilityRole="button"
>
<Icon name="add" size={22} type="ionicons" color={colors.foregroundColor} />
</TouchableOpacity>
);
};
export default PlusIcon;

View file

@ -1,9 +1,11 @@
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { Icon } from '@rneui/themed';
import { useTheme } from '../themes';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import ToolTipMenu from '../TooltipMenu';
import { CommonToolTipActions } from '../../typings/CommonToolTipActions';
const SettingsButton = () => {
const { colors } = useTheme();
@ -12,7 +14,22 @@ const SettingsButton = () => {
navigate('Settings');
};
const onPressMenuItem = useCallback(
(menuItem: string) => {
switch (menuItem) {
case CommonToolTipActions.ManageWallet.id:
navigate('ManageWallets');
break;
default:
break;
}
},
[navigate],
);
const actions = useMemo(() => [CommonToolTipActions.ManageWallet], []);
return (
<ToolTipMenu onPressMenuItem={onPressMenuItem} actions={actions}>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc.settings.default_title}
@ -22,6 +39,7 @@ const SettingsButton = () => {
>
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} />
</TouchableOpacity>
</ToolTipMenu>
);
};

View file

@ -1,4 +1,4 @@
import { AccessibilityRole, ViewStyle } from 'react-native';
import { AccessibilityRole, ViewStyle, ColorValue } from 'react-native';
export interface Action {
id: string | number;
@ -7,13 +7,19 @@ export interface Action {
iconValue: string;
};
menuTitle?: string;
subtitle?: string;
menuState?: 'mixed' | boolean | undefined;
displayInline?: boolean; // Indicates if subactions should be displayed inline or nested (iOS only)
image?: string;
imageColor?: ColorValue;
destructive?: boolean;
hidden?: boolean;
disabled?: boolean;
displayInline?: boolean;
subactions?: Action[]; // Nested/Inline actions (subactions) within an action
}
export interface ToolTipMenuProps {
actions: Action[] | Action[][];
actions: Action[];
children: React.ReactNode;
enableAndroidRipple?: boolean;
dismissMenu?: () => void;

View file

@ -2,6 +2,156 @@ def app_identifiers
["io.bluewallet.bluewallet", "io.bluewallet.bluewallet.watch", "io.bluewallet.bluewallet.watch.extension", "io.bluewallet.bluewallet.Stickers", "io.bluewallet.bluewallet.MarketWidget"]
end
default_platform(:android)
project_root = File.expand_path("..", __dir__)
platform :android do
desc "Prepare the keystore file"
lane :prepare_keystore do
Dir.chdir(project_root) do
keystore_file_hex = ENV['KEYSTORE_FILE_HEX']
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if keystore_file_hex.nil?
Dir.chdir("android") do
UI.message("Creating keystore hex file...")
File.write("bluewallet-release-key.keystore.hex", keystore_file_hex)
sh("xxd -plain -revert bluewallet-release-key.keystore.hex > bluewallet-release-key.keystore") do |status|
UI.user_error!("Error reverting hex to keystore") unless status.success?
end
UI.message("Keystore created successfully.")
File.delete("bluewallet-release-key.keystore.hex")
end
end
end
lane :update_version_build_and_sign_apk do
Dir.chdir(project_root) do
build_number = ENV['BUILD_NUMBER']
UI.user_error!("BUILD_NUMBER environment variable is missing") if build_number.nil?
# Get the version name from build.gradle
version_name = sh("grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '\"'").strip
# Manually update the versionCode in build.gradle
UI.message("Updating versionCode in build.gradle to #{build_number}...")
build_gradle_path = "android/app/build.gradle"
build_gradle_contents = File.read(build_gradle_path)
new_build_gradle_contents = build_gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}")
File.write(build_gradle_path, new_build_gradle_contents)
# Get the branch name and default to 'master' if empty
branch_name = ENV['GITHUB_HEAD_REF'] || `git rev-parse --abbrev-ref HEAD`.strip.gsub(/[\/\\:?*"<>|]/, '_')
if branch_name.nil? || branch_name.empty?
branch_name = 'master'
end
# Append branch name only if it's not 'master'
if branch_name != 'master'
signed_apk_name = "BlueWallet-#{version_name}-#{build_number}-#{branch_name}.apk"
else
signed_apk_name = "BlueWallet-#{version_name}-#{build_number}.apk"
end
# Continue with the build process
Dir.chdir("android") do
UI.message("Building APK...")
gradle(
task: "assembleRelease",
project_dir: "android"
)
UI.message("APK build completed.")
# Define the output paths
unsigned_apk_path = "app/build/outputs/apk/release/app-release-unsigned.apk"
signed_apk_path = "app/build/outputs/apk/release/#{signed_apk_name}"
# Rename the unsigned APK to include the version and build number
if File.exist?(unsigned_apk_path)
UI.message("Renaming APK to #{signed_apk_name}...")
FileUtils.mv(unsigned_apk_path, signed_apk_path)
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
else
UI.error("Unsigned APK not found at path: #{unsigned_apk_path}")
next
end
# Sign the APK using apksigner
UI.message("Signing APK with apksigner...")
apksigner_path = "#{ENV['ANDROID_HOME']}/build-tools/34.0.0/apksigner"
sh("#{apksigner_path} sign --ks ./bluewallet-release-key.keystore --ks-pass=pass:#{ENV['KEYSTORE_PASSWORD']} #{signed_apk_path}")
UI.message("APK signed successfully: #{signed_apk_path}")
end
end
end
desc "Upload APK to BrowserStack and post result as PR comment"
lane :upload_to_browserstack_and_comment do
Dir.chdir(project_root) do
# Fetch the APK path from environment variables
apk_path = ENV['APK_PATH']
# Attempt to find the APK if not provided
if apk_path.nil? || apk_path.empty?
UI.message("No APK path provided, attempting to find the artifact...")
apk_path = `find ./ -name "*.apk"`.strip
UI.user_error!("No APK file found") if apk_path.nil? || apk_path.empty?
end
UI.message("Uploading APK to BrowserStack: #{apk_path}...")
upload_to_browserstack_app_live(
file_path: apk_path,
browserstack_username: ENV['BROWSERSTACK_USERNAME'],
browserstack_access_key: ENV['BROWSERSTACK_ACCESS_KEY']
)
# Extract the BrowserStack URL from the output
app_url = ENV['BROWSERSTACK_LIVE_APP_ID']
UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty?
# Prepare necessary values for the PR comment
apk_filename = File.basename(apk_path)
apk_download_url = ENV['APK_OUTPUT_PATH'] # Assuming this path is accessible to the PR
browserstack_hashed_id = app_url.gsub('bs://', '')
pr_number = ENV['GITHUB_PR_NUMBER']
comment = <<~COMMENT
### APK Successfully Uploaded to BrowserStack
You can test it on the following devices:
- [Google Pixel 5 (Android 12.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=12.0&device=Google+Pixel+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 7 (Android 13.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Google+Pixel+7&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 8 (Android 14.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Google+Pixel+8&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 3a (Android 9.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=9.0&device=Google+Pixel+3a&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Z Fold 5 (Android 13.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Z+Fold+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Z Fold 6 (Android 14.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Samsung+Galaxy+Z+Fold+6&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Tab S9 (Android 13.0)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Tab+S9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Note 9 (Android 8.1)](https://app-live.browserstack.com/dashboard#os=android&os_version=8.1&device=Samsung+Galaxy+Note+9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
**Filename**: [#{apk_filename}](#{apk_download_url})
**BrowserStack App URL**: #{app_url}
COMMENT
if pr_number
begin
sh("GH_TOKEN=#{ENV['GH_TOKEN']} gh pr comment #{pr_number} --body '#{comment}'")
UI.success("Posted comment to PR ##{pr_number}")
rescue => e
UI.error("Failed to post comment to PR: #{e.message}")
end
else
UI.important("No PR number found. Skipping PR comment.")
end
end
end
end
platform :ios do
before_all do |lane, options|
@ -29,7 +179,8 @@ platform :ios do
type: "development",
app_identifier: app_identifier,
readonly: false, # This will regenerate the provisioning profile if needed
force_for_new_devices: true # This forces match to add new devices to the profile
force_for_new_devices: true,
clone_branch_directly: true
)
end
@ -73,6 +224,7 @@ platform :ios do
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
git_url: ENV["GIT_URL"],
type: "appstore",
clone_branch_directly: true, # Skip if the branch already exists (Exit 128 error)
platform: platform,
app_identifier: app_identifier,
team_id: ENV["ITC_TEAM_ID"],
@ -90,7 +242,8 @@ platform :ios do
type: "development",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true
readonly: true,
clone_branch_directly: true
)
end
@ -100,7 +253,9 @@ platform :ios do
type: "appstore",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true
readonly: true,
clone_branch_directly: true
)
end
@ -112,14 +267,16 @@ platform :ios do
platform: "catalyst",
app_identifier: app_identifier,
readonly: false,
force_for_new_devices: true
force_for_new_devices: true,
clone_branch_directly: true
)
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_identifier,
readonly: false
readonly: false,
clone_branch_directly: true
)
end
end
@ -136,7 +293,7 @@ platform :ios do
# Set the new build number
increment_build_number(
xcodeproj: "BlueWallet.xcodeproj",
xcodeproj: "ios/BlueWallet.xcodeproj",
build_number: ENV["NEW_BUILD_NUMBER"]
)
@ -146,7 +303,7 @@ platform :ios do
desc "Install CocoaPods dependencies"
lane :install_pods do
UI.message("Installing CocoaPods dependencies...")
cocoapods
cocoapods(podfile: "ios/Podfile")
end
desc "Build the application"
@ -154,7 +311,7 @@ platform :ios do
UI.message("Building the application...")
build_app(
scheme: "BlueWallet",
workspace: "BlueWallet.xcworkspace",
workspace: "ios/BlueWallet.xcworkspace",
export_method: "app-store",
include_bitcode: false,
configuration: "Release",
@ -188,8 +345,8 @@ platform :ios do
changelog = ENV["LATEST_COMMIT_MESSAGE"]
upload_to_testflight(
api_key_path: "appstore_api_key.json",
ipa: "./build/BlueWallet.#{ENV['PROJECT_VERSION']}(#{ENV['NEW_BUILD_NUMBER']}).ipa",
api_key_path: "./appstore_api_key.json",
ipa: "./BlueWallet.#{ENV['PROJECT_VERSION']}(#{ENV['NEW_BUILD_NUMBER']}).ipa",
skip_waiting_for_build_processing: true, # Do not wait for processing
changelog: changelog
)
@ -309,7 +466,7 @@ lane :update_release_notes do |options|
'it' => release_notes_text, # Italian
'ja' => release_notes_text, # Japanese
'ms' => release_notes_text, # Malay
'nb-NO' => release_notes_text, # Norwegian
'nb' => release_notes_text, # Norwegian
'pl' => release_notes_text, # Polish
'pt-BR' => release_notes_text, # Portuguese (Brazil)
'pt-PT' => release_notes_text, # Portuguese (Portugal)

5
fastlane/Pluginfile Normal file
View file

@ -0,0 +1,5 @@
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-browserstack'

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

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