diff --git a/.circleci/build-uitest-config.yml b/.circleci/build-uitest-config.yml deleted file mode 100644 index 1f5eeadaf..000000000 --- a/.circleci/build-uitest-config.yml +++ /dev/null @@ -1,236 +0,0 @@ -# .circleci/config.yml -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/2.0/configuration-reference -# Inspired by: https://medium.com/uptech-team/swift-package-manager-and-how-to-cache-it-with-ci-14968cd58c5f -version: 2.1 -orbs: - macos: circleci/macos@2 - ruby: circleci/ruby@2.5.3 -jobs: - build-uitest: - macos: - xcode: 16.4.0 - resource_class: macos.m1.medium.gen1 - working_directory: ~/ios/ - environment: - HOMEBREW_NO_AUTO_UPDATE: 1 - FASTLANE_SKIP_UPDATE_CHECK: 1 - FL_OUTPUT_DIR: output - FASTLANE_LANE: run_ui_iphone16promax_tests - XCODE_SCHEME: brainwalletUITests - XCODE_PROJECT: brainwallet.xcodeproj - FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 - FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 3 - shell: /bin/bash --login -o pipefail - steps: - - add_ssh_keys: - fingerprints: - - "SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g" - - checkout - - # Restore all caches - - restore_cache: - name: Restore caches - keys: - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}- - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}- - - combined-cache-v1-{{ arch }}- - - # Setup Ruby, clean environment, and install dependencies - - run: - name: Setup Ruby environment and dependencies - command: | - # Install Ruby with rbenv - export PATH="$HOME/.rbenv/bin:$PATH" - if ! command -v rbenv &> /dev/null; then - brew update - brew install rbenv ruby-build - fi - eval "$(rbenv init -)" - rbenv install -s 3.3.0 - rbenv global 3.3.0 - ruby -v - - # Clean gem environment - rm -f Gemfile.lock - rm -rf vendor/bundle - gem uninstall fastlane -aIx || true - gem uninstall scan -aIx || true - gem cleanup - - # Install Bundler and dependencies - gem install bundler:2.6.9 - bundle config set --local path 'vendor/bundle' - bundle install --jobs=4 --retry=3 - - # Setup SSH and initialize submodules - - run: - name: Setup SSH and submodules - command: | - echo 'github.com ssh-rsa SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g' >> ~/.ssh/known_hosts - ssh-add -D - ssh-add ~/.ssh/id_rsa_* - git submodule sync --recursive - git submodule update --init --recursive --jobs=4 --filter=blob:none -- ':(exclude)Private/ios-games' - git submodule init && git submodule update --init Modules/core - - # Setup environment files (MUST be before build) - - run: - name: Setup environment files - command: | - # Export environment variables - echo 'export GOOGLE_SERVICES_PLIST="$GOOGLE_SERVICES_PLIST"' >> $BASH_ENV - echo 'export REMOTE_CONFIG_DEFAULTS="$REMOTE_CONFIG_DEFAULTS"' >> $BASH_ENV - echo 'export DEBUG_SERVICE_DATA="$DEBUG_SERVICE_DATA"' >> $BASH_ENV - - # Create environment files in root directory (required by Xcode build) - echo "$GOOGLE_SERVICES_PLIST" | base64 --decode > GoogleService-Info.plist - echo "$REMOTE_CONFIG_DEFAULTS" | base64 --decode > remote-config-defaults.plist - echo "$DEBUG_SERVICE_DATA" | base64 --decode > service-data.plist - - # Fail fast on a bad env var here instead of a cryptic - # "unable to read input file as a property list" from xcodebuild's - # CopyPlistFile step later. Naming the broken var/file points - # straight at what to fix in CircleCI's project env vars. - check_plist() { - if [ ! -s "$2" ] || ! /usr/libexec/PlistBuddy -c "Print" "$2" >/dev/null 2>&1; then - echo "ERROR: $1 did not decode to a valid plist -- check its value in CircleCI's project env vars" - exit 1 - fi - } - check_plist "GOOGLE_SERVICES_PLIST -> GoogleService-Info.plist" GoogleService-Info.plist - check_plist "REMOTE_CONFIG_DEFAULTS -> remote-config-defaults.plist" remote-config-defaults.plist - check_plist "DEBUG_SERVICE_DATA -> service-data.plist" service-data.plist - - echo "GoogleService-Info.plist created successfully:" - ls -la GoogleService-Info.plist - echo "File size: $(wc -c < GoogleService-Info.plist) bytes" - # Do NOT print file contents here -- this repo is public and - # CircleCI build logs for public GitHub repos are viewable - # without a CircleCI login by default, so dumping any lines of a - # decoded secret (this file's API_KEY, GOOGLE_APP_ID, etc.) would - # leak it. check_plist above already validates the content is a - # real plist without printing it; that's the right way to verify - # this, not head/cat. - - # Also copy to brainwallet directory if needed - mkdir -p ./brainwallet - cp GoogleService-Info.plist ./brainwallet/PreLaunchResources/ - cp remote-config-defaults.plist ./brainwallet/PreLaunchResources/ - cp service-data.plist ./brainwallet/PreLaunchResources/ - - # Verify copies were successful - echo "Files in brainwallet directory:" - ls -la ./brainwallet/ - - # Prepare build environment - - run: - name: Prepare build environment - command: | - # Clean derived data - rm -rf ~/Library/Developer/Xcode/DerivedData - rm -rf ./DerivedData - - # Boot simulator - DEVICE_ID=$(xcrun simctl list devices | grep "iPhone 16 Pro Max" | grep -v "unavailable" | head -1 | sed 's/.*(//' | sed 's/).*//') - if [ -n "$DEVICE_ID" ]; then - echo "Booting iPhone 16 Pro Max simulator..." - xcrun simctl boot "$DEVICE_ID" || echo "Device may already be booted" - sleep 10 - else - echo "iPhone 16 Pro Max not found, using available device" - xcrun simctl list devices - fi - - run: - name: Verify build prerequisites - command: | - pwd - echo "Verifying GoogleService-Info.plist is available for xcodebuild:" - - # Check root directory - if [ -f "GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in root directory" - ls -la GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in root directory" - fi - - # Check brainwallet subdirectory - if [ -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in brainwallet directory" - ls -la ./brainwallet/GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in brainwallet directory" - fi - - # Find all plist files for debugging - echo "All .plist files in project:" - find . -name "*.plist" -type f - - # Ensure at least one GoogleService-Info.plist exists - if [ ! -f "GoogleService-Info.plist" ] && [ ! -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "ERROR: GoogleService-Info.plist not found in any expected location!" - exit 1 - fi - - echo "✅ All build prerequisites verified" - - run: - name: Run Fastlane iPhone 16 simulator Tests - command: | - export PATH="$HOME/.rbenv/bin:$PATH" - eval "$(rbenv init -)" - bundle exec fastlane $FASTLANE_LANE - - save_cache: - name: Save combined cache - key: combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - paths: - - ~/.rbenv - - vendor/bundle - - ~/.gem - - SourcePackages/ - - ~/Library/Caches/org.swift.swiftpm/ - - ~/Library/org.swift.swiftpm/ - - ./DerivedData - - ~/Library/Developer/Xcode/DerivedData - - ~/Library/Caches/com.apple.dt.Xcode/ - - # Store artifacts and handle failures - - run: - name: Capture crash logs - command: | - find ~/Library/Logs/DiagnosticReports -name "*Brainwallet*" -type f -exec echo "Found crash log: {}" \; -exec cat {} \; || echo "No crash logs found" - find ~/Library/Developer/CoreSimulator/Devices/*/data/Library/Logs/CrashReporter/ -name "*Brainwallet*" -type f -exec echo "Found simulator crash log: {}" \; -exec cat {} \; || echo "No simulator crash logs found" - log show --predicate 'process == "Brainwallet"' --last 10m || echo "No system logs found for Brainwallet" - when: on_fail - - - store_artifacts: - path: output - - store_test_results: - path: output/scan - - store_artifacts: - path: ~/Library/Logs/DiagnosticReports - destination: crash-logs - when: on_fail -workflows: - version: 4 - # UI test workflow - only runs when manually triggered or on specific branches - build-uitest-brainwallet-ios: - jobs: - - build-uitest: - context: - - bw-ios-test - # Weekly scheduled workflow UITest - weekly-uittest-maintenance-workflow: - triggers: - - schedule: - # Run daily at 6:00 AM UTC (adjust for your timezone) - cron: "0 6 * * *" - filters: - branches: - only: - - develop - jobs: - - weekly-uittest-maintenance: - context: - - circleci-agents \ No newline at end of file diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 8b5117d41..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,282 +0,0 @@ -# .circleci/config.yml -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/2.0/configuration-reference -# Inspired by: https://medium.com/uptech-team/swift-package-manager-and-how-to-cache-it-with-ci-14968cd58c5f -version: 2.1 -orbs: - macos: circleci/macos@2 - ruby: circleci/ruby@2.5.3 -jobs: - build-unit-tests-per-push: - macos: - xcode: 16.4.0 - resource_class: macos.m1.medium.gen1 - working_directory: ~/ios/ - environment: - HOMEBREW_NO_AUTO_UPDATE: 1 - FASTLANE_SKIP_UPDATE_CHECK: 1 - FL_OUTPUT_DIR: output - FASTLANE_LANE: run_unit_tests_iPhone16ProMax - XCODE_SCHEME: brainwalletUnitTests - XCODE_PROJECT: brainwallet.xcodeproj - FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 - FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 3 - shell: /bin/bash --login -o pipefail - steps: - - add_ssh_keys: - fingerprints: - - "SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g" - - checkout - - # Restore all caches - - restore_cache: - name: Restore caches - keys: - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}- - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}- - - combined-cache-v1-{{ arch }}- - - # Setup Ruby, clean environment, and install dependencies - - run: - name: Setup Ruby environment and dependencies - command: | - # Install Ruby with rbenv - export PATH="$HOME/.rbenv/bin:$PATH" - if ! command -v rbenv &> /dev/null; then - brew update - brew install rbenv ruby-build - fi - eval "$(rbenv init -)" - rbenv install -s 3.3.0 - rbenv global 3.3.0 - ruby -v - - # Clean gem environment - rm -f Gemfile.lock - rm -rf vendor/bundle - gem uninstall fastlane -aIx || true - gem uninstall scan -aIx || true - gem cleanup - - # Install Bundler and dependencies - gem install bundler:2.6.9 - bundle config set --local path 'vendor/bundle' - bundle install --jobs=4 --retry=3 - # Setup SSH & GH Token and initialize submodules - - run: - name: Configure Github Auth & Setup SSH and submodules - command: | - echo 'github.com ssh-rsa SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g' >> ~/.ssh/known_hosts - ssh-add ~/.ssh/id_rsa_* - sed -i "s#https://github.com/#https://${GH_TOKEN}@github.com/#g" .gitmodules - git submodule init && git submodule update --init Modules/core && git submodule update --init Private/general-purpose - - run: - name: Setup environment files - command: | - # Export environment variables - echo 'export BRAINWALLET_IOS_STOREKIT_V1_0_FILE="$BRAINWALLET_IOS_STOREKIT_V1_0_FILE"' >> $BASH_ENV - echo 'export GOOGLE_SERVICES_PLIST="$GOOGLE_SERVICES_PLIST"' >> $BASH_ENV - echo 'export REMOTE_CONFIG_DEFAULTS="$REMOTE_CONFIG_DEFAULTS"' >> $BASH_ENV - echo 'export DEBUG_SERVICE_DATA="$DEBUG_SERVICE_DATA"' >> $BASH_ENV - - # Create environment files in root directory (required by Xcode build) - echo "$BRAINWALLET_IOS_STOREKIT_V1_0_FILE" | base64 --decode > Brainwallet-StoreKit-v1.storekit - echo "$GOOGLE_SERVICES_PLIST" | base64 --decode > GoogleService-Info.plist - echo "$REMOTE_CONFIG_DEFAULTS" | base64 --decode > remote-config-defaults.plist - echo "$DEBUG_SERVICE_DATA" | base64 --decode > service-data.plist - - # Fail fast on a bad env var here instead of a cryptic - # "unable to read input file as a property list" from xcodebuild's - # CopyPlistFile step later (this exact failure mode hit Xcode - # Cloud build 431: DEBUG_SERVICE_DATA didn't decode to a valid - # plist). Naming the broken var/file points straight at what to - # fix in the CircleCI project env vars. - check_plist() { - if [ ! -s "$2" ] || ! /usr/libexec/PlistBuddy -c "Print" "$2" >/dev/null 2>&1; then - echo "ERROR: $1 did not decode to a valid plist -- check its value in CircleCI's project env vars" - exit 1 - fi - } - check_plist "GOOGLE_SERVICES_PLIST -> GoogleService-Info.plist" GoogleService-Info.plist - check_plist "REMOTE_CONFIG_DEFAULTS -> remote-config-defaults.plist" remote-config-defaults.plist - check_plist "DEBUG_SERVICE_DATA -> service-data.plist" service-data.plist - # Brainwallet-StoreKit-v1.storekit is JSON (Xcode's StoreKit - # Configuration format), not a plist -- plutil -lint rejects JSON - # outright, so validate with a real JSON parser instead. - if [ ! -s "Brainwallet-StoreKit-v1.storekit" ] || ! python3 -m json.tool Brainwallet-StoreKit-v1.storekit >/dev/null 2>&1; then - echo "ERROR: BRAINWALLET_IOS_STOREKIT_V1_0_FILE -> Brainwallet-StoreKit-v1.storekit did not decode to valid JSON -- check its value in CircleCI's project env vars" - exit 1 - fi - - echo "GoogleService-Info.plist created successfully:" - ls -la GoogleService-Info.plist - echo "File size: $(wc -c < GoogleService-Info.plist) bytes" - # Do NOT print file contents here -- this repo is public and - # CircleCI build logs for public GitHub repos are viewable - # without a CircleCI login by default, so dumping any lines of a - # decoded secret (this file's API_KEY, GOOGLE_APP_ID, etc.) would - # leak it. check_plist above already validates the content is a - # real plist without printing it; that's the right way to verify - # this, not head/cat. - - # Also copy to brainwallet directory if needed - mkdir -p ./brainwallet - cp Brainwallet-StoreKit-v1.storekit ./brainwallet/PreLaunchResources/ - cp GoogleService-Info.plist ./brainwallet/PreLaunchResources/ - cp remote-config-defaults.plist ./brainwallet/PreLaunchResources/ - cp service-data.plist ./brainwallet/PreLaunchResources/ - - # Verify copies were successful - echo "Files in brainwallet directory:" - ls -la ./brainwallet/ - - # Prepare build environment - - run: - name: Prepare build environment - command: | - # Clean derived data - rm -rf ~/Library/Developer/Xcode/DerivedData - rm -rf ./DerivedData - - # Boot simulator - DEVICE_ID=$(xcrun simctl list devices | grep "iPhone 16 Pro Max" | grep -v "unavailable" | head -1 | sed 's/.*(//' | sed 's/).*//') - if [ -n "$DEVICE_ID" ]; then - echo "Booting iPhone 16 Pro Max simulator..." - xcrun simctl boot "$DEVICE_ID" || echo "Device may already be booted" - sleep 10 - else - echo "iPhone 16 Pro Max not found, using available device" - xcrun simctl list devices - fi - - run: - name: Verify build prerequisites - command: | - pwd - echo "Verifying GoogleService-Info.plist is available for xcodebuild:" - - # Check root directory - if [ -f "GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in root directory" - ls -la GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in root directory" - fi - - # Check brainwallet subdirectory - if [ -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in brainwallet directory" - ls -la ./brainwallet/GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in brainwallet directory" - fi - - # Find all plist files for debugging - echo "All .plist files in project:" - find . -name "*.plist" -type f - - # Ensure at least one GoogleService-Info.plist exists - if [ ! -f "GoogleService-Info.plist" ] && [ ! -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "ERROR: GoogleService-Info.plist not found in any expected location!" - exit 1 - fi - - echo "✅ All build prerequisites verified" - # ── i18n coverage check ──────────────────────────────────────────────────── - # Lightweight informational job: runs on Linux (no Mac machine needed), - # reports translation coverage per language, and fails the pipeline only - # if any language drops below COVERAGE_WARN_PCT (default 80 %). - # It never blocks a release for in-flight translations — raise the threshold - # once the auto-translate workflow has caught up. - - run: - name: Check i18n coverage - command: python3 scripts/test_i18n_coverage.py - - - run: - name: Run Fastlane iPhone 16 simulator Tests - command: | - export PATH="$HOME/.rbenv/bin:$PATH" - eval "$(rbenv init -)" - bundle exec fastlane $FASTLANE_LANE - - save_cache: - name: Save combined cache - key: combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - paths: - - ~/.rbenv - - vendor/bundle - - ~/.gem - - SourcePackages/ - - ~/Library/Caches/org.swift.swiftpm/ - - ~/Library/org.swift.swiftpm/ - - ./DerivedData - - ~/Library/Developer/Xcode/DerivedData - - ~/Library/Caches/com.apple.dt.Xcode/ - # Store artifacts and handle failures - - run: - name: Capture crash logs - command: | - find ~/Library/Logs/DiagnosticReports -name "*Brainwallet*" -type f -exec echo "Found crash log: {}" \; -exec cat {} \; || echo "No crash logs found" - find ~/Library/Developer/CoreSimulator/Devices/*/data/Library/Logs/CrashReporter/ -name "*Brainwallet*" -type f -exec echo "Found simulator crash log: {}" \; -exec cat {} \; || echo "No simulator crash logs found" - log show --predicate 'process == "Brainwallet"' --last 10m || echo "No system logs found for Brainwallet" - when: on_fail - - store_test_results: - path: test_output - - store_artifacts: - path: ~/Library/Logs/DiagnosticReports - destination: crash-logs - when: on_fail - - run: - name: "Update tests-passed badge (shields.io gist endpoint)" - # Only the primary branch's run should own the badge state - a feature - # branch with fewer/broken tests shouldn't flap the README badge. - # Requires GIST_TOKEN (a PAT with only the `gist` scope) and GIST_ID - # to be set as CircleCI project env vars. - when: always - command: | - if [ "$CIRCLE_BRANCH" != "develop" ]; then - echo "Not on develop, skipping badge update." - exit 0 - fi - if [ -z "$GIST_TOKEN" ] || [ -z "$GIST_ID" ]; then - echo "GIST_TOKEN/GIST_ID not set, skipping badge update." - exit 0 - fi - - f="test_output/report.junit" - total=0 - failures=0 - skipped=0 - if [ -f "$f" ]; then - total=$(grep -o 'tests="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') - failures=$(grep -o 'failures="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') - skipped=$(grep -o 'skipped="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') - fi - passed=$(( ${total:-0} - ${failures:-0} - ${skipped:-0} )) - - if [ "${failures:-0}" -gt 0 ]; then - message="${passed} passed, ${failures} failed" - color="red" - else - message="${passed} passed" - color="brightgreen" - fi - - payload=$(printf '{"files":{"tests-badge.json":{"content":"{\\"schemaVersion\\":1,\\"label\\":\\"tests\\",\\"message\\":\\"%s\\",\\"color\\":\\"%s\\"}"}}}' "$message" "$color") - - curl -s -X PATCH \ - -H "Authorization: token ${GIST_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/gists/${GIST_ID}" \ - -d "$payload" > /dev/null - - echo "Badge updated: ${message}" - -workflows: - version: 4 - - # Main workflow - runs unit tests on every push - build-test-brainwallet-ios: - jobs: - - build-unit-tests-per-push: - context: - - bw-ios-test diff --git a/.circleci/daily-develop-config.yml b/.circleci/daily-develop-config.yml deleted file mode 100644 index 621e596af..000000000 --- a/.circleci/daily-develop-config.yml +++ /dev/null @@ -1,237 +0,0 @@ -# .circleci/config.yml -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/2.0/configuration-reference -# Inspired by: https://medium.com/uptech-team/swift-package-manager-and-how-to-cache-it-with-ci-14968cd58c5f -version: 2.1 -orbs: - macos: circleci/macos@2 - ruby: circleci/ruby@2.5.3 -jobs: - # Daily maintenance job on branch: develop - daily-maintenance: - macos: - xcode: 16.4.0 - resource_class: macos.m1.medium.gen1 - working_directory: ~/ios/ - environment: - HOMEBREW_NO_AUTO_UPDATE: 1 - FASTLANE_SKIP_UPDATE_CHECK: 1 - FL_OUTPUT_DIR: output - FASTLANE_LANE: run_all_tests_iPhone16ProMax - XCODE_SCHEME: brainwalletUnitTests - XCODE_PROJECT: brainwallet.xcodeproj - FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 - FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 3 - shell: /bin/bash --login -o pipefail - steps: - - add_ssh_keys: - fingerprints: - - "SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g" - - checkout - - # Restore all caches - - restore_cache: - name: Restore caches - keys: - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}- - - combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}- - - combined-cache-v1-{{ arch }}- - - # Setup Ruby, clean environment, and install dependencies - - run: - name: Setup Ruby environment and dependencies - command: | - # Install Ruby with rbenv - export PATH="$HOME/.rbenv/bin:$PATH" - if ! command -v rbenv &> /dev/null; then - brew update - brew install rbenv ruby-build - fi - eval "$(rbenv init -)" - rbenv install -s 3.3.0 - rbenv global 3.3.0 - ruby -v - - # Clean gem environment - rm -f Gemfile.lock - rm -rf vendor/bundle - gem uninstall fastlane -aIx || true - gem uninstall scan -aIx || true - gem cleanup - - # Install Bundler and dependencies - gem install bundler:2.6.9 - bundle config set --local path 'vendor/bundle' - bundle install --jobs=4 --retry=3 - # Setup SSH & GH Token and initialize submodules - - run: - name: Configure Github Auth & Setup SSH and submodules - command: | - echo 'github.com ssh-rsa SHA256:Z4bsDPFhTv8vuTERTBcs6b5RHFRspyI77b7tQUsju1g' >> ~/.ssh/known_hosts - ssh-add -D - ssh-add ~/.ssh/id_rsa_* - sed -i "s#https://github.com/#https://${GH_TOKEN}@github.com/#g" .gitmodules - git submodule init && git submodule update --init Modules/core && git submodule update --init Private/general-purpose - # Setup environment files (MUST be before build) - - run: - name: Setup environment files - command: | - # Export environment variables - echo 'export BRAINWALLET_IOS_STOREKIT_V1_0_FILE="$BRAINWALLET_IOS_STOREKIT_V1_0_FILE"' >> $BASH_ENV - echo 'export GOOGLE_SERVICES_PLIST="$GOOGLE_SERVICES_PLIST"' >> $BASH_ENV - echo 'export REMOTE_CONFIG_DEFAULTS="$REMOTE_CONFIG_DEFAULTS"' >> $BASH_ENV - echo 'export DEBUG_SERVICE_DATA="$DEBUG_SERVICE_DATA"' >> $BASH_ENV - - # Create environment files in root directory (required by Xcode build) - echo "$BRAINWALLET_IOS_STOREKIT_V1_0_FILE" | base64 --decode > Brainwallet-StoreKit-v1.storekit - echo "$GOOGLE_SERVICES_PLIST" | base64 --decode > GoogleService-Info.plist - echo "$REMOTE_CONFIG_DEFAULTS" | base64 --decode > remote-config-defaults.plist - echo "$DEBUG_SERVICE_DATA" | base64 --decode > service-data.plist - - # Fail fast on a bad env var here instead of a cryptic - # "unable to read input file as a property list" from xcodebuild's - # CopyPlistFile step later. Naming the broken var/file points - # straight at what to fix in CircleCI's project env vars. - check_plist() { - if [ ! -s "$2" ] || ! /usr/libexec/PlistBuddy -c "Print" "$2" >/dev/null 2>&1; then - echo "ERROR: $1 did not decode to a valid plist -- check its value in CircleCI's project env vars" - exit 1 - fi - } - check_plist "GOOGLE_SERVICES_PLIST -> GoogleService-Info.plist" GoogleService-Info.plist - check_plist "REMOTE_CONFIG_DEFAULTS -> remote-config-defaults.plist" remote-config-defaults.plist - check_plist "DEBUG_SERVICE_DATA -> service-data.plist" service-data.plist - # Brainwallet-StoreKit-v1.storekit is JSON (Xcode's StoreKit - # Configuration format), not a plist -- plutil -lint rejects JSON - # outright, so validate with a real JSON parser instead. - if [ ! -s "Brainwallet-StoreKit-v1.storekit" ] || ! python3 -m json.tool Brainwallet-StoreKit-v1.storekit >/dev/null 2>&1; then - echo "ERROR: BRAINWALLET_IOS_STOREKIT_V1_0_FILE -> Brainwallet-StoreKit-v1.storekit did not decode to valid JSON -- check its value in CircleCI's project env vars" - exit 1 - fi - - echo "GoogleService-Info.plist created successfully:" - ls -la GoogleService-Info.plist - echo "File size: $(wc -c < GoogleService-Info.plist) bytes" - # Do NOT print file contents here -- this repo is public and - # CircleCI build logs for public GitHub repos are viewable - # without a CircleCI login by default, so dumping any lines of a - # decoded secret (this file's API_KEY, GOOGLE_APP_ID, etc.) would - # leak it. check_plist above already validates the content is a - # real plist without printing it; that's the right way to verify - # this, not head/cat. - - # Also copy to brainwallet directory if needed - mkdir -p ./brainwallet - cp Brainwallet-StoreKit-v1.storekit ./brainwallet/PreLaunchResources/ - cp GoogleService-Info.plist ./brainwallet/PreLaunchResources/ - cp remote-config-defaults.plist ./brainwallet/PreLaunchResources/ - cp service-data.plist ./brainwallet/PreLaunchResources/ - - # Verify copies were successful - echo "Files in brainwallet directory:" - ls -la ./brainwallet/ - - # Prepare build environment - - run: - name: Prepare build environment - command: | - # Clean derived data - rm -rf ~/Library/Developer/Xcode/DerivedData - rm -rf ./DerivedData - - # Boot simulator - DEVICE_ID=$(xcrun simctl list devices | grep "iPhone 16 Pro Max" | grep -v "unavailable" | head -1 | sed 's/.*(//' | sed 's/).*//') - if [ -n "$DEVICE_ID" ]; then - echo "Booting iPhone 16 Pro Max simulator..." - xcrun simctl boot "$DEVICE_ID" || echo "Device may already be booted" - sleep 10 - else - echo "iPhone 16 Pro Max not found, using available device" - xcrun simctl list devices - fi - - run: - name: Verify build prerequisites - command: | - pwd - echo "Verifying GoogleService-Info.plist is available for xcodebuild:" - - # Check root directory - if [ -f "GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in root directory" - ls -la GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in root directory" - fi - - # Check brainwallet subdirectory - if [ -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "✅ GoogleService-Info.plist found in brainwallet directory" - ls -la ./brainwallet/GoogleService-Info.plist - else - echo "❌ GoogleService-Info.plist NOT found in brainwallet directory" - fi - - # Find all plist files for debugging - echo "All .plist files in project:" - find . -name "*.plist" -type f - - # Ensure at least one GoogleService-Info.plist exists - if [ ! -f "GoogleService-Info.plist" ] && [ ! -f "./brainwallet/GoogleService-Info.plist" ]; then - echo "ERROR: GoogleService-Info.plist not found in any expected location!" - exit 1 - fi - - echo "✅ All build prerequisites verified" - - run: - name: Run Fastlane iPhone 16 simulator Tests - command: | - export PATH="$HOME/.rbenv/bin:$PATH" - eval "$(rbenv init -)" - bundle exec fastlane $FASTLANE_LANE - - save_cache: - name: Save combined cache - key: combined-cache-v1-{{ arch }}-{{ checksum "Gemfile" }}-{{ checksum "Package.resolved" }}-{{ checksum "brainwallet.xcodeproj/project.pbxproj" }} - paths: - - ~/.rbenv - - vendor/bundle - - ~/.gem - - SourcePackages/ - - ~/Library/Caches/org.swift.swiftpm/ - - ~/Library/org.swift.swiftpm/ - - ./DerivedData - - ~/Library/Developer/Xcode/DerivedData - - ~/Library/Caches/com.apple.dt.Xcode/ - - # Store artifacts and handle failures - - run: - name: Capture crash logs - command: | - find ~/Library/Logs/DiagnosticReports -name "*Brainwallet*" -type f -exec echo "Found crash log: {}" \; -exec cat {} \; || echo "No crash logs found" - find ~/Library/Developer/CoreSimulator/Devices/*/data/Library/Logs/CrashReporter/ -name "*Brainwallet*" -type f -exec echo "Found simulator crash log: {}" \; -exec cat {} \; || echo "No simulator crash logs found" - log show --predicate 'process == "Brainwallet"' --last 10m || echo "No system logs found for Brainwallet" - when: on_fail - - - store_artifacts: - path: output - - store_test_results: - path: output/scan - - store_artifacts: - path: ~/Library/Logs/DiagnosticReports - destination: crash-logs - when: on_fail -workflows: - version: 4 - daily-maintenance-workflow: - triggers: - - schedule: - # Run daily at 6:00 AM UTC (adjust for your timezone) - cron: "0 6 * * *" - filters: - branches: - only: - - develop - jobs: - - daily-maintenance: - context: - - circleci-agents diff --git a/.github/workflows/auto-translate-ios.yml b/.github/workflows/auto-translate-ios.yml index 05b6e75a1..a95a806cd 100644 --- a/.github/workflows/auto-translate-ios.yml +++ b/.github/workflows/auto-translate-ios.yml @@ -7,12 +7,6 @@ on: - develop paths: - '**/Localizable.xcstrings' - pull_request: - branches: - - main - - develop - paths: - - '**/Localizable.xcstrings' workflow_dispatch: inputs: force_retranslate: @@ -39,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.ref }} + ref: ${{ github.ref }} token: ${{ secrets.TRANSLATION_PAT_GHA_CLAUDE_JUL082026 }} - name: Set up Python @@ -85,7 +79,7 @@ jobs: gh pr create \ --title "🌍 Auto-translations: missing iOS strings updated" \ --body "$(cat /tmp/pr_body.md)" \ - --base "${{ github.event.pull_request.base.ref || github.ref_name }}" \ + --base "${{ github.ref_name }}" \ --head "$BRANCH" \ --label "translations" \ --reviewer "${{ github.actor }}" \ No newline at end of file diff --git a/.github/workflows/i18n-coverage.yml b/.github/workflows/i18n-coverage.yml new file mode 100644 index 000000000..b4d9a7b54 --- /dev/null +++ b/.github/workflows/i18n-coverage.yml @@ -0,0 +1,45 @@ +name: 🌐 i18n Coverage Check + +on: + push: + branches: + - main + - develop + paths: + - '**/Localizable.xcstrings' + - 'scripts/test_i18n_coverage.py' + pull_request: + branches: + - main + - develop + paths: + - '**/Localizable.xcstrings' + - 'scripts/test_i18n_coverage.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + i18n-coverage: + name: Check translation coverage + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Lightweight informational check: reports translation coverage per + # language and fails the job only if any language drops below + # COVERAGE_WARN_PCT (default 80%). It never blocks a release for + # in-flight translations -- raise the threshold once the + # auto-translate workflow has caught up. + - name: Run i18n coverage check + run: python3 scripts/test_i18n_coverage.py + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: i18n-coverage-report + path: /tmp/i18n_coverage_report.txt + if-no-files-found: ignore diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 5a019787a..6863cf0a6 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -33,7 +33,7 @@ jobs: -- '*.swift' '*.m' '*.h' '*.c' '*.xib' '*.storyboard' \ '*.xcconfig' '*.plist' '*.yaml' '*.yml' '*.json' \ 'Package.swift' 'Package.resolved' 'Podfile' 'Podfile.lock' \ - '.circleci/**' '.github/**' \ + 'ci_scripts/**' '.github/**' \ | head -c 32000 > pr_diff.txt # Summarise stats for the prompt @@ -83,20 +83,20 @@ jobs: /\.xib$|\.storyboard$|Color|Theme|Style|Asset/.test(f) ); const isConfig = files.some(f => - /\.xcconfig$|\.plist$|Package\.swift$|Podfile|\.circleci|\.github/.test(f) + /\.xcconfig$|\.plist$|Package\.swift$|Podfile|ci_scripts|\.github/.test(f) ); const isPerfOrFix = files.some(f => /Fix|Bug|Crash|Patch|Hotfix/i.test(f) ); const isCICD = files.some(f => - /\.circleci|\.github|fastlane|Fastfile|Matchfile/.test(f) + /ci_scripts|\.github|fastlane|Fastfile|Matchfile/.test(f) ); // --- Build Claude prompt --- const prompt = ` You are a senior iOS engineer reviewing a pull request for the Brainwallet iOS app (Swift, SwiftUI + UIKit hybrid, AppDelegate architecture, Firebase Analytics/Crashlytics, - CircleCI + Xcode Cloud CI, Swift Package Manager dependencies). + Xcode Cloud CI, Swift Package Manager dependencies). Repository layout: - brainwallet/ → Main app source (Swift, SwiftUI Views, ViewControllers) @@ -105,7 +105,7 @@ jobs: - BrainwalletUITests/ → XCUITest UI test suite - Modules/ → SPM local modules - Private/ → Private keys / provisioning (gitignored in prod) - - .circleci/ → CircleCI pipeline config + - ci_scripts/ → Xcode Cloud custom build scripts - .github/ → GitHub Actions workflows - fastlane/ → Fastlane lanes (scan, match, deliver) - BrainwalletHybrid.xcworkspace → Workspace root diff --git a/BrainwalletUnitTests/Legacy BRTests/BRAddressExtensionTests.swift b/BrainwalletUnitTests/Legacy BRTests/BRAddressExtensionTests.swift new file mode 100644 index 000000000..77cdcf7bb --- /dev/null +++ b/BrainwalletUnitTests/Legacy BRTests/BRAddressExtensionTests.swift @@ -0,0 +1,97 @@ +import BRCore +@testable import brainwallet +import XCTest + +/// Exercises BRAddress's Swift-side pointer interop (init from string/scriptPubKey, +/// the scriptPubKey/hash160/description accessors, and Equatable/Hashable) with +/// self-contained, deterministic fixtures. These don't depend on wallet setup or the +/// keychain, so they pin down exact byte-for-byte round-tripping behavior and guard +/// against regressions in the unsafe-pointer scoping those accessors rely on. +class BRAddressExtensionTests: XCTestCase { + /// A syntactically valid P2PKH scriptPubKey: OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG. + private func p2pkhScript(hash160: [UInt8]) -> [UInt8] { + precondition(hash160.count == 20) + return [0x76, 0xa9, 0x14] + hash160 + [0x88, 0xac] + } + + private let sampleHash160: [UInt8] = (0..<20).map { UInt8($0) } + + // MARK: - init?(scriptPubKey:) / scriptPubKey round trip + + func testInitFromScriptPubKeyRoundTripsExactBytes() throws { + let script = p2pkhScript(hash160: sampleHash160) + let address = try XCTUnwrap(BRAddress(scriptPubKey: script)) + XCTAssertEqual(address.scriptPubKey, script, + "scriptPubKey getter should reproduce the exact bytes the address was built from") + } + + func testInitFromScriptPubKeyGarbageReturnsNil() { + XCTAssertNil(BRAddress(scriptPubKey: [0xFF, 0xFF, 0xFF]), + "A malformed scriptPubKey should fail to produce an address") + } + + // MARK: - hash160 + + func testHash160MatchesTheHashTheScriptWasBuiltFrom() throws { + let script = p2pkhScript(hash160: sampleHash160) + let address = try XCTUnwrap(BRAddress(scriptPubKey: script)) + let hash = try XCTUnwrap(address.hash160) + let hashBytes = withUnsafeBytes(of: hash) { Array($0) } + XCTAssertEqual(hashBytes, sampleHash160, + "hash160 should be the same 20 bytes encoded in the source scriptPubKey") + } + + func testHash160IsConsistentAcrossRepeatedCalls() throws { + let script = p2pkhScript(hash160: sampleHash160) + let address = try XCTUnwrap(BRAddress(scriptPubKey: script)) + let first = try XCTUnwrap(address.hash160) + let second = try XCTUnwrap(address.hash160) + let firstBytes = withUnsafeBytes(of: first) { Array($0) } + let secondBytes = withUnsafeBytes(of: second) { Array($0) } + XCTAssertEqual(firstBytes, secondBytes, "hash160 must be deterministic across repeated reads of the same address") + } + + // MARK: - init?(string:) / description round trip + + func testInitFromStringRoundTripsThroughDescription() throws { + let script = p2pkhScript(hash160: sampleHash160) + let original = try XCTUnwrap(BRAddress(scriptPubKey: script)) + let addressString = original.description + XCTAssertFalse(addressString.isEmpty) + + let rebuilt = try XCTUnwrap(BRAddress(string: addressString)) + XCTAssertEqual(rebuilt.description, addressString, + "description should reproduce the exact string the address was built from") + XCTAssertEqual(rebuilt.scriptPubKey, script, + "an address rebuilt from its own string should still resolve to the same scriptPubKey") + } + + func testInitFromEmptyStringSucceedsWithEmptyDescription() throws { + let address = try XCTUnwrap(BRAddress(string: "")) + XCTAssertEqual(address.description, "") + } + + func testInitFromOversizedStringReturnsNil() { + let oversized = String(repeating: "1", count: MemoryLayout.size + 1) + XCTAssertNil(BRAddress(string: oversized), + "A string longer than the fixed BRAddress buffer must fail to init") + } + + // MARK: - Equatable / Hashable + + func testAddressesFromSameBytesAreEqualAndHashConsistently() throws { + let script = p2pkhScript(hash160: sampleHash160) + let a = try XCTUnwrap(BRAddress(scriptPubKey: script)) + let b = try XCTUnwrap(BRAddress(scriptPubKey: script)) + XCTAssertEqual(a, b) + XCTAssertEqual(a.hashValue, b.hashValue) + } + + func testAddressesFromDifferentBytesAreNotEqual() throws { + let scriptA = p2pkhScript(hash160: sampleHash160) + let scriptB = p2pkhScript(hash160: sampleHash160.reversed()) + let a = try XCTUnwrap(BRAddress(scriptPubKey: scriptA)) + let b = try XCTUnwrap(BRAddress(scriptPubKey: scriptB)) + XCTAssertNotEqual(a, b) + } +} diff --git a/BrainwalletUnitTests/Legacy BRTests/BWAPIClientTests.swift b/BrainwalletUnitTests/Legacy BRTests/BWAPIClientTests.swift index 2c17470b1..64d76f131 100644 --- a/BrainwalletUnitTests/Legacy BRTests/BWAPIClientTests.swift +++ b/BrainwalletUnitTests/Legacy BRTests/BWAPIClientTests.swift @@ -10,8 +10,8 @@ class FakeAuthenticator: WalletAuthenticator { init() { let count = 32 var keyData = Data(count: count) - let result = keyData.withUnsafeMutableBytes { - SecRandomCopyBytes(kSecRandomDefault, count, $0) + let result = keyData.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + SecRandomCopyBytes(kSecRandomDefault, count, buffer.baseAddress!) } if result != errSecSuccess { fatalError("couldnt generate random data for key") @@ -33,7 +33,9 @@ class FakeAuthenticator: WalletAuthenticator { k.compressed = 1 let pkLen = BRKeyPrivKey(&k, nil, 0) var pkData = Data(count: pkLen) - BRKeyPrivKey(&k, pkData.withUnsafeMutableBytes { $0 }, pkLen) + _ = pkData.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + BRKeyPrivKey(&k, buffer.baseAddress?.assumingMemoryBound(to: CChar.self), pkLen) + } return String(data: pkData, encoding: .utf8) } } @@ -43,19 +45,16 @@ class BWAPIClientTests: XCTestCase { var authenticator: WalletAuthenticator! var client: BWAPIClient! var sut: BWAPIClient! - var mockURLSession: MockURLSession! - override func setUpWithError() throws { try super.setUpWithError() authenticator = FakeAuthenticator() // each test will get its own account client = BWAPIClient(authenticator: authenticator) sut = BWAPIClient(authenticator: authenticator) } - + override func tearDownWithError() throws { sut = nil - mockURLSession = nil authenticator = nil client = nil try super.tearDownWithError() @@ -66,19 +65,25 @@ class BWAPIClientTests: XCTestCase { let b = pubKey1.base58DecodedData() let b2 = b.base58 XCTAssertEqual(pubKey1, b2) // sanity check on our base58 functions - let key = client.authKey!.publicKey.withUnsafeBytes { (ptr: UnsafePointer) -> BRKey in + let key = client + .authKey!.publicKey + .withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> BRKey in var k = BRKey() - BRKeySetPubKey(&k, ptr, client.authKey!.publicKey.count) + BRKeySetPubKey(&k, buffer.baseAddress? + .assumingMemoryBound(to: UInt8.self), client.authKey! + .publicKey.count) return k } - XCTAssertEqual(pubKey1, key.publicKey.base58) // the key decoded from our encoded key is the same + XCTAssertEqual(pubKey1, key.publicKey.base58) + // the key decoded from our encoded key is the same } /* func testHandshake() { // test that we can get a token and access /me let req = URLRequest(url: client.url("/me")) let exp = expectation(description: "auth") - client.dataTaskWithRequest(req, authenticated: true, retryCount: 0) { (data, resp, err) in + client + .dataTaskWithRequest(req, authenticated: true, retryCount: 0) { (data, resp, err) in XCTAssertEqual(resp?.statusCode, 200) exp.fulfill() }.resume() @@ -133,7 +138,8 @@ class BWAPIClientTests: XCTestCase { let url = sut.url(path, args: args) // Then - XCTAssertTrue(url.absoluteString.contains("John Doe") || url.absoluteString.contains("%20")) + XCTAssertTrue(url.absoluteString + .contains("John Doe") || url.absoluteString.contains("%20")) } func testURL_WithEmptyArgs_ReturnsPathOnly() { @@ -239,7 +245,7 @@ class BWAPIClientTests: XCTestCase { func testURLSession_ServerTrustChallenge_ForWrongHost_Rejects() { // Given let session = URLSession.shared - let task = URLSessionDataTask() + let task = session.dataTask(with: URL(string: "https://evil.example.com")!) let protectionSpace = URLProtectionSpace( host: "evil.example.com", port: 443, @@ -274,8 +280,8 @@ class BWAPIClientTests: XCTestCase { func testURLSession_Redirect_ToDifferentHost_DoesNotFollow() { // Given let session = URLSession.shared - let task = URLSessionDataTask() let originalURL = URL(string: "https://api.grunt.ltd/endpoint")! + let task = session.dataTask(with: originalURL) let newURL = URL(string: "https://evil.example.com/phishing")! var originalRequest = URLRequest(url: originalURL) @@ -300,8 +306,62 @@ class BWAPIClientTests: XCTestCase { waitForExpectations(timeout: 1.0) } - - + + func testURLSession_Redirect_FromDifferentHost_DoesNotFollow() { + // Given: the task's own current request is NOT on our API host, even + // though the redirect target is -- an untrusted origin shouldn't be + // able to route a request onto our API this way either. + let session = URLSession.shared + let originalURL = URL(string: "https://evil.example.com/redirector")! + let task = session.dataTask(with: originalURL) + let newURL = URL(string: "https://api.grunt.ltd/endpoint")! + let newRequest = URLRequest(url: newURL) + let response = HTTPURLResponse( + url: originalURL, + statusCode: 302, + httpVersion: nil, + headerFields: nil + )! + + let expectation = self.expectation(description: "Redirect handled") + + // When + sut.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: newRequest) { request in + // Then + XCTAssertNil(request) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testURLSession_Redirect_SameHost_Follows() { + // Given: both the originating task and the redirect target stay on our + // own API -- this legitimate case should still be followed. + let session = URLSession.shared + let originalURL = URL(string: "https://api.grunt.ltd/endpoint")! + let task = session.dataTask(with: originalURL) + let newURL = URL(string: "https://api.grunt.ltd/redirected")! + let newRequest = URLRequest(url: newURL) + let response = HTTPURLResponse( + url: originalURL, + statusCode: 302, + httpVersion: nil, + headerFields: nil + )! + + let expectation = self.expectation(description: "Redirect handled") + + // When + sut.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: newRequest) { request in + // Then + XCTAssertEqual(request?.url, newURL) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + // MARK: - URL Extension Tests func testResourceString_PathOnly() { @@ -430,34 +490,6 @@ class BWAPIClientTests: XCTestCase { } } -// MARK: - Mock Classes - -class MockAuthenticationChallengeSender: NSObject, URLAuthenticationChallengeSender { - func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} - func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} - func cancel(_ challenge: URLAuthenticationChallenge) {} -} - -class MockURLSession: URLSession { - var mockDataTask: MockURLSessionDataTask? - - override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { - let task = MockURLSessionDataTask() - task.completionHandler = completionHandler - mockDataTask = task - return task - } -} - -class MockURLSessionDataTask: URLSessionDataTask { - var completionHandler: ((Data?, URLResponse?, Error?) -> Void)? - - override func resume() { - // Simulate completion - completionHandler?(nil, nil, nil) - } -} - // MARK: - Extensions for Testing extension UserDefaults { diff --git a/BrainwalletUnitTests/Legacy BRTests/ExtensionsCryptoTests.swift b/BrainwalletUnitTests/Legacy BRTests/ExtensionsCryptoTests.swift new file mode 100644 index 000000000..26a6c1425 --- /dev/null +++ b/BrainwalletUnitTests/Legacy BRTests/ExtensionsCryptoTests.swift @@ -0,0 +1,80 @@ +import BRCore +@testable import brainwallet +import XCTest + +/// Verifies the hashing/encoding helpers in Extensions.swift against known +/// test vectors and round-trip self-consistency. There was no coverage of +/// these at all before -- added alongside migrating them off the deprecated +/// typed-pointer withUnsafeBytes/withUnsafeMutableBytes overloads to +/// UnsafeRawBufferPointer/UnsafeMutableRawBufferPointer, so a mistake in the +/// pointer plumbing shows up as a wrong hash rather than just a warning. +class ExtensionsCryptoTests: XCTestCase { + func testMD5KnownVector() { + XCTAssertEqual("abc".md5(), "900150983cd24fb0d6963f7d28e17f72") + } + + func testSHA256KnownVector() { + let data = "abc".data(using: .utf8)! + XCTAssertEqual(data.sha256.hexString, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + } + + func testSHA1ProducesTwentyBytes() { + let data = "abc".data(using: .utf8)! + XCTAssertEqual(data.sha1.count, 20) + } + + func testBase58RoundTrip() { + let data = "abc".data(using: .utf8)! + let encoded = data.base58 + XCTAssertFalse(encoded.isEmpty) + XCTAssertEqual(encoded.base58DecodedData(), data, + "decoding a base58 string should reproduce the original bytes") + } + + func testUInt256RoundTrip() { + let bytes = (0..<32).map { UInt8($0) } + let data = Data(bytes) + let value = data.uInt256 + let reEncoded = withUnsafeBytes(of: value) { Data($0) } + XCTAssertEqual(reEncoded, data, "uInt256 should reinterpret the exact 32 bytes it was read from") + } + + func testOffsetAccessorsReadLittleEndian() { + // 0x0102030405060708 stored little-endian, followed by one more byte. + let data = Data([0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0xFF]) + XCTAssertEqual(data.uInt8(atOffset: 8), 0xFF) + XCTAssertEqual(data.uInt32(atOffset: 0), 0x0506_0708) + XCTAssertEqual(data.uInt64(atOffset: 0), 0x0102_0304_0506_0708) + } + + func testOffsetAccessorsReturnZeroPastTheEnd() { + let data = Data([0x01, 0x02]) + XCTAssertEqual(data.uInt32(atOffset: 0), 0) + XCTAssertEqual(data.uInt64(atOffset: 0), 0) + } + + // MARK: - BRMasterPubKey Data round trip + + func testMasterPubKeyRoundTripsThroughData() throws { + var mpk = BRMasterPubKey() + mpk.fingerPrint = 0xDEAD_BEEF + let chainCodeBytes = Data((0..<32).map { UInt8($0) + 1 }) + mpk.chainCode = chainCodeBytes.withUnsafeBytes { $0.load(as: UInt256.self) } + + let encoded = Data(masterPubKey: mpk) + XCTAssertEqual(encoded.count, 4 + 32 + 33) + + let decoded = try XCTUnwrap(encoded.masterPubKey) + XCTAssertEqual(decoded.fingerPrint, mpk.fingerPrint) + let originalChainCode = withUnsafeBytes(of: mpk.chainCode) { Data($0) } + let decodedChainCode = withUnsafeBytes(of: decoded.chainCode) { Data($0) } + XCTAssertEqual(decodedChainCode, originalChainCode) + let originalPubKey = withUnsafeBytes(of: mpk.pubKey) { Data($0) } + let decodedPubKey = withUnsafeBytes(of: decoded.pubKey) { Data($0) } + XCTAssertEqual(decodedPubKey, originalPubKey) + } + + func testMasterPubKeyReturnsNilForShortData() { + XCTAssertNil(Data(count: 10).masterPubKey) + } +} diff --git a/BrainwalletUnitTests/Legacy BRTests/MockAuthenticationChallengeSender.swift b/BrainwalletUnitTests/Legacy BRTests/MockAuthenticationChallengeSender.swift new file mode 100644 index 000000000..8e2bbc328 --- /dev/null +++ b/BrainwalletUnitTests/Legacy BRTests/MockAuthenticationChallengeSender.swift @@ -0,0 +1,16 @@ +// +// MockAuthenticationChallengeSender.swift +// brainwallet +// +// Created by Kerry Washington on 8/17/26. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// +import BRCore +@testable import brainwallet +import XCTest + +class MockAuthenticationChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} +} diff --git a/BrainwalletUnitTests/Legacy BRTests/WalletManagerAuthTests.swift b/BrainwalletUnitTests/Legacy BRTests/WalletManagerAuthTests.swift new file mode 100644 index 000000000..ecc9a21df --- /dev/null +++ b/BrainwalletUnitTests/Legacy BRTests/WalletManagerAuthTests.swift @@ -0,0 +1,112 @@ +@testable import brainwallet +import XCTest + +/// Covers the WalletManager+Auth business logic not already exercised by +/// WalletAuthenticationTests (pin lock-out) and WalletCreationTests (random +/// seed-phrase generation): recovering a wallet from a known phrase, the +/// seedPhrase(pin:)/setSeedPhrase round trip, changing an existing pin, pinLength, +/// and wipeWallet. These also guard against regressions in the entropy/seed +/// pointer scoping inside setRandomSeedPhrase()'s underlying BIP39 encode path. +class WalletManagerAuthTests: XCTestCase { + private let walletManager: WalletManager = try! WalletManager(store: Store(), dbPath: nil) + private let pin = "123456" + private let validPhrase = "kind butter gasp around unfair tape again suit else example toast orphan" + + override func setUp() { + super.setUp() + clearKeychain() + } + + override func tearDown() { + super.tearDown() + clearKeychain() + } + + // MARK: - setSeedPhrase + + func testSetSeedPhraseSucceedsOnFreshWallet() { + XCTAssertTrue(walletManager.noWallet, "Wallet should not exist before setSeedPhrase") + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase), "Setting a valid seed phrase on a fresh wallet should succeed") + XCTAssertFalse(walletManager.noWallet, "Wallet should exist once a seed phrase has been set") + } + + func testSetSeedPhraseFailsWhenWalletAlreadyExists() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertFalse(walletManager.setSeedPhrase(validPhrase), "setSeedPhrase should refuse to overwrite an existing wallet") + } + + // MARK: - seedPhrase(pin:) round trip + + func testSeedPhraseReturnsOriginalPhraseWithCorrectPin() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin), "Setting PIN should succeed") + XCTAssertEqual(walletManager.seedPhrase(pin: pin), validPhrase, + "seedPhrase(pin:) should return exactly the phrase that was set") + } + + func testSeedPhraseReturnsNilWithWrongPin() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + XCTAssertNil(walletManager.seedPhrase(pin: "000000"), "seedPhrase(pin:) should refuse an incorrect PIN") + } + + // MARK: - changePin + + func testChangePinAllowsAuthenticationWithNewPinOnly() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + + let newPin = "654321" + XCTAssertTrue(walletManager.changePin(newPin: newPin, pin: pin), "changePin should succeed with the correct current PIN") + XCTAssertTrue(walletManager.authenticate(pin: newPin), "Authentication should succeed with the new PIN") + } + + func testChangePinFailsWithWrongCurrentPin() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + XCTAssertFalse(walletManager.changePin(newPin: "654321", pin: "000000"), "changePin should refuse an incorrect current PIN") + } + + // MARK: - pinLength + + func testPinLengthReflectsTheConfiguredPin() { + XCTAssertEqual(walletManager.pinLength, kPinDigitConstant, "pinLength should fall back to the default before any PIN is set") + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + XCTAssertEqual(walletManager.pinLength, pin.utf8.count, "pinLength should reflect the digit count of the configured PIN") + } + + // MARK: - wipeWallet + + func testWipeWalletClearsTheWallet() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + XCTAssertFalse(walletManager.noWallet) + + XCTAssertTrue(walletManager.wipeWallet(), "wipeWallet with the force-wipe sentinel should succeed unconditionally") + XCTAssertTrue(walletManager.noWallet, "Wallet should no longer exist after wipeWallet") + } + + func testWipeWalletRequiresCorrectPinWhenNotForced() { + XCTAssertTrue(walletManager.setSeedPhrase(validPhrase)) + XCTAssertTrue(walletManager.forceSetPin(newPin: pin)) + + XCTAssertFalse(walletManager.wipeWallet(pin: "000000"), "wipeWallet with a wrong PIN should fail") + XCTAssertFalse(walletManager.noWallet, "Wallet should be untouched after a failed wipe attempt") + } + + // MARK: - userAccount + + func testUserAccountRoundTripsThroughKeychain() throws { + let account: [AnyHashable: Any] = ["token": "abc123", "expires": 42] + walletManager.userAccount = account + + let fetched = try XCTUnwrap(walletManager.userAccount) + XCTAssertEqual(fetched["token"] as? String, "abc123") + XCTAssertEqual(fetched["expires"] as? Int, 42) + } + + func testUserAccountIsNilBeforeItIsSet() { + XCTAssertNil(walletManager.userAccount) + } +} diff --git a/BrainwalletUnitTests/Signup View Tests/SignupAskViewTests.swift b/BrainwalletUnitTests/Signup View Tests/SignupAskViewTests.swift index e769e0827..ec91424e9 100644 --- a/BrainwalletUnitTests/Signup View Tests/SignupAskViewTests.swift +++ b/BrainwalletUnitTests/Signup View Tests/SignupAskViewTests.swift @@ -44,9 +44,7 @@ final class SignupAskViewTests: XCTestCase { // Simulates the "Maybe later (Skip)" button action for a new wallet let isRestoring = false - if isRestoring { - path.append(.inputWordsView) - } else { + if !isRestoring { path.append(.yourSeedWordsView) } @@ -61,8 +59,6 @@ final class SignupAskViewTests: XCTestCase { let isRestoring = true if isRestoring { path.append(.inputWordsView) - } else { - path.append(.yourSeedWordsView) } XCTAssertEqual(path, [.inputWordsView]) diff --git a/Modules/core b/Modules/core index 2ee53dab9..36f38224b 160000 --- a/Modules/core +++ b/Modules/core @@ -1 +1 @@ -Subproject commit 2ee53dab9f411460dd163a4aefd7e72878d71ed1 +Subproject commit 36f38224b1ecca091a4c118b618254ab1575bd32 diff --git a/Private/bw-gdlib b/Private/bw-gdlib index ab90257ff..e06af016d 160000 --- a/Private/bw-gdlib +++ b/Private/bw-gdlib @@ -1 +1 @@ -Subproject commit ab90257ffe4f4497249f6733581be33d366e372d +Subproject commit e06af016d25a61320bb1828709eae18fa1f6d819 diff --git a/README.md b/README.md index fad0865b0..37ca34a8a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,7 @@ **Brainwallet** is a free, open-source, self-custodial [Litecoin](https://litecoin.org) wallet for iOS. Your seed phrase and keys stay on your device — Brainwallet never has custody of your funds. -### CircleCI status [![Release](https://img.shields.io/github/v/release/gruntsoftware/ios?style=plastic)](https://github.com/gruntsoftware/ios/releases) -[![CircleCI](https://dl.circleci.com/status-badge/img/gh/gruntsoftware/ios/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/gruntsoftware/ios/tree/main) -[![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/grunt-claude-bot/1121e3f9e22e6fda5273ea795bcc85be/raw/tests-badge.json)](https://dl.circleci.com/status-badge/redirect/gh/gruntsoftware/ios/tree/develop) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ## App Store @@ -56,7 +53,7 @@ ## Testing -Test targets are `BrainwalletUnitTests` and `BrainwalletUITests`. Run via Fastlane, e.g. `bundle exec fastlane run_unit_tests_iPhone16ProMax` (see `fastlane/Fastfile` for other lanes). CI runs on CircleCI (`.circleci/config.yml`). +Test targets are `BrainwalletUnitTests` and `BrainwalletUITests`. Run via Fastlane, e.g. `bundle exec fastlane run_unit_tests_iPhone16ProMax` (see `fastlane/Fastfile` for other lanes). CI runs on Xcode Cloud (`ci_scripts/`); i18n translation coverage is checked separately via a GitHub Actions workflow (`.github/workflows/i18n-coverage.yml`). ## Security @@ -74,25 +71,6 @@ For the full, up-to-date changelog see [GitHub Releases](https://github.com/grun --- -### **v3.9.16** ---- -- Fixed `user_did_complete_sync` analytics event never firing: `syncState` had usually already flipped to `.success` by the time the final progress update landed, so the `isSyncing` gate skipped the check. `WalletCoordinator` now owns the metric directly, tracking accumulated active-foreground sync time and logging a new time-to-98%-sync duration (#161) -- Fixed a Simulator launch crash (`dyld: Library not loaded: @rpath/BWIOSGdx.framework/BWIOSGdx`): `BWIOSGdx.xcframework` is now linked device-only via SDK-scoped build settings instead of the unconditional Frameworks build phase; also stabilized a flaky `LockScreenViewUITests` test that depended on locale/theme-dependent SF Symbol accessibility labels (#153) -- Fixed CircleCI and Xcode Cloud both failing on every build: stopped building `BWIOSGdx.xcframework` in CI, fixed `ci_post_clone.sh` silently writing garbage secrets, and added fail-fast validation for generated secret files on both providers (#154) -- Fixed a Crashlytics-reported `EXC_BAD_ACCESS` in `_peerThreadRoutine` by bumping `Modules/core`: `threadCleanup` is now null-checked before being invoked, matching the other optional callbacks in the same function (#155) -- Fixed a Simulator crash regression reintroduced while bumping `Private/bw-gdlib` to v1.6.8; restored the device-only linking from #153 (#156) -- Removed confirmed-unused code and resources across the repo — orphaned directories, 55 orphaned `.swift` files, dead Redux actions, unused SwiftUI view modifiers, and dead image/sound resources (328 files changed, 12,450 lines removed) (#157) - -**Full Changelog**: https://github.com/gruntsoftware/ios/compare/v3.9.15...v3.9.16 - -**Why grab this update?** -- Sync now finishes without the app second-guessing itself — fewer crash-prone edge cases means a smoother first launch and a wallet that's ready faster -- Every release stays self-custodial by design: your seed phrase and keys never leave your device, update after update -- Free, open-source, and actively maintained — audit the code yourself or just trust that it's still being cared for -- [Download Brainwallet on the App Store](https://apps.apple.com/us/app/brainwallet/id6444157498) and keep your Litecoin truly in your own hands - ---- - ### **v3.9.15** [PR [#151](https://github.com/gruntsoftware/ios/pull/151)] --- - Fixed game-exit analytics events being silently dropped: `bw-gdlib` now wraps the exit payload as `{"exitData": ..., "events": [...]}`, and iOS decodes it (`GameExitPayload`) and forwards every collected event to Firebase Analytics instead of throwing/discarding it; Android's `AndroidLauncher` now forwards the same `jsonString` too diff --git a/brainwallet.xcodeproj/project.pbxproj b/brainwallet.xcodeproj/project.pbxproj index 2779cc230..dda805295 100644 --- a/brainwallet.xcodeproj/project.pbxproj +++ b/brainwallet.xcodeproj/project.pbxproj @@ -294,7 +294,6 @@ C312D39C2D7DC27700BB97A4 /* WalletInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D0A92D7DC27700BB97A4 /* WalletInfo.swift */; }; C312D3A72D7DC27700BB97A4 /* UnsafeMutablePointerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2C32D7DC27700BB97A4 /* UnsafeMutablePointerExtension.swift */; }; C312D3B62D7DC27700BB97A4 /* PaymentProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2892D7DC27700BB97A4 /* PaymentProtocol.swift */; }; - C312D3B72D7DC27700BB97A4 /* UIView+FrameChangeBlocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D0832D7DC27700BB97A4 /* UIView+FrameChangeBlocking.swift */; }; C312D3BC2D7DC27700BB97A4 /* Constants+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2382D7DC27700BB97A4 /* Constants+Events.swift */; }; C312D3C22D7DC27700BB97A4 /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D22A2D7DC27700BB97A4 /* BundleExtension.swift */; }; C312D3C32D7DC27700BB97A4 /* FailedAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2482D7DC27700BB97A4 /* FailedAlertView.swift */; }; @@ -315,7 +314,6 @@ C312D3FC2D7DC27700BB97A4 /* UIView+InitAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D0842D7DC27700BB97A4 /* UIView+InitAdditions.swift */; }; C312D3FD2D7DC27700BB97A4 /* LocaleChangeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2632D7DC27700BB97A4 /* LocaleChangeViewModel.swift */; }; C312D3FE2D7DC27700BB97A4 /* ReduxState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D0AC2D7DC27700BB97A4 /* ReduxState.swift */; }; - C312D4002D7DC27700BB97A4 /* MessageUIPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2802D7DC27700BB97A4 /* MessageUIPresenter.swift */; }; C312D4022D7DC27700BB97A4 /* DrawableCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D1E32D7DC27700BB97A4 /* DrawableCircle.swift */; }; C312D4062D7DC27700BB97A4 /* IntroStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D2542D7DC27700BB97A4 /* IntroStepView.swift */; }; C312D40C2D7DC27700BB97A4 /* InAppAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = C312D1E82D7DC27700BB97A4 /* InAppAlert.swift */; }; @@ -379,6 +377,7 @@ C38F57252FD490430005538A /* boingspringmouthharp042013.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = C38F57242FD490430005538A /* boingspringmouthharp042013.mp3 */; }; C395E2B83013770A00CF5F5B /* EnterPinForSeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395E2B2301376FE00CF5F5B /* EnterPinForSeedView.swift */; }; C3B0BBC32FF6DB11000D5DB8 /* AlertToast in Frameworks */ = {isa = PBXBuildFile; productRef = C3B0BBC22FF6DB11000D5DB8 /* AlertToast */; }; + C3BC6CBD303331FB00A04919 /* UIApplication+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BC6CBC303331D800A04919 /* UIApplication+Additions.swift */; }; C3C95F082FA13DEF002E2BEE /* SignupAskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C95F072FA13DEF002E2BEE /* SignupAskView.swift */; }; C3CE34612FFD8D1F004A2ECA /* LilitaOne.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C3CE34602FFD8D1F004A2ECA /* LilitaOne.ttf */; }; C3E18A9F2F9E657000966C74 /* ShopCardsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E18A9E2F9E656600966C74 /* ShopCardsView.swift */; }; @@ -390,6 +389,8 @@ C3FACF6C2F9E023700886FAD /* GameHubCarouselBentoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FACF6B2F9E023700886FAD /* GameHubCarouselBentoView.swift */; }; C3FACF6E2F9E025300886FAD /* MoonPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FACF6D2F9E025300886FAD /* MoonPayView.swift */; }; C3FACF702F9E026500886FAD /* SocialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FACF6F2F9E026500886FAD /* SocialsView.swift */; }; + C74016995BF8E51D0570D4A3 /* VariableBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D907D0CB963306B3055BAA1B /* VariableBlurView.swift */; }; + FF9BADE3A170F008C98BDA4D /* UIPickerView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 556F43635FF15DBD8CE79E53 /* UIPickerView+Extension.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -800,6 +801,7 @@ 24F5BD572E04711E0010D340 /* CurrencyPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyPickerView.swift; sourceTree = ""; }; 24F64C1F2DAFBD94001DC9B6 /* WelcomMojiDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomMojiDemoView.swift; sourceTree = ""; }; 24FBB8692E035E07002B5F84 /* SettingsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHostingController.swift; sourceTree = ""; }; + 556F43635FF15DBD8CE79E53 /* UIPickerView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPickerView+Extension.swift"; sourceTree = ""; }; 5C3F1465DCBE495A8993C1FD /* BalanceBentoReviewPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceBentoReviewPolicy.swift; sourceTree = ""; }; 755CD9D11DAA197C0075898E /* libBRCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBRCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; 75A2A7901DA5934300A983D8 /* Brainwallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Brainwallet.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -827,7 +829,6 @@ C312D0802D7DC27700BB97A4 /* UITableView+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Additions.swift"; sourceTree = ""; }; C312D0812D7DC27700BB97A4 /* UIView+AnimationAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+AnimationAdditions.swift"; sourceTree = ""; }; C312D0822D7DC27700BB97A4 /* UIView+BRWAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+BRWAdditions.swift"; sourceTree = ""; }; - C312D0832D7DC27700BB97A4 /* UIView+FrameChangeBlocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+FrameChangeBlocking.swift"; sourceTree = ""; }; C312D0842D7DC27700BB97A4 /* UIView+InitAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+InitAdditions.swift"; sourceTree = ""; }; C312D0852D7DC27700BB97A4 /* UIViewController+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Alerts.swift"; sourceTree = ""; }; C312D0862D7DC27700BB97A4 /* UIViewController+BRWAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+BRWAdditions.swift"; sourceTree = ""; }; @@ -906,7 +907,6 @@ C312D2792D7DC27700BB97A4 /* LockScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenViewModel.swift; sourceTree = ""; }; C312D27D2D7DC27700BB97A4 /* LWActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LWActivityIndicator.swift; sourceTree = ""; }; C312D27F2D7DC27700BB97A4 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; - C312D2802D7DC27700BB97A4 /* MessageUIPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageUIPresenter.swift; sourceTree = ""; }; C312D2812D7DC27700BB97A4 /* MockSeeds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSeeds.swift; sourceTree = ""; }; C312D2822D7DC27700BB97A4 /* ModalPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresenter.swift; sourceTree = ""; }; C312D2832D7DC27700BB97A4 /* MoonpayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoonpayHelper.swift; sourceTree = ""; }; @@ -960,6 +960,7 @@ C353E3B42FAE9F9000BFFB30 /* ShopBentoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBentoViewModel.swift; sourceTree = ""; }; C38F57242FD490430005538A /* boingspringmouthharp042013.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = boingspringmouthharp042013.mp3; sourceTree = ""; }; C395E2B2301376FE00CF5F5B /* EnterPinForSeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterPinForSeedView.swift; sourceTree = ""; }; + C3BC6CBC303331D800A04919 /* UIApplication+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Additions.swift"; sourceTree = ""; }; C3C95F072FA13DEF002E2BEE /* SignupAskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupAskView.swift; sourceTree = ""; }; C3CE34602FFD8D1F004A2ECA /* LilitaOne.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = LilitaOne.ttf; sourceTree = ""; }; C3E18A9E2F9E656600966C74 /* ShopCardsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCardsView.swift; sourceTree = ""; }; @@ -971,6 +972,7 @@ C3FACF6B2F9E023700886FAD /* GameHubCarouselBentoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameHubCarouselBentoView.swift; sourceTree = ""; }; C3FACF6D2F9E025300886FAD /* MoonPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoonPayView.swift; sourceTree = ""; }; C3FACF6F2F9E026500886FAD /* SocialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialsView.swift; sourceTree = ""; }; + D907D0CB963306B3055BAA1B /* VariableBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableBlurView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1184,6 +1186,7 @@ 242871822DE5C4E10035DE48 /* Additions */ = { isa = PBXGroup; children = ( + C3BC6CBC303331D800A04919 /* UIApplication+Additions.swift */, C312D0872D7DC27700BB97A4 /* UIViewControllerContextTransitioning+BRAdditions.swift */, C312D0882D7DC27700BB97A4 /* UIViewPropertyAnimator+BRWAdditions.swift */, C312D07A2D7DC27700BB97A4 /* UIFont+BRWAdditions.swift */, @@ -1217,6 +1220,7 @@ C312D2C12D7DC27700BB97A4 /* UIApplication+Extension.swift */, C312D2852D7DC27700BB97A4 /* NSNotificationNameExtension.swift */, C312D2C22D7DC27700BB97A4 /* UITableViewExtension.swift */, + 556F43635FF15DBD8CE79E53 /* UIPickerView+Extension.swift */, C312D2C32D7DC27700BB97A4 /* UnsafeMutablePointerExtension.swift */, C312D2C72D7DC27700BB97A4 /* View+Extension.swift */, C312D07D2D7DC27700BB97A4 /* UINavigationController+Extension.swift */, @@ -2012,7 +2016,6 @@ C312D0722D7DC27700BB97A4 /* LAContext+Extensions.swift */, C312D07B2D7DC27700BB97A4 /* UIImage+Utils.swift */, C312D0822D7DC27700BB97A4 /* UIView+BRWAdditions.swift */, - C312D0832D7DC27700BB97A4 /* UIView+FrameChangeBlocking.swift */, C312D0852D7DC27700BB97A4 /* UIViewController+Alerts.swift */, C312D1B92D7DC27700BB97A4 /* BiometricsSpendingLimitViewController.swift */, C312D1C12D7DC27700BB97A4 /* PinPadViewController.swift */, @@ -2073,7 +2076,6 @@ C312D2632D7DC27700BB97A4 /* LocaleChangeViewModel.swift */, C312D27D2D7DC27700BB97A4 /* LWActivityIndicator.swift */, C312D27F2D7DC27700BB97A4 /* MainViewController.swift */, - C312D2802D7DC27700BB97A4 /* MessageUIPresenter.swift */, C312D2812D7DC27700BB97A4 /* MockSeeds.swift */, C312D2822D7DC27700BB97A4 /* ModalPresenter.swift */, 24B7D75C2DF6F9F60031F129 /* ModalPresenter+Extension.swift */, @@ -2094,6 +2096,7 @@ C312D2C02D7DC27700BB97A4 /* TransferAmountViewModel.swift */, C3236A002D7DE34D007039A1 /* URLController.swift */, C312D2C62D7DC27700BB97A4 /* UserDefaultsUpdater.swift */, + D907D0CB963306B3055BAA1B /* VariableBlurView.swift */, C312D2C82D7DC27700BB97A4 /* WalletCoordinator.swift */, C312D2C92D7DC27700BB97A4 /* WalletManager.swift */, C312D2CA2D7DC27700BB97A4 /* WalletManager+Auth.swift */, @@ -2237,7 +2240,6 @@ C35312F32FE1B5B100FC2840 /* Flatten+Sign+Relocate Asset Frameworks */, 75A2A78D1DA5934300A983D8 /* Frameworks */, 75A2A78E1DA5934300A983D8 /* Resources */, - C3D00A6D2D1B10E900AC2840 /* Run Script for CircleCI SPM Caching */, C3A3FFB526FE46E8000FE955 /* Mark Dev Notes */, 58A9FE4C291BC37400B75825 /* Count number of swift lines */, 246F97EC2E846230001923A6 /* Embed Frameworks */, @@ -2602,26 +2604,6 @@ shellPath = /bin/sh; shellScript = "# http://www.benzado.com/blog/post/329/make-xcode-nag-you-about-unfinished-todos\necho \"make-xcode-nag-you-about-unfinished-todos for swift files only\"\nKEYWORDS=\"DEV:|TODO:|FIXME:|\\?\\?\\?:|\\!\\!\\!:\"\nfind \"${SRCROOT}\\brainwallet\" \\( -name \"*.swift\" \\) -print0 | \\\nxargs -0 egrep --with-filename --line-number --only-matching \"($KEYWORDS).*\\$\" | \\\nperl -p -e \"s/($KEYWORDS)/ warning: \\$1/\"\n"; }; - C3D00A6D2D1B10E900AC2840 /* Run Script for CircleCI SPM Caching */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run Script for CircleCI SPM Caching"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/newOutputFile-circleci-spm-cache", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n#cp brainwallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved Package.resolved\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2699,6 +2681,7 @@ C312D2EC2D7DC27700BB97A4 /* AnimatableIcon.swift in Sources */, 242C43EF2F25397300CF6EA4 /* WalkthroughStep3View.swift in Sources */, C312D2F12D7DC27700BB97A4 /* UITableViewExtension.swift in Sources */, + FF9BADE3A170F008C98BDA4D /* UIPickerView+Extension.swift in Sources */, 246C3F5E2F244E61009BC881 /* TutorialReceiveBentoView.swift in Sources */, C3E18AA12F9EC22000966C74 /* ConfirmationStatus.swift in Sources */, 242871882DE5D36F0035DE48 /* SyncSubBentoViewModel.swift in Sources */, @@ -2718,7 +2701,9 @@ 246C3F5A2F2431B6009BC881 /* TutorialSendBentoView.swift in Sources */, C312D3112D7DC27700BB97A4 /* UIViewControllerContextTransitioning+BRAdditions.swift in Sources */, C312D3122D7DC27700BB97A4 /* UIApplication+Extension.swift in Sources */, + C3BC6CBD303331FB00A04919 /* UIApplication+Additions.swift in Sources */, C312D3132D7DC27700BB97A4 /* UserDefaultsUpdater.swift in Sources */, + C74016995BF8E51D0570D4A3 /* VariableBlurView.swift in Sources */, C312D3152D7DC27700BB97A4 /* UILabel+BRWAdditions.swift in Sources */, C312D3172D7DC27700BB97A4 /* BiometricsSpendingLimitViewController.swift in Sources */, C312D3192D7DC27700BB97A4 /* Rate.swift in Sources */, @@ -2840,7 +2825,6 @@ 242C43F32F253A3E00CF6EA4 /* TutorialWalkthroughBentoView.swift in Sources */, C312D3B62D7DC27700BB97A4 /* PaymentProtocol.swift in Sources */, 24B0653D2DC6476A00B5E000 /* BentoSendPrepView.swift in Sources */, - C312D3B72D7DC27700BB97A4 /* UIView+FrameChangeBlocking.swift in Sources */, 24F378022DE89C7800A388AB /* MoonPayHelper.swift in Sources */, C312D3BC2D7DC27700BB97A4 /* Constants+Events.swift in Sources */, C312D3C22D7DC27700BB97A4 /* BundleExtension.swift in Sources */, @@ -2882,7 +2866,6 @@ C312D3FE2D7DC27700BB97A4 /* ReduxState.swift in Sources */, 24A0ACAB2E0C812C0077E695 /* SettingsLitecoinDetailView.swift in Sources */, 24D7AE562DCFF6870078252E /* NewConfirmView.swift in Sources */, - C312D4002D7DC27700BB97A4 /* MessageUIPresenter.swift in Sources */, C312D4022D7DC27700BB97A4 /* DrawableCircle.swift in Sources */, 24B0653B2DC6433800B5E000 /* SimpleHeaderView.swift in Sources */, C312D4062D7DC27700BB97A4 /* IntroStepView.swift in Sources */, @@ -3212,7 +3195,7 @@ CODE_SIGN_ENTITLEMENTS = brainwallet/brainwallet.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2602306; + CURRENT_PROJECT_VERSION = 2602311; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEPLOYMENT_POSTPROCESSING = NO; DEVELOPMENT_TEAM = U8WDVUDC2P; @@ -3234,7 +3217,7 @@ "$(FRAMEWORK_SEARCH_PATHS)", ); LIBRARY_SEARCH_PATHS = "$(SRCROOT)/bw-gdlib/"; - MARKETING_VERSION = 3.9.16; + MARKETING_VERSION = 3.9.17; ONLY_ACTIVE_ARCH = YES; "OTHER_LDFLAGS[sdk=iphoneos*]" = ( "$(inherited)", @@ -3414,7 +3397,7 @@ CODE_SIGN_ENTITLEMENTS = brainwallet/brainwallet.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2602306; + CURRENT_PROJECT_VERSION = 2602311; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_TEAM = U8WDVUDC2P; DOCC_EXTRACT_SWIFT_INFO_FOR_OBJC_SYMBOLS = YES; @@ -3435,7 +3418,7 @@ "$(FRAMEWORK_SEARCH_PATHS)", ); LIBRARY_SEARCH_PATHS = "$(SRCROOT)/bw-gdlib/"; - MARKETING_VERSION = 3.9.16; + MARKETING_VERSION = 3.9.17; ONLY_ACTIVE_ARCH = YES; "OTHER_LDFLAGS[arch=*]" = "-ObjC"; "OTHER_LDFLAGS[sdk=iphoneos*]" = ( diff --git a/brainwallet/Additions/String+Additions.swift b/brainwallet/Additions/String+Additions.swift index b1143397f..08c370703 100644 --- a/brainwallet/Additions/String+Additions.swift +++ b/brainwallet/Additions/String+Additions.swift @@ -87,7 +87,7 @@ extension String { } bytes[index >> 1] |= nibble } - return Data(bytes: bytes) + return Data(bytes) } } diff --git a/brainwallet/Additions/UIApplication+Additions.swift b/brainwallet/Additions/UIApplication+Additions.swift new file mode 100644 index 000000000..f47b526bd --- /dev/null +++ b/brainwallet/Additions/UIApplication+Additions.swift @@ -0,0 +1,11 @@ +import UIKit + +extension UIApplication { + /// The active scene's key window, replacing the deprecated + /// `UIApplication.shared.windows.filter { $0.isKeyWindow }.first`. + var currentKeyWindow: UIWindow? { + connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first + } +} diff --git a/brainwallet/Additions/UIButton+BRWAdditions.swift b/brainwallet/Additions/UIButton+BRWAdditions.swift index 237f710f8..1c6730cbb 100644 --- a/brainwallet/Additions/UIButton+BRWAdditions.swift +++ b/brainwallet/Additions/UIButton+BRWAdditions.swift @@ -6,10 +6,6 @@ extension UIButton { button.setTitle(title, for: .normal) button.setImage(image, for: .normal) button.titleLabel?.font = UIFont.customMedium(size: 11.0) - if let font = button.titleLabel?.font { - let spacing: CGFloat = C.padding[1] / 2.0 - let titleSize = NSString(string: title).size(withAttributes: [NSAttributedString.Key.font: font]) - } return button } diff --git a/brainwallet/Additions/UserDefaults+Additions.swift b/brainwallet/Additions/UserDefaults+Additions.swift index 51c170e59..810f9b66a 100644 --- a/brainwallet/Additions/UserDefaults+Additions.swift +++ b/brainwallet/Additions/UserDefaults+Additions.swift @@ -18,6 +18,7 @@ private let hasPromptedShareDataKey = "hasPromptedShareDataKey" private let didSeeTransactionCorruption = "DidSeeTransactionCorruption" private let hasLoggedInitialSyncDurationKey = "hasLoggedInitialSyncDurationKey" private let foregroundSyncDurationSecondsKey = "foregroundSyncDurationSecondsKey" +private let pendingNotificationBadgeCountKey = "pendingNotificationBadgeCountKey" let timeSinceLastExitKey = "TimeSinceLastExit" let shouldRequireLoginTimeoutKey = "ShouldRequireLoginTimeoutKey" @@ -274,3 +275,19 @@ extension UserDefaults { set { defaults.set(newValue, forKey: foregroundSyncDurationSecondsKey) } } } + +// MARK: - Notifications + +extension UserDefaults { + /// Mirrors the badge count this app has last asked UNUserNotificationCenter + /// to display. UIApplication.applicationIconBadgeNumber -- the old + /// synchronous getter/setter -- was deprecated in iOS 17 in favor of + /// UNUserNotificationCenter.setBadgeCount(_:withCompletionHandler:), which + /// has no matching getter, so this is the app's own record of what it last + /// set. Kept in sync with the two places that reset the system badge to 0 + /// (AppDelegate's launch and applicationDidBecomeActive). + static var pendingNotificationBadgeCount: Int { + get { return defaults.integer(forKey: pendingNotificationBadgeCountKey) } + set { defaults.set(newValue, forKey: pendingNotificationBadgeCountKey) } + } +} diff --git a/brainwallet/App Launch Classes/AppDelegate.swift b/brainwallet/App Launch Classes/AppDelegate.swift index f669d3870..d04489da2 100644 --- a/brainwallet/App Launch Classes/AppDelegate.swift +++ b/brainwallet/App Launch Classes/AppDelegate.swift @@ -21,6 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { return true } + UserDefaults.pendingNotificationBadgeCount = 0 UNUserNotificationCenter.current().setBadgeCount(0) { _ in } var regionCode2Char: String = "RU" @@ -87,7 +88,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { [UIAlertController.self]) .tintColor = BrainwalletUIColor.content - UIView.swizzleSetFrame() self.applicationController.launch(application: UIApplication.shared, window: thisWindow) return true @@ -125,6 +125,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { } func applicationDidBecomeActive(_: UIApplication) { + UserDefaults.pendingNotificationBadgeCount = 0 UNUserNotificationCenter.current().setBadgeCount(0) { error in if let error = error { debugPrint("Failed to set badge count: \(error.localizedDescription)") diff --git a/brainwallet/BWAPIClient Classes/BWAPIClient+FiatLTCRates.swift b/brainwallet/BWAPIClient Classes/BWAPIClient+FiatLTCRates.swift index 6ff87335e..9c2c79d2a 100644 --- a/brainwallet/BWAPIClient Classes/BWAPIClient+FiatLTCRates.swift +++ b/brainwallet/BWAPIClient Classes/BWAPIClient+FiatLTCRates.swift @@ -86,7 +86,6 @@ extension BWAPIClient { rates = array.compactMap { Rate(data: $0) } } }.resume() - attemptRequest() } return rates } diff --git a/brainwallet/BWAPIClient Classes/BWAPIClient.swift b/brainwallet/BWAPIClient Classes/BWAPIClient.swift index 693563bef..d0c773bb5 100644 --- a/brainwallet/BWAPIClient Classes/BWAPIClient.swift +++ b/brainwallet/BWAPIClient Classes/BWAPIClient.swift @@ -192,11 +192,19 @@ open class BWAPIClient: NSObject, URLSessionDelegate, URLSessionTaskDelegate, BW public func urlSession(_: URLSession, task: URLSessionTask, willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { var actualRequest = request - if let currentReq = task.currentRequest, var curHost = currentReq.url?.host, let curScheme = currentReq.url?.scheme { + if let currentReq = task.currentRequest, var curHost = currentReq.url?.host, let curScheme = currentReq.url?.scheme, + var newHost = request.url?.host, let newScheme = request.url?.scheme { if let curPort = currentReq.url?.port, curPort != 443, curPort != 80 { curHost = "\(curHost):\(curPort)" } - if curHost == host, curScheme == proto { + if let newPort = request.url?.port, newPort != 443, newPort != 80 { + newHost = "\(newHost):\(newPort)" + } + // Only follow redirects that both originate from AND continue to point at our + // own API -- checking the origin alone would follow a redirect anywhere the + // moment it's issued by api.grunt.ltd, silently sending an (often + // authenticated) follow-up request off our domain. + if curHost == host, curScheme == proto, newHost == host, newScheme == proto { // follow the redirect if we're interacting with our API actualRequest = decorateRequest(request) log("redirecting \(String(describing: currentReq.url)) to \(String(describing: request.url))") diff --git a/brainwallet/Bento Effects /BentoBackgroundHelpers.swift b/brainwallet/Bento Effects /BentoBackgroundHelpers.swift index 3026f8693..1f8db44ad 100644 --- a/brainwallet/Bento Effects /BentoBackgroundHelpers.swift +++ b/brainwallet/Bento Effects /BentoBackgroundHelpers.swift @@ -197,8 +197,7 @@ struct StaticBackgroundView: View { var body: some View { GeometryReader { geometry in - - let width = geometry.size.width + ZStack { Group { RoundedRectangle(cornerRadius: bentoCornerRadius) diff --git a/brainwallet/Bento Views/Game Hub Bentos/MoonPayView.swift b/brainwallet/Bento Views/Game Hub Bentos/MoonPayView.swift index 2a7577aa5..67601cb0b 100644 --- a/brainwallet/Bento Views/Game Hub Bentos/MoonPayView.swift +++ b/brainwallet/Bento Views/Game Hub Bentos/MoonPayView.swift @@ -28,10 +28,6 @@ struct MoonPayView: View { GeometryReader { geometry in let width = geometry.size.width - let height = geometry.size.height - - let labelBackground = Color.white.opacity(0.1) - let labelForeground = Color.white ZStack { StaticBackgroundView(userPrefersDarkTheme: .constant(false), diff --git a/brainwallet/Bento Views/Game Hub Bentos/SocialsView.swift b/brainwallet/Bento Views/Game Hub Bentos/SocialsView.swift index 71345fc9c..fed60c6c6 100644 --- a/brainwallet/Bento Views/Game Hub Bentos/SocialsView.swift +++ b/brainwallet/Bento Views/Game Hub Bentos/SocialsView.swift @@ -26,8 +26,6 @@ struct SocialsBentoView: View { var body: some View { GeometryReader { geometry in - let width = geometry.size.width - ZStack { StaticBackgroundView(userPrefersDarkTheme: .constant(false), imageName: "socials_background_1") diff --git a/brainwallet/Emoji Classes/RowHowToSetEmoji.swift b/brainwallet/Emoji Classes/RowHowToSetEmoji.swift index 2f9b3bdf3..03beed3b2 100644 --- a/brainwallet/Emoji Classes/RowHowToSetEmoji.swift +++ b/brainwallet/Emoji Classes/RowHowToSetEmoji.swift @@ -15,8 +15,6 @@ struct RowHowToSetEmoji: View { let rowIndex: Int var body: some View { GeometryReader { geometry in - let height = geometry.size.height - let width = geometry.size.width let iconBorderRatio = 2.3 let iconSize: CGFloat = 26.0 diff --git a/brainwallet/Extensions/Extensions.swift b/brainwallet/Extensions/Extensions.swift index 11e017272..1aa5f54fd 100644 --- a/brainwallet/Extensions/Extensions.swift +++ b/brainwallet/Extensions/Extensions.swift @@ -40,9 +40,9 @@ public extension String { var result = Data(count: 128 / 8) let resultCount = result.count - return result.withUnsafeMutableBytes { (resultBytes: UnsafeMutablePointer) -> String in - data.withUnsafeBytes { dataBytes in - BRMD5(resultBytes, dataBytes, data.count) + return result.withUnsafeMutableBytes { (resultBytes: UnsafeMutableRawBufferPointer) -> String in + data.withUnsafeBytes { (dataBytes: UnsafeRawBufferPointer) in + BRMD5(resultBytes.baseAddress, dataBytes.baseAddress, data.count) } var hash = String() for i in 0 ..< resultCount { @@ -55,7 +55,9 @@ public extension String { func base58DecodedData() -> Data { let len = BRBase58Decode(nil, 0, self) var data = Data(count: len) - _ = data.withUnsafeMutableBytes { BRBase58Decode($0, len, self) } + _ = data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + BRBase58Decode(buffer.baseAddress?.assumingMemoryBound(to: UInt8.self), len, self) + } return data } @@ -153,10 +155,12 @@ public extension Data { } var base58: String { - return withUnsafeBytes { (selfBytes: UnsafePointer) -> String in + return withUnsafeBytes { (selfBuffer: UnsafeRawBufferPointer) -> String in + let selfBytes = selfBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) let len = BRBase58Encode(nil, 0, selfBytes, self.count) var data = Data(count: len) - return data.withUnsafeMutableBytes { (b: UnsafeMutablePointer) in + return data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) -> String in + let b = buffer.baseAddress!.assumingMemoryBound(to: Int8.self) BRBase58Encode(b, len, selfBytes, self.count) return String(cString: b) } @@ -165,9 +169,9 @@ public extension Data { var sha1: Data { var data = Data(count: 20) - data.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) in - self.withUnsafeBytes { (selfBytes: UnsafePointer) in - BRSHA1(bytes, selfBytes, self.count) + data.withUnsafeMutableBytes { (bytes: UnsafeMutableRawBufferPointer) in + self.withUnsafeBytes { (selfBytes: UnsafeRawBufferPointer) in + BRSHA1(bytes.baseAddress, selfBytes.baseAddress, self.count) } } return data @@ -175,9 +179,9 @@ public extension Data { var sha256: Data { var data = Data(count: 32) - data.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) in - self.withUnsafeBytes { (selfBytes: UnsafePointer) in - BRSHA256(bytes, selfBytes, self.count) + data.withUnsafeMutableBytes { (bytes: UnsafeMutableRawBufferPointer) in + self.withUnsafeBytes { (selfBytes: UnsafeRawBufferPointer) in + BRSHA256(bytes.baseAddress, selfBytes.baseAddress, self.count) } } return data @@ -188,8 +192,8 @@ public extension Data { } var uInt256: UInt256 { - return withUnsafeBytes { (ptr: UnsafePointer) -> UInt256 in - ptr.pointee + return withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt256 in + buffer.load(as: UInt256.self) } } @@ -197,8 +201,8 @@ public extension Data { let offt = Int(offset) let size = MemoryLayout.size if count < offt + size { return 0 } - return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (ptr: UnsafePointer) -> UInt8 in - ptr.pointee + return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt8 in + buffer.load(as: UInt8.self) } } @@ -206,8 +210,8 @@ public extension Data { let offt = Int(offset) let size = MemoryLayout.size if count < offt + size { return 0 } - return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (ptr: UnsafePointer) -> UInt32 in - CFSwapInt32LittleToHost(ptr.pointee) + return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt32 in + CFSwapInt32LittleToHost(buffer.load(as: UInt32.self)) } } @@ -215,16 +219,18 @@ public extension Data { let offt = Int(offset) let size = MemoryLayout.size if count < offt + size { return 0 } - return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (ptr: UnsafePointer) -> UInt64 in - CFSwapInt64LittleToHost(ptr.pointee) + return subdata(in: offt ..< (offt + size)).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt64 in + CFSwapInt64LittleToHost(buffer.load(as: UInt64.self)) } } func compactSign(key: BRKey) -> Data { - return withUnsafeBytes { (_: UnsafePointer) -> Data in + return withUnsafeBytes { (_: UnsafeRawBufferPointer) -> Data in var data = Data(count: 65) var k = key - _ = data.withUnsafeMutableBytes { BRKeyCompactSign(&k, $0, 65, self.uInt256) } + _ = data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + BRKeyCompactSign(&k, buffer.baseAddress, 65, self.uInt256) + } return data } } @@ -234,9 +240,8 @@ public extension Data { gettimeofday(&tv, nil) var t = UInt64(tv.tv_usec) * 1_000_000 + UInt64(tv.tv_usec) let p = [UInt8](repeating: 0, count: 4) - return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes { (dat: UnsafePointer) -> [UInt8] in - let buf = UnsafeBufferPointer(start: dat, count: MemoryLayout.size) - return p + Array(buf) + return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> [UInt8] in + p + Array(buffer) } } @@ -272,9 +277,18 @@ public extension Data { var masterPubKey: BRMasterPubKey? { guard count >= (4 + 32 + 33) else { return nil } var mpk = BRMasterPubKey() - mpk.fingerPrint = subdata(in: 0 ..< 4).withUnsafeBytes { $0.pointee } - mpk.chainCode = subdata(in: 4 ..< (4 + 32)).withUnsafeBytes { $0.pointee } - mpk.pubKey = subdata(in: (4 + 32) ..< (4 + 32 + 33)).withUnsafeBytes { $0.pointee } + mpk.fingerPrint = subdata(in: 0 ..< 4).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt32 in + buffer.load(as: UInt32.self) + } + mpk.chainCode = subdata(in: 4 ..< (4 + 32)).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt256 in + buffer.load(as: UInt256.self) + } + let pubKeyBytes = subdata(in: (4 + 32) ..< (4 + 32 + 33)) + withUnsafeMutablePointer(to: &mpk.pubKey) { pubKeyPtr in + pubKeyPtr.withMemoryRebound(to: UInt8.self, capacity: 33) { bytePtr in + _ = pubKeyBytes.copyBytes(to: UnsafeMutableBufferPointer(start: bytePtr, count: 33)) + } + } return mpk } @@ -383,7 +397,9 @@ public extension BRKey { var k = self let len = BRKeyPubKey(&k, nil, 0) var data = Data(count: len) - BRKeyPubKey(&k, data.withUnsafeMutableBytes { (d: UnsafeMutablePointer) -> UnsafeMutablePointer in d }, len) + _ = data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + BRKeyPubKey(&k, buffer.baseAddress, len) + } return data } } diff --git a/brainwallet/Extensions/UIPickerView+Extension.swift b/brainwallet/Extensions/UIPickerView+Extension.swift new file mode 100644 index 000000000..1cb65a8fb --- /dev/null +++ b/brainwallet/Extensions/UIPickerView+Extension.swift @@ -0,0 +1,31 @@ +// +// UIPickerView+Extension.swift +// brainwallet +// +// Created by Kerry Washington on 18/08/2026. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// + +import UIKit + +/// `UIPickerView.appearance().backgroundColor` only clears the picker's own +/// top-level background - the wheel's visible row/selection fill is actually +/// painted by its internal subviews (an inner `UIPickerTableView` per component), +/// which the appearance proxy never touches. Clearing those subviews directly +/// is what actually removes the background SwiftUI's `.pickerStyle(.wheel)` shows. +extension UIPickerView { + override open func didMoveToWindow() { + super.didMoveToWindow() + clearSubviewBackgrounds() + } + + override open func layoutSubviews() { + super.layoutSubviews() + clearSubviewBackgrounds() + } + + private func clearSubviewBackgrounds() { + backgroundColor = .clear + subviews.forEach { $0.backgroundColor = .clear } + } +} diff --git a/brainwallet/Legacy_BW_BRClasses/BRAddressExtension.swift b/brainwallet/Legacy_BW_BRClasses/BRAddressExtension.swift index 4b62426b5..87aaab8c3 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRAddressExtension.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRAddressExtension.swift @@ -11,30 +11,35 @@ extension BRAddress: @retroactive CustomStringConvertible, @retroactive Hashable self.init() let cStr = [CChar](string.utf8CString) guard cStr.count <= MemoryLayout.size else { return nil } - UnsafeMutableRawPointer(mutating: &s) - .assumingMemoryBound(to: CChar.self).update(from: cStr, count: cStr.count) + withUnsafeMutableBytes(of: &s) { sBuffer in + sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self).update(from: cStr, count: cStr.count) + } } init?(scriptPubKey: [UInt8]) { self.init() - guard BRAddressFromScriptPubKey(UnsafeMutableRawPointer(mutating: &s) - .assumingMemoryBound(to: CChar.self), - MemoryLayout.size, scriptPubKey, scriptPubKey.count) > 0 - else { return nil } + let success = withUnsafeMutableBytes(of: &s) { sBuffer in + BRAddressFromScriptPubKey(sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self), + MemoryLayout.size, scriptPubKey, scriptPubKey.count) > 0 + } + guard success else { return nil } } init?(scriptSig: [UInt8]) { self.init() - guard BRAddressFromScriptSig(UnsafeMutableRawPointer(mutating: &s) - .assumingMemoryBound(to: CChar.self), - MemoryLayout.size, scriptSig, scriptSig.count) > 0 else { return nil } + let success = withUnsafeMutableBytes(of: &s) { sBuffer in + BRAddressFromScriptSig(sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self), + MemoryLayout.size, scriptSig, scriptSig.count) > 0 + } + guard success else { return nil } } var scriptPubKey: [UInt8]? { var script = [UInt8](repeating: 0, count: 25) - let count = BRAddressScriptPubKey(&script, script.count, - UnsafeRawPointer([s]) - .assumingMemoryBound(to: CChar.self)) + let count = withUnsafeBytes(of: s) { sBuffer in + BRAddressScriptPubKey(&script, script.count, + sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self)) + } guard count > 0 else { return nil } if count < script.count { script.removeSubrange(count...) } return script @@ -42,14 +47,17 @@ extension BRAddress: @retroactive CustomStringConvertible, @retroactive Hashable var hash160: UInt160? { var hash = UInt160() - guard BRAddressHash160(&hash, UnsafeRawPointer([s]) - .assumingMemoryBound(to: CChar.self)) != 0 - else { return nil } + let success = withUnsafeBytes(of: s) { sBuffer in + BRAddressHash160(&hash, sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self)) != 0 + } + guard success else { return nil } return hash } public var description: String { - return String(cString: UnsafeRawPointer([s]).assumingMemoryBound(to: CChar.self)) + return withUnsafeBytes(of: s) { sBuffer in + String(cString: sBuffer.baseAddress!.assumingMemoryBound(to: CChar.self)) + } } public var hashValue: Int { diff --git a/brainwallet/Legacy_BW_BRClasses/BRKeyExtension.swift b/brainwallet/Legacy_BW_BRClasses/BRKeyExtension.swift index 9e8d86219..b6b2070f2 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRKeyExtension.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRKeyExtension.swift @@ -44,7 +44,9 @@ extension BRKey { let count = BRKeyPrivKey(&self, nil, 0) var data = CFDataCreateMutable(secureAllocator, count) as Data data.count = count - guard data.withUnsafeMutableBytes({ BRKeyPrivKey(&self, $0, count) }) != 0 else { return nil } + guard data.withUnsafeMutableBytes({ (buffer: UnsafeMutableRawBufferPointer) in + BRKeyPrivKey(&self, buffer.baseAddress?.assumingMemoryBound(to: CChar.self), count) + }) != 0 else { return nil } return CFStringCreateFromExternalRepresentation(secureAllocator, data as CFData, CFStringBuiltInEncodings.UTF8.rawValue) as String } @@ -59,7 +61,9 @@ extension BRKey { let count = BRKeyBIP38Key(&self, nil, 0, nfcPhrase as String) var data = CFDataCreateMutable(secureAllocator, count) as Data data.count = count - guard data.withUnsafeMutableBytes({ BRKeyBIP38Key(&self, $0, count, nfcPhrase as String) }) != 0 + guard data.withUnsafeMutableBytes({ (buffer: UnsafeMutableRawBufferPointer) in + BRKeyBIP38Key(&self, buffer.baseAddress?.assumingMemoryBound(to: CChar.self), count, nfcPhrase as String) + }) != 0 else { return nil } return CFStringCreateFromExternalRepresentation(secureAllocator, data as CFData, CFStringBuiltInEncodings.UTF8.rawValue) as String diff --git a/brainwallet/Legacy_BW_BRClasses/BRMasterKeyExtension.swift b/brainwallet/Legacy_BW_BRClasses/BRMasterKeyExtension.swift index 28accea93..c8b253168 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRMasterKeyExtension.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRMasterKeyExtension.swift @@ -1,7 +1,7 @@ import BRCore import Foundation -extension BRMasterPubKey: Equatable { +extension BRMasterPubKey: @retroactive Equatable { public static func == (l: BRMasterPubKey, r: BRMasterPubKey) -> Bool { return l.fingerPrint == r.fingerPrint && l.chainCode == r.chainCode && l.pubKey == r.pubKey } diff --git a/brainwallet/Legacy_BW_BRClasses/BRReplicatedKVStore.swift b/brainwallet/Legacy_BW_BRClasses/BRReplicatedKVStore.swift index faae880fe..76d50fbf4 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRReplicatedKVStore.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRReplicatedKVStore.swift @@ -600,7 +600,7 @@ open class BRReplicatedKVStore: NSObject { do { _ = try self.setRemoteVersion(key: key, localVer: localVer, remoteVer: newRemoteVer) } catch let e where e is BRReplicatedKVStoreError { - return completionHandler(e as! BRReplicatedKVStoreError) + return completionHandler(e as? BRReplicatedKVStoreError) } catch { return completionHandler(.replicationError) } @@ -739,9 +739,8 @@ open class BRReplicatedKVStore: NSObject { gettimeofday(&tv, nil) var t = UInt64(tv.tv_usec) * 1_000_000 + UInt64(tv.tv_usec) let p = [UInt8](repeating: 0, count: 4) - return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes { (dat: UnsafePointer) -> [UInt8] in - let buf = UnsafeBufferPointer(start: dat, count: MemoryLayout.size) - return p + Array(buf) + return Data(bytes: &t, count: MemoryLayout.size).withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> [UInt8] in + p + Array(buffer) } } diff --git a/brainwallet/Legacy_BW_BRClasses/BRTxInputExtension.swift b/brainwallet/Legacy_BW_BRClasses/BRTxInputExtension.swift index d49059848..6beaba02b 100644 --- a/brainwallet/Legacy_BW_BRClasses/BRTxInputExtension.swift +++ b/brainwallet/Legacy_BW_BRClasses/BRTxInputExtension.swift @@ -3,7 +3,8 @@ import Foundation extension BRTxInput { var swiftAddress: String { - get { return String(cString: UnsafeRawPointer([address]).assumingMemoryBound(to: CChar.self)) } + get { return String(cString: UnsafeRawPointer([address]) + .assumingMemoryBound(to: CChar.self)) } set { BRTxInputSetAddress(&self, newValue) } } @@ -15,7 +16,8 @@ extension BRTxInput { } var swiftScript: [UInt8] { - get { return [UInt8](UnsafeBufferPointer(start: script, count: scriptLen)) } + get { return [UInt8](UnsafeBufferPointer(start: script, + count: scriptLen)) } set { BRTxInputSetScript(&self, newValue, newValue.count) } } } diff --git a/brainwallet/Localizations/Localizable.xcstrings b/brainwallet/Localizations/Localizable.xcstrings index ca046d27b..7c93f511e 100644 --- a/brainwallet/Localizations/Localizable.xcstrings +++ b/brainwallet/Localizations/Localizable.xcstrings @@ -1044,6 +1044,7 @@ } }, "%@ %lld" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5857,6 +5858,136 @@ } } }, + "BUY / RECEIVE" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "شراء / استقبال" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KAUFEN / EMPFANGEN" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "COMPRAR / RECIBIR" + } + }, + "fa-IR" : { + "stringUnit" : { + "state" : "translated", + "value" : "خرید / دریافت" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACHETER / RECEVOIR" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "खरीदें / प्राप्त करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "BELI / TERIMA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACQUISTA / RICEVI" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "購入 / 受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구매 / 수신" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "KOPEN / ONTVANGEN" + } + }, + "pa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ਖਰੀਦੋ / ਪ੍ਰਾਪਤ ਕਰੋ" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "KUP / ODBIERZ" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "COMPRAR / RECEBER" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "КУПИТЬ / ПОЛУЧИТЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "KÖP / MOTTA" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ซื้อ / รับ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SATINALMA / ALMA" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "КУПИТИ / ОТРИМАТИ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "购买 / 接收" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "購買 / 接收" + } + } + } + }, "Buy Gift Cards with LTC!" : { "localizations" : { "ar" : { @@ -8463,6 +8594,137 @@ } } }, + "COPY NEW ADDRESS" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نسخ العنوان الجديد" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "NEUE ADRESSE KOPIEREN" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "COPIAR NUEVA DIRECCIÓN" + } + }, + "fa-IR" : { + "stringUnit" : { + "state" : "translated", + "value" : "کپی آدرس جدید" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "COPIER NOUVELLE ADRESSE" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नया पता कॉपी करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "SALIN ALAMAT BARU" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "COPIA NUOVO INDIRIZZO" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しいアドレスをコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 주소 복사" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "NIEUW ADRES KOPIËREN" + } + }, + "pa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ਨਵਾਂ ਪਤਾ ਕਾਪੀ ਕਰੋ" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "KOPIUJ NOWY ADRES" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "COPIAR NOVO ENDEREÇO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОПИРОВАТЬ НОВЫЙ АДРЕС" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "KOPIERA NY ADRESS" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คัดลอกที่อยู่ใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "YENİ ADRESİ KOPYALA" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОПІЮВАТИ НОВУ АДРЕСУ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制新地址" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複製新地址" + } + } + } + }, "Copy Wallet Addresses" : { "localizations" : { "ar" : { @@ -13030,136 +13292,6 @@ } } }, - "GET LTC" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "احصل على LTC" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC erhalten" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "OBTENER LTC" - } - }, - "fa-IR" : { - "stringUnit" : { - "state" : "translated", - "value" : "دریافت LTC" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "OBTENIR LTC" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC प्राप्त करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "DAPATKAN LTC" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "OTTIENI LTC" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC を取得" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC 받기" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC ONTVANGEN" - } - }, - "pa" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC ਪ੍ਰਾਪਤ ਕਰੋ" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "OTRZYMAJ LTC" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "OBTER LTC" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ПОЛУЧИТЬ LTC" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "HÄMTA LTC" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รับ LTC" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "LTC AL" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ОТРИМАТИ LTC" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "获取 LTC" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "取得 LTC" - } - } - } - }, "Get some Litecoin for your Brainwallet" : { "localizations" : { "ar" : { @@ -21253,6 +21385,136 @@ } } }, + "POWERED BY MOONPAY" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مدعوم من MOONPAY" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "POWERED BY MOONPAY" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "IMPULSADO POR MOONPAY" + } + }, + "fa-IR" : { + "stringUnit" : { + "state" : "translated", + "value" : "پشتیبانی‌شده توسط MOONPAY" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PROPULSÉ PAR MOONPAY" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOONPAY द्वारा संचालित" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "DIDUKUNG OLEH MOONPAY" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "POWERED BY MOONPAY" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "POWERED BY MOONPAY" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOONPAY 제공" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AANGEDREVEN DOOR MOONPAY" + } + }, + "pa" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOONPAY ਦੁਆਰਾ ਸੰਚਾਲਿਤ" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WSPIERANE PRZEZ MOONPAY" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "FORNECIDO PELA MOONPAY" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "РАБОТАЕТ НА MOONPAY" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "DRIVS AV MOONPAY" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ขับเคลื่อนโดย MOONPAY" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MOONPAY TARAFINDAN DESTEKLENMEKTEDIR" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЗАБЕЗПЕЧЕНО MOONPAY" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 MOONPAY 提供支持" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 MOONPAY 提供支持" + } + } + } + }, "Re-Enter PIN" : { "extractionState" : "stale", "localizations" : { @@ -25815,136 +26077,6 @@ } } }, - "Set amount:" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحديد المبلغ:" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Betrag festlegen:" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Establecer monto:" - } - }, - "fa-IR" : { - "stringUnit" : { - "state" : "translated", - "value" : "مبلغ تعیین شده:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quantité réglée :" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "राशि निर्धारित करें:" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tetapkan jumlah:" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imposta importo:" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定量:" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "금액 설정:" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bedrag instellen:" - } - }, - "pa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਰਕਮ ਸੈੱਟ ਕਰੋ:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ustawiona kwota:" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Definir o valor:" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Установить сумму:" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ange belopp:" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ตั้งค่าจำนวน:" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Miktarı ayarla:" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Встановлена сума:" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设定金额:" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定金額:" - } - } - } - }, "Set app passcode" : { "localizations" : { "ar" : { @@ -34421,6 +34553,7 @@ } }, "via" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/brainwallet/MessageUIPresenter.swift b/brainwallet/MessageUIPresenter.swift deleted file mode 100644 index d367c0f2b..000000000 --- a/brainwallet/MessageUIPresenter.swift +++ /dev/null @@ -1,118 +0,0 @@ -import MessageUI -import UIKit - -class MessageUIPresenter: NSObject { - weak var presenter: UIViewController? - - func presentMailCompose(litecoinAddress: String, image: UIImage) { - presentMailCompose(string: "litecoin: \(litecoinAddress)", image: image) - } - - func presentMailCompose(bitcoinURL: String, image: UIImage) { - presentMailCompose(string: bitcoinURL, image: image) - } - - private func presentMailCompose(string: String, image: UIImage) { - guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } - originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes - UINavigationBar.appearance().titleTextAttributes = nil - let emailView = MFMailComposeViewController() - emailView.setMessageBody(string, isHTML: false) - if let data = image.pngData() { - emailView.addAttachmentData(data, mimeType: "image/png", fileName: "litecoinqr.png") - } - emailView.mailComposeDelegate = self - present(emailView) - } - - func presentFeedbackCompose() { - guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } - originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes - UINavigationBar.appearance().titleTextAttributes = nil - let emailView = MFMailComposeViewController() - emailView.setToRecipients([C.feedbackEmail]) - emailView.mailComposeDelegate = self - present(emailView) - } - - func presentSupportCompose() { - guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } - originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes - UINavigationBar.appearance().titleTextAttributes = nil - let emailView = MFMailComposeViewController() - emailView.setSubject("Brainwallet Support") - emailView.setToRecipients([C.supportEmail]) - emailView.setMessageBody(C.troubleshootingQuestions, isHTML: true) - emailView.mailComposeDelegate = self - present(emailView) - } - - func presentMailCompose(emailAddress: String) { - guard MFMailComposeViewController.canSendMail() else { showEmailUnavailableAlert(); return } - originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes - UINavigationBar.appearance().titleTextAttributes = nil - let emailView = MFMailComposeViewController() - emailView.setToRecipients([emailAddress]) - emailView.mailComposeDelegate = self - present(emailView) - } - - func presentMessageCompose(address: String, image: UIImage) { - presentMessage(string: "litecoin: \(address)", image: image) - } - - func presentMessageCompose(bitcoinURL: String, image: UIImage) { - presentMessage(string: bitcoinURL, image: image) - } - - private func presentMessage(string: String, image: UIImage) { - guard MFMessageComposeViewController.canSendText() else { showMessageUnavailableAlert(); return } - originalTitleTextAttributes = UINavigationBar.appearance().titleTextAttributes - UINavigationBar.appearance().titleTextAttributes = nil - let textView = MFMessageComposeViewController() - textView.body = string - if let data = image.pngData() { - textView.addAttachmentData(data, typeIdentifier: "public.image", filename: "litecoinqr.png") - } - textView.messageComposeDelegate = self - present(textView) - } - - fileprivate var originalTitleTextAttributes: [NSAttributedString.Key: Any]? - - private func present(_ viewController: UIViewController) { - presenter?.view.isFrameChangeBlocked = true - presenter?.present(viewController, animated: true, completion: {}) - } - - fileprivate func dismiss(_ viewController: UIViewController) { - UINavigationBar.appearance().titleTextAttributes = originalTitleTextAttributes - viewController.dismiss(animated: true, completion: { - self.presenter?.view.isFrameChangeBlocked = false - }) - } - - private func showEmailUnavailableAlert() { - let alert = UIAlertController(title: "S.ErrorMessages.emailUnavailableTitle" , message: "S.ErrorMessages.emailUnavailableMessage" , preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Ok" , style: .default, handler: nil)) - presenter?.present(alert, animated: true, completion: nil) - } - - private func showMessageUnavailableAlert() { - let alert = UIAlertController(title: "S.ErrorMessages.messagingUnavailableTitle" , message: "S.ErrorMessages.messagingUnavailableMessage" , preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Ok" , style: .default, handler: nil)) - presenter?.present(alert, animated: true, completion: nil) - } -} - -extension MessageUIPresenter: MFMailComposeViewControllerDelegate { - func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error _: Error?) { - dismiss(controller) - } -} - -extension MessageUIPresenter: MFMessageComposeViewControllerDelegate { - func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith _: MessageComposeResult) { - dismiss(controller) - } -} diff --git a/brainwallet/ModalPresenter+Extension.swift b/brainwallet/ModalPresenter+Extension.swift index 922391c3d..0e18dceeb 100644 --- a/brainwallet/ModalPresenter+Extension.swift +++ b/brainwallet/ModalPresenter+Extension.swift @@ -57,11 +57,7 @@ extension ModalPresenter { func presentAlert(_ type: AlertType, completion: @escaping () -> Void) { let alertView = AlertView(type: type) - guard let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first - else { - return - } - + guard let window = UIApplication.shared.currentKeyWindow else { return } let size = window.bounds.size window.addSubview(alertView) diff --git a/brainwallet/ModalPresenter.swift b/brainwallet/ModalPresenter.swift index 25aa5cd8a..028cf3a89 100644 --- a/brainwallet/ModalPresenter.swift +++ b/brainwallet/ModalPresenter.swift @@ -18,7 +18,6 @@ class ModalPresenter: Subscriber { let window: UIWindow let alertHeight: CGFloat = 260.0 let modalTransitionDelegate: ModalTransitionDelegate - let messagePresenter = MessageUIPresenter() let verifyPinTransitionDelegate = TransitioningDelegate() let noAuthApiClient: BWAPIClient var currentRequest: PaymentRequest? @@ -103,12 +102,7 @@ class ModalPresenter: Subscriber { func showLightWeightAlert(message: String) { let alert = LightWeightAlert(message: message) - - guard let view = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first - else { - return - } - + guard let view = UIApplication.shared.currentKeyWindow else { return } view.addSubview(alert) alert.constrain([ alert.centerXAnchor.constraint(equalTo: view.centerXAnchor), diff --git a/brainwallet/ModalTransitionDelegate.swift b/brainwallet/ModalTransitionDelegate.swift index c022f0d92..61cf44e65 100644 --- a/brainwallet/ModalTransitionDelegate.swift +++ b/brainwallet/ModalTransitionDelegate.swift @@ -62,6 +62,8 @@ class ModalTransitionDelegate: NSObject, Subscriber { break case .possible: break + @unknown default: + break } } @@ -79,7 +81,7 @@ extension ModalTransitionDelegate: UIViewControllerTransitioningDelegate { presentedViewController = presented return PresentModalAnimator(shouldCoverBottomGap: type == .regular, completion: { let panGr = UIPanGestureRecognizer(target: self, action: #selector(ModalTransitionDelegate.didUpdate(gr:))) - UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.removeGestureRecognizer(panGr) + UIApplication.shared.currentKeyWindow?.removeGestureRecognizer(panGr) self.panGestureRecognizer = panGr }) } diff --git a/brainwallet/New Main Classes/CoreModeView.swift b/brainwallet/New Main Classes/CoreModeView.swift index 0c9032ea8..e3fe7a2a0 100644 --- a/brainwallet/New Main Classes/CoreModeView.swift +++ b/brainwallet/New Main Classes/CoreModeView.swift @@ -31,8 +31,6 @@ struct CoreModeView: View { var body: some View { GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height NewMainView(viewModel: newMainViewModel, receiveViewModel: newReceiveViewModel) } } diff --git a/brainwallet/New Main Classes/NewMainView.swift b/brainwallet/New Main Classes/NewMainView.swift index 37de47f28..76ee7b8c4 100644 --- a/brainwallet/New Main Classes/NewMainView.swift +++ b/brainwallet/New Main Classes/NewMainView.swift @@ -120,7 +120,6 @@ struct NewMainView: View { var body: some View { GeometryReader { geometry in - let width = geometry.size.width let height = geometry.size.height let midBentoHeight = geometry.size.height let sheetContentHeight = height * 0.7 @@ -470,16 +469,20 @@ struct NewMainView: View { } } .sheet(isPresented: $userDidTapBuyReceive) { - BuyReceiveView(viewModel: newReceiveViewModel, isModalMode: true) + BuyReceiveView(viewModel: newReceiveViewModel, + isModalMode: true) .cornerRadius(bentoCornerRadius) .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationBackground(.ultraThinMaterial) } .sheet(isPresented: $newMainViewModel.shouldShowBuyReceive) { - BuyReceiveView(viewModel: newReceiveViewModel, isModalMode: true) + BuyReceiveView(viewModel: newReceiveViewModel, + isModalMode: true) .cornerRadius(bentoCornerRadius) .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationBackground(.ultraThinMaterial) } .sheet(isPresented: $newMainViewModel.shouldShowSocials) { WebView(url: socialsURL, scrollToSignup: .constant(false)) diff --git a/brainwallet/New Receive Classes/BuyReceiveView.swift b/brainwallet/New Receive Classes/BuyReceiveView.swift index 6bc008bba..ae3480a69 100644 --- a/brainwallet/New Receive Classes/BuyReceiveView.swift +++ b/brainwallet/New Receive Classes/BuyReceiveView.swift @@ -89,6 +89,7 @@ struct BuyReceiveView: View { @State private var pickedSegment = 1 + @State private var qrPlaceholder: UIImage = UIImage(systemName: "qrcode")! @@ -98,6 +99,8 @@ struct BuyReceiveView: View { let setAmountSize: CGFloat = 60.0 let modalCorner: CGFloat = 55.0 let buttonCorner: CGFloat = 26.0 + let presetCorner: CGFloat = 10.0 + let pickerRowHeight: CGFloat = 34.0 let headerFont: Font = .ibmPlexSansBold(size: 26.0) let liveQuoteFont: Font = .ibmPlexSansSemiBold(size: 25.0) let subHeaderFont: Font = .ibmPlexSansSemiBold(size: 17.0) @@ -113,14 +116,17 @@ struct BuyReceiveView: View { let viewName = "receive" - init(viewModel: NewReceiveViewModel, isModalMode: Bool?) { + init(viewModel: NewReceiveViewModel, + isModalMode: Bool?) { self.viewModel = viewModel self.isModalMode = isModalMode ?? false - UISegmentedControl.appearance().selectedSegmentTintColor = BrainwalletUIColor.surface UISegmentedControl.appearance().backgroundColor = BrainwalletUIColor.background UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(Color.primary)], for: .selected) UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(Color.secondary)], for: .normal) + /// The wheel currency picker's row/selection background is cleared by + /// UIPickerView+Extension.swift, which reaches its internal subviews - + /// the appearance proxy alone can't touch those. } func updateFiatAmounts() { @@ -131,6 +137,53 @@ struct BuyReceiveView: View { fiatMaxAmount = viewModel.fiatMaxAmount } + /// A single bordered, pill-style preset amount button (mirrors the Android layout's + /// row of "$21 / $210 / $29849 / Custom" chips, with a checkmark on the selected one). + @ViewBuilder + func presetButton(title: String, tag: Int) -> some View { + Button(action: { + pickedSegment = tag + userWantsCustomAmount = (tag == 3) + + if tag == 0 { + pickedAmount = fiatMinAmount + } else if tag == 1 { + pickedAmount = fiatTenXAmount + } else if tag == 2 { + pickedAmount = fiatMaxAmount + } + + if !userWantsCustomAmount { + updateFiatAmounts() + } + pickedAmountString = String(format: "%d", pickedAmount) + keyboardFocused = userWantsCustomAmount + }) { + HStack(spacing: 4.0) { + if pickedSegment == tag { + Image(systemName: "checkmark") + .font(.system(size: 11.0, weight: .bold)) + .foregroundColor(BrainwalletColor.content) + } + Text(title) + .font(detailFont) + .foregroundColor(BrainwalletColor.content) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .padding(.horizontal, 8.0) + .padding(.vertical, 10.0) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: presetCorner) + .stroke(pickedSegment == tag ? + BrainwalletColor.content : + BrainwalletColor.content.opacity(0.25), + lineWidth: pickedSegment == tag ? 1.5 : 1.0) + ) + } + } + var body: some View { GeometryReader { geometry in @@ -138,14 +191,18 @@ struct BuyReceiveView: View { let width = geometry.size.width let height = geometry.size.height - let modalWidth = geometry.size.width * 0.9 - - let modalReceiveViewHeight = height * 0.9 - let modalBuyViewHeight = height * 0.95 + let containerWidth = width * 0.9 + let containerHeight = height * 0.75 + let modalWidth = containerWidth * 0.94 ZStack { - BrainwalletColor.surface.edgesIgnoringSafeArea(.all) + /// Modal itself stays clear, lightly blurred over whatever sits behind the sheet. + /// A dialed-down VariableBlurView instead of `.ultraThinMaterial` + opacity, + /// since opacity fades the blur away entirely rather than thinning it. + VariableBlurView(intensity: 0.01) + .edgesIgnoringSafeArea(Edge.Set.all) + VStack { if userIsBuying { VStack { @@ -176,6 +233,19 @@ struct BuyReceiveView: View { .padding(.bottom, 5.0) } else { VStack { + + /// Header Group + ZStack { + Text("BUY / RECEIVE") + .font(.ibmPlexSansSemiBold(size: 16.0)) + .foregroundColor(BrainwalletColor.content) + .frame(maxWidth: .infinity, alignment: .center) + .padding(10.0) + } + .padding(.horizontal, 20.0) + .padding(.vertical, 10.0) + /// Header Group + /// Receive Address Group ReceiveAddressView(viewModel: viewModel, newAddress: $newAddress, @@ -187,17 +257,19 @@ struct BuyReceiveView: View { alignment: .top) .opacity(keyboardFocused ? 0 : 1) .padding(.top, 1.0) + + Divider() + .padding(.horizontal, 5.0) + .opacity(keyboardFocused ? 0 : 1) /// Receive Address Group /// Set Amount Group - HStack { - Spacer() + HStack(alignment: .center) { Picker("", selection: $pickedCurrency) { ForEach(viewModel.currencies, id: \.self) { - Text("\($0.code) (\($0.symbol))") - .font(subHeaderFont) - .foregroundColor(BrainwalletColor.content) + Text($0.code) + .font(.ibmPlexSansSemiBold(size: 19.0)) .padding(4.0) } } @@ -205,11 +277,33 @@ struct BuyReceiveView: View { updateFiatAmounts() } .pickerStyle(.wheel) - .frame(width: width * 0.3, height: 70, alignment: .center) + .frame(width: width * 0.3, height: pickerRowHeight * 3, alignment: .center) + .overlay( + /// Brackets the currently selected row with a hairline + /// above and below it, centered on the wheel's frame. + GeometryReader { pickerGeometry in + let pickerWidth = pickerGeometry.size.width + let centerY = pickerGeometry.size.height / 2.0 + + ZStack { + Rectangle() + .fill(BrainwalletColor.content.opacity(0.25)) + .frame(width: pickerWidth, height: 1.0) + .position(x: pickerWidth / 2.0, y: centerY - pickerRowHeight / 2.0) + + Rectangle() + .fill(BrainwalletColor.content.opacity(0.25)) + .frame(width: pickerWidth, height: 1.0) + .position(x: pickerWidth / 2.0, y: centerY + pickerRowHeight / 2.0) + } + } + .allowsHitTesting(false) + ) + .padding(10.0) VStack { Spacer() - Text(String(format: "%.3f Ł", quotedLTCAmount)) + Text(String(format: "%.3fŁ", quotedLTCAmount)) .font(liveQuoteFont) .kerning(0.3) .foregroundColor(BrainwalletColor.content) @@ -217,136 +311,111 @@ struct BuyReceiveView: View { Text("\(quotedTimestamp)") .font(lightDetailFont) + .textCase(.uppercase) .foregroundColor(BrainwalletColor.content) .frame(alignment: .leading) } .frame(height: 70, alignment: .center) - .padding(.trailing, 20.0) + .padding(10.0) .onChange(of: viewModel.quotedTimestamp) { _,newValue in quotedTimestamp = newValue quotedLTCAmount = viewModel.quotedLTCAmount } } - .frame(height: 85, alignment: .center) + .frame(height: pickerRowHeight * 5, alignment: .center) .blur(radius: didFetchData ? 3.0 : 0.0) - HStack { - Spacer() - Picker("", selection: $pickedSegment) { - Text("\(pickedCurrency.symbol) \(fiatMinAmount)") - .font(lightDetailFont) - .padding(8.0) - .tag(0) - Text("\(pickedCurrency.symbol) \(fiatTenXAmount)") - .font(lightDetailFont) - .padding(8.0) - .tag(1) - Text("\(pickedCurrency.symbol) \(fiatMaxAmount)") - .font(lightDetailFont) - .padding(8.0) - .tag(2) - } - .pickerStyle(.segmented) - .onChange(of: pickedSegment) { _,segmentTag in - - if segmentTag == 0 { - pickedAmount = fiatMinAmount - } else if segmentTag == 1 { - pickedAmount = fiatTenXAmount - } else { - pickedAmount = fiatMaxAmount - } - - updateFiatAmounts() - pickedAmountString = String(format: "%d", pickedAmount) - keyboardFocused = false - } - .frame(height: 85, alignment: .center) - .padding(.all, 10.0) + /// Preset amount chips: min / 10x / max / Custom + HStack(spacing: 10.0) { + presetButton(title: "\(pickedCurrency.symbol)\(fiatMinAmount)", tag: 0) + presetButton(title: "\(pickedCurrency.symbol)\(fiatTenXAmount)", tag: 1) + presetButton(title: "\(pickedCurrency.symbol)\(fiatMaxAmount)", tag: 2) + presetButton(title: "Custom", tag: 3) } - .frame(height: 44.0, alignment: .center) + .padding(.horizontal, 20.0) .blur(radius: didFetchData ? 3.0 : 0.0) - HStack { - Spacer() - Text("Set amount:") + + if userWantsCustomAmount { + HStack { + TextField(String(localized:" \(pickedCurrency.symbol) "), + text: $pickedAmountString) .font(subHeaderFont) - .foregroundColor(BrainwalletColor.content) - .padding(4.0) - TextField(String(localized:" \(pickedCurrency.symbol) "), - text: $pickedAmountString) - .font(subHeaderFont) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .focused($keyboardFocused) - .frame(width: 80, alignment: .center) - .onChange(of: pickedAmountString) { _,newValue in - if newValue.count > 6 { - pickedAmountString = "\(fiatMaxAmount)" + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .focused($keyboardFocused) + .frame(width: 80, alignment: .center) + .onChange(of: pickedAmountString) { _,newValue in + if newValue.count > 6 { + pickedAmountString = "\(fiatMaxAmount)" + } } - } - Spacer() - Button(action: { - pickedAmount = Int(pickedAmountString) ?? fiatTenXAmount - updateFiatAmounts() - keyboardFocused = false - }) { - HStack { - Text("Done") - .font(subHeaderFont) - .foregroundColor(BrainwalletColor.surface) - .padding(.all, 8.0) - Text("\(pickedCurrency.symbol)" + pickedAmountString) - .font(subHeaderFont) - .foregroundColor(BrainwalletColor.surface) - .padding(.all, 8.0) + Spacer() + Button(action: { + pickedAmount = Int(pickedAmountString) ?? fiatTenXAmount + updateFiatAmounts() + keyboardFocused = false + }) { + HStack { + Text("Done") + .font(subHeaderFont) + .foregroundColor(BrainwalletColor.surface) + .padding(.all, 8.0) + Text("\(pickedCurrency.symbol)" + pickedAmountString) + .font(subHeaderFont) + .foregroundColor(BrainwalletColor.surface) + .padding(.all, 8.0) + } + .background(BrainwalletColor.content) + .cornerRadius(8.0) } - .background(BrainwalletColor.content) - .cornerRadius(8.0) - } - .onAppear { - pickedAmountString = "\(fiatMinAmount)" } + .frame(height: 40.0, alignment: .center) + .padding(.all, 10.0) + .padding(.horizontal, 10.0) } - .frame(height: 40.0, alignment: .center) - .padding(.all, 10.0) - .blur(radius: didFetchData ? 3.0 : 0.0) /// Set Amount Group Spacer() - /// Get LTC Button Group + /// Buy LTC Button Group Button(action: { userIsBuying.toggle() viewModel.signAndFetchMoonPayUrl() }) { - HStack { - Text("GET LTC") + VStack(alignment: .center, spacing: 4.0) { + + Text("BUY LTC") .frame(width: 120, alignment: .center) .font(liveQuoteFont) - .foregroundColor(BrainwalletColor.content) - Text("via") - .font(subDetailFont) - .foregroundColor(BrainwalletColor.content) - Image("moonpay-logo-type") - .resizable() - .scaledToFit() - .frame(width: 120, alignment: .center) + .foregroundColor(BrainwalletColor.midnight) + + HStack(spacing: 6.0) { + Text("POWERED BY MOONPAY") + .font(subDetailFont) + .foregroundColor(BrainwalletColor.midnight) + + + Image("moonpay-symbol-prp") + .resizable() + .scaledToFit() + .frame(width: 14.0, height: 14.0) + } } - .frame(width: 300, height: modalCorner) - .background(BrainwalletColor.background) - .cornerRadius(modalCorner/4) - .padding(10.0) - .disabled(keyboardFocused ? true : false) + .frame(width: width * 0.9, height: buyButtonSize, alignment: .center) + .background(BrainwalletColor.lavender) + .cornerRadius(buttonCorner) + } - .frame(width: width * 0.4, height: modalCorner, alignment: .bottom) - .padding(.all, 10.0) - /// GET LTC Button Group + .disabled(keyboardFocused ? true : false) + .frame(width: width, alignment: .bottom) + /// Buy LTC Button Group } - .frame(width: width * 0.95, - height: (viewModel.canUserBuy && isExpanded) ? modalBuyViewHeight : modalReceiveViewHeight, + .frame(width: containerWidth, + height: containerHeight, alignment: .top) + .padding(.all, 16.0) .opacity(isExpanded ? 1.0 : 0.0) .background(BrainwalletColor.surface) .onAppear { @@ -369,6 +438,7 @@ struct BuyReceiveView: View { canUserBuy = viewModel.canUserBuy pickedCurrency = viewModel.pickedCurrency updateFiatAmounts() + pickedAmountString = "\(fiatMinAmount)" Analytics.logEvent("user_did_tap_buyreceive_sheet", parameters: nil) diff --git a/brainwallet/New Receive Classes/ReceiveAddressView.swift b/brainwallet/New Receive Classes/ReceiveAddressView.swift index 06dcc01c3..ee38becd0 100644 --- a/brainwallet/New Receive Classes/ReceiveAddressView.swift +++ b/brainwallet/New Receive Classes/ReceiveAddressView.swift @@ -25,6 +25,8 @@ struct ReceiveAddressView: View { @State private var didCopyAddress = false + let addressFont: Font = .ibmPlexSansBold(size: 17.0) + let labelFont: Font = .ibmPlexSansSemiBold(size: 14.0) let subDetailFont: Font = .ibmPlexSansRegular(size: 14.0) let lightDetailFont: Font = .ibmPlexSansLight(size: 18.0) let buttonFont: Font = .ibmPlexSansBold(size: 20.0) @@ -33,6 +35,7 @@ struct ReceiveAddressView: View { let opacityFactor: CGFloat = 0.8 let padding = 18.0 let minimumDragFactor: CGFloat = 250.0 + let copyIconSize: CGFloat = 34.0 init(viewModel: NewReceiveViewModel, newAddress: Binding, qrPlaceholder: Binding, keyboardFocused: FocusState.Binding) { self.viewModel = viewModel @@ -44,63 +47,65 @@ struct ReceiveAddressView: View { GeometryReader { geometry in let width = geometry.size.width let height = geometry.size.height - let qrWidth = geometry.size.width * 0.4 + let qrWidth = geometry.size.width * 0.5 ZStack { - HStack { - RoundedRectangle(cornerRadius: buttonCorner) - .foregroundColor(BrainwalletColor.content - .opacity(0.03)) - .frame(width: width, height: height, alignment: .center) - } - .frame(width: width, height: height, alignment: .center) - Button(action: { UIPasteboard.general.string = viewModel.newReceiveAddress didCopyAddress = true }) { + HStack(alignment: .top, spacing: 12.0) { + + ZStack { + RoundedRectangle(cornerRadius: buttonCorner / 4) + .foregroundColor(.white) + .frame(width: abs(qrWidth - padding), height: abs(qrWidth - padding)) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: buttonCorner / 4)) + Image(uiImage: viewModel.newReceiveAddressQR ?? qrPlaceholder) + .resizable() + .scaledToFit() + .frame(width: abs(qrWidth - padding)) + } + .frame(width: qrWidth, alignment: .top) - HStack { - VStack { - Spacer() + VStack(alignment: .leading, spacing: 10.0) { + Text(newAddress) + .font(.ibmPlexSansSemiBold(size: 23.0)) + .multilineTextAlignment(.leading) + .lineLimit(3) + .minimumScaleFactor(0.8) + .foregroundColor(BrainwalletColor.content) + + Spacer(minLength: 4.0) + Text("COPY NEW ADDRESS") + .font(labelFont) + .kerning(0.5) + .foregroundColor(BrainwalletColor.content.opacity(opacityFactor * 0.8)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) - ZStack { - RoundedRectangle(cornerRadius: buttonCorner / 4) - .foregroundColor(.white) - .frame(width: abs(qrWidth - padding), height: abs(qrWidth - padding)) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: buttonCorner / 4)) - Image(uiImage: viewModel.newReceiveAddressQR ?? qrPlaceholder) + ZStack { + Ellipse() + .stroke(BrainwalletColor.content.opacity(0.4), lineWidth: 1.0) + .frame(width: copyIconSize, height: copyIconSize) + Image(systemName: "doc.on.doc") .resizable() .scaledToFit() - .frame(width: abs(qrWidth - padding)) + .frame(width: 14.0, height: 14.0) + .foregroundColor(BrainwalletColor.content) } - Spacer() - - } - .frame(width: qrWidth, alignment: .trailing) - Spacer() - VStack { - Spacer() - Text(newAddress) - .font(lightDetailFont) - .multilineTextAlignment(.center) - .truncationMode(.middle) - .foregroundColor(BrainwalletColor.content.opacity(opacityFactor)) - .padding(.all, 8.0) - Text("COPY / SHARE") - .font(buttonFont) - .multilineTextAlignment(.center) - .foregroundColor(BrainwalletColor.content.opacity(opacityFactor)) - .padding(.all, 8.0) - Spacer() + .frame(maxWidth: .infinity, alignment: .center) + Spacer(minLength: 4.0) } - .frame(width: qrWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: viewModel.newReceiveAddress) { _,address in newAddress = address } } - .frame(width: width, height: height, alignment: .top) + .padding(.horizontal, 4.0) + .frame(width: width, alignment: .top) .opacity(keyboardFocused ? 0 : 1) } + .buttonStyle(.plain) .simultaneousGesture( DragGesture() .onChanged { value in diff --git a/brainwallet/New Receive Classes/ReceiveHostingController.swift b/brainwallet/New Receive Classes/ReceiveHostingController.swift index 3335c5fa0..030408dac 100644 --- a/brainwallet/New Receive Classes/ReceiveHostingController.swift +++ b/brainwallet/New Receive Classes/ReceiveHostingController.swift @@ -23,7 +23,8 @@ class BuyReceiveHostingController: UIHostingController { init(store: Store, walletManager: WalletManager, isModalMode: Bool) { self.isModalMode = isModalMode let viewModel = NewReceiveViewModel(store: store, walletManager: walletManager, canUserBuy: true) - super.init(rootView: BuyReceiveView(viewModel: viewModel, isModalMode: isModalMode)) + super.init(rootView: BuyReceiveView(viewModel: viewModel, + isModalMode: isModalMode)) viewModel.dismissReceiveModal = { [weak self] in self?.dismissBuyReceiveModal?() diff --git a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendConfirmView.swift b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendConfirmView.swift index f0ec3192e..b3d9ea973 100644 --- a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendConfirmView.swift +++ b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendConfirmView.swift @@ -112,7 +112,6 @@ struct BentoSendConfirmView: View { if viewModel.didVerifyPin() { debugPrint("didVerifyPin post: \(viewModel.pinDigits)\n") - var transactionError: TransactionCreationError? let biometricsMessage = "Authorize this transaction" /// Setup Transaction if let sender = newMainViewModel.sender, @@ -158,7 +157,6 @@ struct BentoSendConfirmView: View { }) } else { - transactionError = .invalidLTCAddress clearPINSettings() } diff --git a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendInitialView.swift b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendInitialView.swift index c03c0481a..3ec4616d9 100644 --- a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendInitialView.swift +++ b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendInitialView.swift @@ -379,7 +379,7 @@ struct BentoSendInitialView: View { return } sendLTCAddress = pasteboard - isSendInformationValid() + _ = isSendInformationValid() } .onChange(of: isLTCValueShown) { _,newValue in newMainViewModel.isLTCValueShown = newValue diff --git a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendModalView.swift b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendModalView.swift index 3b532e5cc..25f191fb0 100644 --- a/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendModalView.swift +++ b/brainwallet/New Send Classes/Send Views/BentoSendViews/BentoSendModalView.swift @@ -109,8 +109,6 @@ struct BentoSendModalView: View { var body: some View { GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height let subViewPad = 20.0 ZStack { backgroundColor.edgesIgnoringSafeArea(.all) diff --git a/brainwallet/PaymentProtocol.swift b/brainwallet/PaymentProtocol.swift index a40c417ed..f6c524615 100644 --- a/brainwallet/PaymentProtocol.swift +++ b/brainwallet/PaymentProtocol.swift @@ -81,7 +81,8 @@ class PaymentProtocolRequest { init?(version: UInt32 = 1, pkiType: String = "none", pkiData: [UInt8]? = nil, details: PaymentProtocolDetails, signature: [UInt8]? = nil) { guard details.isManaged else { return nil } // request must be able take over memory management of details - guard let cPointer = BRPaymentProtocolRequestNew(version, pkiType, pkiData, pkiData?.count ?? 0, details.cPointer, + guard let cPointer = BRPaymentProtocolRequestNew(version, + pkiType, pkiData, pkiData?.count ?? 0, details.cPointer, signature, signature?.count ?? 0) else { return nil } details.isManaged = false self.cPointer = cPointer @@ -96,7 +97,8 @@ class PaymentProtocolRequest { } var bytes: [UInt8] { - var bytes = [UInt8](repeating: 0, count: BRPaymentProtocolRequestSerialize(cPointer, nil, 0)) + var bytes = [UInt8](repeating: 0, + count: BRPaymentProtocolRequestSerialize(cPointer, nil, 0)) BRPaymentProtocolRequestSerialize(cPointer, &bytes, bytes.count) return bytes } @@ -111,7 +113,8 @@ class PaymentProtocolRequest { var pkiData: [UInt8]? { // depends on pkiType, optional guard cPointer.pointee.pkiData != nil else { return nil } - return [UInt8](UnsafeBufferPointer(start: cPointer.pointee.pkiData, count: cPointer.pointee.pkiDataLen)) + return [UInt8](UnsafeBufferPointer(start: cPointer.pointee.pkiData, + count: cPointer.pointee.pkiDataLen)) } var details: PaymentProtocolDetails { // required @@ -120,7 +123,8 @@ class PaymentProtocolRequest { var signature: [UInt8]? { // pki-dependent signature, optional guard cPointer.pointee.signature != nil else { return nil } - return [UInt8](UnsafeBufferPointer(start: cPointer.pointee.signature, count: cPointer.pointee.sigLen)) + return [UInt8](UnsafeBufferPointer(start: cPointer.pointee.signature, + count: cPointer.pointee.sigLen)) } var certs: [[UInt8]] { // array of DER encoded certificates @@ -128,8 +132,12 @@ class PaymentProtocolRequest { var idx = 0 while BRPaymentProtocolRequestCert(cPointer, nil, 0, idx) > 0 { - certs.append([UInt8](repeating: 0, count: BRPaymentProtocolRequestCert(cPointer, nil, 0, idx))) - BRPaymentProtocolRequestCert(cPointer, UnsafeMutablePointer(mutating: certs[idx]), certs[idx].count, idx) + var cert = [UInt8](repeating: 0, + count: BRPaymentProtocolRequestCert(cPointer, nil, 0, idx)) + cert.withUnsafeMutableBufferPointer { buffer in + _ = BRPaymentProtocolRequestCert(cPointer, buffer.baseAddress, buffer.count, idx) + } + certs.append(cert) idx = idx + 1 } @@ -137,8 +145,11 @@ class PaymentProtocolRequest { } var digest: [UInt8] { // hash of the request needed to sign or verify the request - let digest = [UInt8](repeating: 0, count: BRPaymentProtocolRequestDigest(cPointer, nil, 0)) - BRPaymentProtocolRequestDigest(cPointer, UnsafeMutablePointer(mutating: digest), digest.count) + var digest = [UInt8](repeating: 0, + count: BRPaymentProtocolRequestDigest(cPointer, nil, 0)) + digest.withUnsafeMutableBufferPointer { buffer in + _ = BRPaymentProtocolRequestDigest(cPointer, buffer.baseAddress, buffer.count) + } return digest } @@ -149,10 +160,9 @@ class PaymentProtocolRequest { var certs = [SecCertificate]() let policies = [SecPolicy](repeating: SecPolicyCreateBasicX509(), count: 1) var trust: SecTrust? - var trustResult = SecTrustResultType.invalid for c in self.certs { - if let cert = SecCertificateCreateWithData(nil, Data(bytes: c) as CFData) { certs.append(cert) } + if let cert = SecCertificateCreateWithData(nil, Data(c) as CFData) { certs.append(cert) } } if !certs.isEmpty { @@ -160,44 +170,47 @@ class PaymentProtocolRequest { } SecTrustCreateWithCertificates(certs as CFTypeRef, policies as CFTypeRef, &trust) - if let trust = trust { SecTrustEvaluate(trust, &trustResult) } // verify certificate chain + var trustError: CFError? + // verify certificate chain; SecTrustEvaluateWithError also subsumes what + // SecTrustCopyProperties used to be needed for below (a human-readable + // reason for the failure) + let isTrusted = trust.map { SecTrustEvaluateWithError($0, &trustError) } ?? false - // .unspecified indicates a positive result that wasn't decided by the user - guard trustResult == .unspecified || trustResult == .proceed + guard isTrusted else { errMsg = !certs.isEmpty ? "S.PaymentProtocol.Errors.untrustedCertificate" : "S.PaymentProtocol.Errors.missingCertificate" - if let trust = trust, let properties = SecTrustCopyProperties(trust) { - for prop in properties as! [[AnyHashable: Any]] { - if prop["type"] as? String != kSecPropertyTypeError as String { continue } - errMsg = errMsg! + " - " + (prop["value"] as! String) - break - } + if let trustError = trustError { + errMsg = errMsg! + " - " + (trustError as Error).localizedDescription } return false } - var status = errSecUnimplemented var pubKey: SecKey? - if let trust = trust { pubKey = SecTrustCopyPublicKey(trust) } + if let trust = trust { pubKey = SecTrustCopyKey(trust) } + var verifyError: Unmanaged? + var isSignatureValid = false if let pubKey = pubKey, let signature = signature { if pkiType == "x509+sha256" { - status = SecKeyRawVerify(pubKey, .PKCS1SHA256, digest, digest.count, signature, signature.count) + isSignatureValid = SecKeyVerifySignature(pubKey, .rsaSignatureDigestPKCS1v15SHA256, + Data(digest) as CFData, Data(signature) as CFData, &verifyError) } else if pkiType == "x509+sha1" { - status = SecKeyRawVerify(pubKey, .PKCS1SHA1, digest, digest.count, signature, signature.count) + isSignatureValid = SecKeyVerifySignature(pubKey, .rsaSignatureDigestPKCS1v15SHA1, + Data(digest) as CFData, Data(signature) as CFData, &verifyError) } } - guard status == errSecSuccess + guard isSignatureValid else { - if status == errSecUnimplemented { + if pkiType != "x509+sha256", pkiType != "x509+sha1" { errMsg = "S.PaymentProtocol.Errors.unsupportedSignatureType" print(errMsg!) } else { - errMsg = NSError(domain: NSOSStatusErrorDomain, code: Int(status)).localizedDescription - debugPrint(":::SecKeyRawVerify error: " + errMsg!) + let underlyingError = verifyError?.takeRetainedValue() + errMsg = (underlyingError as Error?)?.localizedDescription ?? "S.PaymentProtocol.Errors.unsupportedSignatureType" + debugPrint(":::SecKeyVerifySignature error: " + errMsg!) } return false diff --git a/brainwallet/ReachabilityMonitor.swift b/brainwallet/ReachabilityMonitor.swift index df9ab4b32..f9aa89516 100644 --- a/brainwallet/ReachabilityMonitor.swift +++ b/brainwallet/ReachabilityMonitor.swift @@ -1,47 +1,28 @@ import Foundation -import SystemConfiguration - -private func callback(reachability _: SCNetworkReachability, flags _: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) { - guard let info = info else { return } - let reachability = Unmanaged.fromOpaque(info).takeUnretainedValue() - reachability.notify() -} +import Network +/// Watches network reachability using the Network framework's path monitor — +/// replaces the deprecated SystemConfiguration SCNetworkReachability APIs. class ReachabilityMonitor { init() { - networkReachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "google.com") - start() - } - - var didChange: ((Bool) -> Void)? - - private var networkReachability: SCNetworkReachability? - private let reachabilitySerialQueue = DispatchQueue(label: "com.litecoin.reachabilityQueue") - - func notify() { - DispatchQueue.main.async { - self.didChange?(self.isReachable) + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.didChange?(path.status == .satisfied) + } } + monitor.start(queue: reachabilitySerialQueue) } - var isReachable: Bool { - return flags.contains(.reachable) + deinit { + monitor.cancel() } - private func start() { - var context = SCNetworkReachabilityContext() - context.info = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - guard let reachability = networkReachability else { return } - SCNetworkReachabilitySetCallback(reachability, callback, &context) - SCNetworkReachabilitySetDispatchQueue(reachability, reachabilitySerialQueue) - } + var didChange: ((Bool) -> Void)? - private var flags: SCNetworkReachabilityFlags { - var flags = SCNetworkReachabilityFlags(rawValue: 0) - if let reachability = networkReachability, withUnsafeMutablePointer(to: &flags, { SCNetworkReachabilityGetFlags(reachability, UnsafeMutablePointer($0)) }) == true { - return flags - } else { - return [] - } + private let monitor = NWPathMonitor() + private let reachabilitySerialQueue = DispatchQueue(label: "co.brainwallet.reachabilityQueue") + + var isReachable: Bool { + monitor.currentPath.status == .satisfied } } diff --git a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift index 1236e46bc..175724212 100644 --- a/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift +++ b/brainwallet/Settings Classes/SettingsExpandingBlockchainView.swift @@ -69,8 +69,8 @@ struct SettingsExpandingBlockchainView: View { SettingsLitecoinDetailView(willSync: $willSync) .transition(.opacity) .transition(.slide) - .animation(.easeInOut(duration: 0.3)) .frame(height: shouldExpandBlockchain ? 200 : 0.1) + .animation(.easeInOut(duration: 0.3), value: shouldExpandBlockchain) Spacer() } .alert(String(localized: "Sync with Blockchain?"), diff --git a/brainwallet/Signup Notification Classes/SignupAskView.swift b/brainwallet/Signup Notification Classes/SignupAskView.swift index f1b4e6f80..1b48a62c2 100644 --- a/brainwallet/Signup Notification Classes/SignupAskView.swift +++ b/brainwallet/Signup Notification Classes/SignupAskView.swift @@ -45,8 +45,6 @@ struct SignupAskView: View { } var body: some View { GeometryReader { geometry in - let width = geometry.size.width - ZStack { BrainwalletColor.midnight.edgesIgnoringSafeArea(.all) diff --git a/brainwallet/UIView+FrameChangeBlocking.swift b/brainwallet/UIView+FrameChangeBlocking.swift deleted file mode 100644 index 9d753dadc..000000000 --- a/brainwallet/UIView+FrameChangeBlocking.swift +++ /dev/null @@ -1,44 +0,0 @@ -import UIKit - -extension UIView { - private struct AssociatedKeys { - static var frameBlockedKey = "FrameBlockedKey" - } - - var isFrameChangeBlocked: Bool { - get { - guard let object = objc_getAssociatedObject(self, &AssociatedKeys.frameBlockedKey) as? Bool else { return false } - return object - } - - set { - objc_setAssociatedObject(self, &AssociatedKeys.frameBlockedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - static func swizzleSetFrame() { - guard self == UIView.self else { return } - - // This is now a way to do the equivalent of dispatch_once in swift 3 - let _: () = { - let originalSelector = #selector(setter: UIView.frame) - let swizzledSelector = #selector(UIView.requestSetFrame(_:)) - - let originalMethod = class_getInstanceMethod(self, originalSelector) - let swizzledMethod = class_getInstanceMethod(self, swizzledSelector) - - let didAddMethod = class_addMethod(self, originalSelector, method_getImplementation(swizzledMethod!), method_getTypeEncoding(swizzledMethod!)) - if didAddMethod { - class_replaceMethod(self, swizzledSelector, method_getImplementation(originalMethod!), method_getTypeEncoding(originalMethod!)) - } else { - method_exchangeImplementations(originalMethod!, swizzledMethod!) - } - - }() - } - - @objc func requestSetFrame(_ frame: CGRect) { - guard !isFrameChangeBlocked else { return } - requestSetFrame(frame) - } -} diff --git a/brainwallet/VariableBlurView.swift b/brainwallet/VariableBlurView.swift new file mode 100644 index 000000000..818492cc1 --- /dev/null +++ b/brainwallet/VariableBlurView.swift @@ -0,0 +1,60 @@ +// +// VariableBlurView.swift +// brainwallet +// +// Created by Kerry Washington on 18/08/2026. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// + +import SwiftUI +import UIKit + +/// SwiftUI's `.ultraThinMaterial` is the lightest of Apple's five fixed Material +/// presets - there's no way to dial its blur radius down further, and fading it +/// with `.opacity()` just erases the whole layer (blur + vibrancy) rather than +/// making the blur itself lighter. +/// +/// This wraps a `UIVisualEffectView` and drives its blur through a +/// `UIViewPropertyAnimator` stopped partway (`fractionComplete`), which yields a +/// continuously adjustable blur strength anywhere from 0 (no blur) to 1 (full +/// system blur) using only public API. +private final class VariableBlurUIView: UIVisualEffectView { + + private var animator: UIViewPropertyAnimator? + + init(intensity: CGFloat, style: UIBlurEffect.Style) { + super.init(effect: nil) + + let animator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in + self?.effect = UIBlurEffect(style: style) + } + animator.fractionComplete = intensity + self.animator = animator + } + + func update(intensity: CGFloat) { + animator?.fractionComplete = intensity + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// A SwiftUI blur view whose strength can be set to any value between 0 and 1, +/// instead of being limited to Apple's fixed `.ultraThinMaterial` ... `.ultraThickMaterial` steps. +struct VariableBlurView: UIViewRepresentable { + + /// 0 = no blur (fully see-through), 1 = the system's full-strength blur for `style`. + var intensity: CGFloat + var style: UIBlurEffect.Style = .systemUltraThinMaterial + + func makeUIView(context _: Context) -> UIVisualEffectView { + VariableBlurUIView(intensity: intensity, style: style) + } + + func updateUIView(_ uiView: UIVisualEffectView, context _: Context) { + (uiView as? VariableBlurUIView)?.update(intensity: intensity) + } +} diff --git a/brainwallet/WalletCoordinator.swift b/brainwallet/WalletCoordinator.swift index 29639b9b9..f6553b648 100644 --- a/brainwallet/WalletCoordinator.swift +++ b/brainwallet/WalletCoordinator.swift @@ -257,36 +257,35 @@ class WalletCoordinator: Subscriber { } private func addWalletObservers() { - weak var myself = self - NotificationCenter.default.addObserver(forName: .walletBalanceChangedNotification, object: nil, queue: nil, using: { + NotificationCenter.default.addObserver(forName: .walletBalanceChangedNotification, object: nil, queue: nil, using: { [weak self] _ in - myself?.updateBalance() - myself?.requestTxUpdate() + self?.updateBalance() + self?.requestTxUpdate() }) - NotificationCenter.default.addObserver(forName: .walletTxStatusUpdateNotification, object: nil, queue: nil, using: { _ in - myself?.requestTxUpdate() + NotificationCenter.default.addObserver(forName: .walletTxStatusUpdateNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.requestTxUpdate() }) - NotificationCenter.default.addObserver(forName: .walletTxRejectedNotification, object: nil, queue: nil, using: { note in + NotificationCenter.default.addObserver(forName: .walletTxRejectedNotification, object: nil, queue: nil, using: { [weak self] note in guard let recommendRescan = note.userInfo?["recommendRescan"] as? Bool else { return } - myself?.requestTxUpdate() + self?.requestTxUpdate() if recommendRescan { - myself?.store.perform(action: RecommendRescan.set(recommendRescan)) + self?.store.perform(action: RecommendRescan.set(recommendRescan)) } }) - NotificationCenter.default.addObserver(forName: .walletSyncStartedNotification, object: nil, queue: nil, using: { _ in - myself?.onSyncStart() - myself?.updateTransactions() + NotificationCenter.default.addObserver(forName: .walletSyncStartedNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.onSyncStart() + self?.updateTransactions() }) - NotificationCenter.default.addObserver(forName: .walletSyncStoppedNotification, object: nil, queue: nil, using: { note in - myself?.onSyncStop(notification: note) + NotificationCenter.default.addObserver(forName: .walletSyncStoppedNotification, object: nil, queue: nil, using: { [weak self] note in + self?.onSyncStop(notification: note) }) - NotificationCenter.default.addObserver(forName: .languageChangedNotification, object: nil, queue: nil, using: { _ in - myself?.updateTransactions() + NotificationCenter.default.addObserver(forName: .languageChangedNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.updateTransactions() }) // Foreground/background transitions bound the foreground-sync-duration @@ -294,12 +293,12 @@ class WalletCoordinator: Subscriber { // keep syncing for a few seconds into the background (until the // background task's expiration handler disconnects it), and we don't // want that grace period counted as "foreground" time. - NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil, using: { _ in - myself?.resumeSyncSegmentIfNeeded() + NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.resumeSyncSegmentIfNeeded() }) - NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil, using: { _ in - myself?.pauseSyncSegment() + NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.pauseSyncSegment() }) } @@ -353,7 +352,14 @@ class WalletCoordinator: Subscriber { private func showLocalNotification(message: String) { guard UIApplication.shared.applicationState == .background || UIApplication.shared.applicationState == .inactive else { return } guard store.state.isPushNotificationsEnabled else { return } - UIApplication.shared.applicationIconBadgeNumber = UIApplication.shared.applicationIconBadgeNumber + 1 + + let newBadgeCount = UserDefaults.pendingNotificationBadgeCount + 1 + UserDefaults.pendingNotificationBadgeCount = newBadgeCount + UNUserNotificationCenter.current().setBadgeCount(newBadgeCount) { error in + if let error = error { + debugPrint("Failed to set badge count: \(error.localizedDescription)") + } + } // Create and schedule the notification let content = UNMutableNotificationContent() diff --git a/brainwallet/WalletManager+Auth.swift b/brainwallet/WalletManager+Auth.swift index 83070a535..41b3e1807 100644 --- a/brainwallet/WalletManager+Auth.swift +++ b/brainwallet/WalletManager+Auth.swift @@ -67,7 +67,9 @@ extension WalletManager: WalletAuthenticator { var earliestKeyTime = BIP39CreationTime if let creationTime: Data = try keychainItem(key: KeychainKey.creationTime), creationTime.count == MemoryLayout.stride { - creationTime.withUnsafeBytes { earliestKeyTime = $0.pointee } + creationTime.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in + earliestKeyTime = buffer.load(as: TimeInterval.self) + } } try self.init(masterPubKey: masterPubKey, @@ -390,15 +392,27 @@ extension WalletManager: WalletAuthenticator { // wrapping in an autorelease pool ensures sensitive memory is wiped and released immediately return autoreleasepool { var entropy = UInt128() - let entropyRef = UnsafeMutableRawPointer(mutating: &entropy).assumingMemoryBound(to: UInt8.self) - guard SecRandomCopyBytes(kSecRandomDefault, MemoryLayout.size, entropyRef) == 0 - else { return nil } - let phraseLen = BRBIP39Encode(nil, 0, &words, entropyRef, MemoryLayout.size) - var phraseData = CFDataCreateMutable(secureAllocator, phraseLen) as Data - phraseData.count = phraseLen - guard phraseData.withUnsafeMutableBytes({ - BRBIP39Encode($0, phraseLen, &words, entropyRef, MemoryLayout.size) - }) == phraseData.count else { return nil } + var phraseData = Data() + + // entropyRef is only valid for the lifetime of this closure -- every use of it + // (SecRandomCopyBytes and both BRBIP39Encode calls) has to happen inside here. + let encodeSucceeded: Bool = withUnsafeMutableBytes(of: &entropy) { entropyBuffer in + let entropyRef = entropyBuffer.baseAddress!.assumingMemoryBound(to: UInt8.self) + guard SecRandomCopyBytes(kSecRandomDefault, MemoryLayout.size, entropyRef) == 0 + else { return false } + + let phraseLen = BRBIP39Encode(nil, 0, &words, entropyRef, MemoryLayout.size) + var data = CFDataCreateMutable(secureAllocator, phraseLen) as Data + data.count = phraseLen + guard data.withUnsafeMutableBytes({ (buffer: UnsafeMutableRawBufferPointer) in + BRBIP39Encode(buffer.baseAddress?.assumingMemoryBound(to: CChar.self), phraseLen, &words, entropyRef, MemoryLayout.size) + }) == data.count else { return false } + + phraseData = data + return true + } + guard encodeSucceeded else { return nil } + entropy = UInt128() let phrase = CFStringCreateFromExternalRepresentation(secureAllocator, phraseData as CFData, CFStringBuiltInEncodings.UTF8.rawValue) as String @@ -466,7 +480,7 @@ extension WalletManager: WalletAuthenticator { // KV store — guard against nil authKey (the crash site) do { - if let kv = try? BWAPIClient(authenticator: self).kv { + if let kv = BWAPIClient(authenticator: self).kv { try kv.rmdb() } } catch let error { @@ -534,7 +548,9 @@ extension WalletManager: WalletAuthenticator { let pkLen = BRKeyPrivKey(&key, nil, 0) var pkData = CFDataCreateMutable(secureAllocator, pkLen) as Data pkData.count = pkLen - guard pkData.withUnsafeMutableBytes({ BRKeyPrivKey(&key, $0, pkLen) }) == pkLen else { return nil } + guard pkData.withUnsafeMutableBytes({ (buffer: UnsafeMutableRawBufferPointer) in + BRKeyPrivKey(&key, buffer.baseAddress?.assumingMemoryBound(to: CChar.self), pkLen) + }) == pkLen else { return nil } let privKey = CFStringCreateFromExternalRepresentation(secureAllocator, pkData as CFData, CFStringBuiltInEncodings.UTF8.rawValue) as String try setKeychainItem(key: KeychainKey.apiAuthKey, item: privKey) @@ -624,9 +640,16 @@ private func keychainItem(key: String) throws -> T? { CFStringBuiltInEncodings.UTF8.rawValue) as? T case is Int64.Type: guard data.count == MemoryLayout.stride else { return nil } - return data.withUnsafeBytes { $0.pointee } + return data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> T in + buffer.load(as: T.self) + } case is [AnyHashable: Any].Type: - return NSKeyedUnarchiver.unarchiveObject(with: data) as? T + // Allowlist of plist-compatible classes userAccount's dictionary can actually + // contain -- unarchivedObject(ofClasses:from:) refuses to instantiate anything + // outside this set, unlike the deprecated unarchiveObject(with:) it replaces. + let allowedClasses: [AnyClass] = [NSDictionary.self, NSArray.self, NSString.self, + NSNumber.self, NSDate.self, NSData.self, NSNull.self] + return try NSKeyedUnarchiver.unarchivedObject(ofClasses: allowedClasses, from: data) as? T default: throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecParam)) } @@ -645,13 +668,13 @@ private func setKeychainItem(key: String, item: T?, authenticated: Bool = fal case is Data.Type: data = item as? Data case is String.Type: - data = CFStringCreateExternalRepresentation(secureAllocator, item as! CFString, + data = CFStringCreateExternalRepresentation(secureAllocator, (item as! CFString), CFStringBuiltInEncodings.UTF8.rawValue, 0) as Data case is Int64.Type: data = CFDataCreateMutable(secureAllocator, MemoryLayout.stride) as Data [item].withUnsafeBufferPointer { data?.append($0) } case is [AnyHashable: Any].Type: - data = NSKeyedArchiver.archivedData(withRootObject: item) + data = try NSKeyedArchiver.archivedData(withRootObject: item, requiringSecureCoding: true) default: throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecParam)) } diff --git a/brainwallet/WalletManager.swift b/brainwallet/WalletManager.swift index 9d8252b66..949d1b607 100644 --- a/brainwallet/WalletManager.swift +++ b/brainwallet/WalletManager.swift @@ -1,7 +1,6 @@ import BRCore import Foundation import SQLite3 -import SystemConfiguration import FirebaseAnalytics import FirebaseCrashlytics @@ -33,6 +32,11 @@ class WalletManager: BRWalletListener, BRPeerManagerListener { var userPreferredfpRate: Double = FalsePositiveRates.semiPrivate.rawValue + // Long-lived so networkIsReachable() -- called synchronously from the C + // peer-manager's networkIsReachable callback -- can just read the + // monitor's current path instead of spinning up a new one per call. + private let reachability = ReachabilityMonitor() + static let sharedInstance: WalletManager = { var instance: WalletManager? do { @@ -530,13 +534,7 @@ class WalletManager: BRWalletListener, BRPeerManagerListener { } func networkIsReachable() -> Bool { - var flags: SCNetworkReachabilityFlags = [] - var zeroAddress = sockaddr() - zeroAddress.sa_len = UInt8(MemoryLayout.size) - zeroAddress.sa_family = sa_family_t(AF_INET) - guard let reachability = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { return false } - if !SCNetworkReachabilityGetFlags(reachability, &flags) { return false } - return flags.contains(.reachable) && !flags.contains(.connectionRequired) + return reachability.isReachable } private func loadTransactions() -> [BRTxRef?] { diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh index 354729e5b..99d3766a9 100755 --- a/ci_scripts/ci_post_clone.sh +++ b/ci_scripts/ci_post_clone.sh @@ -10,8 +10,7 @@ RESOURCES_DIR="$CI_PRIMARY_REPOSITORY_PATH/brainwallet/PreLaunchResources" # Write the files to the correct location. # These env vars are base64-encoded in Xcode Cloud's environment variable -# settings (App Store Connect), same as their CircleCI counterparts in -# .circleci/config.yml's "Setup environment files" step -- keep both in sync. +# settings (App Store Connect). echo "$GOOGLE_SERVICES_PLIST" | base64 --decode > "$RESOURCES_DIR/GoogleService-Info.plist" echo "$REMOTE_CONFIG_DEFAULTS" | base64 --decode > "$RESOURCES_DIR/remote-config-defaults.plist" echo "$DEBUG_SERVICE_DATA" | base64 --decode > "$RESOURCES_DIR/service-data.plist" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 477004b54..7f19e2733 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -5,7 +5,6 @@ default_platform :ios platform :ios do before_all do - setup_circle_ci update_fastlane end diff --git a/scripts/test_i18n_coverage.py b/scripts/test_i18n_coverage.py index 3698bd314..d021545d8 100755 --- a/scripts/test_i18n_coverage.py +++ b/scripts/test_i18n_coverage.py @@ -203,7 +203,7 @@ def main(): print(f"\n{'─' * 62}\n") - # Write a plain-text copy for the CircleCI artifact store + # Write a plain-text copy for the CI artifact store (GitHub Actions) artifact_path = Path("/tmp/i18n_coverage_report.txt") try: with open(artifact_path, "w", encoding="utf-8") as f: diff --git a/scripts/translate_xcstrings.py b/scripts/translate_xcstrings.py index 163ddc0e1..aadd9ff6e 100755 --- a/scripts/translate_xcstrings.py +++ b/scripts/translate_xcstrings.py @@ -87,10 +87,15 @@ def load_xcstrings(path: Path) -> dict: def save_xcstrings(data: dict, path: Path): - """Write back with the same compact-ish formatting Xcode uses.""" + """Write back with the same formatting Xcode uses. + + Xcode's String Catalog serializer puts a space before every colon + ("key" : value), which is not json.dump's default ("key": value). Without + matching that, every single line of the file changes on every run, even + when the only real change is a couple of new translations. + """ with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - f.write("\n") + json.dump(data, f, ensure_ascii=False, indent=2, separators=(",", " : ")) def is_passthrough(key: str, value: str) -> bool: