diff --git a/.coverage-floor-functions b/.coverage-floor-functions new file mode 100644 index 00000000..e373ee69 --- /dev/null +++ b/.coverage-floor-functions @@ -0,0 +1 @@ +50 diff --git a/.coverage-floor-lines b/.coverage-floor-lines new file mode 100644 index 00000000..29d6383b --- /dev/null +++ b/.coverage-floor-lines @@ -0,0 +1 @@ +100 diff --git a/.github/workflows/auto-release-pr.yaml b/.github/workflows/auto-release-pr.yaml index cbb705b6..26ad470d 100644 --- a/.github/workflows/auto-release-pr.yaml +++ b/.github/workflows/auto-release-pr.yaml @@ -17,6 +17,7 @@ jobs: create-release-pr: name: Create Release PR runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v4 @@ -29,6 +30,7 @@ jobs: - name: Check for existing PR id: check-pr run: | + set -euo pipefail PR_COUNT=$(gh pr list --base main --head develop --state open --json number --jq 'length') echo "pr_exists=$([[ $PR_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT echo "::notice::Open PRs from develop to main: $PR_COUNT" @@ -39,6 +41,7 @@ jobs: id: check-diff if: steps.check-pr.outputs.pr_exists == 'false' run: | + set -euo pipefail DIFF_COUNT=$(git rev-list --count origin/main..origin/develop) echo "has_changes=$([[ $DIFF_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT echo "commit_count=$DIFF_COUNT" >> $GITHUB_OUTPUT @@ -50,6 +53,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} COMMIT_COUNT: ${{ steps.check-diff.outputs.commit_count }} run: | + set -euo pipefail printf '%s\n' \ "## Automatic Release PR" \ "" \ diff --git a/.github/workflows/auto-staging-pr.yaml b/.github/workflows/auto-staging-pr.yaml new file mode 100644 index 00000000..1616ddb3 --- /dev/null +++ b/.github/workflows/auto-staging-pr.yaml @@ -0,0 +1,74 @@ +name: Auto Staging PR + +on: + push: + branches: [staging] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: auto-staging-pr + cancel-in-progress: false + +jobs: + create-staging-pr: + name: Create Staging PR + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch develop branch + run: git fetch origin develop + + - name: Check for existing PR + id: check-pr + run: | + set -euo pipefail + PR_COUNT=$(gh pr list --base develop --head staging --state open --json number --jq 'length') + echo "pr_exists=$([[ $PR_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "::notice::Open PRs from staging to develop: $PR_COUNT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for differences + id: check-diff + if: steps.check-pr.outputs.pr_exists == 'false' + run: | + set -euo pipefail + DIFF_COUNT=$(git rev-list --count origin/develop..origin/staging) + echo "has_changes=$([[ $DIFF_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "commit_count=$DIFF_COUNT" >> $GITHUB_OUTPUT + echo "::notice::Commits ahead of develop: $DIFF_COUNT" + + - name: Create Staging PR + if: steps.check-pr.outputs.pr_exists == 'false' && steps.check-diff.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_COUNT: ${{ steps.check-diff.outputs.commit_count }} + run: | + set -euo pipefail + printf '%s\n' \ + "## Automatic Staging PR" \ + "" \ + "This PR was automatically created after changes were pushed to staging." \ + "" \ + "**Commits:** ${COMMIT_COUNT} new commit(s)" \ + "" \ + "### Checklist" \ + "- [ ] Review all changes" \ + "- [ ] Verify CI passes" \ + "- [ ] Approve and merge to promote into develop" \ + > /tmp/pr-body.md + + gh pr create \ + --base develop \ + --head staging \ + --title "Promote: staging -> develop" \ + --body-file /tmp/pr-body.md diff --git a/.github/workflows/auto-tag.yaml b/.github/workflows/auto-tag.yaml index 114d6d95..d6abdb68 100644 --- a/.github/workflows/auto-tag.yaml +++ b/.github/workflows/auto-tag.yaml @@ -2,7 +2,7 @@ name: Auto Tag on Merge on: push: - branches: [main, develop] + branches: [develop] permissions: contents: write @@ -15,6 +15,7 @@ jobs: create-tag: name: Create Release Tag runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v4 @@ -28,13 +29,22 @@ jobs: ssh-private-key: ${{ secrets.TAG_DEPLOY_KEY }} - name: Configure Git for SSH - run: git remote set-url origin git@github.com:DFXswiss/realunit-app.git + # `${{ github.repository }}` resolves to `/` for the + # currently running workflow — keeps the remote correct if the repo + # is renamed or transferred between orgs, without a follow-up edit. + run: git remote set-url origin git@github.com:${{ github.repository }}.git - name: Get latest tag id: get-tag run: | - # Get only clean semver tags (vX.Y.Z), exclude pre-release tags - LATEST_TAG=$(git tag -l --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + set -euo pipefail + # Only consider clean semver tags (vX.Y.Z). Legacy beta tags + # (vX.Y.Z-beta.N) are ignored — they were superseded by the + # plain-SemVer schema. The trailing `|| true` keeps the + # pipeline tolerant of an empty grep under `pipefail`: a + # fresh repo with zero matching tags is a legitimate state + # that the next `[ -z ... ]` branch handles explicitly. + LATEST_TAG=$(git tag -l --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true) if [ -z "$LATEST_TAG" ]; then echo "No existing tags found, starting with v0.0.0" @@ -47,8 +57,8 @@ jobs: - name: Calculate next version id: next-version run: | + set -euo pipefail LATEST_TAG="${{ steps.get-tag.outputs.latest_tag }}" - BRANCH="${{ github.ref_name }}" # Remove 'v' prefix and split into parts VERSION="${LATEST_TAG#v}" @@ -56,32 +66,33 @@ jobs: MINOR=$(echo "$VERSION" | cut -d. -f2) PATCH=$(echo "$VERSION" | cut -d. -f3) - # Next release version + # Default: patch-bump on top of latest tag. NEW_PATCH=$((PATCH + 1)) NEXT_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" - if [ "$BRANCH" = "main" ]; then - NEW_TAG="$NEXT_VERSION" - else - # For develop: find latest beta tag for the next version - LATEST_BETA=$(git tag -l --sort=-v:refname "${NEXT_VERSION}-beta.*" | head -n 1) - - if [ -z "$LATEST_BETA" ]; then - NEW_TAG="${NEXT_VERSION}-beta.1" - else - # Extract beta number and increment - BETA_NUM=$(echo "$LATEST_BETA" | sed "s/${NEXT_VERSION}-beta\.//") - NEW_BETA=$((BETA_NUM + 1)) - NEW_TAG="${NEXT_VERSION}-beta.${NEW_BETA}" - fi + # Floor for major/minor jumps: when pubspec.yaml is ahead of the + # patch-bumped tag, use pubspec instead. Patch increments still come + # from the tag history; pubspec is only consulted to trigger jumps. + # `|| true` keeps the pipeline tolerant of an empty grep under + # `pipefail`: a pubspec without a `version:` line is a legitimate + # state that the next `[ -n ... ]` branch handles explicitly. + PUBSPEC_VERSION=$(grep -E '^version:[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+' pubspec.yaml \ + | awk '{print $2}' | cut -d+ -f1 || true) + if [ -n "$PUBSPEC_VERSION" ]; then + HIGHEST=$(printf '%s\n%s\n' "${NEXT_VERSION#v}" "$PUBSPEC_VERSION" | sort -V | tail -n 1) + NEXT_VERSION="v${HIGHEST}" + echo "::notice::pubspec marketing version: $PUBSPEC_VERSION; resolved next: $NEXT_VERSION" fi + NEW_TAG="$NEXT_VERSION" + echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT echo "::notice::New tag: $NEW_TAG" - name: Check if tag exists id: check-tag run: | + set -euo pipefail NEW_TAG="${{ steps.next-version.outputs.new_tag }}" if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then echo "::error::Tag $NEW_TAG already exists!" @@ -93,6 +104,7 @@ jobs: - name: Create and push tag if: steps.check-tag.outputs.exists == 'false' run: | + set -euo pipefail NEW_TAG="${{ steps.next-version.outputs.new_tag }}" git config user.name "github-actions[bot]" diff --git a/.github/workflows/beta-release-android.yaml b/.github/workflows/beta-release-android.yaml deleted file mode 100644 index f41a66fa..00000000 --- a/.github/workflows/beta-release-android.yaml +++ /dev/null @@ -1,96 +0,0 @@ -name: Android Beta Release - -on: - workflow_dispatch: - push: - tags: - - "android/v*" - -jobs: - android-deploy: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v5 - - - name: Set up Java - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - - - uses: android-actions/setup-android@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.41.6" - channel: "stable" - cache: true - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.25.5" - - - name: Init GoMobile - run: | - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init - working-directory: ${{ github.workspace }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.4" - bundler-cache: true - working-directory: android - - - name: Install Bundler - run: gem install bundler -v 2.7.2 - - - name: Install Gems - run: bundle _2.7.2_ install - working-directory: android - - - name: Decode Secrets - env: - PLAY_STORE_JSON: ${{ secrets.PLAY_STORE_JSON_BASE64 }} - ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - run: | - echo "$PLAY_STORE_JSON" | base64 --decode > android/credentials.json - echo "$ANDROID_KEYSTORE" | base64 --decode > android/app/upload-keystore.jks - - - name: Create key.properties - env: - STORE_PWD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - KEY_PWD: ${{ secrets.ANDROID_KEY_PASSWORD }} - KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - run: | - cat < android/key.properties - storeFile=upload-keystore.jks - storePassword=$STORE_PWD - keyAlias=$KEY_ALIAS - keyPassword=$KEY_PWD - EOF - - - name: Install Flutter Dependencies - run: | - flutter pub get - dart run tool/generate_localization.dart - dart run build_runner build --delete-conflicting-outputs - working-directory: ${{ github.workspace }} - - - name: Deploy to Play Store (Beta) - run: bundle exec fastlane beta - working-directory: ./android - env: - NEW_VERSION: ${{ github.ref_name }} - - - name: Upload APK artifact - uses: actions/upload-artifact@v4 - with: - name: android-apk - path: build/app/outputs/flutter-apk/realunit-*.apk diff --git a/.github/workflows/beta-release-ios.yaml b/.github/workflows/beta-release-ios.yaml deleted file mode 100644 index ba8e7142..00000000 --- a/.github/workflows/beta-release-ios.yaml +++ /dev/null @@ -1,96 +0,0 @@ -name: iOS Beta Release - -on: - workflow_dispatch: - push: - tags: - - "ios/v*" -jobs: - build_ios: - runs-on: macos-26 - steps: - - uses: actions/checkout@v4 - - # ------------------- - # Setup Environment - # ------------------- - - - name: Setup SSH access for Fastlane Match - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.MATCH_SSH_KEY }} - host-name: github.com - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.41.6" - channel: "stable" - cache: true - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.25.5" - cache: true - - - name: Init GoMobile - run: | - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init - working-directory: ${{ github.workspace }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.4" - bundler-cache: true - working-directory: ios - - - name: Install Bundler - run: gem install bundler -v 2.7.2 - - - name: Install Gems - run: bundle _2.7.2_ install - working-directory: ios - - - name: Install Flutter Dependencies - run: | - flutter pub get - dart run tool/generate_localization.dart - dart run build_runner build --delete-conflicting-outputs - working-directory: ${{ github.workspace }} - - - name: Setup Pods (CocoaPods) - run: pod install --repo-update - working-directory: ios - - - name: Setup App Store Connect API Key - run: | - # Use an absolute path for reliability - KEY_PATH="${{ github.workspace }}/AuthKey.p8" - - # Create the .p8 file - echo "${{ secrets.APP_STORE_CONNECT_KEY }}" > "$KEY_PATH" - - # Export Environment Variables GLOBALLY for all subsequent steps (including match) - echo "FASTLANE_APPLE_API_KEY_PATH=$KEY_PATH" >> $GITHUB_ENV - echo "FASTLANE_APPLE_API_KEY_ID=${{ secrets.APP_STORE_CONNECT_KEY_ID }}" >> $GITHUB_ENV - echo "FASTLANE_APPLE_API_ISSUER_ID=${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" >> $GITHUB_ENV - echo "MATCH_PASSWORD=${{ secrets.MATCH_PASSWORD }}" >> $GITHUB_ENV - env: - APP_STORE_CONNECT_KEY: ${{ secrets.APP_STORE_CONNECT_KEY }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - - # ------------------- - # Build & Deploy - # ------------------- - - - name: Run Fastlane - run: bundle exec fastlane beta --verbose - working-directory: ios - env: - NEW_VERSION: ${{ github.ref_name }} diff --git a/.github/workflows/bitbox-simulator-slash.yml b/.github/workflows/bitbox-simulator-slash.yml new file mode 100644 index 00000000..45c8fad4 --- /dev/null +++ b/.github/workflows/bitbox-simulator-slash.yml @@ -0,0 +1,99 @@ +name: bitbox-simulator (slash) + +# Comment "/bitbox-simulator" on any PR to launch the official BitBox02 +# simulator and run the testkit's baseline scenarios against real +# firmware logic on demand. Same engine as bitbox-simulator.yml but +# user-triggered. +# +# Variants: +# /bitbox-simulator — defaults (testkit v0.5.0) +# /bitbox-simulator ref=main — bleeding-edge scenarios + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: bitbox-simulator-slash-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + guard: + if: > + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/bitbox-simulator') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + authorized: ${{ steps.authz.outputs.authorized }} + sha: ${{ steps.parse.outputs.sha }} + testkit-ref: ${{ steps.parse.outputs.testkit_ref }} + steps: + - name: Authorization + id: authz + uses: actions/github-script@v7 + env: + ASSOC: ${{ github.event.comment.author_association }} + ACTOR: ${{ github.event.comment.user.login }} + with: + script: | + // OWNER + MEMBER only. `COLLABORATOR` is excluded on purpose: + // it includes outside collaborators (people invited per-repo + // who are NOT in the DFXswiss org). The slash command accepts + // a `ref=` token that resolves into `actions/checkout@v4` with + // `ref:`, and an attacker-controlled ref can run arbitrary + // code on the runner — only trusted org identities are + // allowed to trigger that path. + const allowed = ['OWNER', 'MEMBER']; + const ok = allowed.includes(process.env.ASSOC); + core.setOutput('authorized', ok ? 'true' : 'false'); + if (!ok) { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: `:no_entry_sign: \`@${process.env.ACTOR}\` is not authorized to run \`/bitbox-simulator\`.`, + }); + } + + - name: Parse + resolve PR head + id: parse + if: steps.authz.outputs.authorized == 'true' + uses: actions/github-script@v7 + with: + script: | + const body = (context.payload.comment.body || '').trim(); + const args = body.replace(/^\/bitbox-simulator\s*/, '').split(/\s+/).filter(Boolean); + let testkitRef = 'v0.5.0'; + for (const tok of args) { + const [k, v] = tok.split('='); + if (k === 'ref' && v) testkitRef = v; + } + const { data: pr } = await github.rest.pulls.get({ + ...context.repo, + pull_number: context.issue.number, + }); + core.setOutput('sha', pr.head.sha); + core.setOutput('testkit_ref', testkitRef); + await github.rest.reactions.createForIssueComment({ + ...context.repo, + comment_id: context.payload.comment.id, + content: 'rocket', + }); + + simulator: + needs: guard + if: needs.guard.outputs.authorized == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.guard.outputs.sha }} + - uses: DFXswiss/bitbox-testkit/.github/actions/bitbox-simulator@45a1253d23b545d801cf5a1f42c040b85e389c7d # v0.5.0 + with: + testkit-ref: ${{ needs.guard.outputs.testkit-ref }} diff --git a/.github/workflows/bitbox-simulator.yml b/.github/workflows/bitbox-simulator.yml new file mode 100644 index 00000000..17f05bef --- /dev/null +++ b/.github/workflows/bitbox-simulator.yml @@ -0,0 +1,77 @@ +name: bitbox-simulator + +# Launches the official BitBox02 simulator +# (https://github.com/BitBoxSwiss/bitbox02-firmware/releases) and runs +# the bitbox-testkit baseline scenarios against real firmware logic. +# +# What this validates for realunit-app: +# • bitbox-api ↔ BitBox02 firmware Noise handshake round-trip. +# • ETH-address derivation on chainId=1 AND chainId=137 (the +# multi-byte-v boundary that has historically broken EIP-155 +# consumers). +# • ETH personal-message signing at the firmware-doc 1024-byte upper +# boundary. +# • EIP-1559 sign happy path. +# +# What this does NOT validate: realunit-app's Dart code talking to +# the BitBox via the bitbox_flutter plugin against real hardware. +# That still requires a physical BitBox02. The simulator covers the +# FIRMWARE side of the protocol; the Dart consumer side is exercised +# by Tier 1 (SDK-boundary fake, cross-layer tests under +# `test/integration/`) and ultimately by Tier 3 Maestro flows on +# real hardware. See `docs/testing.md` for the five-tier model and +# issue #314 for the rollout plan. The pinned bitbox_flutter version +# lives in `pubspec.yaml`. +# +# Runs on every PR that touches BitBox surface AND on manual trigger. +# For ad-hoc maintainer validation, bitbox-simulator-slash.yml +# accepts /bitbox-simulator on any PR. + +on: + pull_request: + # Fires on every PR except those targeting `main` — stacked PRs + # (feature → integration → develop) need their own firmware-simulator + # run so a BitBox regression is caught at the lowest possible level, + # not only after the stack has been collapsed to a develop PR. The + # release lane (develop → main) is skipped because the same SHA + # already ran on the develop PR. The `paths:` filter below is the + # real cost control on top of that. + branches-ignore: [main] + paths: + - 'lib/packages/hardware_wallet/**' + - 'lib/packages/wallet/**' + - 'lib/screens/hardware_connect_bitbox/**' + - 'test/packages/hardware_wallet/**' + - 'test/packages/wallet/**' + - 'test/screens/hardware_connect_bitbox/**' + - 'pubspec.yaml' + - '.github/workflows/bitbox-simulator.yml' + workflow_dispatch: + +# `read` only — this workflow does not comment back on the PR, so the +# default-elevated `write` was unused and granted more authority than +# needed (the slash-command sibling keeps `write` because it DOES +# create comments to report authz/parse state). +permissions: + contents: read + pull-requests: read + +concurrency: + group: bitbox-simulator-${{ github.ref }} + cancel-in-progress: true + +jobs: + simulator: + name: BitBox02 simulator (real firmware) + # Skip on draft PRs (matches `pull-request.yaml` + `tier3-handbook.yaml`). + # `workflow_dispatch` falls through because there's no `pull_request` + # context — the condition is "anything that isn't a PR, or a PR + # that isn't a draft". + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: DFXswiss/bitbox-testkit/.github/actions/bitbox-simulator@45a1253d23b545d801cf5a1f42c040b85e389c7d # v0.5.0 + with: + testkit-ref: v0.5.0 diff --git a/.github/workflows/develop-release.yaml b/.github/workflows/develop-release.yaml deleted file mode 100644 index 4793f94f..00000000 --- a/.github/workflows/develop-release.yaml +++ /dev/null @@ -1,171 +0,0 @@ -name: Develop Beta Release - -on: - workflow_dispatch: - push: - tags: - - "v*-beta*" - -permissions: - contents: write - -jobs: - android-apk: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v5 - - # ------------------- - # Setup Environment - # ------------------- - - - name: Set up Java - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - - - uses: android-actions/setup-android@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.41.6" - channel: "stable" - cache: true - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.25.5" - - - name: Init GoMobile - run: | - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init - working-directory: ${{ github.workspace }} - - - name: Decode Secrets - env: - ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - run: | - echo "$ANDROID_KEYSTORE" | base64 --decode > android/app/upload-keystore.jks - - - name: Create key.properties - env: - STORE_PWD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - KEY_PWD: ${{ secrets.ANDROID_KEY_PASSWORD }} - KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - run: | - cat < android/key.properties - storeFile=upload-keystore.jks - storePassword=$STORE_PWD - keyAlias=$KEY_ALIAS - keyPassword=$KEY_PWD - EOF - - - name: Install Flutter Dependencies - run: | - flutter pub get - dart run tool/generate_localization.dart - dart run build_runner build --delete-conflicting-outputs - working-directory: ${{ github.workspace }} - - # ------------------- - # Build APK - # ------------------- - - - name: Extract version - id: version - run: | - VERSION_NAME="${{ github.ref_name }}" - VERSION_NAME="${VERSION_NAME#v}" - echo "version_name=$VERSION_NAME" >> $GITHUB_OUTPUT - - - name: Build APK - run: | - flutter build apk \ - --release \ - --build-name=${{ steps.version.outputs.version_name }} \ - --no-tree-shake-icons \ - --obfuscate \ - --split-debug-info=build/debug_info - - - name: Rename APK - run: | - mv build/app/outputs/flutter-apk/app-release.apk \ - build/app/outputs/flutter-apk/realunit-${{ steps.version.outputs.version_name }}.apk - - - name: Upload APK artifact - uses: actions/upload-artifact@v4 - with: - name: android-apk - path: build/app/outputs/flutter-apk/realunit-*.apk - - github-release: - needs: [android-apk] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Generate Changelog - id: changelog - uses: mikepenz/release-changelog-builder-action@v6.0.1 - with: - configurationJson: | - { - "template": "#{{CHANGELOG}}", - "categories": [ - { - "title": "## Features", - "labels": ["feat", "feature"] - }, - { - "title": "## Fixes", - "labels": ["fix", "bug"] - }, - { - "title": "## Refactor", - "labels": ["refactor"] - }, - { - "title": "## Others", - "labels": ["chore"] - }, - { - "title": "## Not labelled", - "labels": [] - } - ], - "label_extractor": [ - { - "method": "match", - "pattern": "^([\\w\\-]+)", - "target": "$1", - "on_property": "title" - } - ] - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Download APK artifact - uses: actions/download-artifact@v4 - with: - name: android-apk - path: . - - - name: Create Pre-Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - body: ${{ steps.changelog.outputs.changelog }} - files: realunit-*.apk - generate_release_notes: false - draft: false - prerelease: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/golden-regenerate.yaml b/.github/workflows/golden-regenerate.yaml new file mode 100644 index 00000000..a958790d --- /dev/null +++ b/.github/workflows/golden-regenerate.yaml @@ -0,0 +1,95 @@ +name: Golden Regenerate + +# On-demand visual-regression baseline regeneration on the dfx01 self-hosted +# runner. Replaces the previous pattern of introducing/removing a temporary +# `golden-bootstrap.yaml` per regen cycle. +# +# Usage: +# gh workflow run golden-regenerate.yaml --ref +# +# The workflow checks out the dispatched ref, runs `flutter test +# test/goldens --update-goldens` on dfx01, then commits the regenerated PNGs +# back to that same ref as `github-actions[bot]`. If the ref is protected +# (e.g. `develop`/`main`), the push fails cleanly — by design, no force-push +# or bypass. In that case the regenerated baselines are still uploaded as a +# `golden-baselines` artifact so the user can rsync them onto a feature +# branch manually. +# +# Setup steps mirror the `golden-tests` job in `pull-request.yaml` 1:1. +# Keep them in sync — a divergence here would mean the regenerated +# baselines render under a different toolchain than the one that validates +# them on PRs, defeating the whole hardware-determinism story. +on: + workflow_dispatch: + +# Two parallel dispatches on the same ref would race each other's push. +# Cancel the in-flight one so only the newest dispatch's baselines land. +concurrency: + group: golden-regenerate-${{ github.ref }} + cancel-in-progress: true + +# `contents: write` is load-bearing: the auto-commit step pushes back to +# the dispatched branch. The default `GITHUB_TOKEN` permission is `read`. +permissions: + contents: write + +jobs: + regenerate: + name: Regenerate golden baselines + runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + # Explicit token so the later `git push` is authenticated. Without + # this, checkout still works but the remote is configured with no + # credential helper and the push fails with HTTP 403. + token: ${{ secrets.GITHUB_TOKEN }} + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: true + - run: flutter pub get + - run: dart run tool/generate_localization.dart + - run: dart run tool/generate_release_info.dart + - run: flutter pub run build_runner build + + - name: Regenerate goldens + run: flutter test test/goldens --update-goldens + + - name: Commit and push regenerated baselines + id: commit + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Narrow add: only the baseline PNG tree. The `**/goldens/**` + # pattern matches the symmetric artifact path on line 92 — and + # excludes alchemist's transient `failures/` directories, which + # alchemist writes one level up (`/failures/...`), not + # inside `goldens/`. + git add 'test/goldens/screens/**/goldens/**' + if git diff --cached --quiet; then + echo "No baseline changes detected — nothing to commit." + exit 0 + fi + git commit -m "test(goldens): regenerate baselines on dfx01" + # On protected branches (develop/main) this fails by design. + # The next step uploads the PNGs as an artifact so the user can + # still recover the regen output. + git push + + # Runs only if the push step failed. Uploads the regenerated PNGs so + # the user can rsync them locally onto a feature branch and commit + # there — same recovery path the temporary `golden-bootstrap.yaml` + # used to provide. + - name: Upload baselines as fallback artifact + if: failure() && steps.commit.conclusion == 'failure' + uses: actions/upload-artifact@v4 + with: + name: golden-baselines + path: test/goldens/screens/**/goldens/** + # The fallback only runs because the push failed. An empty + # artifact here would silently strand the user — fail loud. + if-no-files-found: error diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml new file mode 100644 index 00000000..45e89853 --- /dev/null +++ b/.github/workflows/handbook-build-check.yaml @@ -0,0 +1,179 @@ +name: Handbook Build Check + +# PR-only build verification for Dockerfile.handbook + the Goldens-assembled +# screenshots. Does NOT push to Docker Hub and does NOT deploy — that +# remains the job of handbook-deploy.yaml (staging push → DEV, develop push → PRD). +# +# Path filter covers everything that goes into the handbook image: +# - docs/handbook/** handbook HTML, README, en/de subtrees +# - scripts/assemble-handbook-screenshots.sh handbook→Golden mapping +# - test/goldens/** Golden baselines (source of every +# screenshot the handbook serves) +# - Dockerfile.handbook multi-stage build +# - handbook.nginx.conf nginx config +# - handbook.htpasswd access gate + +on: + pull_request: + # Include ready_for_review so PRs flipped from Draft to Ready also + # trigger this check (default trigger list misses that event). + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "docs/handbook/**" + - "scripts/assemble-handbook-screenshots.sh" + - "scripts/assemble-handbook-store-listing.py" + - "scripts/templates/store-listing.html.tmpl" + - "scripts/assemble-handbook-legal.py" + - "scripts/build-legal-downloads.sh" + - "scripts/templates/legal-downloads.html.tmpl" + - "test/goldens/**" + # Store-listing section is a derived export of the Fastlane metadata — + # changing it must re-run the generator and re-commit the handbook. + - "ios/fastlane/metadata/**" + - "ios/fastlane/screenshots/**" + - "android/fastlane/metadata/**" + # Legal-downloads section is a derived export of the in-app legal Markdown + # (and the ARB titles) — changing either must re-run the generator and + # re-commit the handbook HTML block. + - "assets/legal/**" + - "assets/languages/**" + - "Dockerfile.handbook" + - "handbook.nginx.conf" + - "handbook.htpasswd" + - ".github/workflows/handbook-build-check.yaml" + +permissions: + contents: read + +concurrency: + group: handbook-build-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build handbook image + container smoke + # Skip on draft PRs (matches the rest of pull-request.yaml). + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Verify assemble-handbook-screenshots.sh runs locally + # Independent sanity-check of the Bash script before the Docker + # multi-stage build runs the same logic. Catches obvious + # mapping errors (missing Golden) without needing the Docker + # daemon to surface them. + run: | + set -euo pipefail + bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots + count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') + if [ "$count" != "52" ]; then + echo "expected 52 screenshots, got $count" >&2 + exit 1 + fi + + - name: Verify store-listing HTML sanitizer drops disallowed markup + # full_description.txt is rendered unescaped in the handbook (Google + # Play allows a small HTML subset), so the generator runs it through an + # allowlist sanitizer. Guard that the sanitizer keeps neutralizing + # injection: a /okx' + out = gen.sanitize_play_html(payload) + assert "ok" in out, out + print("sanitizer OK:", out) + PY + + - name: Verify handbook store-listing section is in sync with metadata + # The store-listing block in docs/handbook/de/index.html is a derived + # export of the Fastlane metadata. Re-run the generator; if the working + # tree changes, the committed handbook is stale — someone edited a + # metadata file (or a screenshot) without re-running the generator. + run: | + set -euo pipefail + python3 scripts/assemble-handbook-store-listing.py /tmp/store-out + if ! git diff --quiet docs/handbook/de/index.html; then + echo "::error::docs/handbook/de/index.html is stale — re-run scripts/assemble-handbook-store-listing.py and commit." + git diff docs/handbook/de/index.html + exit 1 + fi + + - name: Verify handbook legal-downloads section is in sync with assets/legal + # The legal-downloads block in docs/handbook/de/index.html is a derived + # export of assets/legal/*.md + the ARB titles. Re-run the (pure-stdlib, + # deterministic) generator; if the working tree changes, the committed + # handbook is stale — someone edited a legal .md or its ARB title without + # re-running the generator. Only the HTML block is gated here; the + # pandoc-produced PDF/DOCX are non-deterministic and intentionally + # git-ignored, so build-legal-downloads.sh is NOT run in this gate. + run: | + set -euo pipefail + python3 scripts/assemble-handbook-legal.py /tmp/legal-out + if ! git diff --quiet docs/handbook/de/index.html; then + echo "::error::docs/handbook/de/index.html legal-downloads block is stale — re-run scripts/assemble-handbook-legal.py and commit." + git diff docs/handbook/de/index.html + exit 1 + fi + + - name: Build handbook image (no push) + run: docker build -f Dockerfile.handbook -t realunit-handbook:pr-check . + + - name: Smoke-test container starts and serves /healthz + run: | + set -euo pipefail + docker run -d --name handbook -p 8080:8080 realunit-handbook:pr-check + # Give nginx a moment to start. + for i in $(seq 1 10); do + if curl --fail --silent http://127.0.0.1:8080/healthz; then + break + fi + sleep 1 + done + # /healthz must return 200 (unauthenticated by config). + curl --fail --silent http://127.0.0.1:8080/healthz | grep -q OK + + # Gated path must return 401 without credentials (auth wall active). + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/de/) + if [ "$code" != "401" ]; then + echo "expected 401 from /de/ without auth, got $code" >&2 + docker logs handbook + exit 1 + fi + + # Screenshots dir must contain all 52 PNGs assembled from Goldens. + # Hit one of them through the auth gate to verify wiring end-to-end. + # Mix of original 01-26 range + new 27-52 range so a regression + # in either half surfaces here. + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error; do + code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") + # 200 (auth happens to match) or 401 (auth fails but file exists) + # both prove the file is on disk. 404 means it was not assembled. + if [ "$code" = "404" ]; then + echo "screenshot ${name}.png missing from /usr/share/nginx/html/screenshots/" >&2 + docker logs handbook + exit 1 + fi + done + + # Legal downloads must be present (built by the legal-docs-builder + # stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX + # was not produced. Covers PDF + DOCX and both languages. + for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do + code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}") + if [ "$code" = "404" ]; then + echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2 + docker logs handbook + exit 1 + fi + done + + docker stop handbook diff --git a/.github/workflows/handbook-deploy.yaml b/.github/workflows/handbook-deploy.yaml new file mode 100644 index 00000000..f114ec5c --- /dev/null +++ b/.github/workflows/handbook-deploy.yaml @@ -0,0 +1,94 @@ +name: Handbook CI/CD + +# Per-branch handbook deploy pipeline, one environment per branch: +# push to `staging` → build from staging → deploy DEV (dev-handbook.realunit.app) +# push to `develop` → build from develop → deploy PRD (handbook.realunit.app) +# +# DEV and PRD are independent runs keyed by the pushed branch; they no +# longer share a single run that fans out to both. The "DEV is green before +# PRD ships" guarantee is now provided by the branch-promotion flow itself: +# handbook content reaches `develop` only after it has been on `staging` +# (auto-staging-pr.yaml opens the staging → develop PR), i.e. after it was +# built and smoke-tested on DEV. Promoting to develop is what triggers PRD. +# +# This is a deliberate dev/prd-coupling exception scoped to the handbook +# alone — the wallet app itself still follows the strict develop→main +# separation. +# +# All build/deploy/smoke logic lives in the reusable workflow +# .github/workflows/handbook.yaml. This file only wires the per-env +# parameters (source ref, image tag, smoke URL, deploy secrets) and routes +# the pushed branch to its environment via the per-job `if:` guards below. + +on: + push: + branches: [staging, develop] + paths: + - "docs/handbook/**" + - "test/goldens/screens/**" + - "scripts/assemble-handbook-screenshots.sh" + - "scripts/assemble-handbook-store-listing.py" + - "scripts/templates/store-listing.html.tmpl" + # Store-listing section is baked into the image from the Fastlane + # metadata; a metadata- or screenshot-only change must redeploy too. + - "ios/fastlane/metadata/**" + - "ios/fastlane/screenshots/**" + - "android/fastlane/metadata/**" + - "Dockerfile.handbook" + - "handbook.nginx.conf" + - "handbook.htpasswd" + - ".github/workflows/handbook-deploy.yaml" + - ".github/workflows/handbook.yaml" + workflow_dispatch: + +permissions: + contents: read + +# One concurrency lane PER BRANCH so a staging (DEV) deploy and a develop +# (PRD) deploy never serialize against — or cancel — each other. Within a +# single lane a manual workflow_dispatch still can't race a push: both would +# build the same env's tag in parallel, the later build would win on Docker +# Hub, and the target server could pull either depending on timing. +# cancel-in-progress is false so a mid-rollout deploy is never killed (we'd +# otherwise risk recreating the container halfway through). +concurrency: + group: handbook-deploy-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + # Push to staging → DEV. Distinct image tag (:beta) from PRD (:latest) so a + # staging build can never clobber the develop build on Docker Hub (both + # used to push :beta, which raced whenever the two branches were pushed + # close together). + deploy-dev: + if: github.ref_name == 'staging' + uses: ./.github/workflows/handbook.yaml + with: + environment: DEV + ref: staging + docker_tag: dfxswiss/realunit-app-handbook:beta + smoke_url: https://dev-handbook.realunit.app/healthz + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_DEV_SSH_KEY }} + DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }} + DEPLOY_USER: ${{ secrets.DEPLOY_DEV_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_DEV_HOST }} + + # Push to develop → PRD. + deploy-prd: + if: github.ref_name == 'develop' + uses: ./.github/workflows/handbook.yaml + with: + environment: PRD + ref: develop + docker_tag: dfxswiss/realunit-app-handbook:latest + smoke_url: https://handbook.realunit.app/healthz + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_PRD_SSH_KEY }} + DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }} + DEPLOY_USER: ${{ secrets.DEPLOY_PRD_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_PRD_HOST }} diff --git a/.github/workflows/handbook.yaml b/.github/workflows/handbook.yaml new file mode 100644 index 00000000..9c11603d --- /dev/null +++ b/.github/workflows/handbook.yaml @@ -0,0 +1,316 @@ +name: Handbook reusable CI/CD + +# Reusable workflow that builds the handbook nginx image, publishes it to +# Docker Hub, and tells the target server to pull + recreate the container. +# +# Caller (handbook-deploy.yaml) provides the per-environment parameters +# (source ref, tag, smoke URL, deploy secrets) so the deploy logic itself +# lives in exactly one place. Each pushed branch is an independent caller +# run — staging → DEV, develop → PRD — so there is no DEV→PRD `needs:` +# ordering here; "DEV green before PRD" comes from the staging→develop +# promotion flow (see handbook-deploy.yaml header). +# +# Mirrors infrastructure/templates/ssh-deploy-*.yaml from DFXServer/server. + +on: + workflow_call: + inputs: + environment: + description: "Target environment label (dev | prd) — used in job name + smoke log lines" + required: true + type: string + ref: + description: "Git ref of THIS repo to build the handbook from — the pushed branch (staging for DEV, develop for PRD). Determines the deployed handbook 'Stand'." + required: true + type: string + docker_tag: + description: "Full Docker image tag, e.g. dfxswiss/realunit-app-handbook:beta (DEV) / :latest (PRD)" + required: true + type: string + smoke_url: + description: "Public URL to poll after deploy" + required: true + type: string + secrets: + DOCKER_USERNAME: + required: true + DOCKER_PASSWORD: + required: true + DEPLOY_SSH_KEY: + required: true + DEPLOY_SSH_KNOWN_HOSTS: + required: true + DEPLOY_USER: + required: true + DEPLOY_HOST: + required: true + +# Reusable workflows do NOT inherit `permissions` from the caller — GITHUB_TOKEN +# scopes are resolved per-workflow. Mirror the `contents: read` floor that both +# callers set so the reusable behaves identically to the pre-extract setup. +permissions: + contents: read + +env: + DEPLOY_SERVICE: realunit-app-handbook + +jobs: + build-and-deploy: + name: Build and deploy to ${{ inputs.environment }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Build the handbook from the environment's source branch: staging + # for DEV, develop for PRD (passed by handbook-deploy.yaml as the + # pushed branch). This ref governs the deployed handbook content; + # the DFXswiss/api checkout below is a SEPARATE, intentionally + # develop-pinned source for the mail previews and does not vary + # with this input. + ref: ${{ inputs.ref }} + + # RealUnit mail previews are the single source of truth in DFXswiss/api + # (scripts/generate-realunit-previews.js). We pull them in at build time + # instead of checking them into this repo, so a change to a template or + # i18n string in the api repo flows into the handbook image on the next + # build without a manual sync PR. A handbook-relevant push to staging + # (→DEV) or develop (→PRD) — or a manual workflow_dispatch on + # handbook-deploy.yaml in this repo — rebuilds the image and re-pulls + # api@develop here — so mail-template changes in the api repo only flow + # in after such a push/dispatch in this repo. There is NO auto-dispatch + # from the api repo. The api ref stays `develop` for both environments + # (it does not track the handbook's staging/develop split). + - name: Check out DFXswiss/api at develop into _api-checkout/ + uses: actions/checkout@v4 + with: + # DFXswiss/api is a PUBLIC repo, so the runner's default GITHUB_TOKEN + # is sufficient to clone it — no cross-repo PAT setup needed. If the + # api repo is ever flipped to private, this step will start failing + # with HTTP 404 on checkout; the fix is then to add a PAT secret on + # the realunit-app repo and pass it through as `token:` here (and + # through the secrets: block of this reusable workflow + the + # handbook-deploy.yaml caller). + repository: DFXswiss/api + ref: develop + path: _api-checkout + + - name: Set up Node.js for mail-preview generator + uses: actions/setup-node@v4 + with: + # api repo currently does not pin a node version in package.json#engines + # or .nvmrc — its CI workflows use NODE_VERSION='20.x' (see + # DFXswiss/api/.github/workflows/api-pr.yaml). Mirror that exact string + # so a future api-side bump (e.g. to '22.x') stays a one-place change + # to track. + node-version: '20.x' + + - name: Generate RealUnit mail previews from api repo + env: + API_DIR: _api-checkout + HANDLEBARS_PREFIX: _handlebars-only + # Exact expected mail-preview file count: 24 mails + 1 index = 25. + # `-ne` below makes ANY drift (drop OR addition) fail the build — + # adding a new mail upstream therefore requires an explicit edit + # here, which is the whole point: a silently-added (or silently- + # removed) mail must never reach the handbook image undetected. + # When you legitimately add/remove a mail in + # DFXswiss/api/scripts/generate-realunit-previews.js, bump this + # number in the SAME PR as the api-side change-PR (or its + # follow-up). The exact count is computed as: + # N(add() calls in the generator) + # + N(pendingTypes entries the for-loop expands) + # + 1 (00_index.html) + EXPECTED_HTML_COUNT: 25 + run: | + set -euo pipefail + + # The generator script only requires 'handlebars' at runtime (verified + # via `grep require ${API_DIR}/scripts/generate-realunit-previews.js`). + # We deliberately AVOID `npm ci` against the api package-lock — that + # would install ~2400 packages (5+ min wall-clock) just to run a + # single 600-line script. Installing handlebars into an isolated + # prefix outside the api tree keeps `_api-checkout/` clean (its + # package.json/lockfile must remain untouched so a future + # `git status` on the api checkout would still be "clean") and the + # NODE_PATH override below makes `require('handlebars')` resolve + # against the isolated install. + npm install --prefix "./${HANDLEBARS_PREFIX}" --no-save --no-audit --no-fund handlebars + + # Defensive cleanup of any prior staging dir (no-op on ephemeral CI + # runners, but matters when this step is reproduced locally — see + # docs/handbook/README.md → "E-Mail Previews → Lokal regenerieren"). + # Without this, a stale html in docs/handbook/mails/ from a previous + # run would survive into the build context and end up shipped in the + # image even though the upstream generator no longer produces it. + rm -rf docs/handbook/mails + mkdir -p docs/handbook/mails + + # Run the generator. Output goes to ${API_DIR}/scripts/email-previews/realunit/ + # (path baked into the script). We `tee` stdout+stderr into a log so the + # next step can grep for the script's own `console.warn` output — + # specifically the "[trigger] Missing explanation for ..." warning, + # which the script emits (NOT throws) when a mail file stem has no + # entry in the `triggers` map. The script lives in the api repo and + # we cannot upgrade that warning to a throw from here, so the grep + # below is our only line of defence against silently shipping the + # handbook with mails that have no trigger-explanation card. + # + # `pipefail` is already on from `set -euo pipefail`, so a non-zero + # exit from `node` here fails the step — `tee` does not mask it. + LOG_FILE="$(mktemp)" + NODE_PATH="./${HANDLEBARS_PREFIX}/node_modules" \ + node "${API_DIR}/scripts/generate-realunit-previews.js" 2>&1 \ + | tee "${LOG_FILE}" + + # Cross-repo coupling check: the generator only warns; we promote it + # to an error so the build fails fast. Anchor `^` so a future log + # line that happens to contain the substring "[trigger] Missing" + # somewhere mid-line does not trip the check. + if grep -q '^\[trigger\] Missing' "${LOG_FILE}"; then + echo "::error::Generator emitted a '[trigger] Missing explanation for …' warning — see log above. Add the missing entry to the triggers map in DFXswiss/api/scripts/generate-realunit-previews.js." + exit 1 + fi + + OUT_DIR="${API_DIR}/scripts/email-previews/realunit" + + # Explicit existence check for OUT_DIR so we emit a friendly + # `::error::` annotation pointing at the generator, instead of dying + # one line later with a raw bash pipeline-failure trace from + # `ls "${OUT_DIR}"/*.html | wc -l | tr -d ' '` when the directory is + # missing. The count check below would also catch this, but this + # early-exit gives a clearer signal in the CI log. + if [ ! -d "${OUT_DIR}" ]; then + echo "::error::Generator did not create ${OUT_DIR} — it likely crashed before writing any output. See generator log above." + exit 1 + fi + + # Exact-match check (see EXPECTED_HTML_COUNT comment at env: above). + HTML_COUNT=$(ls "${OUT_DIR}"/*.html 2>/dev/null | wc -l | tr -d ' ') + if [ "${HTML_COUNT}" -ne "${EXPECTED_HTML_COUNT}" ]; then + echo "::error::Expected exactly ${EXPECTED_HTML_COUNT} mail preview HTML files in ${OUT_DIR}, got ${HTML_COUNT}. If this drift is intentional (mail added/removed upstream), update EXPECTED_HTML_COUNT in this workflow in the same PR." + exit 1 + fi + if [ ! -s "${OUT_DIR}/00_index.html" ]; then + echo "::error::Mail preview index ${OUT_DIR}/00_index.html is missing or empty" + exit 1 + fi + + # Stage the generated files into docs/handbook/mails/ so the Docker + # build context picks them up (Dockerfile.handbook does + # `COPY docs/handbook/ /usr/share/nginx/html/`). + cp "${OUT_DIR}"/*.html docs/handbook/mails/ + + # nginx has `index index.html` (handbook.nginx.conf line 15) and + # `try_files $uri $uri/ =404` (line 82). A request for /mails/ would + # therefore look up /mails/index.html and 404 because the generator + # names its index `00_index.html` (so it sorts first in a directory + # listing). Duplicating the index as `index.html` lets `/mails/` + # resolve directly without adding a per-location nginx rule. + cp docs/handbook/mails/00_index.html docs/handbook/mails/index.html + + # Final verify (visible in CI log so a future regression is grepable). + ls -la docs/handbook/mails/ | head -40 + echo "Generated $(ls docs/handbook/mails/*.html | wc -l | tr -d ' ') mail preview files" + + # Cleanup runs even when the generator step above (or any other step + # between staging and `docker build`) fails. Without `if: always()`, a + # crash mid-pipeline would leave _api-checkout/ + _handlebars-only/ + # in the workspace, and the subsequent `docker build` (context: .) + # would ship them to BuildKit — bloating the build context with + # ~hundreds of MB of api source the image does not need (Dockerfile + # only `COPY docs/handbook/`, so the files would not enter the image + # itself, but they'd still slow every cached/uncached build). + - name: Drop api checkout + handlebars install from build context + if: always() + env: + API_DIR: _api-checkout + HANDLEBARS_PREFIX: _handlebars-only + run: | + set -euo pipefail + rm -rf "./${API_DIR}" "./${HANDLEBARS_PREFIX}" + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push handbook image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.handbook + push: true + tags: ${{ inputs.docker_tag }} + platforms: linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Install cloudflared + env: + CLOUDFLARED_VERSION: "2025.4.0" + CLOUDFLARED_SHA256: "2561391ee9abdc828afcc52f5ac314b6551a9a6a31ff59cbc64efc63aec04615" + run: | + set -euo pipefail + curl -fsSL "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64" -o /usr/local/bin/cloudflared + echo "${CLOUDFLARED_SHA256} /usr/local/bin/cloudflared" | sha256sum -c - + chmod +x /usr/local/bin/cloudflared + + # Deploy via SSH through Cloudflare Tunnel. The `IdentitiesOnly=yes` + # and `IdentityAgent=none` options pin SSH to ONLY the explicit + # `deploy_key` and disable ssh-agent lookup — without them, + # OpenSSH probes default keys + agent identities first and may + # exhaust the server's MaxAuthTries before reaching deploy_key, + # producing intermittent "Permission denied … Too many + # authentication failures" on otherwise-healthy runs (root cause + # for the apparent smoke-test flakiness originally tracked in + # #487, now closed). + - name: Deploy to server + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o IdentitiesOnly=yes \ + -o IdentityAgent=none \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_HOST }}" \ + ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "${{ env.DEPLOY_SERVICE }}" + + # Post-deploy smoke test: poll the public endpoint until the nginx + # container answers 200, or give up. Without this step a "green" + # deploy can lie — a misconfigured nginx leaves the container + # Up-but-unresponsive while the workflow reports success. Failing + # this step blocks the auto-release PR from collecting a green + # check and surfaces the regression in CI. Mirrors the pattern + # from zkcoins/server `deploy-dev.yaml`. + # + # Callers point smoke_url at the unauthenticated /healthz endpoint + # so the test stays trivial (200 without password) — handbook + # content itself sits behind Basic Auth and the credentials must + # never land in this public workflow file. The handbook nginx + # config keeps /healthz outside auth_basic specifically for this. + - name: Smoke test public endpoint + env: + SMOKE_URL: ${{ inputs.smoke_url }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -euo pipefail + for i in $(seq 1 30); do + code=$(curl -sS -L --max-redirs 3 -o /dev/null -w '%{http_code}' --max-time 10 "$SMOKE_URL" || echo "000") + if [ "$code" = "200" ]; then + echo "${ENVIRONMENT} handbook responded 200 after ${i} attempt(s)" + exit 0 + fi + echo "[$i/30] $SMOKE_URL -> ${code} (waiting 10 s)" + sleep 10 + done + echo "::error::${ENVIRONMENT} handbook never returned 200 within ~5 min after deploy" + exit 1 diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index ccc5e9a4..43276344 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -1,23 +1,366 @@ name: RealUnit Build +# Trigger model: +# - `pull_request` fires on every PR regardless of target branch, +# EXCEPT PRs targeting `main`. Stacked PRs (feature → integration +# → develop) need their own CI run so a regression is caught at +# the lowest possible level, not only after the stack has been +# collapsed to a develop PR. Release PRs (develop → main, opened +# automatically by `auto-release-pr.yaml`) are deliberately +# excluded: `push: develop` already covered the same SHA when the +# commit landed, and concurrency groups don't dedupe across +# push-vs-pull_request events, so allowing both would double +# macOS-runner load on the release lane for no signal. +# - `ready_for_review` makes the workflow fire the moment a draft PR +# is marked ready — drafts themselves skip via the `if:` guard +# below (saves macOS-runner minutes while work is still in progress). +# - `labeled` / `unlabeled` are intentionally omitted: realunit-app has +# no label-gated heavy job here (Tier 3 has its own workflow). +# - `push: develop` and `push: staging` keep post-merge verification +# as an authoritative source of truth, independent of any PR state. +# `staging` participates because feature → staging merges land an +# integration commit whose verification cannot be deferred to the +# auto-opened staging → develop PR (the gate must be authoritative +# at the moment the commit lands on staging). Other branches do not +# need a post-push check — the PR-event already covers them. +# - `workflow_dispatch` is kept as a manual override and is NOT +# draft-gated (see the job `if:` guard below). on: workflow_dispatch: + push: + branches: [develop, staging] pull_request: - branches: - - develop - - main + branches-ignore: [main] + types: [opened, synchronize, reopened, ready_for_review] + +# Group by PR number so a new push to the same PR cancels the +# in-flight run for the outdated commit. macOS runners are scarce — +# letting an obsolete run finish wastes ~10 minutes per push. Grouping +# by SHA (the previous approach) put every commit in its own group, +# which meant no cancellation ever happened and back-to-back pushes +# queued sequentially. +# Falls back to `github.ref` for `push` and `workflow_dispatch` events +# (where there is no `pull_request.number`): `refs/heads/develop` for +# post-merge runs, the dispatched ref for manual triggers. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: build: + name: Analyze & Test + # Skip on draft PRs (matches our `gh pr create --draft` workflow). + # `push` (post-merge gate) and `workflow_dispatch` (manual override) + # always run — the condition is "anything that isn't a PR, or a PR + # that isn't a draft". + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: macos-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: "3.41.6" channel: "stable" + cache: true - run: flutter pub get - run: dart run tool/generate_localization.dart + - run: dart run tool/generate_release_info.dart - run: flutter pub run build_runner build - run: flutter analyze - - run: flutter test + # Excludes the `golden` tag: visual-regression tests live under + # `test/goldens/` and are validated on the dfx01 self-hosted runner + # in the parallel `golden-tests` job (Hardware-Determinismus, see + # `docs/visual-regression-tests.md`). Running them here too would + # both duplicate work and erroneously red this job on macos-latest + # where the Skia/font-rendering does not match the committed + # baselines. + - run: flutter test --coverage --exclude-tags golden + + # Narrow the coverage report to the README-defined activated surface: + # lib/packages/** — services, repositories, signers, utils + # lib/screens/**/cubit(s)/** — Cubit logic per feature + # lib/screens/**/bloc/** — Bloc logic per feature + # Widget files (lib/screens/**/*_page.dart, lib/widgets/**) are covered + # by `testWidgets` specs and excluded from the line-coverage scope by + # design — see README "Coverage scope". Threshold enforcement is the + # next step (see README "Coverage infrastructure roadmap"); this step + # only produces the scoped baseline + a human-readable summary. + # + # The extract patterns use lcov's fnmatch matcher, where `*` is greedy + # across `/` — so `lib/packages/*` matches arbitrarily deep paths under + # that prefix, and `lib/screens/*/cubit/*` matches any feature subtree + # (including nested ones like `lib/screens/kyc/steps/email/cubits/...`). + # `--ignore-errors unused,empty` covers both a missing prefix after a + # rename and a refactor that empties the extracted file — the latter + # would otherwise hard-fail on newer lcov versions. + # + # Note vs. the previous step: `lcov --summary` is no longer guarded by + # `|| true`, so a summary failure (e.g. corrupt tracefile) now surfaces + # as a red step instead of being silently swallowed. Intentional: the + # filtered baseline is the input to the next roadmap item (threshold + # check), and silent failures upstream would invalidate that signal. + - name: Filter coverage to README scope + run: | + set -euo pipefail + if [ ! -f coverage/lcov.info ]; then + echo "::warning::coverage/lcov.info not found — skipping coverage filter" + exit 0 + fi + brew install lcov >/dev/null + lcov --extract coverage/lcov.info \ + 'lib/packages/*' \ + 'lib/screens/*/cubit/*' \ + 'lib/screens/*/cubits/*' \ + 'lib/screens/*/bloc/*' \ + --output-file coverage/lcov.info \ + --ignore-errors unused,empty + # Strip code-generator output from the scoped tracefile. Today + # only `*.g.dart` (Drift schema mirror) is generated and lives + # under `lib/packages/**` — there are no `*.freezed.dart` or + # `*.mocks.dart` files in the tree, so we deliberately keep + # the pattern list narrow. Add the others here the day a + # generator starts emitting them. + lcov --remove coverage/lcov.info \ + '*.g.dart' \ + --output-file coverage/lcov.info \ + --ignore-errors unused,empty + lcov --summary coverage/lcov.info | tee coverage/lcov.summary + + - name: Report coverage summary + if: always() + run: | + set -euo pipefail + if [ ! -f coverage/lcov.summary ]; then + exit 0 + fi + { + echo "## Coverage report (README scope)" + echo + echo "Scope: \`lib/packages/**\` + \`lib/screens/**/cubit(s)/**\` + \`lib/screens/**/bloc/**\`" + echo + echo '```' + cat coverage/lcov.summary + echo '```' + echo + echo "_Floor gate active (see \`.coverage-floor-lines\` / \`.coverage-floor-functions\`). README \"Coverage infrastructure roadmap\" documents the ratchet protocol._" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: coverage/lcov.info + if-no-files-found: warn + + # The scoped summary is the input to the `coverage-floor` gate job. + # `if-no-files-found: error` (not `warn`) is load-bearing: the floor + # gate is wire-up-ready as a required status check on `develop` + # (the ruleset switch is the open roadmap item in README.md), and + # a missing summary MUST surface as a red upload here rather than + # reach the gate job as a silent "nothing to compare against". + # Pairs with the hard `exit 1` on the gate job's download path — + # together they make the gate fail-closed instead of the previous + # fail-open (silent `exit 0`). + - name: Upload coverage summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-summary + path: coverage/lcov.summary + if-no-files-found: error + + # Hard-fails the build when scoped coverage drops below the committed floor. + # Two flat repo-root files hold the integers: `.coverage-floor-lines` and + # `.coverage-floor-functions`. They are diffable, grep-able, and require + # no `yq`/JSON tooling in the runner — same rationale as the rest of this + # workflow: keep the gate readable in a `git blame`, not buried in YAML. + # + # Lives in its own job (not inline in `Analyze & Test`) so it can be set + # as a separately required status check in branch protection — a single + # job name on `Analyze & Test` would let the floor regress without + # blocking merge if it stayed inline. The job graph is: + # build ─► coverage-floor (sequential; needs the summary artifact) + # build ║ bitbox-audit (parallel; informational only) + # + # Ratchet protocol (also documented under README "Coverage infrastructure + # roadmap"): + # * Raising the floor is encouraged on every PR that raises measured + # coverage — bump the file in the same commit and the gate moves up. + # * Lowering the floor needs a reviewer's explicit OK. PR convention is + # the `coverage:lower-floor` label so the regression is visible at + # a glance in the PR list rather than being smuggled in. + # + # Why pure bash + awk instead of `lcov --fail-under-*`: + # the `--fail-under-lines` flag arrived in lcov 2.0 and earlier runner + # images may still pull 1.x out of the package manager. Comparing + # `52.9` against `51` with awk sidesteps that and stays portable. + # + # On the "no data found" path for the functions metric: + # `flutter test --coverage` emits LF/LH (line execution) and BRF/BRH + # (branch) records, but not FN/FNF/FNH (function execution) — that's a + # Flutter limitation, not a repo bug. When the summary reports + # "no data found" for functions, the gate emits a workflow warning + # instead of comparing against an empty value. This is intentionally + # NOT a silent skip: the warning surfaces in the run summary so a + # future Flutter release adding FN records doesn't go unnoticed (and + # the floor file stays committed so the gate activates the moment + # real data appears). + # + # On the missing `coverage/lcov.summary` path: + # the gate fails CLOSED with `exit 1`. Previously this was a silent + # `exit 0` warning, which made the gate effectively advisory — a broken + # upstream filter (e.g. lcov.info missing, brew install failure) would + # skip the gate without anyone noticing. The new behaviour is: if + # `Analyze & Test` did not produce a summary, the gate is red, the PR + # is blocked, and the reviewer sees exactly where the pipeline broke. + coverage-floor: + name: Coverage Floor Gate + needs: build + # Same draft guard as `build`: skip drafts, always run on push/dispatch. + # `build` already enforces this, but mirroring it here keeps the gate's + # behaviour locally readable instead of inferred from `needs:`. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Download coverage summary + uses: actions/download-artifact@v4 + with: + name: coverage-summary + path: coverage + + - name: Enforce coverage floor + run: | + set -euo pipefail + if [ ! -f coverage/lcov.summary ]; then + echo "::error::coverage/lcov.summary not found after artifact download — the build job did not produce a scoped coverage summary" + exit 1 + fi + if [ ! -f .coverage-floor-lines ] || [ ! -f .coverage-floor-functions ]; then + echo "::error::missing .coverage-floor-lines or .coverage-floor-functions at repo root" + exit 1 + fi + LINES_FLOOR="$(cat .coverage-floor-lines)" + FUNCS_FLOOR="$(cat .coverage-floor-functions)" + # Lenient anchored match: `\s*lines\.+:\s*52.9%` and the functions + # equivalent. lcov varies the number of dots between releases, so + # the `\.+` repetition is load-bearing. Captures the bare number, + # no `%` suffix. `sed -E` is portable across BSD and GNU sed — + # gawk's 3-arg `match()` is not, so we stick to sed. + LINES_NOW="$(sed -nE 's/^[[:space:]]*lines\.+:[[:space:]]*([0-9.]+)%.*/\1/p' coverage/lcov.summary | head -n1)" + FUNCS_NOW="$(sed -nE 's/^[[:space:]]*functions\.+:[[:space:]]*([0-9.]+)%.*/\1/p' coverage/lcov.summary | head -n1)" + echo "lines: measured=${LINES_NOW:-}% floor=${LINES_FLOOR}%" + echo "functions: measured=${FUNCS_NOW:-}% floor=${FUNCS_FLOOR}%" + if [ -z "${LINES_NOW}" ]; then + echo "::error::could not parse lines coverage from coverage/lcov.summary" + exit 1 + fi + if ! awk -v now="${LINES_NOW}" -v floor="${LINES_FLOOR}" 'BEGIN { exit !(now+0 >= floor+0) }'; then + echo "::error::lines coverage ${LINES_NOW}% below floor ${LINES_FLOOR}% — see README \"Coverage infrastructure roadmap\"" + exit 1 + fi + if [ -z "${FUNCS_NOW}" ]; then + echo "::warning::functions coverage not reported by lcov (Flutter does not emit FN records) — floor ${FUNCS_FLOOR}% retained for when upstream adds support" + else + if ! awk -v now="${FUNCS_NOW}" -v floor="${FUNCS_FLOOR}" 'BEGIN { exit !(now+0 >= floor+0) }'; then + echo "::error::functions coverage ${FUNCS_NOW}% below floor ${FUNCS_FLOOR}% — see README \"Coverage infrastructure roadmap\"" + exit 1 + fi + fi + + # Visual regression tests on the dfx01 self-hosted runner. Goldens live + # under `test/goldens/` and are validated pixel-for-pixel against committed + # baselines under `test/goldens/screens/**/goldens/macos/`. The Mac Studio + # M3 Ultra is the authoritative render target — its baselines are committed, + # and PRs that drift from them fail this job. Bootstrap and re-generation + # is documented in `docs/visual-regression-tests.md`. + # + # Runs parallel to `build` (which uses GitHub-hosted macos-latest). The + # separation is intentional: + # * `build` covers analyze + unit/widget tests + coverage — needs no + # hardware determinism. + # * `golden-tests` covers pixel-exact rendering — must run on dfx01 so + # baselines and validation use identical Skia/font-rendering state. + # + # On dfx01 outage: temporarily flip `runs-on:` to `macos-15` and regenerate + # baselines in the same PR (see `docs/visual-regression-tests.md`). + golden-tests: + name: Visual Regression + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: true + - run: flutter pub get + - run: dart run tool/generate_localization.dart + - run: dart run tool/generate_release_info.dart + - run: flutter pub run build_runner build + - name: Run visual regression tests + run: flutter test test/goldens + - name: Upload golden diff on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-diffs + path: test/goldens/**/failures/** + if-no-files-found: ignore + + # Runs the bitbox-audit CLI from DFXswiss/bitbox-testkit to surface which + # of the documented BitBox firmware quirks are statically detected in this + # repo and which still need runtime coverage. Intentionally non-blocking + # and not part of required_status_checks — purely informational. + bitbox-audit: + name: BitBox quirks audit + # Same guard pattern as `build`: skip drafts, always run on push/dispatch. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.5" + + - name: Install bitbox-audit + continue-on-error: true + run: go install github.com/DFXswiss/bitbox-testkit/go/cmd/bitbox-audit@v0.5.0 + + - name: Run bitbox-audit + continue-on-error: true + run: | + set -euo pipefail + "$(go env GOPATH)/bin/bitbox-audit" \ + --repo . \ + --format markdown \ + --output bitbox-audit-report.md + + - name: Inline report into run summary + if: always() + continue-on-error: true + run: | + set -euo pipefail + if [ -f bitbox-audit-report.md ]; then + cat bitbox-audit-report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "bitbox-audit-report.md not produced — see job logs." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bitbox-audit-report + path: bitbox-audit-report.md + if-no-files-found: warn diff --git a/.github/workflows/beta-release.yaml b/.github/workflows/release.yaml similarity index 62% rename from .github/workflows/beta-release.yaml rename to .github/workflows/release.yaml index c30ffd01..8732c036 100644 --- a/.github/workflows/beta-release.yaml +++ b/.github/workflows/release.yaml @@ -1,4 +1,22 @@ -name: Beta Release +name: Release + +# Single store-release pipeline. Triggers on every plain `vX.Y.Z` tag and +# routes based on the PATCH component: +# +# * PATCH == 0 (vX.Y.0) → Production Release Candidate. Manually pushed +# MAJOR/MINOR tag marking an App-Store-update +# candidate. GitHub release is created with +# prerelease: false. +# * PATCH >= 1 (vX.Y.Z) → Internal Release. Tag produced by auto-tag on +# develop. GitHub release is created with +# prerelease: true. +# +# Both lanes ship to the same Test tracks (TestFlight + Play Internal). +# Production promotion is still done manually in the store backends. +# +# The `concurrency: store-release` mutex serialises store uploads so a fast +# back-to-back push of `vX.Y.0` followed by `vX.Y.1` cannot race for the +# same store slot. on: workflow_dispatch: @@ -9,14 +27,65 @@ on: permissions: contents: write +concurrency: + group: store-release + cancel-in-progress: false + jobs: + guard: + name: Inspect tag and decide release lane + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + proceed: ${{ steps.check.outputs.proceed }} + is_prerelease: ${{ steps.check.outputs.is_prerelease }} + steps: + - name: Inspect tag + id: check + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + # Strict shape: vX.Y.Z with all parts numeric, no suffix. + if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::notice::Tag $TAG is not a plain vX.Y.Z tag; skipping release." + echo "proceed=false" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + exit 0 + fi + PATCH=$(echo "$TAG" | cut -d. -f3) + if [ "$PATCH" -eq 0 ]; then + echo "::notice::Tag $TAG has PATCH=0; running production-candidate release." + echo "proceed=true" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + else + echo "::notice::Tag $TAG has PATCH=$PATCH; running internal release." + echo "proceed=true" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + fi + + store-metadata-preflight: + # The beta lanes push the store listing (metadata + screenshots) to the + # live consoles alongside the binary, so gate the whole release on the + # same FIXME/character-limit checks that store-metadata.yaml enforces — + # a tag must never ship a FIXME placeholder or oversize field. + needs: guard + if: needs.guard.outputs.proceed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Reject FIXME placeholders + enforce character limits + run: bash scripts/check-store-metadata.sh + android-deploy: - if: ${{ !contains(github.ref_name, 'beta') }} + needs: [guard, store-metadata-preflight] + if: needs.guard.outputs.proceed == 'true' runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v4 # ------------------- # Setup Environment @@ -68,6 +137,7 @@ jobs: PLAY_STORE_JSON: ${{ secrets.PLAY_STORE_JSON_BASE64 }} ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | + set -euo pipefail echo "$PLAY_STORE_JSON" | base64 --decode > android/credentials.json echo "$ANDROID_KEYSTORE" | base64 --decode > android/app/upload-keystore.jks @@ -77,6 +147,7 @@ jobs: KEY_PWD: ${{ secrets.ANDROID_KEY_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | + set -euo pipefail cat < android/key.properties storeFile=upload-keystore.jks storePassword=$STORE_PWD @@ -88,6 +159,7 @@ jobs: run: | flutter pub get dart run tool/generate_localization.dart + dart run tool/generate_release_info.dart --tag=${{ github.ref_name }} dart run build_runner build --delete-conflicting-outputs working-directory: ${{ github.workspace }} @@ -95,9 +167,9 @@ jobs: # Build & Deploy # ------------------- - - name: Deploy to Play Store (Beta) + - name: Upload to Play Store run: bundle exec fastlane beta - working-directory: ./android + working-directory: android env: NEW_VERSION: ${{ github.ref_name }} @@ -108,8 +180,10 @@ jobs: path: build/app/outputs/flutter-apk/realunit-*.apk ios-deploy: - if: ${{ !contains(github.ref_name, 'beta') }} + needs: [guard, store-metadata-preflight] + if: needs.guard.outputs.proceed == 'true' runs-on: macos-26 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -159,6 +233,7 @@ jobs: run: | flutter pub get dart run tool/generate_localization.dart + dart run tool/generate_release_info.dart --tag=${{ github.ref_name }} dart run build_runner build --delete-conflicting-outputs working-directory: ${{ github.workspace }} @@ -189,22 +264,34 @@ jobs: # Build & Deploy # ------------------- - - name: Deploy to App Store (Beta) + - name: Upload to TestFlight run: bundle exec fastlane beta --verbose working-directory: ios env: NEW_VERSION: ${{ github.ref_name }} - - name: Upload ipa artifact + - name: Upload IPA artifact uses: actions/upload-artifact@v4 with: name: ios-ipa path: ios/realunit-*.ipa + # Remove the App Store Connect .p8 private key from the runner + # workspace once Fastlane has finished. Hosted runners are + # ephemeral so the file would be discarded anyway when the VM + # is recycled, but `actions/upload-artifact` above globs from + # the workspace and a stray `AuthKey.p8` would otherwise be + # eligible for inclusion in any future artifact whose `path` + # widened. Defence-in-depth — costs nothing. + - name: Clean up App Store Connect key + if: always() + run: rm -f "${FASTLANE_APPLE_API_KEY_PATH:-${{ github.workspace }}/AuthKey.p8}" || true + github-release: - if: ${{ !contains(github.ref_name, 'beta') }} - needs: [android-deploy, ios-deploy] + needs: [guard, android-deploy, ios-deploy] + if: needs.guard.outputs.proceed == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -217,23 +304,23 @@ jobs: "template": "#{{CHANGELOG}}", "categories": [ { - "title": "## 🚀 Features", + "title": "## Features", "labels": ["feat", "feature"] }, { - "title": "## 🐛 Fixes", + "title": "## Fixes", "labels": ["fix", "bug"] }, { - "title": "## 📦 Refactor", + "title": "## Refactor", "labels": ["refactor"] }, { - "title": "## 📦 Others", + "title": "## Others", "labels": ["chore"] }, { - "title": "## 🌀 Not labelled", + "title": "## Not labelled", "labels": [] } ], @@ -271,6 +358,6 @@ jobs: realunit-*.ipa generate_release_notes: false draft: false - prerelease: false + prerelease: ${{ needs.guard.outputs.is_prerelease == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/store-metadata.yaml b/.github/workflows/store-metadata.yaml new file mode 100644 index 00000000..a8ee56e1 --- /dev/null +++ b/.github/workflows/store-metadata.yaml @@ -0,0 +1,115 @@ +name: Store metadata sync + +on: + push: + branches: [main] + paths: + - 'ios/fastlane/metadata/**' + - 'ios/fastlane/screenshots/**' + - 'android/fastlane/metadata/**' + workflow_dispatch: + inputs: + platform: + description: 'Which store to push to' + required: true + default: 'both' + type: choice + options: [ios, android, both] + +permissions: + contents: read + +concurrency: + group: store-metadata-${{ github.ref }} + cancel-in-progress: false # never abort an in-flight upload + +jobs: + preflight: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v5 + - name: Reject FIXME placeholders + enforce character limits + run: bash scripts/check-store-metadata.sh + + ios: + needs: preflight + if: ${{ github.event_name == 'push' || github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' }} + runs-on: macos-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + working-directory: ios + + - name: Install matching bundler + run: gem install bundler -v 2.7.2 + + - name: Install gems + run: bundle _2.7.2_ install + working-directory: ios + + - name: Write Apple API key + export env (mirrors release.yaml pattern) + run: | + set -euo pipefail + KEY_PATH="${{ github.workspace }}/AuthKey.p8" + echo "$APP_STORE_CONNECT_KEY" > "$KEY_PATH" + echo "FASTLANE_APPLE_API_KEY_PATH=$KEY_PATH" >> $GITHUB_ENV + echo "FASTLANE_APPLE_API_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> $GITHUB_ENV + echo "FASTLANE_APPLE_API_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> $GITHUB_ENV + env: + APP_STORE_CONNECT_KEY: ${{ secrets.APP_STORE_CONNECT_KEY }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + + - name: Run deliver (metadata-only) + run: bundle exec fastlane store_metadata --verbose + working-directory: ios + env: + FASTLANE_HIDE_CHANGELOG: 1 + + - name: Cleanup + if: always() + run: rm -f "${FASTLANE_APPLE_API_KEY_PATH:-${{ github.workspace }}/AuthKey.p8}" || true + + android: + needs: preflight + if: ${{ github.event_name == 'push' || github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + working-directory: android + + - name: Install matching bundler + run: gem install bundler -v 2.7.2 + + - name: Install gems + run: bundle _2.7.2_ install + working-directory: android + + - name: Decode Play Store service-account JSON (mirrors release.yaml pattern) + env: + PLAY_STORE_JSON: ${{ secrets.PLAY_STORE_JSON_BASE64 }} + run: | + set -euo pipefail + echo "$PLAY_STORE_JSON" | base64 --decode > android/credentials.json + + - name: Run supply (metadata-only) + run: bundle exec fastlane store_metadata + working-directory: android + + - name: Cleanup + if: always() + run: rm -f android/credentials.json || true diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml new file mode 100644 index 00000000..eb83f5f2 --- /dev/null +++ b/.github/workflows/tier3-handbook.yaml @@ -0,0 +1,305 @@ +name: Tier 3 — Handbook flows + +# Drives every `.maestro/handbook/*.yaml` flow against a freshly-erased iOS +# Simulator and uploads the per-flow diagnostic captures as a build artifact +# for forensic inspection on assertion failure (pixel drift is owned by the +# Visual Regression job — see "Why no pixel-diff here" below). Tier 3 in the +# five-tier model defined in `docs/testing.md`: real BitBox hardware is +# explicitly out of scope here — the handbook flows only need the software +# wallet path, so a hosted macOS runner is enough. +# +# Trigger model: +# * `pull_request` (any target branch) with a `tier3:full` label gate. +# macOS runner minutes are scarce; running on every PR would dwarf +# the existing Analyze & Test job. Reviewers opt-in by labelling +# the PR. The label gate is the cost control, not the branch +# filter — stacked PRs against integration branches need the same +# opt-in option. +# * `push: develop` always runs — post-merge verification is the +# authoritative source of truth for "did the handbook flows survive +# this merge", independent of any PR state. Same reasoning as +# `pull-request.yaml`. +# * `workflow_dispatch` for manual catch-up runs and a re-run path when +# the macOS image flips an Xcode default and we need to verify the fix +# before re-arming the gate. Always runs (label gate doesn't apply). +# * `labeled` / `unlabeled` keep the trigger surface in sync with the +# `tier3:full` opt-in: adding the label after the PR opened starts a +# run; removing it cancels via the concurrency block below. +# +# Concurrency: +# Same pattern as `pull-request.yaml`: group by `pull_request.number` +# (or `github.ref` for push/dispatch) so a new push to the same PR +# cancels the in-flight run. Grouping by SHA (the previous approach) +# put every commit in its own group, which meant no cancellation +# ever happened and back-to-back pushes queued sequentially on +# scarce macOS runners. The exception: `labeled` / `unlabeled` events +# get their own `ci--label-` group so a label toggle +# does NOT kill an already-running ~15-minute flow execution — the user +# experience of "I added the label, the previous run died" is worse +# than the cost of one extra runner-minute. +# +# Locale: scripts/run-handbook-flows.sh pins the booted simulator to de_CH +# so German handbook assertions pass on `macos-latest` runners (default +# en_US). Local re-captures inherit the same locale → screenshots stable. +# +# Why `simctl erase` per run (not `clearState`): +# `scripts/run-handbook-flows.sh` already does this, but worth restating +# here because it drives the choice of macOS runner. The wallet seed and +# PIN live in the iOS Keychain, which survives both Maestro `clearState` +# and an app reinstall. A booted simulator from a prior run would start +# on the lock screen instead of the welcome flow, breaking every +# assertion downstream. `simctl erase` wipes the whole device — the +# only known-good clean-state for this purpose. +# +# Maestro version pin: +# Maestro 2.3.0–2.5.1 has two intermittent failure modes on Apple +# Silicon + iOS 26.x simulators: (a) XCUITest driver hangs during +# startup with zero stdout, (b) `tapOnElement` reports completed but +# the Flutter app never receives the touch event (verified via +# Maestro `--debug-output`: captured screenshot shows unchanged +# screen after tap). Both regressions tracked upstream in +# mobile-dev-inc/maestro#3137 (closed by upstream with the 2.0.10 +# workaround). We pin via `.maestro-version` (today: `2.0.10`), +# which per the #3137 thread runs ~20 sequential flows reliably +# with ~10 % residual Apple-XCTest crash on screen transitions. +# The retry loop in `scripts/run-handbook-flows.sh` stays in place +# as a safety net for that residual crash class, retrying only on +# `IOSDriverTimeoutException` — assertion failures are never +# retried. The post-mortem block (lsof / ps / simctl log + Maestro +# `--debug-output`) captures failure state so future regressions +# can be diagnosed forensically. The CI-hardening track that landed +# this guard was DFXswiss/realunit-app#487 (now closed). +# +# Why no pixel-diff here: +# Pixel drift on the page renders is owned by the Visual Regression job +# in `pull-request.yaml` (alchemist Goldens on the dfx01 hardware-pinned +# runner). Tier-3 catches the regressions Goldens cannot see — broken +# tap routing, missing navigation, locale/`Intl` problems, iOS-build +# and `simctl install` failures. The per-flow `assertVisible` / +# `extendedWaitUntil` covers those structural modes; the diagnostic +# captures under `build/handbook-captures/` are forensic artefacts for +# assertion failures, not a screenshot baseline. +# +# Why `iPhone 17` specifically: +# The `.maestro/handbook/*.yaml` flows use absolute tap points (e.g. +# `point: 50%,65%`) and assertions on text that sits inside the +# iPhone-17 safe-area layout. Any other device class would either +# reflow elements out of the tap-coordinate path or shift safe-area +# insets in a way that breaks the assertions. When the macOS runner +# image stops shipping iPhone 17 by default, bump this here AND +# re-verify all 26 flows on the new device (run +# `scripts/run-handbook-flows.sh` locally; assertion failures point +# at flows that need their coordinates / waits updated). +# +# Cross-reference: README "CI/CD" table + `docs/testing.md` Tier 3 entry. +on: + workflow_dispatch: + inputs: + flows: + description: "Space-separated flow-name glob patterns to run (empty = all flows)" + required: false + default: "" + type: string + push: + branches: [develop] + pull_request: + # Every PR target except `main` (the release lane is already + # covered by `push: develop` on the same SHA — see the longer + # rationale in `pull-request.yaml`). + branches-ignore: [main] + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +concurrency: + group: >- + ${{ + (github.event.action == 'labeled' || github.event.action == 'unlabeled') + && format('ci-{0}-label-{1}', github.workflow, github.run_id) + || format('ci-{0}-{1}', github.workflow, github.event.pull_request.number || github.ref) + }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + flows: + name: Maestro handbook flows + # PRs only run with the `tier3:full` label. push/dispatch always run. + # Drafts pass through unconditionally on push (they cannot push, so + # the only way here is via a labelled PR) — no extra `draft == false` + # guard needed. + if: >- + github.event_name != 'pull_request' + || contains(github.event.pull_request.labels.*.name, 'tier3:full') + runs-on: macos-latest + # ~12 min setup (Flutter + tooling, build, sim erase/boot/install) plus + # ~26-33 min for the 26 handbook flows — each flow restarts the XCUITest + # driver (~40-60 s). A full run lands around 40-46 min; 60 min gives + # headroom for runner-speed variance and the per-flow retry budget. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: true + + - run: flutter pub get + - run: dart run tool/generate_localization.dart + - run: dart run tool/generate_release_info.dart + - run: flutter pub run build_runner build + + # Bind the iOS cache key to the active Xcode / iOS-SDK version so + # cached `.pcm` module files are not reused across an SDK rev. Without + # this, the cache key matched on `runner.os + lock files` only and + # restored a cached `SwiftShims-*.pcm` whose `module.modulemap` mtime + # no longer matched the SDK on disk — Swift then rejected the cached + # module and the build failed with "module file ... was built: mtime + # changed". Pinning the cache to `xcodebuild -version` invalidates the + # cache the moment the runner image upgrades Xcode. + - name: Resolve Xcode version + id: xcode + run: echo "version=$(xcodebuild -version | tr '\n' '-' | sed 's/[^A-Za-z0-9.-]//g; s/-$//')" >> "$GITHUB_OUTPUT" + + # Cache iOS DerivedData (compiled Flutter modules) + Pods (Cocoapods + # dependencies). On a cache hit, `flutter build ios --simulator + # --debug` reuses the cached objects and the build time drops from + # ~5-6 min to ~2-3 min. Cache-Key invalidates on pubspec.lock or + # Podfile.lock changes — i.e. any dependency bump forces a clean + # build — and additionally on Xcode version (see step above). The + # restore-keys fallback allows partial cache reuse if the lock files + # moved but the SDK is unchanged. + - name: Cache iOS DerivedData + Pods + uses: actions/cache@v4 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ios/Pods + key: ios-derived-data-${{ runner.os }}-${{ steps.xcode.outputs.version }}-${{ hashFiles('pubspec.lock', 'ios/Podfile.lock') }} + restore-keys: | + ios-derived-data-${{ runner.os }}-${{ steps.xcode.outputs.version }}- + + - name: Build iOS simulator app + run: flutter build ios --simulator --debug + + # `scripts/run-handbook-flows.sh` does its own `simctl shutdown / erase / + # boot / bootstatus / install` once it has a booted UDID. We just need + # ANY iPhone 17 simulator in `Booted` state for the script to discover. + # + # UDID resolution: + # `macos-latest` pre-creates three devices literally named "iPhone 17" + # across iOS 26.0 / 26.1 / 26.2 runtimes. We resolve a UDID explicitly + # instead of `simctl boot "iPhone 17"` so the device choice is + # deterministic and visible in the workflow log. The resolver filters + # to available `iPhone 17` devices on any `iOS-26` runtime, then sorts + # by the numeric tuple of the trailing version segments — so an + # eventual `iOS-26-10` outranks `iOS-26-2` (a lexicographic sort would + # pick the wrong one because `"2" > "1"` as strings). + # Fails loudly with `sys.exit` if no iPhone 17 is available on any + # iOS-26 runtime: we deliberately do NOT fall back to another model + # or major version, because a missing iPhone 17 means the runner + # image changed and the screenshots need re-capturing in the same PR. + - name: Boot iOS Simulator + run: | + set -euo pipefail + UDID="$(xcrun simctl list devices available --json | /usr/bin/python3 -c " + import json, sys + def runtime_key(rt): + # 'com.apple.CoreSimulator.SimRuntime.iOS-26-2' -> (26, 2) + # Anything non-numeric in the trailing chunks → -1, so a + # malformed entry sorts below well-formed ones rather than + # crashing the resolver. + parts = rt.split('iOS-', 1)[-1].split('-') + return tuple(int(p) if p.isdigit() else -1 for p in parts) + devices = json.load(sys.stdin)['devices'] + candidates = [] + for runtime, devs in devices.items(): + if 'iOS-26' not in runtime: + continue + for d in devs: + if d.get('name') == 'iPhone 17' and d.get('isAvailable'): + candidates.append((runtime, d['udid'])) + if not candidates: + sys.exit('no available iPhone 17 device on an iOS 26.x runtime') + # Sort by numeric runtime tuple descending → highest patch wins, + # so iOS-26-10 beats iOS-26-2 once Apple ships that. + candidates.sort(key=lambda c: runtime_key(c[0]), reverse=True) + print(candidates[0][1]) + ")" + echo "Booting iPhone 17 UDID=$UDID" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + + - name: Install Maestro CLI + run: | + set -euo pipefail + # `.maestro-version` is committed; if it ever goes missing or + # empty, fail loudly here rather than letting Maestro's installer + # try to fetch `cli-/maestro.zip` and crash on a 404 with a + # confusing error message downstream. + MAESTRO_VERSION="$(cat .maestro-version)" + if [ -z "$MAESTRO_VERSION" ]; then + echo "::error::.maestro-version is empty or missing at repo root" + exit 1 + fi + export MAESTRO_VERSION + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + # The script handles per-flow execution, screenshot staging via /tmp + # (CoreSimulator can't write to ~/Documents under macOS TCC), and the + # full simctl reset cycle. Don't duplicate any of that logic here — + # any change in flow handling should land in the script so manual runs + # and CI runs stay in sync. + # + # MAESTRO_DRIVER_STARTUP_TIMEOUT: hosted macOS runners are noisy + # neighbours — observed `simctl install Runner.app` taking ~90 s on + # a slow run (vs. ~10 s typical). Maestro's default 90 s XCUITest + # driver startup window then runs out before the simulator is + # fully responsive, producing `IOSDriverTimeoutException`. Bump to + # 300 s so the driver has headroom even when the runner is loaded. + # MAESTRO_CLI_NO_ANALYTICS skips the startup analytics HTTP call. + # Pre-flight diagnostic state: tool versions + simulator runtimes + # + loopback config. Captured unconditionally because the cheapest + # moment to diagnose a hang is "what did the environment look like + # right before the run". Cross-references the post-mortem block + # in scripts/run-handbook-flows.sh. + - name: Diagnostics — pre-flight network + tool state + run: | + set +e + echo "=== /etc/hosts ===" + cat /etc/hosts + echo + echo "=== ifconfig lo0 ===" + ifconfig lo0 + echo + echo "=== xcodebuild -version ===" + xcodebuild -version + echo + echo "=== maestro --version ===" + "$HOME/.maestro/bin/maestro" --version + echo + echo "=== xcrun simctl list runtimes ===" + xcrun simctl list runtimes | grep -i ios + + - name: Run handbook flows + env: + MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" + MAESTRO_CLI_NO_ANALYTICS: "1" + HANDBOOK_FLOWS: ${{ inputs.flows }} + run: | + # set -f: keep word-splitting (multiple patterns) but disable this + # shell's filename expansion, so a pattern like `2*` reaches the + # script literally instead of being glob-expanded against the cwd. + set -f + scripts/run-handbook-flows.sh $HANDBOOK_FLOWS + + - name: Upload Tier-3 navigation-smoke captures + if: always() + uses: actions/upload-artifact@v4 + with: + name: handbook-captures + path: build/handbook-captures/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 41d9be21..ce510890 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,30 @@ app.*.map.json # FVM Version Cache .fvm/ -.fvmrc \ No newline at end of file +.fvmrc + +# Auto-generated at handbook build time from DFXswiss/api +# (see .github/workflows/handbook.yaml — step "Generate RealUnit mail previews +# from api repo"). Never commit; source of truth is the api repo. +docs/handbook/mails/ + +# Assembled at handbook build time from test/goldens/screens/ via +# scripts/assemble-handbook-screenshots.sh (see Dockerfile.handbook). +# Source of truth are the Golden baselines under test/goldens/; this +# directory is only populated transiently for local previews. +docs/handbook/screenshots/ + +# Built at handbook build time from assets/legal/*.md via pandoc +# (scripts/build-legal-downloads.sh, see Dockerfile.handbook). The PDF/DOCX are +# NON-deterministic (pandoc embeds timestamps/metadata), so — unlike the +# committed legal-downloads HTML block in de/index.html — they are never +# committed and never sync-gated; they exist only inside the image. This +# directory holds the assembly output for local previews only. +docs/handbook/legal/ + +# Scratch directories produced when reproducing the handbook CI's +# mail-preview generation locally (see docs/handbook/README.md → "E-Mail +# Previews → Lokal regenerieren"). Mirrors the names the CI uses so a +# `git status` after a local repro stays clean. +_api-checkout/ +_handlebars-only/ \ No newline at end of file diff --git a/.maestro-version b/.maestro-version new file mode 100644 index 00000000..0a692060 --- /dev/null +++ b/.maestro-version @@ -0,0 +1 @@ +2.0.10 diff --git a/.maestro/handbook/01-welcome.yaml b/.maestro/handbook/01-welcome.yaml new file mode 100644 index 00000000..efad68db --- /dev/null +++ b/.maestro/handbook/01-welcome.yaml @@ -0,0 +1,12 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/01-welcome.png. +appId: swiss.realunit.app +--- +- launchApp: + appId: swiss.realunit.app + clearState: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Start' + timeout: 30000 diff --git a/.maestro/handbook/02-create-vs-restore.yaml b/.maestro/handbook/02-create-vs-restore.yaml new file mode 100644 index 00000000..19fab7bc --- /dev/null +++ b/.maestro/handbook/02-create-vs-restore.yaml @@ -0,0 +1,34 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/02-create-vs-restore.png. +# Depends on 01-welcome being the previous flow (no clearState, continues from Start screen). +# +# The tap is gated on the next screen not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +# +# Welcome cards are targeted by Semantics id (see +# lib/screens/welcome/welcome_page.dart) instead of regex text-matching: +# both step groups stay in the widget tree at all times (AnimatedSlide +# translates the off-screen group out of view but doesn't remove it), so +# regex text checks against card titles are unreliable across copy +# changes and animation half-states. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + id: 'welcome-software-wallet' + commands: + - tapOn: + text: 'Start' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: 'welcome-software-wallet' + timeout: 30000 diff --git a/.maestro/handbook/03-software-wallet-terms.yaml b/.maestro/handbook/03-software-wallet-terms.yaml new file mode 100644 index 00000000..b84d4d2e --- /dev/null +++ b/.maestro/handbook/03-software-wallet-terms.yaml @@ -0,0 +1,33 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/03-software-wallet-terms.png. +# +# The tap is gated on the next screen not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +# +# Welcome cards are targeted by Semantics id (see +# lib/screens/welcome/welcome_page.dart) instead of regex text-matching: +# both step groups stay in the widget tree at all times (AnimatedSlide +# translates the off-screen group out of view but doesn't remove it), so +# regex text checks against card titles are unreliable across copy +# changes and animation half-states. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + id: 'welcome-create-wallet' + commands: + - tapOn: + id: 'welcome-software-wallet' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: 'welcome-create-wallet' + timeout: 30000 diff --git a/.maestro/handbook/04-seed-hidden.yaml b/.maestro/handbook/04-seed-hidden.yaml new file mode 100644 index 00000000..359424fe --- /dev/null +++ b/.maestro/handbook/04-seed-hidden.yaml @@ -0,0 +1,33 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/04-seed-hidden.png. +# Seed-backup screen with the SeedBlurCard still covered. BackdropFilter screen — +# Maestro's own takeScreenshot would render black; xcrun captures correctly. +# +# The tap is gated on the seed-backup screen not yet showing and re-tapped if +# it was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +# +# The "Neue Wallet erstellen" welcome card is targeted by Semantics id (see +# lib/screens/welcome/welcome_page.dart) instead of regex text-matching: +# both step groups stay in the widget tree at all times (AnimatedSlide +# translates the off-screen group out of view but doesn't remove it), so +# regex text checks against card titles are unreliable across copy +# changes and animation half-states. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Wallet-Sicherung' + commands: + - tapOn: + id: 'welcome-create-wallet' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Wallet-Sicherung' + timeout: 30000 diff --git a/.maestro/handbook/05-seed-revealed.yaml b/.maestro/handbook/05-seed-revealed.yaml new file mode 100644 index 00000000..819f32c8 --- /dev/null +++ b/.maestro/handbook/05-seed-revealed.yaml @@ -0,0 +1,25 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/05-seed-revealed.png. +# Tap the SeedBlurCard (wrapped in ExcludeSemantics, so coordinate-based tap). +# +# The tap is gated on the revealed seed not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# card is revealed, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Ich habe es gesichert' + commands: + - tapOn: + point: 50%,50% + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Ich habe es gesichert' + timeout: 30000 diff --git a/.maestro/handbook/06-verify-seed.yaml b/.maestro/handbook/06-verify-seed.yaml new file mode 100644 index 00000000..65caf1cd --- /dev/null +++ b/.maestro/handbook/06-verify-seed.yaml @@ -0,0 +1,28 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/06-verify-seed.png. +# In kDebugMode the verify-seed cubit auto-fills the required words from the +# generated seed, so the input fields are visibly pre-populated. The screenshot +# documents the verify step UI; the user-facing flow asks the user to type +# the words manually in release builds. +# +# The tap is gated on the verify screen not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Sicherung überprüfen' + commands: + - tapOn: + text: 'Ich habe es gesichert' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Sicherung überprüfen' + timeout: 30000 diff --git a/.maestro/handbook/07-onboarding-completed.yaml b/.maestro/handbook/07-onboarding-completed.yaml new file mode 100644 index 00000000..46f5524d --- /dev/null +++ b/.maestro/handbook/07-onboarding-completed.yaml @@ -0,0 +1,25 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/07-onboarding-completed.png. +# Wallet created — final onboarding confirmation screen "Ihre Wallet ist bereit". +# +# The tap is gated on the confirmation screen not yet showing and re-tapped if +# it was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Weiter' + commands: + - tapOn: + text: 'Bestätigen' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Weiter' + timeout: 30000 diff --git a/.maestro/handbook/08-pin-setup.yaml b/.maestro/handbook/08-pin-setup.yaml new file mode 100644 index 00000000..37045b7a --- /dev/null +++ b/.maestro/handbook/08-pin-setup.yaml @@ -0,0 +1,25 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/08-pin-setup.png. +# Empty PIN setup numpad — "Erstellen Sie Ihre PIN". +# +# The tap is gated on the PIN numpad not yet showing and re-tapped if it was +# silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Erstellen Sie Ihre PIN' + commands: + - tapOn: + text: 'Weiter' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Erstellen Sie Ihre PIN' + timeout: 30000 diff --git a/.maestro/handbook/09-pin-confirm.yaml b/.maestro/handbook/09-pin-confirm.yaml new file mode 100644 index 00000000..6ac387e9 --- /dev/null +++ b/.maestro/handbook/09-pin-confirm.yaml @@ -0,0 +1,26 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/09-pin-confirm.png. +# After entering six 1s, the same screen flips to confirm mode with the title +# "Bestätigen Sie Ihre PIN" (single SetupPinPage with create/confirm states). +# +# Each tap is gated on the confirm screen not yet showing and re-tapped if a +# digit was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + +# iOS 26, mobile-dev-inc/maestro#3137). The gate also stops the loop the +# instant the screen flips, so no surplus tap bleeds into the confirm PIN. +appId: swiss.realunit.app +--- +- repeat: + times: 12 + commands: + - runFlow: + when: + notVisible: 'Bestätigen Sie Ihre PIN' + commands: + - tapOn: + text: '1' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Bestätigen Sie Ihre PIN' + timeout: 30000 diff --git a/.maestro/handbook/10-biometric-prompt.yaml b/.maestro/handbook/10-biometric-prompt.yaml new file mode 100644 index 00000000..010a2cd0 --- /dev/null +++ b/.maestro/handbook/10-biometric-prompt.yaml @@ -0,0 +1,42 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/10-biometric-prompt.png. +# Re-entering the same six 1s. After the PIN is confirmed the app shows a +# bottom-sheet asking whether to enable biometric unlock, but ONLY if the +# simulator has Face ID enrolled (`LocalAuthentication.canCheckBiometrics` +# returns true). A freshly `simctl erase`-ed runner does not have +# enrollment, and Apple removed the `simctl ui ... biometric` subcommand +# around Xcode 26 (verified locally: `simctl ui --help` lists only +# `appearance`, `increase_contrast`, `content_size`), so we cannot force +# enrollment on CI. The flow handles both paths: with enrollment the +# bottom sheet appears ("Überspringen"); without enrollment the app +# proceeds straight to the dashboard ("RealUnit kaufen"). The captured +# screenshot will then show the dashboard instead of the biometric +# prompt — local re-captures on a developer's machine with Face ID +# enrolled produce the documented screenshot. +# +# Each tap is gated on neither post-confirm state being reached yet and +# re-tapped if a digit was silently dropped (Maestro/XCUITest tap-loss on +# Apple Silicon + iOS 26, mobile-dev-inc/maestro#3137). The gate also +# keeps surplus iterations from tapping through the biometric bottom +# sheet on a local re-capture. The closing extendedWaitUntil fails here, +# in this flow, if the PIN never completed — instead of one flow later +# in 11-dashboard with a misleading "RealUnit kaufen not visible". +appId: swiss.realunit.app +--- +- repeat: + times: 12 + commands: + - runFlow: + when: + notVisible: + text: '.*Überspringen.*|.*RealUnit kaufen.*' + commands: + - tapOn: + text: '1' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Überspringen.*|.*RealUnit kaufen.*' + timeout: 30000 diff --git a/.maestro/handbook/11-dashboard.yaml b/.maestro/handbook/11-dashboard.yaml new file mode 100644 index 00000000..a24a2bd0 --- /dev/null +++ b/.maestro/handbook/11-dashboard.yaml @@ -0,0 +1,37 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/11-dashboard.png. +# Skip biometric prompt if visible; land on dashboard. Backend calls +# (portfolio, price, history) may still be in-flight; we use the +# "RealUnit kaufen" action button as the readiness signal — once it +# renders, the page layout is final. The skip tap is conditional +# because the biometric prompt only appears with Face ID enrolled on +# the simulator (see 10-biometric-prompt.yaml header). On CI runners +# without enrollment the app jumps straight to the dashboard and +# there is no "Überspringen" button to tap. +# +# When the prompt is present the skip tap is gated on the dashboard not +# yet showing and re-tapped if it was silently dropped (Maestro/XCUITest +# tap-loss on Apple Silicon + iOS 26, mobile-dev-inc/maestro#3137). The +# gate stops the loop the instant the dashboard appears, so surplus +# iterations no-op. +appId: swiss.realunit.app +--- +- runFlow: + when: + visible: 'Überspringen' + commands: + - repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'RealUnit kaufen' + commands: + - tapOn: + text: 'Überspringen' + optional: true + - waitForAnimationToEnd +- extendedWaitUntil: + visible: 'RealUnit kaufen' + timeout: 30000 diff --git a/.maestro/handbook/12-settings.yaml b/.maestro/handbook/12-settings.yaml new file mode 100644 index 00000000..2491e485 --- /dev/null +++ b/.maestro/handbook/12-settings.yaml @@ -0,0 +1,26 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/12-settings.png. +# Tap the top-right hamburger icon. It's an IconButton(Icons.menu) with no +# tooltip/semantic label, so we tap by coordinate. +# +# The tap is gated on the settings screen not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 91%,10% + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Einstellungen' + timeout: 30000 diff --git a/.maestro/handbook/13-settings-languages.yaml b/.maestro/handbook/13-settings-languages.yaml new file mode 100644 index 00000000..9cfb8288 --- /dev/null +++ b/.maestro/handbook/13-settings-languages.yaml @@ -0,0 +1,26 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/13-settings-languages.png. +# +# The tap is gated on the languages screen not yet showing and re-tapped if it +# was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Deutsch.*' + commands: + - tapOn: + text: '.*Sprachen.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Deutsch.*' + timeout: 5000 diff --git a/.maestro/handbook/14-settings-currency.yaml b/.maestro/handbook/14-settings-currency.yaml new file mode 100644 index 00000000..9a24ecfd --- /dev/null +++ b/.maestro/handbook/14-settings-currency.yaml @@ -0,0 +1,38 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/14-settings-currency.png. +# +# Both taps (back to the settings list, then into the currency screen) are +# gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Schweizer.*' + commands: + - tapOn: + text: '.*Währung.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Schweizer.*' + timeout: 5000 diff --git a/.maestro/handbook/15-settings-network.yaml b/.maestro/handbook/15-settings-network.yaml new file mode 100644 index 00000000..6e2f69ae --- /dev/null +++ b/.maestro/handbook/15-settings-network.yaml @@ -0,0 +1,38 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/15-settings-network.png. +# +# Both taps (back to the settings list, then into the network screen) are +# gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Mainnet.*' + commands: + - tapOn: + text: '.*Netzwerk.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Mainnet.*' + timeout: 5000 diff --git a/.maestro/handbook/16-settings-wallet-address.yaml b/.maestro/handbook/16-settings-wallet-address.yaml new file mode 100644 index 00000000..5a38a3cb --- /dev/null +++ b/.maestro/handbook/16-settings-wallet-address.yaml @@ -0,0 +1,39 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/16-settings-wallet-address.png. +# Receive equivalent — shows the wallet's EVM address + QR code. +# +# Both taps (back to the settings list, then into the wallet-address screen) +# are gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*0x.*' + commands: + - tapOn: + text: '.*Wallet-Adresse.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*0x.*' + timeout: 5000 diff --git a/.maestro/handbook/17-settings-backup-pin.yaml b/.maestro/handbook/17-settings-backup-pin.yaml new file mode 100644 index 00000000..85714d8d --- /dev/null +++ b/.maestro/handbook/17-settings-backup-pin.yaml @@ -0,0 +1,40 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/17-settings-backup-pin.png. +# Tapping "Wallet-Sicherung" routes through the PIN gate before showing the +# recovery phrase. The numeric keypad re-appears with the verify-PIN copy. +# +# Both taps (back to the settings list, then into the wallet-backup PIN gate) +# are gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*PIN.*' + commands: + - tapOn: + text: '.*Wallet-Sicherung.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*PIN.*' + timeout: 5000 diff --git a/.maestro/handbook/18-settings-seed-hidden.yaml b/.maestro/handbook/18-settings-seed-hidden.yaml new file mode 100644 index 00000000..61efc8c3 --- /dev/null +++ b/.maestro/handbook/18-settings-seed-hidden.yaml @@ -0,0 +1,27 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/18-settings-seed-hidden.png. +# After entering the PIN, the settings seed page renders the same SeedBlurCard +# used during onboarding (BackdropFilter — only xcrun can capture it). +# +# Each tap is gated on the seed page not yet showing and re-tapped if a +# digit was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + +# iOS 26, mobile-dev-inc/maestro#3137). +appId: swiss.realunit.app +--- +- repeat: + times: 12 + commands: + - runFlow: + when: + notVisible: + text: '.*Wiederherstellungs.*' + commands: + - tapOn: + text: '1' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Wiederherstellungs.*' + timeout: 30000 diff --git a/.maestro/handbook/19-settings-seed-revealed.yaml b/.maestro/handbook/19-settings-seed-revealed.yaml new file mode 100644 index 00000000..f3328637 --- /dev/null +++ b/.maestro/handbook/19-settings-seed-revealed.yaml @@ -0,0 +1,25 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/19-settings-seed-revealed.png. +# Same BIP-39 grid as 05-seed-revealed; lives behind the PIN gate inside Settings. +# +# The tap is gated on the reveal prompt still showing and re-tapped if it was +# silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). The gate stops the loop the instant the +# prompt disappears, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + visible: 'Hier tippen, um anzuzeigen' + commands: + - tapOn: + point: 50%,65% + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + notVisible: 'Hier tippen, um anzuzeigen' + timeout: 30000 diff --git a/.maestro/handbook/20-settings-legal-documents.yaml b/.maestro/handbook/20-settings-legal-documents.yaml new file mode 100644 index 00000000..d7529872 --- /dev/null +++ b/.maestro/handbook/20-settings-legal-documents.yaml @@ -0,0 +1,41 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/20-settings-legal-documents.png. +# Continues from 19-settings-seed-revealed (no clearState). The seed page is +# pushed on top of the Settings menu, so we tap back to the menu, then open +# "Rechtsdokumente" -> SettingsLegalDocumentsPage. +# +# Both taps (back to the settings list, then into the legal-documents screen) +# are gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Dokumente der Aktionariat AG.*' + commands: + - tapOn: + text: '.*Rechtsdokumente.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Dokumente der Aktionariat AG.*' + timeout: 30000 diff --git a/.maestro/handbook/21-settings-aktionariat-documents.yaml b/.maestro/handbook/21-settings-aktionariat-documents.yaml new file mode 100644 index 00000000..97dbe249 --- /dev/null +++ b/.maestro/handbook/21-settings-aktionariat-documents.yaml @@ -0,0 +1,30 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/21-settings-aktionariat-documents.png. +# Continues from 20-settings-legal-documents. From SettingsLegalDocumentsPage +# tap the "Dokumente der Aktionariat AG" entry -> SettingsAktionariatDocumentsPage. +# The settle target is "Impressum", which is one of the four document tiles on +# the Aktionariat sub-page and is not present on SettingsLegalDocumentsPage. +# +# The tap is gated on the Aktionariat sub-page not yet showing and re-tapped +# if it was silently dropped (Maestro/XCUITest tap-loss on Apple Silicon + +# iOS 26, mobile-dev-inc/maestro#3137). The gate stops the loop the instant +# the screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Impressum.*' + commands: + - tapOn: + text: '.*Dokumente der Aktionariat AG.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Impressum.*' + timeout: 30000 diff --git a/.maestro/handbook/22-settings-dfx-documents.yaml b/.maestro/handbook/22-settings-dfx-documents.yaml new file mode 100644 index 00000000..06273f7e --- /dev/null +++ b/.maestro/handbook/22-settings-dfx-documents.yaml @@ -0,0 +1,43 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/22-settings-dfx-documents.png. +# Continues from 21-settings-aktionariat-documents. Tap back to +# SettingsLegalDocumentsPage, then open the "Dokumente der DFX AG" entry +# -> SettingsDfxDocumentsPage. The settle target is "Allgemeine +# Geschäftsbedingungen", the DFX terms tile, which is unique to this sub-page. +# +# Both taps (back to the legal-documents list, then into the DFX sub-page) +# are gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Dokumente der DFX AG.*' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Allgemeine Geschäftsbedingungen.*' + commands: + - tapOn: + text: '.*Dokumente der DFX AG.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Allgemeine Geschäftsbedingungen.*' + timeout: 30000 diff --git a/.maestro/handbook/23-settings-contact.yaml b/.maestro/handbook/23-settings-contact.yaml new file mode 100644 index 00000000..ed5fda16 --- /dev/null +++ b/.maestro/handbook/23-settings-contact.yaml @@ -0,0 +1,87 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/23-settings-contact.png. +# Continues from 22-settings-dfx-documents (no clearState). The DFX-documents +# sub-page is pushed on top of SettingsLegalDocumentsPage, which in turn sits +# on the Settings menu. We tap back twice to return to the Settings menu, then +# open "Kontakt" -> SettingsContactPage. +# +# Settle target: the AppBar title "Kontakt" is visible WHILE +# "Wallet-Adresse" (a settings-menu-only tile, not present on the contact +# page) has disappeared. This combination uniquely identifies the contact +# page without depending on `/v1/company-info/RealUnit`. +# +# Why NOT settle on "info@realunit.ch": +# The email tile is rendered from `state.companyInfo.email` in +# SettingsContactCubit (PR #499), which fetches `/v1/company-info/RealUnit`. +# That endpoint currently returns 404 on both api.dfx.swiss and +# dev.api.dfx.swiss — the backend side of #499 is not yet deployed. The +# cubit swallows the company-info error (`catchError -> null`) so the page +# still renders, but the email/phone/website/imprint tiles all collapse to +# `SizedBox.shrink()`. Asserting on the email string therefore times out +# even though the page navigation itself succeeded. The Support tile is +# also unreliable as a target: it only renders when +# `UserCapabilitiesDto.supportAvailable` is true, which defaults to false +# for freshly-created handbook wallets. +# +# Once the backend exposes `/v1/company-info/RealUnit`, this flow can be +# tightened back to assert on the email tile — see PR #515 for context. +# +# The first back tap gates on "Dokumente der Aktionariat AG" — that tile lives +# on SettingsLegalDocumentsPage but not on the DFX sub-page, so it cleanly +# marks arrival at the legal-documents list. ("Dokumente der DFX AG" would not +# work here: it is also the title of the DFX sub-page we are leaving.) +# +# All three taps (back to the legal-documents list, back to the Settings menu, +# then into the contact screen) are gated on their target not yet showing and +# re-tapped if one was silently dropped (Maestro/XCUITest tap-loss on Apple +# Silicon + iOS 26, mobile-dev-inc/maestro#3137). Each gate stops its loop the +# instant the screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Dokumente der Aktionariat AG.*' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +# Gate the "Kontakt" tap on "Wallet-Adresse" still being visible — i.e. we +# are still on the settings menu and the tap has not yet taken effect. Once +# we land on SettingsContactPage, "Wallet-Adresse" disappears from the tree +# and the loop stops re-tapping immediately. +- repeat: + times: 3 + commands: + - runFlow: + when: + visible: + text: '.*Wallet-Adresse.*' + commands: + - tapOn: + text: 'Kontakt' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + notVisible: + text: '.*Wallet-Adresse.*' + timeout: 30000 +- extendedWaitUntil: + visible: 'Kontakt' + timeout: 30000 diff --git a/.maestro/handbook/24-settings-delete-wallet.yaml b/.maestro/handbook/24-settings-delete-wallet.yaml new file mode 100644 index 00000000..18c695bc --- /dev/null +++ b/.maestro/handbook/24-settings-delete-wallet.yaml @@ -0,0 +1,45 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/24-settings-delete-wallet.png. +# Continues from 23-settings-contact (no clearState). The contact page is +# pushed on top of the Settings menu, so we tap back to the menu, then open +# "Geschäftsbeziehung beenden". That entry does not navigate to a new page — +# it presents the SettingsConfirmLogoutWalletSheet modal, the confirmation +# step for terminating the business relationship and signing out of the +# wallet. This flow documents that sheet: it stops on the modal and does NOT +# tick the confirmation checkbox or tap "Abmelden". +# +# Both taps (back to the Settings menu, then opening the terminate entry) are +# gated on their target not yet showing and re-tapped if one was silently +# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, +# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the +# sheet/screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: 'Einstellungen' + commands: + - tapOn: + point: 8%,8% + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Aus REALU Wallet abmelden.*' + commands: + - tapOn: + text: '.*Geschäftsbeziehung beenden.*' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Aus REALU Wallet abmelden.*' + timeout: 30000 diff --git a/.maestro/handbook/25-restore-wallet.yaml b/.maestro/handbook/25-restore-wallet.yaml new file mode 100644 index 00000000..182dd19c --- /dev/null +++ b/.maestro/handbook/25-restore-wallet.yaml @@ -0,0 +1,113 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/25-restore-wallet.png. +# Continues from 24-settings-delete-wallet (no clearState): the +# SettingsConfirmLogoutWalletSheet modal is showing. We complete the +# termination here — tick the "Ich habe meine Wiederherstellungsphrase +# gesichert." checkbox (which enables the disabled "Abmelden" button), then +# tap "Abmelden". Confirming the logout wipes the wallet and drops the app +# back to onboarding (welcome). +# +# From there we walk the restore path: from the HomePage splash tap "Start" +# -> WelcomePage, tap the Software-Wallet card to flip the welcome toggle +# to step 2, then tap the Wallet-wiederherstellen card -> RestoreWalletPage. +# The settle target is the seed-entry description text, which is unique to +# RestoreWalletView (the "Wallet wiederherstellen" heading shares its string +# with the welcome card and would match too early). +# +# Welcome cards are targeted by Semantics id (see +# lib/screens/welcome/welcome_page.dart) instead of regex text-matching of +# their titles. Both step groups stay in the widget tree at all times — +# AnimatedSlide translates the off-screen group out of view but doesn't +# remove it from the semantic tree — so regex text checks against card +# titles match off-screen widgets and break `notVisible` gates. The id +# targets are stable across copy refreshes and animation half-states, and +# use the same pattern as the `wallet-logout-confirm-checkbox` and +# `home-terms-link` Semantics ids elsewhere in the app. +# +# The checkbox is now wrapped in `Semantics(identifier: +# 'wallet-logout-confirm-checkbox', toggled: …)` with a row-wide opaque +# GestureDetector (see +# lib/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart) +# so Maestro can target it deterministically via `tapOn: id:` instead of the +# previous wrap-sensitive `leftOf:` coordinate-tap that XCUITest silently +# dropped on Apple Silicon + iOS 26 (mobile-dev-inc/maestro#3137) — that +# silent loss is what caused the prior run to never check the box, leaving +# "Abmelden" disabled and every downstream step a no-op against the still +# visible modal. We tap the checkbox once by id (a single deterministic +# Semantics target with a full-row tap surface), then gate the "Abmelden" +# button tap on the modal still being visible — if "Abmelden" was silently +# dropped we re-tap until the modal closes. We deliberately do not loop the +# checkbox itself: tapping it twice would toggle it back off again, since +# the underlying `setState(() => isChecked = !isChecked)` is not idempotent. +# If the checkbox tap is the one that's silently dropped, the next +# `extendedWaitUntil notVisible "Aus REALU Wallet abmelden"` (with the +# Abmelden button still disabled) fails loudly rather than passing silently +# downstream — and the same Semantics-id strategy that fixed flow 26's +# Nutzungsbedingungen-link tap has been reliable enough since. +# +# All tap-leading steps (Abmelden, Start, Software-Wallet, Wallet +# wiederherstellen) are gated on their target not yet showing and re-tapped +# if one was silently dropped. Each gate stops its loop the instant the +# screen changes, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- tapOn: + id: 'wallet-logout-confirm-checkbox' +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + visible: + text: '.*Aus REALU Wallet abmelden.*' + commands: + - tapOn: + text: 'Abmelden' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + notVisible: + text: '.*Aus REALU Wallet abmelden.*' + timeout: 15000 +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + id: 'welcome-software-wallet' + commands: + - tapOn: + text: 'Start' + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + id: 'welcome-restore-wallet' + commands: + - tapOn: + id: 'welcome-software-wallet' + optional: true +- waitForAnimationToEnd +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Wiederherstellungs-Wörter.*' + commands: + - tapOn: + id: 'welcome-restore-wallet' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Wiederherstellungs-Wörter.*' + timeout: 30000 diff --git a/.maestro/handbook/26-terms.yaml b/.maestro/handbook/26-terms.yaml new file mode 100644 index 00000000..41ae4f7d --- /dev/null +++ b/.maestro/handbook/26-terms.yaml @@ -0,0 +1,57 @@ +# Tier-3 navigation smoke. The handbook screenshot for this flow is the +# visual-regression Golden mapped in scripts/assemble-handbook-screenshots.sh; +# this flow's diagnostic capture lands in build/handbook-captures/26-terms.png. +# Self-contained: launches fresh with clearState. On the HomePage splash the +# copy above the Start button is a TextSubstringHighlighting rich-text widget +# (lib/widgets/text_substring_highlighting.dart). The highlighted substring +# "Nutzungsbedingungen" is rendered through a WidgetSpan wrapped in +# `Semantics(identifier: 'home-terms-link', button: true)` so Maestro can +# target it deterministically by id on iOS — Flutter's Semantics `identifier` +# is what `tapOn: id:` matches against on the iOS accessibility tree. +# +# We previously coordinate-tapped a relative point inside the RichText body +# because the bare RichText + TapGestureRecognizer collapses into a single +# StaticText node that Maestro couldn't pick up by text. That tap was +# wrap-sensitive and got dropped by XCUITest on Apple Silicon + iOS 26 +# (mobile-dev-inc/maestro#3137). With the dedicated Semantics node the tap +# is by id, so word-wrap variations no longer matter. +# +# The closing assertion targets the LegalDocumentPage H1 "Nutzungsbedingungen +# der RealUnit Wallet App" — the bare word "Nutzungsbedingungen" also shows +# on the splash, so asserting it alone would pass even if the tap missed. +# +# The id-tap is still gated on the terms document not yet showing and re-tapped +# if it was silently dropped. The gate stops the loop the instant the document +# opens, so surplus iterations no-op. +appId: swiss.realunit.app +--- +- launchApp: + appId: swiss.realunit.app + clearState: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: 'Start' + timeout: 30000 +# Anchor that the full splash paragraph (not just the Start button) is on +# screen before the id-tap — without this guard Maestro can fire during the +# splash-image fade-in while the RichText is still painting. +- extendedWaitUntil: + visible: + text: '.*Mit der Nutzung dieser App.*' + timeout: 10000 +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: '.*Nutzungsbedingungen der RealUnit Wallet App.*' + commands: + - tapOn: + id: 'home-terms-link' + optional: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + text: '.*Nutzungsbedingungen der RealUnit Wallet App.*' + timeout: 30000 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06ec3038..e37671ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ ```bash flutter pub get dart run tool/generate_localization.dart # generate i18n from ARB files +dart run tool/generate_release_info.dart # generate release_info.dart (writes the `dev` sentinel locally) flutter pub run build_runner build # generate code (drift, etc.) flutter test # run all tests flutter analyze # lint check @@ -12,6 +13,20 @@ flutter analyze # lint check After changing ARB files, always regenerate: `dart run tool/generate_localization.dart` +## Branch Flow + +Three branches participate in the release lane: + +- `staging` — integration branch. **All feature PRs target `staging`**, not `develop`. Same protections as `develop`: 1 approval + `Analyze & Test` + `Visual Regression` + `Coverage Floor Gate`. +- `develop` — pre-release. Receives changes via [`auto-staging-pr.yaml`](.github/workflows/auto-staging-pr.yaml), which opens a `staging → develop` PR on every push to `staging`. +- `main` — production. Receives changes via [`auto-release-pr.yaml`](.github/workflows/auto-release-pr.yaml), which opens a `develop → main` PR on every push to `develop`. + +``` +feature/* ──(PR)──> staging ──(auto-PR)──> develop ──(auto-PR)──> main +``` + +The auto-opened promotion PRs are idempotent — only one is open per branch pair at any time. Each one waits for the same review + CI gates as the underlying branch. Tagged releases (`v*`) trigger after the relevant branch receives the commit; see the Release Versioning workflow table in the README for details. + ## API Access — CRITICAL - The app is **only allowed to talk to the DFX API**: `api.dfx.swiss` (mainnet) and `dev.api.dfx.swiss` (testnet/Sepolia). No other hosts. @@ -19,6 +34,78 @@ After changing ARB files, always regenerate: `dart run tool/generate_localizatio - If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to [`DFXswiss/api`](https://github.com/DFXswiss/api) and let the app call that endpoint. The API is the single gateway. - All network calls must go through `AppStore.httpClient` with `buildUri(_host, …)` — `_host` resolves to the DFX API host via `ApiConfig`. Do not instantiate `http.Client`/`Dio`/`Web3Client` against other hosts. +## API as Decision Authority — CRITICAL + +Network access is one half of the gateway rule. **Business decisions are the other half.** The DFX API is the single source of truth for what the user is allowed to do, what state they are in, and what they should be asked to do next. The realunit-app is a **rendering layer** for what the API says. + +### The rule + +- **The app does not decide if a flow is allowed.** The API decides. If the API accepts a call, the app must not block it pre-emptively. +- **The app does not interpret status strings into business meaning.** It renders what the API returns as `currentStep` / `nextAction` / `state`. +- **The app does not duplicate backend sets/enums as gating logic.** DTO mirroring for type safety is fine; local `_requiredStepNames`, `actionableStatuses`, `_minLevelForActions`, `_minAmountChf` constants are not. +- **Prompts to the user fire only when the API requests them.** "Please verify yourself" appears only when the API signals a pending KYC step — never because the app inferred something from a level number or expired timestamp. + +### The test (Wer entscheidet?) + +Before adding an `if` / `switch` / `.filter()` on API data, ask: + +1. Does the API already return the answer I'm computing? → use it directly. +2. If I remove this local logic and render the API field 1:1, what breaks? → if "nothing", remove it; if "a missing field", extend the API. +3. When local and API disagree, who wins? → the API. Always. + +### What is OK in the app + +- UI input validation (format, required field, length) — UI concern +- Display formatting (date, currency, locale) — UI concern +- Local security gates (PIN, wallet lock, BitBox connection) — physical security boundary, cannot be API-driven +- Cryptographic operations (EIP-712 signing, key derivation) — must be local + +### What is NOT OK in the app + +- Deciding which KYC steps are required (`_requiredStepNames`, `_minLevelForActions`) — **the API decides via `requiredKycSteps()` on its side** +- Deciding which step status is "actionable" or "pending" (`actionableStatuses`, `pendingStatuses`) — **the API returns `currentStep` directly** +- Min/max transaction amounts, fees, supported currencies hardcoded — **must come from `/quote` / `/fiat` / `/asset` endpoints** +- Routing flows based on local conditions (`if isBitbox → sellBitbox`) — **API signals the required workflow** +- Feature visibility based on derived local state (Support link only if `emailSet`, Edit only if `!inReview`) — **API returns capability flags** +- Pre-flight validation duplicating API rules — **call the API, render its error** + +### When the API doesn't yet expose what we need + +Extend the API, then change the app. **Do not add app-side workarounds.** Open an issue / PR in [`DFXswiss/api`](https://github.com/DFXswiss/api) describing the missing field (e.g. `KycStepDto.isRequired`, `BuyQuoteDto.minAmount`, `SettingsCapabilityDto.canBackup`) and wait for it. Temporary local logic is technical debt that **stays** — every shortcut accumulates as another place the app diverges from the API. + +### Audit + +A full audit of current violations lives in [`docs/api-authority-audit.md`](docs/api-authority-audit.md). New PRs must not add to it; ideally they reduce it. + +### Consuming API capabilities — eight rules + +When the API exposes a capability (boolean flag or struct on `UserCapabilitiesDto` / similar), the app's consumer code follows the rules below. The mirror — the rules for how the API exposes capabilities — lives in [`DFXswiss/api:CONTRIBUTING.md`](https://github.com/DFXswiss/api/blob/develop/CONTRIBUTING.md) under "API Capability Design". Both sets were synthesised from the `#3733 → #3761 → #3767(closed) → #3772(merged 2026-05-26)` review sequence with @davidleomay. + +1. **Read the capability shape, don't reconstruct it.** If the API ships `canEditName: bool`, the page binds `onPressed` to `canEditName`. If it ships `createSupportTicket: { available, missingPrerequisite? }`, the tap handler routes on the discriminator. Never `if (user.mail == null)` or `if (user.kyc.level >= 30)` as a stand-in. + +2. **Tile/button visibility for discoverable actions is unconditional.** Tiles that gate prerequisites (Support, KYC, etc.) stay visible regardless of capability state — the user discovers the action even pre-signin. The capability struct's `available` field controls only the **tap outcome** (push the target page vs. push the prerequisite capture page), not the **visibility**. + +3. **Map prerequisite types to UI components, not to business rules.** When the capability says `missingPrerequisite: 'Email'`, the app pushes the email capture page. It does NOT contain a comment like "this means mail is required because support needs it" — the rule is on the backend. The app's switch is a UI dispatch, not domain logic. + +4. **Legacy backend tolerance — capability optional, sane fallback.** All new capability fields are nullable in the Dart DTO (`createSupportTicket?: CreateSupportTicketCapabilityDto`). On pre-rollout backends the field is null; the consumer falls back to the unprotected direct-push. The behaviour is identical to today's behaviour (pre-capability), so the user is no worse off — the capability just adds the smarter path when the backend supports it. + +5. **No reactive 400-handling for what a capability could pre-tell.** If the backend exposes a pre-tap capability, the app must consume it. Attempting the action and reacting to a typed `BadRequestException` post-submit is a regression — the user loses form data, the error-body parsing is brittle, the navigation choreography is fragile. Reactive errors are only for things the capability can't predict (network failures, transient backend issues, race conditions). + +6. **Pair-PR discipline.** App-side capability adoption happens in the same PR that deletes the local logic the capability replaces. PR title: `refactor(): consume `. PR body cites the V-ID from `docs/api-authority-audit.md` and the API-side commit. The PR is opened **after** the API PR has merged on `develop` — running ahead of the API merge means the consumer sees null forever in DEV and the change effectively dead-codes itself. + +7. **Tests pin the contract, not the implementation.** Cubit tests assert that `init()` with `capability == null` emits the legacy-fallback success state; with `available: true` emits the available state; with `available: false, missingPrerequisite: Email` emits the prerequisite-required state. Widget tests assert that taps in each state push the correct route. Don't test "if mail == null, the tile state is unavailable" — that ties the test to the backend rule, exactly what we're trying to decouple. + +8. **Push back on capability shape that's over-engineered.** Capabilities should be the minimum dynamic info to fulfil a UX requirement. Endpoint paths, HTTP methods, and i18n strings belong in Swagger / the client respectively. If a proposed API field looks like it duplicates static information, comment on the API PR asking for the minimum surface. Reference template: . + +#### Concrete pattern — Support ticket flow + +The first capability that follows this design lives end-to-end across these PRs: + +- API: [DFXswiss/api#3772](https://github.com/DFXswiss/api/pull/3772) — adds `createSupportTicket: { available, missingPrerequisite? }`. +- App: companion PR on this repo consuming the field — `SettingsContactPage` keeps the tile unconditional, `SettingsContactCubit` hydrates the capability from `/v2/user`, the tap handler in the view routes on `capability.available` / `capability.missingPrerequisite`. + +Future capabilities follow the same shape: bool for hide-able, `{ available, missingPrerequisite? }` for discoverable. The closed `MissingPrerequisite` enum grows additively as new prerequisite gates appear server-side. + ## Project Architecture ``` @@ -58,12 +145,30 @@ lib/ - Before adding a new key: search existing keys first — reuse where possible. - Avoid using `S.current` (no context) in cubits/blocs. Prefer emitting typed states and resolving localization in the UI via `S.of(context)`. When localization is needed in functions, pass `BuildContext` rather than `S`. +## Release Versioning + +- Single source of truth for a published build: the git tag. Tags are plain SemVer `vX.Y.Z` — no pre-release suffix. The previous `vX.Y.Z-beta.N` schema has been retired and tags carrying any suffix are rejected by the generator. +- PATCH (`v1.0.X`, X >= 1) is bumped automatically by `.github/workflows/auto-tag.yaml` on every push to `develop`. MINOR / MAJOR are manual tag pushes — they mark an App-Store-update candidate. +- Both release workflows ship to Test tracks only (TestFlight + Play Internal). Production promotion is done manually in the store backends, never by a tag push. +- `tool/generate_release_info.dart` derives the in-app `releaseTag`, the platform-identical `versionCode` and the `marketingVersion` from the tag. Schema: `MAJOR * 10_000_000 + MINOR * 100_000 + PATCH * 1_000 + 999`. The fixed `+999` suffix keeps new build codes strictly above the legacy beta train (highest published was `v1.0.0-beta.14` → `10_000_014`). +- Local builds carry `releaseTag = 'dev'` (versionCode `0`) so the settings footer reads `Version dev` instead of a stale pinned build number. +- `pubspec.yaml`'s `version:` field has two roles: + - `+0` is a sentinel for local builds — CI always overrides `--build-name` / `--build-number` from the tag. Don't bump the `+N` part manually. + - The `X.Y.Z` part is consumed by `auto-tag.yaml` as a **floor** for MAJOR / MINOR bumps. Patch increments come from the latest tag; pubspec is only consulted to trigger jumps. To start a new MINOR / MAJOR train (e.g. `1.1.0`), bump the `X.Y.Z` part in `pubspec.yaml` on `develop` and the next auto-tag will pick it up. Patch-level work needs no edit — just push to develop. +- Schema limits: `MAJOR`, `MINOR`, `PATCH` in `0..99`. The generator hard-fails outside these bounds. Before approaching `PATCH = 99` on a given train, bump `pubspec.yaml`'s MINOR (e.g. `1.0.99` → `1.1.0`) so auto-tag starts a new train. There is intentionally no safety net — surprising a CI cap is preferable to silently overflowing the version code. + +See the README's "Release versioning" section for the full table and the typical patch flow. + ## State Management - **Bloc**: For complex event-driven flows. Events are `sealed class` extending `Equatable`. States use `final class` — use `copyWith` or distinct state classes (Initial, Loading, Success, Failure) depending on the use case. - **Cubit**: For simpler state. States extend `Equatable` — use `copyWith` or distinct state classes depending on the use case. - State files are separate from bloc/cubit files. +## Wallet Modes + +The app supports three wallet modes (`software`, `bitbox`, `debug`) with different signing capabilities. Any feature that needs an EIP-712 signature must gate on `getIt().wallet.walletType` and surface a dedicated failure state for modes that cannot sign (today: `debug`). See [`docs/wallet-modes.md`](docs/wallet-modes.md) for the full table and the `KycSignatureUnsupportedFailure` precedent. + ## DTOs & Models - DTOs live in `lib/packages/service/dfx/models/{resource}/dto/` @@ -89,6 +194,25 @@ lib/ - Uses `flutter_test`, `bloc_test`, and `mocktail` (NOT mockito). - Test structure mirrors `lib/` structure. - Test helper at `test/helper/` (provides `pumpApp`). +- For BitBox-related code, the layered test strategy (Tier 0–4) is documented in [`docs/testing.md`](docs/testing.md), with concrete patterns for cubit tests, widget tests, service + HTTP tests, and `FakeBitboxCredentials`-backed integration tests. +- [`docs/testing.md`](docs/testing.md) also lists the surface that needs an infra PR first (Drift repositories, `getIt`-coupled pages, `path_provider`-coupled cubits, the Sumsub SDK, plugin-coupled widgets). Don't try to mock around those without changing the injection point. +- Service-lifecycle tests are mandatory for any service with a `Timer`, observer/subscription loop, or platform/MethodChannel dependency: instantiate the real class (no mock of the service itself), swap `BitboxUsbPlatform.instance` in `setUp` and restore in `tearDown`. Tests with periodic-timer or observer behaviour MUST drive time via `package:fake_async` (`fakeAsync` zone + `async.elapse(...)`). Wall-clock `Future.delayed` is not acceptable for time-bound assertions. + - Why: mocking the service-under-test hides timer leaks, unsubscribed listeners, and double-init bugs; wall-clock delays make tests slow and flaky. + - See: `test/packages/hardware_wallet/bitbox_service_test.dart`. +- Exception surface tests are mandatory: every typed exception in `lib/` (any class that `implements Exception` or `extends Exception`) MUST override `toString()` so the rendered string does not contain `Instance of` and is non-empty, AND MUST be enumerated in the shared surface test the moment it is introduced. When a new typed exception is added to `lib/`, it MUST be added to the enumeration in `exception_surface_test.dart` in the same PR. The test exists to catch precisely this kind of drift. + - Why: exceptions surface in logs, Sentry, and user-facing error states — the Dart default `Instance of '...'` is useless for debugging and unfriendly for users. + - See: `test/packages/service/dfx/exceptions/exception_surface_test.dart`. +- Platform-specific code paths (USB transports, BLE lifecycle, secure storage, biometric prompts, deep links) MUST either ship an `integration_test/` counterpart exercising the real plugin or vendor simulator, OR carry an inline `// @no-integration-test: ` annotation as either a file-level dartdoc comment OR immediately above the function/method declaration.[^integration-test] + - Why: unit tests with mocked platform channels cannot catch real-device regressions (permission prompts, OS-level lifecycle, transport quirks); the annotation makes the absence of an integration test a deliberate, reviewable decision. + - See: grep the annotation with + ```bash + rg "^//\s*@no-integration-test:" lib/ + ``` +- Visual-regression Goldens under `test/goldens/screens/` are also the source of the 26 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing. + - Why: single source of truth — a UI regression that breaks a Golden also breaks the handbook image before either ships; eliminates the previous "two pipelines, two truths" problem. + - See: [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md) section "Handbook screenshots are sourced from Goldens". + +[^integration-test]: Activates once an `integration_test/` directory exists in the repo; until then, treat option 1 as N/A and the `// @no-integration-test:` annotation as the documenting form. ## Widget Guidelines diff --git a/Dockerfile.handbook b/Dockerfile.handbook new file mode 100644 index 00000000..49abbf12 --- /dev/null +++ b/Dockerfile.handbook @@ -0,0 +1,102 @@ +# syntax=docker/dockerfile:1 +# +# Static nginx host for docs/handbook/ — served at handbook.realunit.app (PRD) +# and dev-handbook.realunit.app (DEV) via Cloudflare Tunnel. +# +# Built independently of the Flutter app: no Flutter toolchain, no app code. +# Build context is the repo root; only docs/handbook/, scripts/, and +# test/goldens/ are copied in. +# +# The 52 screenshots (`screenshots/NN-name.png`) are assembled from the +# visual-regression Golden baselines under `test/goldens/screens/` via +# scripts/assemble-handbook-screenshots.sh — one Golden per handbook +# entry, see the mapping in that script. `docs/handbook/screenshots/` is +# git-ignored (the directory holds the assembly output for local previews +# only); the second COPY below populates it inside the image from the +# multi-stage assembly result. +# +# Note: docs/handbook/mails/ is git-ignored and populated at CI build time +# by the "Generate RealUnit mail previews from api repo" step in +# .github/workflows/handbook.yaml — DFXswiss/api is the single source of +# truth for those previews. The first COPY picks them up automatically as +# part of the docs/handbook/ tree. + +FROM alpine:3.20 AS screenshots-builder +WORKDIR /work +RUN apk add --no-cache bash coreutils +COPY scripts/assemble-handbook-screenshots.sh ./scripts/ +COPY test/goldens/screens/ ./test/goldens/screens/ +RUN bash ./scripts/assemble-handbook-screenshots.sh /out + +# Store-listing section: derived export of the Fastlane metadata +# (ios/fastlane/metadata + android/fastlane/metadata + screenshots). The +# generator copies the PNGs to /out/{ios,android}/... and rewrites the +# block in docs/handbook/de/index.html in +# place. Single source of truth is the Fastlane metadata; this handbook +# section just renders it (same upstream/downstream model as mails/). +# +# Note: this stage always renders from the metadata and serves its own +# rewritten index.html, so the IMAGE is self-consistent even if the +# committed docs/handbook/de/index.html were stale. Keeping the committed +# handbook in sync with the metadata is enforced separately by the sync +# gate in handbook-build-check.yaml (the generator is pure-stdlib and +# version-stable, so the runner's python3 and this Alpine python3 produce +# identical output). +FROM alpine:3.20 AS store-listing-builder +WORKDIR /work +RUN apk add --no-cache python3 +COPY scripts/assemble-handbook-store-listing.py ./scripts/ +COPY scripts/templates/store-listing.html.tmpl ./scripts/templates/ +COPY ios/fastlane/metadata/ ./ios/fastlane/metadata/ +COPY ios/fastlane/screenshots/ ./ios/fastlane/screenshots/ +COPY android/fastlane/metadata/ ./android/fastlane/metadata/ +COPY docs/handbook/de/index.html ./docs/handbook/de/index.html +RUN python3 ./scripts/assemble-handbook-store-listing.py /out && cp ./docs/handbook/de/index.html /out/index.html + +# Legal-downloads section: derived export of the in-app legal Markdown +# (assets/legal/*.md). Two separate concerns, by design: +# - assemble-handbook-legal.py rewrites the deterministic block in index.html (committed + sync-gated upstream). +# - build-legal-downloads.sh renders the NON-deterministic PDF/DOCX via pandoc +# (weasyprint PDF engine — no TeX), git-ignored like the screenshots. +# This stage takes the store-listing-rewritten index.html as input so BOTH the +# store-listing and the legal-downloads blocks survive into the final image. +FROM alpine:3.20 AS legal-docs-builder +WORKDIR /work +# font-dejavu (+ a rebuilt fontconfig cache) is required: weasyprint renders via +# Pango, which aborts ("No fonts configured in FontConfig" → "Error producing +# PDF") on a bare Alpine that ships no font files. DejaVu covers the Latin/German +# glyphs the legal texts use. +RUN apk add --no-cache python3 pandoc weasyprint bash font-dejavu \ + && fc-cache -f +COPY scripts/assemble-handbook-legal.py ./scripts/ +COPY scripts/build-legal-downloads.sh ./scripts/ +COPY scripts/templates/legal-downloads.html.tmpl ./scripts/templates/ +COPY assets/legal/ ./assets/legal/ +COPY assets/languages/ ./assets/languages/ +COPY --from=store-listing-builder /out/index.html ./docs/handbook/de/index.html +RUN python3 ./scripts/assemble-handbook-legal.py /out \ + && bash ./scripts/build-legal-downloads.sh /out \ + && cp ./docs/handbook/de/index.html /out/index.html + +FROM nginx:1.27.5-alpine + +COPY docs/handbook/ /usr/share/nginx/html/ +COPY --from=screenshots-builder /out/ /usr/share/nginx/html/screenshots/ +# Store-listing PNGs + the substituted index.html (overwrites the verbatim +# docs/handbook copy above with the generated export). +COPY --from=store-listing-builder /out/ios/ /usr/share/nginx/html/store/ios/ +COPY --from=store-listing-builder /out/android/ /usr/share/nginx/html/store/android/ +COPY --from=store-listing-builder /out/index.html /usr/share/nginx/html/de/index.html +# Legal-downloads PDFs/DOCX + the index.html with BOTH the store-listing and the +# legal-downloads blocks rewritten (this copy overwrites the store-listing one +# above, so the legal stage's index.html is the authoritative final version). +COPY --from=legal-docs-builder /out/legal/ /usr/share/nginx/html/legal/ +COPY --from=legal-docs-builder /out/index.html /usr/share/nginx/html/de/index.html +COPY handbook.nginx.conf /etc/nginx/conf.d/default.conf +COPY handbook.htpasswd /etc/nginx/handbook.htpasswd + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- --timeout=3 http://127.0.0.1:8080/healthz >/dev/null || exit 1 diff --git a/README.md b/README.md index 8573ec96..6feaef2b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,201 @@ # Real Unit App -A Flutter Wallet for Real Unit Investors. +A Flutter wallet for Real Unit investors. Multi-chain, BitBox-ready, KYC-aware. -## Getting Started +> **Status:** Early development. APIs, flows and UI are still moving. + +## Contributing + +**New PRs may only merge into `develop` if test coverage is 100% on the activated surface.** Concretely: + +- `flutter test --coverage` must report 100% lines / functions / branches on every file in the activated surface (see Coverage scope below). CI will fail the build below threshold. +- Defensive code that genuinely cannot be reached in `flutter_test` (platform channels without a test override, native plugin entry points, BLE callbacks) is exempted by an inline `// coverage:ignore-line` annotation with a one-line reason. +- The branch is protected on GitHub: a PR cannot be merged while CI is red. + +**Coverage scope:** `lib/packages/**` (services, repositories, signers, utils) and the `cubits/` + `bloc/` directories under each `lib/screens//`. Widget files (`lib/screens//_page.dart` and `lib/widgets/**`) are exercised via `testWidgets` specs and excluded from the line-coverage gate — widget tests count as `widget` coverage in the feature matrix, not as line %. Generated files (`*.g.dart` from `build_runner` / Drift) are also stripped after the scope extract — they are tool output, not developer code, and would otherwise drag the scoped line % down for free. + +The five-tier testing model (Tier 0 Cubit unit · Tier 1 FakeBitbox integration · Tier 2 firmware simulator · Tier 3 Maestro flows (handbook simulator + deferred BitBox02 hardware) · Tier 4 BLE VCR/replay stretch) is tracked in [#314](https://github.com/DFXswiss/realunit-app/issues/314). See [`docs/testing.md`](docs/testing.md) for the full tier picker. New BitBox-touching PRs are expected to add tests at the appropriate tier(s). + +## Coverage infrastructure roadmap + +The 100% rule above is the target state. Until the items below land, it is aspirational and not yet CI-enforced: + +- [x] `flutter test --coverage` step in `.github/workflows/pull-request.yaml` +- [x] lcov filter narrowed to the activated surface (`lib/packages/**` + `lib/screens/**/cubit(s)/**` + `lib/screens/**/bloc/**`) and a per-run summary posted to the workflow step summary +- [x] lcov threshold check failing the build below a committed floor on the scope above +- [x] Floor gate lives in its own CI job (`Coverage Floor Gate`), wired up as a required status check on `develop` + `main` +- [x] GitHub branch protection on `develop` requiring the `Coverage Floor Gate` check (ruleset `PRs` / id `11317379`) +- [x] Inline `// coverage:ignore-*` annotations on truly unreachable paths, each with a one-line reason — applied to Drift schema getters across `lib/packages/storage/`, defensive `assert(false) → throw StateError` fallthroughs in `wallet.dart`, `BitboxCredentials` sync entry points that only exist to satisfy the web3dart interface, the platform-channel forwarders in `PathProviderAdapter` and `BiometricServiceAdapter`, and the `_localTesting` dev-only `Uri.http` branch in `api_config.dart` + +**Ratchet protocol.** The committed floor lives in two flat files at the repo root: `.coverage-floor-lines` and `.coverage-floor-functions` (integer percent, no `%` suffix). CI fails the build when scoped coverage drops below either value. Raising the floor is encouraged on every PR that raises measured coverage — bump the file in the same commit and the gate moves up. Lowering the floor requires explicit reviewer sign-off; PR convention is the `coverage:lower-floor` label so the regression is visible in the PR list rather than smuggled in. The functions floor is parked at a placeholder today because `flutter test --coverage` does not emit `FN` records — the gate warns instead of failing on that metric until upstream adds support. + +> **Before first use:** two PR labels are referenced by this tooling but are not auto-created. Run `gh label create tier3:full` once on the repo to enable per-PR opt-in for the Tier 3 handbook workflow — without the label the workflow's `if:` gate never matches and the job silently skips on PRs. Run `gh label create coverage:lower-floor` once to make floor-lowering PRs grep-able; the coverage floor gate itself runs unconditionally on every PR, this label is a review-convention marker only and is not read by any workflow. + +Three PRs have closed the largest gaps for KYC + BitBox logic: [#319](https://github.com/DFXswiss/realunit-app/pull/319) (Tier 0 cubit tests), [#320](https://github.com/DFXswiss/realunit-app/pull/320) (Tier 1 FakeBitbox integration), [#321](https://github.com/DFXswiss/realunit-app/pull/321) (dashboard buy actions + auth service tests). + +## Features + +User-facing functions, their activation status, and the tests that cover them. It is the source of truth for "what does this wallet actually do" — keep it in sync when adding or removing a flow. + +**Status legend:** `always` = ships on every build · `hardware` = needs a BitBox hardware wallet (see [Supported hardware wallets](#supported-hardware-wallets) below) · `planned` = surface exists but flow not yet implemented. + +**Triage legend** (MVP testing decision): `mvp` = in MVP scope, must reach full test coverage before launch · `defer` = ships but does not block MVP coverage (coverage required eventually, no hard deadline) · `planned` = not in scope for MVP. + +**Tests legend:** `widget` = `testWidgets` spec under `test/screens/**` · `golden` = visual-regression spec under `test/goldens/**` (pixel-exact baseline rendered on the dfx01 self-hosted runner, see [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md)) · `unit` = pure-Dart `test/packages/**` spec · `cubit` = `bloc_test`-style spec for a Bloc/Cubit · `integration` = `test/integration/**` spec crossing ≥ 2 production layers with `FakeBitboxCredentials` · `e2e` = Maestro YAML flow on real hardware · `—` = no test exists. + +> Per-feature line-coverage % is not surfaced in this table. The repo-wide scoped coverage is enforced by the `Coverage Floor Gate` CI job against `.coverage-floor-lines` / `.coverage-floor-functions`; the lcov artifact attached to every PR run holds the per-file breakdown. + +### Supported hardware wallets + +`hardware`-status flows require a BitBox device. Platform availability depends on the model: + +| Device | Android | iOS | +| --- | --- | --- | +| BitBox 02 | yes | no | +| BitBox 02 Nova | yes | yes | + +The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has no Bluetooth, so iOS support requires a BitBox 02 Nova. + +### Onboarding & authentication + +| Feature | Status | Triage | Tests | +| --- | --- | --- | --- | +| Welcome screen | always | mvp | widget (`welcome_page_test.dart`, `welcome/widgets/welcome_card_test.dart`) + golden (`welcome/welcome_golden_test.dart`) | +| Create wallet — software (generate seed) | always | mvp | widget (`create_wallet/create_wallet_page_test.dart`) + golden (`create_wallet/create_wallet_golden_test.dart`); no cubit/service test | +| Create wallet — BitBox (hardware connect) | hardware | mvp | golden (`hardware_connect_bitbox/connect_bitbox_golden_test.dart`); integration test added via [#320](https://github.com/DFXswiss/realunit-app/pull/320) | +| Restore wallet — software seed phrase | always | mvp | widget (`restore_wallet/restore_wallet_page_test.dart`) + golden (`restore_wallet/restore_wallet_golden_test.dart`) | +| Verify seed phrase (3-word challenge) | always | mvp | widget (`verify_seed/verify_seed_page_test.dart`) + golden (`verify_seed/verify_seed_golden_test.dart`) | +| Setup PIN | always | mvp | widget (`pin/setup_pin_page_test.dart`) + golden (`pin/setup_pin_golden_test.dart`) | +| Verify PIN (unlock) | always | mvp | widget (`pin/verify_pin_page_test.dart`) + golden (`pin/verify_pin_golden_test.dart`) | +| Biometric unlock (Face ID / Touch ID / fingerprint) | always | mvp | — | +| Legal disclaimer (post-onboarding gate) | always | mvp | golden (`legal/legal_disclaimer_golden_test.dart`, `legal/legal_document_golden_test.dart`); cubit transition covered in [#319](https://github.com/DFXswiss/realunit-app/pull/319) | +| Onboarding completion | always | mvp | widget (`onboarding/onboarding_completed_page_test.dart`) + golden (`onboarding/onboarding_completed_golden_test.dart`) | + +### Wallet actions + +| Feature | Status | Triage | Tests | +| --- | --- | --- | --- | +| Dashboard — asset list + total balance | always | mvp | cubit/bloc (`dashboard/dashboard_bloc_test.dart`, `dashboard/balance_cubit_test.dart`, `dashboard/portfolio_chart_cubit_test.dart`, `dashboard/price_chart_cubit_test.dart`, `dashboard/pending_transactions_cubit_test.dart`, `dashboard/dashboard_transaction_history_cubit_test.dart`) + widget (`dashboard/widgets/**`) + golden (`dashboard/dashboard_golden_test.dart`) | +| Receive — address + QR code | always | mvp | widget (`receive/widgets/qr_address_widget_test.dart`) + golden (`receive/receive_golden_test.dart`) | +| Transaction history | always | mvp | widget (`transaction_history/transaction_history_page_test.dart`) + golden (`transaction_history/transaction_history_golden_test.dart`) | +| Sell to BitBox (on-chain transfer) | hardware | defer | golden (`sell_bitbox/sell_bitbox_golden_test.dart`) | + +### DFX backend integration + +| Feature | Status | Triage | Tests | +| --- | --- | --- | --- | +| Buy — DFX fiat on-ramp (SEPA) | always | mvp | widget (`buy/buy_page_test.dart`) + golden (`buy/buy_golden_test.dart`) + unit (`real_unit_buy_payment_info_service_test.dart`); added via [#321](https://github.com/DFXswiss/realunit-app/pull/321) | +| Sell — DFX fiat off-ramp (IBAN) | always | mvp | widget (`sell/sell_page_test.dart`) + golden (`sell/sell_golden_test.dart`, `sell/sell_bank_account_selection_golden_test.dart`); added via [#321](https://github.com/DFXswiss/realunit-app/pull/321) | +| KYC: Email + 2FA gate | always | mvp | widget (`kyc_email_page_test.dart`, `kyc_2fa_page_test.dart`) + golden (`kyc/kyc_email_golden_test.dart`, `kyc/kyc_email_verification_golden_test.dart`, `kyc/kyc_2fa_golden_test.dart`); cubit added via [#319](https://github.com/DFXswiss/realunit-app/pull/319) | +| KYC: Registration + BitBox EIP-712 sign | always | mvp | widget (`kyc_registration_page_test.dart`) + golden (`kyc/kyc_registration_golden_test.dart`) + unit (`eip712_signer_test.dart`); cubit / `registration_submit` / sign-flow integration tests added via [#319](https://github.com/DFXswiss/realunit-app/pull/319) + [#320](https://github.com/DFXswiss/realunit-app/pull/320) | +| KYC: Nationality | always | mvp | widget (`kyc_nationality_page_test.dart`) + golden (`kyc/kyc_nationality_golden_test.dart`) | +| KYC: Financial data | always | mvp | widget (`kyc_financial_data_page_test.dart`) + golden (`kyc/kyc_financial_data_golden_test.dart`, `kyc/kyc_financial_data_failure_golden_test.dart`, `kyc/kyc_financial_data_loading_golden_test.dart`, `kyc/kyc_financial_data_questions_golden_test.dart`) | +| KYC: Ident | always | mvp | widget (`kyc_ident_page_test.dart`) + golden (`kyc/kyc_ident_golden_test.dart`) | +| KYC: Pending / Completed / Failure | always | mvp | widget (`kyc/subpages/kyc_*_page_test.dart`) + golden (`kyc/kyc_pending_golden_test.dart`, `kyc/kyc_completed_golden_test.dart`, `kyc/kyc_failure_golden_test.dart`, `kyc/kyc_loading_golden_test.dart`) | +| KYC: AccountMergeRequested / UnsupportedStepFailure | always | mvp | golden (`kyc/kyc_account_merge_golden_test.dart`); cubit paths added via [#319](https://github.com/DFXswiss/realunit-app/pull/319) | +| `DFXAuthService` (lazy auth + 401 retry) | always | mvp | — (unit tests added via [#319](https://github.com/DFXswiss/realunit-app/pull/319) + [#321](https://github.com/DFXswiss/realunit-app/pull/321)) | +| `balance_service` (balance fetch + cache) | always | mvp | unit (`balance_service_test.dart`) | +| `format_fixed` / `parse_fixed` (decimal helpers) | always | mvp | unit (`format_fixed_test.dart`, `parse_fixed_test.dart`) | +| `ApiException` mapping | always | mvp | unit (`exceptions/api_exception_test.dart`) | +| `ApiConfig` parsing | always | mvp | unit (`api_config_test.dart`) | + +### Settings + +| Feature | Status | Triage | Tests | +| --- | --- | --- | --- | +| Settings — root (sections list) | always | defer | golden (`settings/settings_golden_test.dart`) | +| Wallet address (export) | always | defer | widget (`settings_wallet_address/settings_wallet_address_page_test.dart`) + golden (`settings_wallet_address/settings_wallet_address_golden_test.dart`) | +| User data — overview | always | defer | widget (`settings_user_data/settings_user_data_page_test.dart`) + golden (`settings_user_data/settings_user_data_golden_test.dart`) | +| User data — edit name / address / phone | always | defer | widget (3 subpage specs under `settings_user_data/subpages/`) + golden (`settings_user_data/settings_edit_name_golden_test.dart`, `settings_user_data/settings_edit_address_golden_test.dart`, `settings_user_data/settings_edit_phone_number_golden_test.dart`, `settings_user_data/settings_edit_loading_golden_test.dart`, `settings_user_data/settings_edit_failure_golden_test.dart`, `settings_user_data/settings_edit_pending_golden_test.dart`) | +| Show seed phrase | always | defer | widget (`settings_seed/settings_seed_page_test.dart`) + golden (`settings_seed/settings_seed_golden_test.dart`) | +| Legal documents | always | defer | widget (`settings_legal_documents/settings_legal_documents_page_test.dart`) + golden (`settings_legal_documents/settings_legal_documents_golden_test.dart`, `settings_legal_documents/settings_aktionariat_documents_golden_test.dart`, `settings_legal_documents/settings_dfx_documents_golden_test.dart`) | +| Currencies / Languages / Network | always | defer | golden (`settings_currencies/settings_currencies_golden_test.dart`, `settings_languages/settings_languages_golden_test.dart`, `settings_network/settings_network_golden_test.dart`) | +| Tax report | always | defer | golden (`settings_tax_report/settings_tax_report_golden_test.dart`) | +| Contact | always | defer | golden (`settings_contact/settings_contact_golden_test.dart`) | + +### Support + +| Feature | Status | Triage | Tests | +| --- | --- | --- | --- | +| Support — root (chat / create / list buttons) | always | defer | golden (`support/support_golden_test.dart`) | +| Support — chat | always | defer | widget (`support/support_chat_page_test.dart`) + golden (`support/support_chat_golden_test.dart`) | +| Support — create ticket | always | defer | widget (`support/support_create_ticket_page_test.dart`) + golden (`support/support_create_ticket_golden_test.dart`) | +| Support — tickets list | always | defer | widget (`support/support_tickets_page_test.dart`) + golden (`support/support_tickets_golden_test.dart`) | + +## Triage gaps + +The activated surface (see "Coverage scope" above) is at **100 % scoped line coverage**. Every file under `lib/packages/**`, `lib/screens/**/cubit(s)/**`, and `lib/screens/**/bloc/**` either ships with tests or carries an `// coverage:ignore-*` annotation with a documented reason. The previous bullet list of partially-covered services, KYC cubits, biometric unlock, and DFX backend services has been retired — those gaps are closed. + +Out of scope of the gate and tracked elsewhere: + +- **Widget render paths** — measured separately via `testWidgets` specs, not in the line-coverage gate (deliberate; see `docs/testing.md` "Tier 0" rationale). +- **Visual regression (goldens)** — every `lib/screens/**/*_page.dart` has a `test/goldens/**/*_golden_test.dart` companion, validated pixel-exact on the dfx01 self-hosted runner by the `Visual Regression` CI job. Not folded into the line-coverage gate. The one exception is `lib/screens/web_view/web_view_page.dart` — `InAppWebView` is a platform-view that has no headless render in `flutter_test`, the spec is committed with `skip: true`. See [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md). +- **Tier 2 (firmware simulator)** — runs in `bitbox-simulator.yml`, not folded into the scoped coverage number. +- **Tier 3 (Maestro handbook flows)** — runs in `tier3-handbook.yaml`, not folded in. +- **`lib/widgets/chain_asset_icon.dart`** and **`lib/widgets/image_picker_sheet.dart`** — `Image.asset` / `ImagePicker` platform-channel paths, see "Surface that needs infra work" in `docs/testing.md`. + +## Testing tiers + +[#314](https://github.com/DFXswiss/realunit-app/issues/314) defines a 5-tier model for BitBox-touching code: + +- **Tier 0 — Cubit unit tests** (`bloc_test` + `mocktail`). Fast, no platform, no BitBox. Covers every state transition. +- **Tier 1 — FakeBitbox integration tests** (`FakeBitboxCredentials` at the BitBox boundary, runs under `flutter test --coverage`). Drives multi-layer flows without hardware. Specs live under `test/integration/`. +- **Tier 2 — Firmware simulator** (TCP transport + Docker `bitbox02-firmware/simulator`). End-to-end with real crypto, no hardware. Planned. +- **Tier 3 — Maestro flows** (`.maestro/handbook/*.yaml` for software-only flows; the BitBox02-hardware variant is deferred and has no flow files committed yet). The handbook flows run on a fresh iOS Simulator, automated via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml) — opt-in on PRs via the `tier3:full` label, always runs on push to `develop`. An upstream Maestro driver-hang regression on `macos-latest` runners makes intermittent first-attempt failures expected; `scripts/run-handbook-flows.sh` retries the driver-hang class up to 3× per flow (CI-hardening work originally tracked in [#487](https://github.com/DFXswiss/realunit-app/issues/487), now closed). The hardware variant remains manually triggered before each release until Phase 3 of [#314](https://github.com/DFXswiss/realunit-app/issues/314) lands. +- **Tier 4 — BLE VCR / replay** (capture on hardware once, replay deterministically). Stretch — most of its value is covered by Tier 2 + Tier 3 in tandem. + +Non-BitBox code only needs Tier 0 + widget tests; Tier 1+ are reserved for hardware-coupled paths. + +## Tests + +| Stack | Command | What it covers | +| -------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Flutter | `flutter test` | Unit + widget specs under `test/**` (pure-Dart `test` and `testWidgets`) | +| Coverage | `flutter test --coverage` | Writes `coverage/lcov.info`. CI narrows it to the activated surface and hard-fails when scoped coverage drops below the floor in `.coverage-floor-lines` / `.coverage-floor-functions`. See "Coverage infrastructure roadmap" above for the ratchet protocol. | +| Analyzer | `flutter analyze` | Dart static analysis per `analysis_options.yaml` | + +Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred. + +## CI/CD + +| Workflow | Trigger | Action | +| ---------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `pull-request.yaml` | Any PR except PRs to `main` · push `develop` · manual | `flutter analyze` + `flutter test --coverage --exclude-tags golden`, scope lcov to the activated surface, fail below the committed floor, upload lcov artifact. In parallel, the `Visual Regression` job runs `flutter test test/goldens` on the dfx01 self-hosted runner against the committed pixel baselines under `test/goldens/**/goldens/macos/` and uploads diff PNGs on failure. Jobs: `Analyze & Test`, `Coverage Floor Gate`, `Visual Regression`, `BitBox quirks audit`. | +| `tier3-handbook.yaml` | Any PR except PRs to `main`, with label `tier3:full` · push `develop` · manual | Tier-3 navigation/tap-routing smoke: runs every `.maestro/handbook/*.yaml` flow on a fresh `iPhone 17` simulator and uploads diagnostic captures (`build/handbook-captures/`) as a build artifact. Pixel drift on the page renders is owned by `Visual Regression` in `pull-request.yaml`, not this job. | +| `bitbox-simulator.yml` | Any PR except PRs to `main` touching `lib/packages/hardware_wallet/**`, `lib/packages/wallet/**`, `lib/screens/hardware_connect_bitbox/**`, their test mirrors, `pubspec.yaml`, or the workflow itself · manual | Runs the BitBox02 firmware simulator with `bitbox-testkit` baselines (Tier 2) | +| `bitbox-simulator-slash.yml` | `/bitbox-simulator` comment on any PR | Same engine as above, on-demand per PR (variants: default / `ref=main`) | +| `auto-staging-pr.yaml` | Push `staging` · manual | Opens Staging PR `staging` → `develop` | +| `auto-release-pr.yaml` | Push `develop` · manual | Opens Release PR `develop` → `main` | +| `auto-tag.yaml` | Push `develop` | Creates the next `vX.Y.Z` patch tag (PATCH = previous + 1, MINOR/MAJOR from pubspec floor) | +| `release.yaml` | Tag `v*` · manual | Single store-release pipeline. Guard job routes by PATCH: `vX.Y.0` → production candidate (GitHub release, prerelease: false); `vX.Y.Z` (Z >= 1) → internal release (GitHub pre-release). Both lanes deploy Android + iOS to Play Internal + TestFlight; production promotion stays manual in the store backends. | +| `store-metadata.yaml` | Push `main` under `*/fastlane/metadata/**` or `ios/fastlane/screenshots/**` · manual `workflow_dispatch` | Sync App Store + Play Store listing text + screenshots without rebuilding the app. A `preflight` gate rejects `FIXME-` placeholders and over-length text fields before either store upload runs. | +| `handbook-deploy.yaml` | Push `staging` (→ DEV) or `develop` (→ PRD) under `docs/handbook/**`, `Dockerfile.handbook`, `handbook.nginx.conf`, `handbook.htpasswd`, or the workflow files · manual | Builds the handbook image from the pushed branch and deploys it to the matching environment via the reusable `handbook.yaml`: `staging` → DEV (`:beta`, dev-handbook.realunit.app), `develop` → PRD (`:latest`, handbook.realunit.app). Independent per-branch runs with distinct image tags; "DEV green before PRD" is enforced by the staging→develop promotion flow, not an in-run `needs:` | +| `handbook.yaml` | Called by `handbook-deploy.yaml` (`workflow_call`) | Reusable build → Docker Hub push → server pull/recreate → smoke check, parameterised per environment | + +## Release versioning + +Tags follow plain SemVer: `vMAJOR.MINOR.PATCH`. There is no pre-release suffix — the previous `vX.Y.Z-beta.N` schema has been retired. + +| Component | When does it bump? | Workflow | Distribution | +| --- | --- | --- | --- | +| `PATCH` (`v1.0.X` with X >= 1) | Automatically on every push to `develop` (see `auto-tag.yaml`). | `release.yaml` (internal lane) | TestFlight + Play Internal. | +| `MINOR` (`v1.X.0`) | Manual tag push (App-Store-update marker). | `release.yaml` (production-candidate lane) | TestFlight + Play Internal. Production promotion is done manually in the store backends. | +| `MAJOR` (`vX.0.0`) | Manual tag push. | `release.yaml` (production-candidate lane) | TestFlight + Play Internal. Production promotion is done manually in the store backends. | + +A single release workflow (`release.yaml`) listens on the `v*` tag pattern and uses a guard job to route based on the PATCH component: patch tags go through the internal lane (`prerelease: true` on GitHub), MAJOR/MINOR tags through the production-candidate lane (`prerelease: false`). Either way the build lands in the Test tracks first — the App Store / Play Store production track is never updated by a tag push. + +Both `beta` lanes push the store **listing** (Fastlane metadata + screenshots) alongside the binary on every tag-driven release, so the listings stay in sync with the build. Android `supply` writes the (global) Play listing with the AAB. iOS `deliver` runs with `skip_app_version_update: false`, so it creates or selects the editable App Store version itself and stages the listing there — the Deliverfile's `submit_for_review false` + `automatic_release false` keep it staged for a human to submit; deliver never auto-submits or releases. The iOS `deliver` is **best-effort** (`deliver_best_effort` in `ios/fastlane/Fastfile`): until the app's first App Store version is created once in App Store Connect, Apple rejects creating it via the API ("cannot create a new version of the App in the current state"), so the push is logged loudly and skipped without failing the release (the TestFlight binary already shipped). After that one-time bootstrap, every release syncs the listing automatically. The same surface can also be synced without a binary via the `store_metadata` lane / `store-metadata.yaml`. Either way, `release.yaml` runs the same `scripts/check-store-metadata.sh` preflight (FIXME placeholders + character/URL limits) as `store-metadata.yaml` in a gating `store-metadata-preflight` job before either deploy lane runs — a tag can never ship a `FIXME-` placeholder or an oversize field to the live consoles. Production promotion / final submit stay manual. + +The build number is derived deterministically from the tag by `tool/generate_release_info.dart` using `MAJOR * 10_000_000 + MINOR * 100_000 + PATCH * 1_000 + 999`. The fixed `+999` suffix keeps every new build strictly above the legacy beta build codes; the first new build `v1.0.15` lands at `10_015_999`, comfortably above the highest published legacy beta `v1.0.0-beta.14` at `10_000_014`. + +`pubspec.yaml`'s `version:` field has two roles: + +- The `+0` build-number sentinel is for local builds — CI always overrides `--build-name` / `--build-number` from the tag. Don't bump the `+N` part manually. +- The `X.Y.Z` part is a **floor** for MAJOR / MINOR jumps. Patch increments come from the latest tag; pubspec is only consulted to trigger jumps. To start a new MINOR / MAJOR train (e.g. `v1.1.0`), bump pubspec on `develop` and the next auto-tag will pick it up. + +Typical patch flow: PR merges into `staging` → `auto-staging-pr.yaml` opens the `staging → develop` PR → after that PR merges, `auto-tag.yaml` creates `v1.0.X` on `develop` → `release.yaml` (internal lane) ships the build to TestFlight + Play Internal. + +## Getting started Before getting started, please make sure you have Flutter version 3.41.6 and the latest version of golang and gomobile installed. @@ -17,7 +210,7 @@ gomobile init dart run tool/generate_localization.dart ``` -### 2. Generate Drift Files +### 2. Generate Drift files ```shell dart run build_runner build --delete-conflicting-outputs @@ -29,7 +222,7 @@ dart run build_runner build --delete-conflicting-outputs flutter pub get ``` -### 3. Start app +### 4. Start the app ```shell flutter run diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 370b0abe..731360f9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + generated.match(/releaseTag = '([^']+)'/)&.[](1), + "marketing_version" => generated.match(/releaseMarketingVersion = '([^']+)'/)&.[](1), + "version_code" => generated.match(/releaseVersionCode = (\d+)/)&.[](1), + } + missing = info.select { |_, v| v.nil? }.keys + UI.user_error!("release-info generator missing keys: #{missing.join(', ')}") unless missing.empty? + # `dev` sentinel (versionCode=0) is intended for local builds only — + # Play Store rejects versionCode 0. Fail loudly before invoking gradle/gym. + if info["version_code"].to_i <= 0 + UI.user_error!("refusing to build a store release without a release tag (got: '#{raw_tag}')") + end + info +end + platform :android do - + def release_track return "internal" end @@ -25,44 +52,82 @@ platform :android do return "../build/app/outputs/bundle/release/app-release.aab" end - def next_build_number - internal_codes = google_play_track_version_codes(track: release_track) || [] - version_code = internal_codes - - version_code.empty? ? 1 : version_code.max + 1 - end - desc "Build and upload to Play Store Beta" lane :beta do - new_build_number = next_build_number raw_version_name = ENV["NEW_VERSION"] || "v0.0.0" - new_version_name = raw_version_name.delete('v') + info = resolve_release_info(raw_version_name) + tag_name = info["release_tag"] + marketing_version = info["marketing_version"] + version_code = info["version_code"] Dir.chdir("..") do - sh("flutter", "build", "appbundle", + sh("flutter", "build", "appbundle", "--release", - "--build-name=#{new_version_name}", - "--build-number=#{new_build_number}", - "--no-tree-shake-icons", - "--obfuscate", + "--build-name=#{marketing_version}", + "--build-number=#{version_code}", + "--no-tree-shake-icons", + "--obfuscate", "--split-debug-info=build/debug_info" ) - sh("flutter", "build", "apk", + sh("flutter", "build", "apk", "--release", - "--build-name=#{new_version_name}", - "--build-number=#{new_build_number}", - "--no-tree-shake-icons", - "--obfuscate", + "--build-name=#{marketing_version}", + "--build-number=#{version_code}", + "--no-tree-shake-icons", + "--obfuscate", "--split-debug-info=build/debug_info" ) - File.rename( - "../build/app/outputs/flutter-apk/app-release.apk", - "../build/app/outputs/flutter-apk/realunit-#{new_version_name}.apk") + File.rename( + "../build/app/outputs/flutter-apk/app-release.apk", + "../build/app/outputs/flutter-apk/realunit-#{tag_name}.apk" + ) end + # Upload the binary + changelog only. The changelog is tied to this + # build's version code (taken from the AAB); the listing metadata and + # images are pushed by the dedicated call below, keeping the two + # concerns separate and avoiding a duplicate metadata upload. upload_to_play_store( track: release_track, - aab: aab_path + aab: aab_path, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + + # Push the Play Store listing (metadata + screenshots) alongside the AAB. + # No changelog here: supply can only attach a changelog to a version + # code, which only a binary upload provides — uploading a changelog + # without a binary aborts ("no version code given"). The binary call + # above owns the changelog. Defaults come from Appfile. + upload_to_play_store( + metadata_path: "./fastlane/metadata/android", + skip_upload_apk: true, + skip_upload_aab: true, + skip_upload_changelogs: true, + skip_upload_metadata: false, + skip_upload_images: false, + skip_upload_screenshots: false, + track: release_track + ) + end + + desc "Upload Play Store listing (metadata + screenshots) without a binary" + lane :store_metadata do + # Metadata-only listing sync (no binary): skip the changelog because + # supply needs a version code to attach it, which only a binary upload + # provides. Hard-pin the internal track — the listing is global but the + # changelog is per-track, so this can never touch the production track + # even if release_track is ever changed. + upload_to_play_store( + metadata_path: "./fastlane/metadata/android", + skip_upload_apk: true, + skip_upload_aab: true, + skip_upload_changelogs: true, + skip_upload_metadata: false, + skip_upload_images: false, + skip_upload_screenshots: false, + track: "internal" ) end -end \ No newline at end of file +end diff --git a/android/fastlane/metadata/android/de-DE/changelogs/default.txt b/android/fastlane/metadata/android/de-DE/changelogs/default.txt new file mode 100644 index 00000000..59318c26 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/changelogs/default.txt @@ -0,0 +1 @@ +Erste Veröffentlichung der RealUnit Wallet App. diff --git a/android/fastlane/metadata/android/de-DE/full_description.txt b/android/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 00000000..c63a2f3d --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,23 @@ +Die offizielle App der RealUnit Schweiz AG – für den einfachen, gebührenfreien Kauf und die sichere Verwahrung der RealUnit Aktientoken. Ohne Bank. Ohne Gebühren. Direkt in Ihrer Hand. + +Ihre Vorteile + +✔ Kostenloser Kauf von RealUnit Token + +✔ Bankenunabhängige, sichere Verwahrung Ihrer Token + +✔ Aktueller Aktienkurs und persönliche Vermögensübersicht + +✔ Belege für Handel und Steuern jederzeit abrufbar + +✔ Kompatibel mit der Hardware Wallet Bitbox02 Nova + +Für wen ist die App? + +Die RealUnit App richtet sich an Anlegerinnen und Anleger, die ihr Vermögen ausserhalb des Bankensystems verwalten möchten – transparent, sicher und selbstbestimmt. + +Über RealUnit Schweiz AG + +Die RealUnit Schweiz AG ist eine börsenkotierte Investmentgesellschaft, welche breit diversifiziert in Realwerte investiert. Wir verfolgen das Ziel, das uns anvertraute Vermögen bestmöglich vor Krisen und Kaufkraftverlust zu schützen und das Privateigentum zu sichern. + +Jetzt herunterladen und Ihre finanzielle Souveränität zurückgewinnen. diff --git a/android/fastlane/metadata/android/de-DE/images/featureGraphic.png b/android/fastlane/metadata/android/de-DE/images/featureGraphic.png new file mode 100644 index 00000000..d8bcd7eb Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/featureGraphic.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/icon.png b/android/fastlane/metadata/android/de-DE/images/icon.png new file mode 100644 index 00000000..31f0a7ed Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/icon.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/01_welcome.png b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/01_welcome.png new file mode 100644 index 00000000..fd458be7 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/01_welcome.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/02_dashboard.png b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/02_dashboard.png new file mode 100644 index 00000000..dc6385f4 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/02_dashboard.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/03_security.png b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/03_security.png new file mode 100644 index 00000000..13de6c03 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/03_security.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/04_buy.png b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/04_buy.png new file mode 100644 index 00000000..ca594816 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/04_buy.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/05_tax.png b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/05_tax.png new file mode 100644 index 00000000..246d9125 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/phoneScreenshots/05_tax.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/01_welcome.png b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/01_welcome.png new file mode 100644 index 00000000..d8dfc01e Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/01_welcome.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/02_dashboard.png b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/02_dashboard.png new file mode 100644 index 00000000..383993dd Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/02_dashboard.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/03_security.png b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/03_security.png new file mode 100644 index 00000000..7f5df13d Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/03_security.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/04_buy.png b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/04_buy.png new file mode 100644 index 00000000..08f71406 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/04_buy.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/05_tax.png b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/05_tax.png new file mode 100644 index 00000000..0ec8fb43 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/05_tax.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/01_welcome.png b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/01_welcome.png new file mode 100644 index 00000000..afed4a5c Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/01_welcome.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/02_dashboard.png b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/02_dashboard.png new file mode 100644 index 00000000..0e61f300 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/02_dashboard.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/03_security.png b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/03_security.png new file mode 100644 index 00000000..fbd57439 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/03_security.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/04_buy.png b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/04_buy.png new file mode 100644 index 00000000..166fcc31 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/04_buy.png differ diff --git a/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/05_tax.png b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/05_tax.png new file mode 100644 index 00000000..acd7038b Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/tenInchScreenshots/05_tax.png differ diff --git a/android/fastlane/metadata/android/de-DE/short_description.txt b/android/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 00000000..4fd1aa64 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +RealUnit Token kaufen, verwahren & verwalten – sicher und bankenunabhängig. diff --git a/android/fastlane/metadata/android/de-DE/title.txt b/android/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 00000000..d9451788 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +RealUnit Wallet diff --git a/android/fastlane/metadata/android/de-DE/video.txt b/android/fastlane/metadata/android/de-DE/video.txt new file mode 100644 index 00000000..e69de29b diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 00000000..2e76eefd --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,88 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OpenSans-Bold.ttf b/assets/fonts/OpenSans-Bold.ttf new file mode 100644 index 00000000..b7fadfa4 Binary files /dev/null and b/assets/fonts/OpenSans-Bold.ttf differ diff --git a/assets/fonts/OpenSans-BoldItalic.ttf b/assets/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 00000000..136a4b4c Binary files /dev/null and b/assets/fonts/OpenSans-BoldItalic.ttf differ diff --git a/assets/fonts/OpenSans-Italic.ttf b/assets/fonts/OpenSans-Italic.ttf new file mode 100644 index 00000000..e99cb92d Binary files /dev/null and b/assets/fonts/OpenSans-Italic.ttf differ diff --git a/assets/fonts/OpenSans-Regular.ttf b/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 00000000..8529c432 Binary files /dev/null and b/assets/fonts/OpenSans-Regular.ttf differ diff --git a/assets/fonts/OpenSans-SemiBold.ttf b/assets/fonts/OpenSans-SemiBold.ttf new file mode 100644 index 00000000..f210dd95 Binary files /dev/null and b/assets/fonts/OpenSans-SemiBold.ttf differ diff --git a/assets/images/splash/splash_background.png b/assets/images/splash/splash_background.png index 88b93ce9..125a5039 100644 Binary files a/assets/images/splash/splash_background.png and b/assets/images/splash/splash_background.png differ diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 9e05b096..e3f21595 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -18,20 +18,23 @@ "biometricAuthenticationActivate": "Biometrische Authentifizierung aktivieren", "biometricAuthenticationActivateDescription": "Verwenden Sie Face ID oder Ihren Fingerabdruck, um Ihre Wallet schnell und sicher zu entsperren.", "birthday": "Geburtstag", - "bitbox": "BitBox", + "bitbox": "BitBox Hardware-Wallet", + "bitboxDisconnectedDescription": "Die Verbindung zur BitBox wurde unterbrochen. Bitte verbinden Sie Ihre BitBox erneut und versuchen Sie es noch einmal.", + "bitboxDisconnectedTitle": "BitBox ist nicht verbunden", + "bitboxReconnect": "BitBox erneut verbinden", "blockchain": "Blockchain", "buy": "Kaufen", "buyExecutedDescription": "Sobald Ihre Überweisung eingegangen ist, übertragen wir die REALU-Token in Ihre Wallet. Über den Fortschritt Ihrer Transaktion informieren wir Sie per E-Mail.", "buyExecutedReference": "Ihre Referenz", "buyExecutedTitle": "Vielen Dank.", "buyMinAmount": "Mindestbetrag: ${amount} ${currency}", - "buyPaymentConfirm": "Klicken Sie hier, sobald Sie die Überweisung getätigt haben", + "buyPaymentConfirm": "Zahlungsanweisungen per E-Mail anfordern", "buyPaymentConfirmFailed": "Es gibt ein technisches Problem. Bitte versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", "buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!", - "buyRealu": "RealUnit Token kaufen", "buyRealUnit": "RealUnit kaufen", + "buyRealu": "RealUnit Token kaufen", "cancel": "Abbrechen", "changeAddress": "Adresse ändern", "changeInReview": "Änderung in Prüfung", @@ -47,17 +50,24 @@ "confirm": "Bestätigen", "connectBitboxCheckPairingCode": "Überprüfen Sie, ob dieser Code mit dem auf Ihrem BitBox-Gerät angezeigten übereinstimmt, und bestätigen Sie anschließend.", "connectBitboxConnecting": "Gerät gefunden. Auf Ihrer BitBox erscheint in Kürze ein Kopplungscode. Bitte lassen Sie ihn stehen – derselbe Code erscheint anschließend auch hier zum Vergleich.", - "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox02 mit Ihrem Smartphone.", - "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox02 mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", + "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.", + "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", "connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", - "connectBitboxTitle": "BitBox02 verbinden", + "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", + "connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.", + "connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen", + "connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.", + "connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen", + "connectBitboxTitle": "BitBox verbinden", "connected": "Verbunden", - "connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox02.", + "connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.", "connectedBitboxTitle": "Verbindung erfolgreich", "contact": "Kontakt", "contactSupport": "Support kontaktieren", "contactSupportDescription": "FAQ, Tickets & Chat", + "continueAnyway": "Trotzdem fortfahren", "copyClipboard": "In die Zwischenablage kopiert", + "countriesLoadFailed": "Die Länderliste konnte nicht geladen werden. Bitte versuchen Sie es erneut.", "country": "Land", "createWallet": "Neue Wallet erstellen", "createWalletConfirm": "Ich habe es gesichert", @@ -86,7 +96,7 @@ "financialDataQuestion": "Frage ${current} von ${total}", "firstName": "Vorname", "from": "von", - "hardwareWalletSubtitle": "Verwahren Sie Ihre RealUnit Aktientoken auf diesem separaten, physischen Gerät (einer \"Hardware Wallet\") aus der Schweiz.", + "hardwareWalletSubtitle": "Ich besitze eine Bitbox02 Nova und möchte RealUnit Token darauf verwahren.", "iban": "IBAN", "ibanInvalid": "IBAN ist ungültig", "ibanRequired": "IBAN ist erforderlich", @@ -105,8 +115,16 @@ "kycCompletedDescription": "Danke dass Sie sich Zeit genommen haben für die Verifizierung. Sie haben nun genug Rechte um die Aktion durchzuführen.", "kycFailure": "Fehler beim Laden", "kycFailureDescription": "Es ist ein Fehler beim Laden aufgekommen: ${message}. Bitte versuchen Sie es zu einem späteren Zeitpunkt. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", + "kycLinkWalletDescription": "Sie sind als Aktionär eingetragen. Bestätigen Sie diese Wallet, um sie Ihrem Konto hinzuzufügen.", + "kycLinkWalletSubmit": "Wallet hinzufügen", + "kycLinkWalletTitle": "Wallet hinzufügen", + "kycMergeProcessingDescription": "Wir schließen die Zusammenführung Ihres Kontos ab. Das dauert in der Regel nur einen Moment — Ihr Verifizierungsstatus wird automatisch aktualisiert.", + "kycMergeProcessingTitle": "Konten werden zusammengeführt", "kycPending": "Daten werden geprüft", "kycPendingDescription": "Ihr folgender Schritt ist gerade noch unter Prüfung: ${step}. Bitte haben Sie noch ein wenig Geduld und schauen Sie zu einem späteren Zeitpunkt nochmal rein.", + "kycRequiredFailureMessage": "Bitte schliessen Sie zuerst Ihre Verifizierung ab.", + "kycSignatureUnsupportedDescription": "Dieses Feature erfordert eine EIP-712-Signatur. Im Debug-Modus (Adresse + Signatur) ist dies technisch nicht möglich. Bitte verwenden Sie eine Software-Wallet oder BitBox, um RealUnit zu nutzen.", + "kycSignatureUnsupportedTitle": "Signatur nicht verfügbar", "kycUnsupportedStepDescription": "Der aktuelle KYC-Schritt (${step}) kann in dieser App nicht abgeschlossen werden. Bitte kontaktieren Sie den Support.", "label": "Bezeichnung", "languageEnglish": "Englisch", @@ -130,6 +148,8 @@ "legalDisclaimerTitle": "Wichtige rechtliche Hinweise für Investoren & Bestätigung des Wohnsitzes", "legalDisclaimerTitle2": "Weitere rechtliche Hinweise", "legalDisclaimerYes": "Zustimmen", + "legalDocumentLoadFailed": "Dokument konnte nicht geladen werden", + "legalDocumentLoadFailedDescription": "Beim Laden des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "legalDocuments": "Rechtsdokumente", "location": "Ort", "logout": "Abmelden", @@ -138,6 +158,7 @@ "name": "Name", "networkMainnet": "Mainnet", "networkTestnet": "Testnet", + "newWalletSubtitle": "Ich möchte eine neue Wallet erstellen.", "next": "Weiter", "number": "Nummer", "onboardingCompletedSubtitle": "Gratulation, Sie haben erfolgreich eine Wallet eröffnet. Sichern Sie im nächsten Schritt den Zugriff auf diese Mobile-App.", @@ -176,13 +197,13 @@ "proofDocument": "Nachweis-Dokument", "purposeOfPayment": "Verwendungszweck", "qrCode": "QR-Code", - "realunitStockprice": "RealUnit Aktienkurs", "realunitStockToken": "RealUnit Aktientoken", + "realunitStockprice": "RealUnit Aktienkurs", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Aus REALU Wallet abmelden", "realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.", "realunitWalletLogoutSubtitle": "Sie können sich abmelden, nachdem Sie bestätigt haben, dass Sie Ihre Wiederherstellungsphrase sicher gespeichert haben.", - "realunitWalletSubtitle": "Verwalten Sie Ihre RealUnit Token kostenfrei und bankenunabhängig.", + "realunitWalletSubtitle": "Kaufen und verwalten Sie RealUnit Aktientoken kostenfrei und bankenunabhängig.", "receive": "Empfangen", "receiver": "Empfänger", "recoveryWords": "Wiederherstellungs-Wörter", @@ -199,19 +220,20 @@ "registerEmailVerificationButton": "Ich habe meine E-Mail bestätigt", "registerEmailVerificationDescription": "Wie es aussieht, haben Sie bereits ein Konto. Wir haben Ihnen gerade eine E-Mail geschickt. Um mit Ihrem bestehenden Konto fortzufahren, bestätigen Sie bitte Ihre E-Mail-Adresse, indem Sie auf den zugesandten Link klicken.", "registerEmailVerificationFailed": "Sie haben Ihre E-Mail noch nicht bestätigt.", - "registerEmailVerificationRegistrationFailed": "Ihre neue Wallet konnte Ihrem Account zugeordnet, jedoch konnte die Wallet nicht registriert werden. Bitte melden Sie sich beim Support.", + "registerEmailVerificationRegistrationFailed": "Die Wallet-Registrierung wurde noch nicht abgeschlossen. Bitte versuchen Sie es in wenigen Sekunden erneut. Falls das Problem weiterhin besteht, kontaktieren Sie den Support.", "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registerPhoneNumberTooShort": "Telefonnummer ist zu kurz", "registrationFailed": "Registrierung fehlgeschlagen:\n${message}", - "registrationRequired": "Registrierung erforderlich", - "registrationRequiredDescription": "Um RealUnit Token kaufen zu können, müssen Sie sich einmalig registrieren.", + "registrationForwardingFailed": "Registrierung angenommen, aber die Weiterleitung an die Gesellschaft ist verzögert. Wir versuchen es automatisch erneut.", + "registrationRequired": "Zusätzliche Angaben erforderlich", + "registrationRequiredDescription": "Um RealUnit Token zu kaufen, benötigen wir noch einige Daten von Ihnen.", "reset": "Zurücksetzen", "residence": "Residenz", "restoreWallet": "Wallet wiederherstellen", "restoreWalletFromSeedDescription": "Bitte geben Sie Ihre 12 Wiederherstellungs-Wörter in der korrekten Reihenfolge ein, um wieder Zugriff auf Ihre Wallet zu erhalten.", - "restoreWalletSubtitle": "Ich habe bereits eine Wallet (z.B. Aktionariat) und möchte dieses Wiederherstellen.", + "restoreWalletSubtitle": "Ich habe bereits eine Wallet (z.B. Aktionariat) und möchte diese wiederherstellen.", "restoreWalletSuccessful": "Wallet wiederhergestellt", "retry": "Wiederholen", "save": "Speichern", @@ -224,18 +246,18 @@ "sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft", "sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.", "sellBitboxDepositFrom": "Sie senden", - "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.", "sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen", "sellBitboxDepositTitle": "ZCHF an DFX senden", "sellBitboxDepositTo": "DFX-Einzahlung", + "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxEthReady": "Wallet bereit", "sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.", "sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.", "sellBitboxSwapFrom": "Sie senden", - "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxSwapTitle": "REALU → ZCHF tauschen", "sellBitboxSwapTo": "Sie erhalten", + "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxWaitingForEth": "Gasgebühren werden angefordert", "sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.", "sellMinAmount": "Mindestbetrag: ${amount} ${currency}", @@ -244,9 +266,15 @@ "sellSuccess": "Verkauf erfolgreich", "sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.", "sending": "Wird gesendet", + "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", "settings": "Einstellungen", + "settingsAppVersion": "Version ${tag}", "settingsCurrency": "Währung", + "settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden", + "settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsDeleteWallet": "Geschäftsbeziehung beenden", + "settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden", + "settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsLanguages": "Sprachen", "settingsNetwork": "Netzwerk", "settingsTaxReport": "Steuerbericht", @@ -254,22 +282,27 @@ "settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.", "settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.", "showSeed": "Seed anzeigen", - "signature": "Signatur", - "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", "signMessage": "Signierte Nachricht", "signMessageGet": "Signierte Nachricht abrufen", + "signature": "Signatur", + "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", "skip": "Überspringen", "softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.", "softwareTermsTextHighlighted": "Nutzungsbedingungen", - "softwareWallet": "Digitale Wallet (App)", - "softwareWalletSubtitle": "Ich möchte eine neue Wallet für den Handel und die Aufbewahrung der RealUnit Token erstellen.", + "softwareWallet": "Software-Wallet (App)", + "softwareWalletSubtitle": "Ich möchte eine neue Wallet erstellen oder meine bestehende Wallet (z.B. Aktionariat) wiederherstellen.", "start": "Start", "startDate": "Anfangsdatum", "street": "Strasse", "supportBugReport": "Fehlerbericht", "supportChat": "Support Chat", + "supportChatSupportLabel": "Support", "supportCreateTicket": "Neues Ticket erstellen", "supportCreateTicketDescription": "Erstellen Sie ein neues Support-Ticket", + "supportEmailCaptureContinue": "Weiter", + "supportEmailCaptureDescription": "Um ein Support-Ticket zu erfassen, benötigen wir Ihre E-Mail-Adresse.", + "supportEmailCaptureTitle": "E-Mail-Adresse hinzufügen", + "supportEmailMergeRequiresVerification": "Diese E-Mail-Adresse ist bereits einer anderen Wallet zugeordnet. Bitte wählen Sie eine andere Adresse oder kontaktieren Sie den Support per E-Mail.", "supportEnterMessage": "Nachricht eingeben", "supportGenericIssue": "Allgemeines Anliegen", "supportKycIssue": "KYC-Problem", @@ -283,6 +316,7 @@ "supportTicketCreated": "Ticket erstellt", "supportTransactionIssue": "Transaktionsproblem", "supportTypeMessage": "Beschreiben Sie Ihr Anliegen", + "swissPaymentTextInvalid": "Nur in der Schweiz gültige Buchstaben und Zeichen sind erlaubt", "tapHereToView": "Hier tippen, um anzuzeigen", "taxReport": "Steuerbericht", "taxReportDescription": "Hier können Sie Ihren Steuerbericht für ein spezifisches Datum generieren.", @@ -295,9 +329,9 @@ "transactionBuy": "Kauf", "transactionHistory": "Transaktionshistorie", "transactionPending": "In Bearbeitung", - "transactions": "Transaktionen", "transactionSell": "Verkauf", "transactionWaitingForPayment": "Warte auf Zahlung", + "transactions": "Transaktionen", "twoFa": "2-Faktor Authentifizierung", "twoFaCodeRequired": "Code ist erforderlich", "twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein", @@ -306,7 +340,9 @@ "twoFaSendCodeFailed": "Es ist ein Problem beim Senden der Mail aufgetreten", "twoFaWrongCode": "Der Code ist falsch", "userData": "Nutzerdaten", + "userDataLoadFailed": "Beim Laden der Nutzerdaten ist ein Fehler aufgetreten.", "userDataNotFound": "Zu dieser Wallet sind keine Nutzerdaten hinterlegt.", + "verifySeedCommitFailed": "Ihre Wallet konnte nicht gespeichert werden — zum Erneut-Versuchen tippen", "verifySeedInvalid": "Die Wörter stimmen nicht überein", "verifySeedSubtitle": "Bitte geben Sie die folgenden Wörter aus Ihrer Wiederherstellungsphrase ein, um zu bestätigen, dass Sie sie korrekt notiert haben.", "verifySeedSuccessful": "Seed erfolgreich überprüft", @@ -320,4 +356,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} \ No newline at end of file +} diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 13c645b1..56e4db7f 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -18,20 +18,23 @@ "biometricAuthenticationActivate": "Activate biometric authentication", "biometricAuthenticationActivateDescription": "Use Face ID or fingerprint to unlock your wallet quickly and securely.", "birthday": "Birthday", - "bitbox": "BitBox", + "bitbox": "BitBox Hardware Wallet", + "bitboxDisconnectedDescription": "The connection to the BitBox was lost. Please reconnect your BitBox and try again.", + "bitboxDisconnectedTitle": "BitBox is not connected", + "bitboxReconnect": "Reconnect BitBox", "blockchain": "Blockchain", "buy": "Buy", "buyExecutedDescription": "As soon as your transfer has been received, we will transfer the REALU tokens to your wallet. We will inform you about the progress of your transaction by email.", "buyExecutedReference": "Your reference", "buyExecutedTitle": "Thank you.", "buyMinAmount": "Minimum amount: ${amount} ${currency}", - "buyPaymentConfirm": "Click here once you have made the transfer", + "buyPaymentConfirm": "Request payment instructions by email", "buyPaymentConfirmFailed": "There is a technical problem. Please try again later. If the error persists, contact our support team.", "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", "buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!", - "buyRealu": "Buy RealUnit Token", "buyRealUnit": "Buy RealUnit", + "buyRealu": "Buy RealUnit Token", "cancel": "Cancel", "changeAddress": "Change address", "changeInReview": "Change in review", @@ -47,17 +50,24 @@ "confirm": "Confirm", "connectBitboxCheckPairingCode": "Verify this code matches the one shown on your BitBox device, then confirm.", "connectBitboxConnecting": "Device found. A pairing code will appear on your BitBox shortly. Please keep it visible – the same code will appear here for comparison.", - "connectBitboxContent": "Please connect your BitBox02 with your Smartphone.", - "connectBitboxContentIos": "Please connect your BitBox02 with your Smartphone and activate Bluetooth.", + "connectBitboxContent": "Please connect your BitBox with your Smartphone.", + "connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.", "connectBitboxFailed": "Something went wrong. Please try to connect again.", - "connectBitboxTitle": "Connect BitBox02", + "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", + "connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.", + "connectBitboxSignatureCapturingTitle": "Confirm sign-in", + "connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.", + "connectBitboxSignatureFailedTitle": "Sign-in not completed", + "connectBitboxTitle": "Connect BitBox", "connected": "Connected", - "connectedBitboxContent": "Please confirm and follow the last steps on your BitBox02.", + "connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.", "connectedBitboxTitle": "Connection successful", "contact": "Contact", "contactSupport": "Contact support", "contactSupportDescription": "FAQ, tickets & chat", + "continueAnyway": "Continue anyway", "copyClipboard": "Copied to clipboard", + "countriesLoadFailed": "Could not load the country list. Please try again.", "country": "Country", "createWallet": "Create new Wallet", "createWalletConfirm": "I’ve written it down", @@ -86,7 +96,7 @@ "financialDataQuestion": "Question ${current} of ${total}", "firstName": "First name", "from": "from", - "hardwareWalletSubtitle": "Store your RealUnit stock tokens on this separate, physical device (a \"hardware wallet\") from Switzerland.", + "hardwareWalletSubtitle": "I own a Bitbox02 Nova and want to store RealUnit tokens on it.", "iban": "IBAN", "ibanInvalid": "IBAN is invalid", "ibanRequired": "IBAN is required", @@ -105,8 +115,16 @@ "kycCompletedDescription": "Thank you for taking the time to verify. You now have sufficient rights to perform the action.", "kycFailure": "Error while loading", "kycFailureDescription": "An error occurred while loading: ${message}. Please try again later. If the error persists, contact our support team.", + "kycLinkWalletDescription": "You are registered as a shareholder. Confirm this wallet to add it to your account.", + "kycLinkWalletSubmit": "Add wallet", + "kycLinkWalletTitle": "Add wallet", + "kycMergeProcessingDescription": "We're finishing your account merge. This usually takes a moment — your verification status will update automatically.", + "kycMergeProcessingTitle": "Merging your accounts", "kycPending": "Data is being verified", "kycPendingDescription": "Your next step is currently being reviewed: ${step}. Please be patient and check back later.", + "kycRequiredFailureMessage": "Please complete your identity verification first.", + "kycSignatureUnsupportedDescription": "This feature requires an EIP-712 signature. The Debug mode (address + signature) cannot produce one. Please use a Software Wallet or a BitBox to use RealUnit.", + "kycSignatureUnsupportedTitle": "Signature not available", "kycUnsupportedStepDescription": "The current KYC step (${step}) cannot be completed in this app. Please contact support.", "label": "Label", "languageEnglish": "English", @@ -130,6 +148,8 @@ "legalDisclaimerTitle": "Important legal notices for investors & confirmation of residence", "legalDisclaimerTitle2": "Further legal notices", "legalDisclaimerYes": "Agree", + "legalDocumentLoadFailed": "Could not load the document", + "legalDocumentLoadFailedDescription": "Something went wrong while loading this document. Please try again.", "legalDocuments": "Legal documents", "location": "Location", "logout": "Logout", @@ -138,6 +158,7 @@ "name": "Name", "networkMainnet": "Mainnet", "networkTestnet": "Testnet", + "newWalletSubtitle": "I want to create a new wallet.", "next": "Continue", "number": "Number", "onboardingCompletedSubtitle": "Congratulations, you have successfully opened a wallet. Next, secure access to this mobile app.", @@ -176,13 +197,13 @@ "proofDocument": "Proof document", "purposeOfPayment": "Purpose of payment", "qrCode": "QR code", - "realunitStockprice": "RealUnit Stockprice", "realunitStockToken": "RealUnit Stock Token", + "realunitStockprice": "RealUnit Stockprice", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Log out of REALU Wallet", "realunitWalletLogoutCheck": "I have backed up my recovery phrase.", "realunitWalletLogoutSubtitle": "You can log out after confirming that you have securely stored your recovery phrase.", - "realunitWalletSubtitle": "Manage your RealUnit tokens free of charge and independently of banks.", + "realunitWalletSubtitle": "Buy and manage RealUnit stock tokens free of charge and independently of banks.", "receive": "Receive", "receiver": "Receiver", "recoveryWords": "recovery words", @@ -199,14 +220,15 @@ "registerEmailVerificationButton": "I have confirmed my email address", "registerEmailVerificationDescription": "It looks like you already have an account. We have just sent you an email. To continue with your existing account, please confirm your email address by clicking on the link in the email.", "registerEmailVerificationFailed": "You have not yet confirmed your email address.", - "registerEmailVerificationRegistrationFailed": "Your new wallet has been assigned to your account, but the wallet could not be registered. Please contact support.", + "registerEmailVerificationRegistrationFailed": "Wallet registration is not yet complete. Please try again in a few seconds. If the issue persists, contact support.", "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registerPhoneNumberTooShort": "Phone number is too short", "registrationFailed": "Registration failed:\n${message}", - "registrationRequired": "Registration required", - "registrationRequiredDescription": "To purchase RealUnit tokens, you must register once.", + "registrationForwardingFailed": "Registration accepted, but forwarding to the company is delayed. We will retry automatically.", + "registrationRequired": "Additional information required", + "registrationRequiredDescription": "To purchase RealUnit tokens, we need some additional information from you.", "reset": "Reset", "residence": "Residence", "restoreWallet": "Restore wallet", @@ -224,18 +246,18 @@ "sellBitboxCheckingEth": "Checking your wallet balance", "sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.", "sellBitboxDepositFrom": "You send", - "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.", "sellBitboxDepositRetryTitle": "Deposit failed", "sellBitboxDepositTitle": "Send ZCHF to DFX", "sellBitboxDepositTo": "DFX deposit", + "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxEthReady": "Wallet ready", "sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.", "sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.", "sellBitboxSwapFrom": "You send", - "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxSwapTitle": "Swap REALU → ZCHF", "sellBitboxSwapTo": "You receive", + "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxWaitingForEth": "Requesting gas funds", "sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.", "sellMinAmount": "Minimum amount: ${amount} ${currency}", @@ -244,9 +266,15 @@ "sellSuccess": "Sell successful", "sellSuccessDescription": "The amount will be paid into the bank account you have specified.", "sending": "Sending", + "setNationalityFailed": "Could not set your nationality:\n${message}", "settings": "Settings", + "settingsAppVersion": "Version ${tag}", "settingsCurrency": "Currency", + "settingsCurrencyLoadFailed": "Failed to load currencies", + "settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.", "settingsDeleteWallet": "Terminate business relationship", + "settingsLanguageLoadFailed": "Failed to load languages", + "settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.", "settingsLanguages": "Languages", "settingsNetwork": "Network", "settingsTaxReport": "Tax report", @@ -254,22 +282,27 @@ "settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.", "settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.", "showSeed": "Show Seed", - "signature": "Signature", - "signingCancelled": "Signature cancelled — please confirm on the BitBox again", "signMessage": "Sign Message", "signMessageGet": "Get Sign Message", + "signature": "Signature", + "signingCancelled": "Signature cancelled — please confirm on the BitBox again", "skip": "Skip", "softwareTermsText": "By using this app, you accept the terms of use of this software.", "softwareTermsTextHighlighted": "terms of use", - "softwareWallet": "Software Wallet", - "softwareWalletSubtitle": "I would like to create a new wallet for trading and storing RealUnit tokens.", + "softwareWallet": "Software Wallet (App)", + "softwareWalletSubtitle": "I want to create a new wallet or restore my existing wallet (e.g. Aktionariat).", "start": "Start", "startDate": "Start date", "street": "Street", "supportBugReport": "Bug report", "supportChat": "Support chat", + "supportChatSupportLabel": "Support", "supportCreateTicket": "Create new ticket", "supportCreateTicketDescription": "Create a new support ticket", + "supportEmailCaptureContinue": "Continue", + "supportEmailCaptureDescription": "To open a support ticket, we need your email address.", + "supportEmailCaptureTitle": "Add email address", + "supportEmailMergeRequiresVerification": "This email is already linked to another wallet. Please choose a different address or contact support by email.", "supportEnterMessage": "Enter message", "supportGenericIssue": "General issue", "supportKycIssue": "KYC issue", @@ -283,6 +316,7 @@ "supportTicketCreated": "Ticket created", "supportTransactionIssue": "Transaction issue", "supportTypeMessage": "Describe your issue", + "swissPaymentTextInvalid": "Only letters and characters valid in Switzerland are allowed", "tapHereToView": "Tap here to view", "taxReport": "Tax report", "taxReportDescription": "Here you can generate your tax report for a specific date.", @@ -295,9 +329,9 @@ "transactionBuy": "Buy", "transactionHistory": "Transaction history", "transactionPending": "Processing", - "transactions": "Transactions", "transactionSell": "Sell", "transactionWaitingForPayment": "Waiting for payment", + "transactions": "Transactions", "twoFa": "Two-factor authentication", "twoFaCodeRequired": "Code is required", "twoFaCodeTooShort": "Code should be 6 digits", @@ -306,7 +340,9 @@ "twoFaSendCodeFailed": "There was a problem sending the email", "twoFaWrongCode": "The Code is wrong", "userData": "User data", + "userDataLoadFailed": "An error occurred while loading user data.", "userDataNotFound": "No user data is stored for this wallet.", + "verifySeedCommitFailed": "Couldn't save your wallet — tap to retry", "verifySeedInvalid": "The words don't match", "verifySeedSubtitle": "Please enter the following words from your recovery phrase to confirm you've written them down correctly.", "verifySeedSuccessful": "Seed successfully verified", @@ -320,4 +356,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} \ No newline at end of file +} diff --git a/docs/api-authority-audit.md b/docs/api-authority-audit.md new file mode 100644 index 00000000..cb1625d4 --- /dev/null +++ b/docs/api-authority-audit.md @@ -0,0 +1,332 @@ +# API Authority Audit + +This document inventories every place in the realunit-app where business decisions +are made locally instead of being delegated to the DFX API, as required by the +*"API as Decision Authority"* rule in [`CONTRIBUTING.md`](../CONTRIBUTING.md). + +Findings were produced by four parallel scans (2026-05-21) over the KYC, Buy/Sell, +Wallet/Service-layer, and Settings areas, then deduplicated and ranked by impact. + +Each item lists: +- The violation ID (`V`) used to cross-reference with [`api-authority-plan.md`](api-authority-plan.md) +- The violation site (`file:line`) +- The local decision being made +- What an API-driven version should look like +- Whether closing it requires an API change in [`DFXswiss/api`](https://github.com/DFXswiss/api) + +The `V` anchors are stable: every finding in this audit carries one, and every +wave entry in the plan cites the `V` it closes. To answer *"which wave closes +audit finding V12?"* search for `V12` in `api-authority-plan.md`. + +--- + +## P0 — User-visible blockers caused by app-side gating + +These are the items where the app actively prevents a user from doing something +the API would accept. Fixing these directly resolves real user complaints (e.g. +the 2026-05-21 ident-misroute report that triggered this audit). + +### KYC routing decided in the cubit instead of by the API + +- **V1** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:16-22` — `_requiredStepNames` set + - **Local decision:** which step names count as "required for trading" + - **Backend already owns this** in `api/src/subdomains/generic/kyc/enums/kyc.enum.ts:requiredKycSteps(userData)` — the app duplicates a subset + - **API change needed:** add `isRequired: bool` to `KycStepDto`, drop the local set +- **V2** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:24, 171` — `_minLevelForActions = 30` + `level < _requiredLevel` check + - **Local decision:** which numeric level unlocks trading + - **API change needed:** API returns `canTrade: bool` / `canPerformAction: bool` per user — the app renders, doesn't compute +- **V3** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:142, 156-162` — `pendingStatuses` + `actionableStatuses` sets + - **Local decision:** which `ReviewStatus` values mean "user must act" vs "wait for review" + - **API change needed:** API's `KycInfoMapper.toDto` already picks `currentStep` — the app should render `currentStep` directly and stop iterating `kycSteps` +- **V5** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:134-168` — manual filter + routing chain over `kycSteps` + - **Local decision:** entire next-step selection algorithm — duplicates `KycService.tryContinue` on the API + - **API change needed:** none — the `currentStep` field from `PUT /v2/kyc` already contains the answer; remove the loop, route from `currentStep` only + - **Closed by:** W2.2 (subsumed by the `_runCheckKyc` rewrite that collapses V1, V2, V3 — V5 is *the same loop* those three constants drive). Tagged separately so reviewers can grep the routing-chain code path explicitly. +- **V45** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:_continueKyc` — `_continueKyc` repeats the same manual filter over `kycSteps` + - **Local decision:** `kycStatus.kycSteps.firstWhere((step) => step.isCurrent)` — parallel code path with the same anti-pattern as V5's `_runCheckKyc` loop, called after a realunit registration completes + - **API change needed:** none — the `currentStep` field is already authoritative; consume it directly. When W2.2 rewrites `_runCheckKyc` to render `currentStep` directly, this loop must also be deleted in the same PR + - **Closed by:** W2.2 (#494). `_continueKyc` now reads `KycSessionDto.currentStep` directly. A missing `currentStep` surfaces `KycUnsupportedStepFailure(null)` instead of throwing a bare `StateError` (which had been leaking as raw stack-trace text into the user-facing i18n message). +- **V4** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:179-182` — `e.statusCode == 403 || e.code == 'TFA_REQUIRED'` → emit 2FA step + - **Local decision:** translate HTTP status into a UI flow + - **API change needed:** API returns `nextStep: '2fa'` in the response body — app does not switch on status codes +- **V20** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:88-104` — auto-register email when `level < 10` + - **Local decision:** infer that level<10 means "the email step is implicit, call the registration endpoint silently" + - **API change needed:** if auto-registration is desired the API performs it server-side; the app calls `continueKyc` and renders what comes back + +### Hardcoded transaction limits + +- **V7** — `lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart:16, 50-60` — `_minAmountChf = 100` pre-flight + - **API change needed:** `POST /v1/buy/quote` already validates min/max — return `minAmount` / `maxAmount` / `error` from the API, surface its error verbatim +- **V8** — `lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart:17, 82-112` — `_minAmountChf = 10` + `validateMinAmount()` pre-flight + - **API change needed:** same shape on `POST /v1/sell/quote` — remove the local validate method entirely + +### Hardcoded routing forks based on wallet type + +- **V16** — `lib/screens/sell/widgets/sell_button.dart:60-62` — `if (state.isBitbox) → AppRoutes.sellBitbox` + - **Local decision:** which sell-flow page to use based on hardware-wallet presence + - **API change needed:** API returns `requiredWorkflow: 'sell' | 'sellBitbox'` (or a list of steps) — app dispatches on that string + +### Feature visibility decided by local heuristics + +- **V6a** — `lib/screens/settings_user_data/settings_user_data_page.dart:239` — Edit button hidden if `statusLabel != null` (i.e. `inReview`) + - **API change needed:** API returns `editable: bool` per field; app does not introspect status to compute editability +- **V6b** — `lib/screens/settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart:22` — `if (session.currentStep?.status == KycStepStatus.inReview)` blocks editing + - Same as V6a — render an API capability flag, do not switch on status +- **V6c** — `lib/screens/settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart:22` — same `if (session.currentStep?.status == KycStepStatus.inReview)` interpretation as V6b + - Identical shape, separate cubit. Both must be migrated together in W3.2 — listed separately so the grep target is unambiguous. +- **V6d** — `lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart:18-22` — `_changeStepNames` static const set ({nameChange, addressChange, phoneChange}) + - **Local decision:** which KYC step names represent a user-data change flow — same shape as `_requiredStepNames` (V1), just a different subset + - **API change needed:** API exposes a `category: 'changeRequest'` (or similar) flag on `KycStepDto`, app filters by it +- **V9** — `lib/screens/settings_contact/settings_contact_page.dart:54-67` (the page reads `emailSet`) + `lib/screens/settings_contact/cubit/settings_contact_cubit.dart:22` (the cubit computes `emailSet: userDto.mail != null` from the user DTO) — "Contact Support" only shown if email is set + - **API change needed:** API exposes `support.available: bool` (or always allow it through the support endpoint and the API returns 400 if not eligible) + - **Closed by:** [api#3772](https://github.com/DFXswiss/api/pull/3772) (`createSupportTicket: { available, missingPrerequisite? }` on `UserCapabilitiesDto`) + companion app PR. Path was non-linear — see the Wave-3 lessons-learned in [`api-authority-plan.md`](api-authority-plan.md#lessons-learned--wave-3-reset-2026-05-26) for the six-PR history that landed the final shape. +- **V13b** — `lib/screens/settings/settings_page.dart:100` — "Wallet Backup" only shown if `walletType == WalletType.software` + - **Boundary case:** wallet-type is a device-local fact (BitBox cannot expose its seed); this one is **defensible** as a UI capability. **Accepted as documented exception** (see *Documented exceptions* in `api-authority-plan.md`). Tagged for completeness. + +--- + +## P1 — Local interpretation of API state + +These do not block users today, but every one accumulates drift between the +backend's understanding of state and the app's interpretation of it. + +### Hardcoded language sent to backend on registration + +- **V41** — `lib/packages/service/dfx/real_unit_registration_service.dart:117` — `lang: 'DE'` constant sent to the API on registration completion + - **Local decision:** account language is hardcoded to `'DE'` regardless of the user's actual settings-language / device locale — silently miscategorizes the user's UI language + - **API change needed:** API derives account language from `Accept-Language` header (or settings-language) on the request; or — interim — the app sends the user's actual settings-language. The app must not pick a fixed value + - **Closed by:** W4.4 (extended) — handled together with the recommended-language work in W4.3 / W4.4 + +### Client-side mapping of KYC financial-question key to UI action + +- **V43** — `lib/screens/kyc/steps/financial_data/subpages/kyc_financial_data_questions_page.dart:110-122` — switch on `question.key == 'tnc'` / `'notification_of_changes'` to pick action target + - **Local decision:** which `key` opens a webview (with a hardcoded URL) vs which routes to Support — encoded in the rendering page; any new question type added server-side silently fails to surface the right action + - **API change needed:** extend `KycFinancialQuestion` DTO with a structured `link: { url?: string, action?: 'support' \| 'webview' }` (or equivalent). The page renders the link metadata; it does not switch on `key` literals + - **Closed by:** W3.1 / W3.2 + +### Local session gates that should be positioned by the API + +- **V21** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:31, 113-115` — `_legalDisclaimerAccepted` + - The flag itself is a legitimate per-session security gate. **The violation is its position in routing** — the app inserts disclaimer between email and registration unilaterally. **API change needed:** API returns `currentStep: 'legalDisclaimer'` when it wants the disclaimer to show. +- **V22** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:40, 117-119` — `_registrationSignProduced` + - Same shape — per-session sign-gate is fine; deciding *when* to surface the registration page from the cubit is not. **API drives the position.** + +### JWT decoded locally to detect merge + +- **V23** — `lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart:49-63` — parses JWT, extracts `account` claim, compares before/after to detect merge + - **API change needed:** `POST /v1/realunit/register/wallet` (or a new `/kyc/check-merge`) returns `{ merged: true, mergedAccountId }` directly — the app does not introspect tokens + +### Transaction state interpretation in the UI + +- **V24** — `lib/screens/dashboard/widgets/pending_transaction_row.dart:49-51` — `if (transaction.state == .waitingForPayment)` switches label + - **API change needed:** API returns `statusLabel` / `statusKey` as a string the app can render or translate; app does not switch on enums + +### Status code semantics + +- **V25** — `lib/packages/service/dfx/dfx_auth_service.dart:233-239` — `401 → refresh token` + - HTTP-standard behavior, but still a local interpretation. **Accepted as documented exception** (see *Documented exceptions* in `api-authority-plan.md`); 401-on-this-endpoint contractually means "JWT expired". Tagged for completeness. + +### Polling/retry orchestration with local generation tokens + +- **V26** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:42-69` — `_runGeneration` cancellation token + 30s timeout +- **V27** — `lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart:24-37` — `_mergeDetected` + generation tracking, multi-step propagation race handling + - **Local decision:** when to give up, how to retry, what counts as a recoverable failure + - **API change needed:** API exposes a single idempotent `/check-merge` endpoint that handles propagation internally — app stops orchestrating + +### Registration-submit treats backend rejection as success + +- **V15** — `lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart:76, 92` — on `"already registered"` API error, emit `Success` to let `KycCubit` resolve the next state + - **Local decision:** that an API "no" is actually "yes, with a different next step" + - **API change needed:** API returns `{ status: 'already_registered', nextStep: 'merge' }` and the app dispatches; the cubit must not paper over a 400 + +### Local startup gate that delays API surface + +- **V34** — `lib/main.dart:120` — `if (!homeState.softwareTermsAccepted) ...` blocks the dashboard until terms acceptance + - **Local decision:** that the user cannot reach any *API-allowed* screen until a UI-local preference is set + - **API change needed:** none — the gate itself is acceptable as a one-time UX overlay, but its *position* (between boot and any API-driven flow) violates the rendering-layer rule. Move to a one-time Dashboard overlay so the API gets to drive routing first + +--- + +## P2 — Hardcoded lists / config that should come from the API + +### Currency, language, country, network + +- **V12** — `lib/styles/currency.dart:3-22` — `enum Currency { EUR, CHF }` + - **API change needed:** call `/v1/fiat` and render the list returned for this user's region +- **V46** — `lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart:50-53` — `[RegistrationUserType.values.first]` restricts the account-type dropdown to a single value + - **Local decision:** the DTO carries multiple `RegistrationUserType` values but the UI only renders `.values.first` (i.e. `human`). Which account types this branded app exposes is a business decision frozen in code + - **API change needed:** `availableUserTypes: ['human']` capability flag on `UserV2Dto` (or on the registration endpoint response); app renders the returned list. Same shape as V12 / V13 + - **Deferred:** not in W3.1 / W3.2 scope — the dropdown source is `RegistrationUserType.values` (a local enum), not a capability list from `UserCapabilitiesDto`. Surfacing the available account types requires a separate API change (own capability field or endpoint extension) and a UI migration; tracked for a follow-up wave. +- **V13** — `lib/styles/language.dart:3-22` — `enum Language { EN, DE }` + - **API change needed:** call `/v1/language` (already exists on the DFX API) +- **V14** — `lib/widgets/form/country_field.dart:65-79` — `['CH', 'DE', 'IT', 'FR']` priority list at top + - **API change needed:** `/v1/country?priority=true` returns ordered list; UI does not hardcode preference +- **V28** — `lib/packages/config/network_mode.dart:4-20` — `enum NetworkMode { mainnet, testnet }` + - **Boundary case:** network mode determines *which* API host the app calls. Cannot itself be API-driven (chicken-and-egg). **Accepted as documented exception** (see `api-authority-plan.md`). Tagged for completeness. + +### Currency / language dropdowns rendered from local enum + +- **V10a** — `lib/screens/buy/widgets/payment_converter.dart:83` — `Currency.values.map()` +- **V10b** — `lib/screens/sell/widgets/sell_converter.dart:201` — `Currency.values.map()` +- **V10c** — `lib/screens/settings_currencies/settings_currencies_page.dart:26` — `Currency.values.map()` (settings currency picker) +- **V13c** — `lib/screens/settings_languages/settings_languages_page.dart:24` — `Language.values.map()` (settings language picker) + - All four are the same root cause as V12 / V13 — fix the source enum and these surfaces switch to the API list automatically. Closed together by W1.3 (currencies) / W1.4 (languages). + +### Legal documents URLs hardcoded + +- **V17** — `lib/packages/config/legal_documents_config.dart:69-122, :160-191, :193-236` — Registration-Agreement PDFs (DE/EN), RealUnit Prospekt URLs (`:69-122`), Aktionariat document URLs (`:160-191`), DFX-Docs URLs (`:193-236`) + - **API change needed:** `/v1/legal-document?type=registration&language=de` returns the current URL + version; app renders without knowing URLs in advance. Same endpoint also covers the Aktionariat and DFX-Docs blocks +- **V44** — `lib/screens/kyc/steps/financial_data/constants/kyc_financial_data_links.dart:2` — hardcoded `https://dfx.swiss/terms-and-conditions` + - **Local decision:** same root cause as V17 but outside `legal_documents_config.dart` — a separate constants file holds a legal URL + - **API change needed:** `/v1/legal-document` endpoint (the same endpoint W4.1 introduces); app reads the URL instead of compiling it in + - **Closed by:** W4.4 (same wave as V17) + +### Company contact info hardcoded + +- **V18** — `lib/screens/settings_contact/settings_contact_page.dart:82, 93-94, 104, 109, 133-134` — phone, email, website, postal address + - **API change needed:** `/v1/company-info` (or the existing `/v1/settings`) returns this for the RealUnit branding; allows future white-labeling + +### Asset configuration hardcoded + +- **V29** — `lib/packages/config/api_config.dart:19-22` — RealUnit token address, chainId, decimals (mainnet + Sepolia variants) + - **API change needed:** `/v1/asset?app=realunit` returns the canonical token configuration; the app reads + caches per network mode +- **V30** — `lib/packages/utils/default_assets.dart:3-22` — ETH/ZCHF asset IDs per network + - **Boundary case:** the app *is* the RealUnit wallet, by definition it knows which token it manages. **Out of scope** for the current waves — listed for completeness but explicitly accepted as a boundary case (see Wave 5 rationale in `api-authority-plan.md`). Asset IDs from `/v1/asset` would be cleaner but this is the lowest-priority offender — defer until a multi-asset wallet need surfaces. + +### Date / size constants + +- **V31** — `lib/screens/transaction_history/transaction_history_page.dart:68-69, :82` — `firstDate: DateTime(2025)` on the start-date picker (`:68-69`) **and** the end-date picker (`:82`) +- **V32** — `lib/screens/settings_tax_report/settings_tax_report_page.dart:73` — `firstDate: DateTime(2025)` on tax-report picker + - **API change needed:** `/v1/user/account-bounds` returns `{ firstTransactionDate, lastTransactionDate }`; both pickers use that as `firstDate` +- **V33** — `lib/screens/settings_seed/settings_seed_view.dart:98` — `if (wordCount != 12)` mnemonic length check + - **Local concern — local crypto invariant.** BIP-39 length is structural, not a business rule. **Accepted as documented exception.** Tagged for completeness. + +### Default language selection + +- **V19** — `lib/packages/repository/settings_repository.dart:18-24` — `systemLang == 'de' ? 'de' : 'en'` + - **API change needed:** API recommends a default language per user/region; until then, this is acceptable Frontend-only behavior (no user has been onboarded yet). + +### Default currency selection + +- **V42** — `lib/packages/repository/settings_repository.dart:28` — `_sharedPreferences.getString('currency') ?? 'CHF'` + - **Local decision:** the fallback currency, used when the user has never picked one, is hardcoded to CHF. Same shape as V19's language fallback, just for currency + - **API change needed:** API recommends a default currency per user/region (alongside the recommended language from W4.3); app uses it as the fallback. Same wave as V19 + - **Closed by:** W4.4 (extended) — alongside the recommended-language work + +### Tax-report date transformation chosen client-side + +- **V47** — `lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart:53-64` — `_getDateWithLatestTime(selectedDate)` decides whether to ask the API for "now minus 1 minute" (today) or "end of day" (past dates) + - **Local decision:** which exact UTC timestamp the API should evaluate the balance at, derived locally from "is the selected date today" + - **API change needed:** `/v1/realunit/balance/pdf` accepts a date (not a timestamp) and the server picks the appropriate evaluation moment. The app sends `date` only; backend owns the time semantics. UX-only — not user-blocking + - **Closed by:** W5.1 (extended) + +### Faucet-vs-ready decision derived from raw API balances + +- **V48** — `lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart:51, :81` — `if (_paymentInfo.ethBalance >= _paymentInfo.requiredGasEth) … else _requestFaucet()` (and the same comparison in the 5s polling loop at `:81`) + - **Local decision:** the client compares two API-supplied numbers to decide "request faucet" vs "ready". A semantic decision encoded as a numeric inequality + - **API change needed:** `SellPaymentInfoDto` (or the sell endpoint response) exposes `needsFaucet: bool` and optionally `faucetPollingHint: int` (seconds). App renders the boolean and the hinted polling interval instead of computing them + - **Closed by:** W5 (new sub-item) + +### Support categories + labels hardcoded + +- **V49** — `lib/screens/support/subpages/support_create_ticket_page.dart:85-110` (UI list of `SupportIssueType` tiles) + `lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart:48-57` (non-i18n English labels in `_getTicketName`) + - **Local decision:** which support categories this branded app exposes — a business decision baked into the page — and the human-readable label of each category, in English only, baked into the cubit + - **API change needed:** `/v1/support/issue-types` capability endpoint returning `{ key, icon, label }[]`. App renders the list with localized labels from the API (or i18n keys keyed on `key`). Adding a new category requires no app release + - **Closed by:** W4 (or a new endpoint slot in W4) — additive + +--- + +## P3 — DTO/enum mirroring (acceptable boilerplate, but watch for drift) + +These are not violations of the rule (DTOs *must* mirror the API for type safety), +but they're listed so reviewers know what to keep in sync when the API changes. + +- **V35** — `lib/packages/service/dfx/models/kyc/kyc_level.dart` — `KycLevel`, `KycStepName`, `KycStepStatus`, `KycStepType`, `KycStepReason` enums with `fromValue` / `toValue` +- **V36** — `lib/packages/service/dfx/models/registration/registration_status.dart` +- **V37** — `lib/packages/service/dfx/models/registration/registration_email_status.dart` +- **V38** — `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:224-231` — `_mapStepName()` switch from `KycStepName` to UI `KycStep` + +The `KycStepName → KycStep` map is borderline: it's a UI-routing decision (which +page to show per backend step). If `KycStepDto` carried a `uiHint: 'identPage'` +field, the app would not need this map at all. **API change suggested but not +required.** + +--- + +## P4 — Already addressed / documented elsewhere + +For completeness: + +- **V11** — `lib/screens/sell/widgets/sell_bank_account_field.dart` + `lib/screens/sell/widgets/sell_bank_account_selection_page.dart` — auto-selection now consumes `BankAccountDto.default` (mapped through to `BankAccount.isDefault`) under a strict `isDefault && isActive` filter on both surfaces; no positional fallback. Multi-default ambiguity is logged via `developer.log` and resolved by picking the first list entry. **Closed by:** W1.2 / [`DFXswiss/realunit-app#495`](https://github.com/DFXswiss/realunit-app/pull/495). +- **V39** — `lib/screens/kyc/steps/email/kyc_email_page.dart:91` — `markRegistrationSignProduced()` after merge confirmation. Local session-gate position, called from a code path where the API already signaled success. Fixed by [`DFXswiss/realunit-app#466`](https://github.com/DFXswiss/realunit-app/pull/466). **OK.** +- **V40** — `KycEmailVerificationCubit._completeRegistration` — surfaces failures correctly ([`DFXswiss/realunit-app#466`](https://github.com/DFXswiss/realunit-app/pull/466) / [`DFXswiss/api#3731`](https://github.com/DFXswiss/api/pull/3731)). Once [`DFXswiss/api#3731`](https://github.com/DFXswiss/api/pull/3731) merges and the `register/wallet` endpoint is idempotent, the client-side retry logic at the email verification page can be simplified further. + +--- + +## Summary + +Numbers below are the **canonical counts** used everywhere this audit is referenced (plan, PR body, future PRs). Recounted from this file on 2026-05-21. + +| Severity | Count | V-IDs | Primary location | +|---|---|---|---| +| **P0** — blocks users today | 16 | V1–V5, V6a–V6d, V7, V8, V9, V13b, V16, V20, V45 | `kyc_cubit.dart` (7 incl. `_continueKyc`), buy/sell payment-info cubits (2), settings user-data + edit cubits (4), settings_contact (1), settings (1), sell_button (1) | +| **P1** — local interpretation, no immediate block | 11 | V15, V21–V27, V34, V41, V43 | `kyc_cubit.dart` + email-verification + registration-submit cubits + main.dart + real_unit_registration_service + financial-data questions page | +| **P2** — hardcoded lists/config | 22 | V10a–V10c, V12, V13, V13c, V14, V17–V19, V28–V33, V42, V44, V46, V47, V48, V49 | currency/language/country, legal docs, company info, assets, date pickers, default currency, tax-report date, faucet decision, support categories, registration user types | +| **P3** — DTO mirroring (informational) | 4 | V35–V38 | service/dfx/models | +| **P4** — fixed or in-flight | 3 | V11, V39, V40 | tracked in [`DFXswiss/realunit-app#466`](https://github.com/DFXswiss/realunit-app/pull/466) / [`DFXswiss/realunit-app#495`](https://github.com/DFXswiss/realunit-app/pull/495) / [`DFXswiss/api#3731`](https://github.com/DFXswiss/api/pull/3731) | + +**Total distinct violations across P0–P2:** 49 (16 + 11 + 22). Recounted on 2026-05-21 after a post-initial-review audit pass found 9 additional violations (V41–V49) that the initial four-stream scan had missed; V11 moved to P4 once W1.2 shipped. +**Plus boundary cases accepted as documented exceptions:** V13b, V25, V28, V30, V33 — tagged in the audit, not counted as actionable. +**Actionable P0–P2 (excluding documented exceptions):** 44. + +**Most-affected single file:** `lib/screens/kyc/cubits/kyc/kyc_cubit.dart` — +~10 distinct violations. The entire `_runCheckKyc` body should be replaceable by +"render `currentStep` from the API, that's it" once the matching API fields land. + +## How to use this list + +- **For new PRs:** check whether your change touches any line in this file. If yes, + prefer to *remove* a violation (P0 → P3 in that order) rather than add one. +- **For backend PRs:** every P0/P1 item has a paired API field that's missing. + When you extend the API to deliver that field, the app PR that consumes it + should also delete the corresponding local logic in the same PR. +- **For the architecture review on 2026-05-21:** the P0 list is the actionable + short-list. P2 is a longer-term cleanup. P3/P4 are acknowledged exceptions. + +--- + +## Shipped (2026-05-21) + +Pair-PRs landed against the rule, in dependency order: + +| Wave | API PR | App PR | Closes V-IDs | +|---|---|---|---| +| Foundation | — | [realunit-app#491](https://github.com/DFXswiss/realunit-app/pull/491) | rule + audit + plan | +| W1.5 | — | [#492](https://github.com/DFXswiss/realunit-app/pull/492) | V4 — `TFA_REQUIRED` body code | +| W1.1+1.2 | — | [#493](https://github.com/DFXswiss/realunit-app/pull/493) | V7, V8 — buy/sell min from quote | +| W1.2bank | — | [#495](https://github.com/DFXswiss/realunit-app/pull/495) | V11 — bank-account default | +| W1.3+1.4 | — | [#496](https://github.com/DFXswiss/realunit-app/pull/496) | V12, V13 — currency + language from API | +| W2 | [api#3732](https://github.com/DFXswiss/api/pull/3732) | [#494](https://github.com/DFXswiss/realunit-app/pull/494) | V1, V2, V3, V5, V45 — **closes the 2026-05-21 ident-misroute** | +| W3 | [api#3733](https://github.com/DFXswiss/api/pull/3733) | [#497](https://github.com/DFXswiss/realunit-app/pull/497) | V6a, V6b, V9 + structured ALREADY_REGISTERED status | +| W4 | [api#3734](https://github.com/DFXswiss/api/pull/3734) | [#499](https://github.com/DFXswiss/realunit-app/pull/499) | V14, V17, V18 — legal-document + company-info + country.displayOrder | + +PRs #491 and #492 are already merged. The remaining 9 are Draft per DFXswiss convention. Every PR has full test coverage; `flutter test` and `npm test` clean across all branches. The W2 pair specifically closes the 2026-05-21 incident report (user_data 338759 ident-misroute). + +## Outstanding — next phase + +Items not shipped in the 2026-05-21 batch, in priority order: + +**P0 remainders:** +- V6c (settings_edit_address_cubit) — same shape as V6b; landed in W3.2 as part of the broader capability migration. +- V6d (`_changeStepNames` static set) — small follow-up to W3.2; the page already reads `capabilities` for the gating decision, the set just hangs around in the cubit for the `pendingSteps` informational badge. +- V16 (sell_button isBitbox routing) — re-evaluated: BitBox vs software-wallet is a device-local fact the API cannot substitute for. Treat as documented exception unless a future `supportedSignMethods` API field changes the picture. +- V20 (auto-register email at level<10) — the cubit still owns this branch; backend-side auto-registration would close it. Spec'd for Wave 5. + +**Wave 4 follow-ups (post-merge):** +- V19 (recommended language per region) — not part of W4's initial scope; spec'd for a follow-up alongside V20. +- WebDocumentConfig hardcodes (5 entries in `legal_documents_config.dart`: EU prospectus pages, CH stock-exchange prospectus, articles of association, investment regulations) — these point at marketing-managed download pages, not versioned PDFs; documented exception. + +**P1 / P2 long tail:** +- V21, V22 — local session gates whose *position* should be API-driven (separate follow-up after W4). +- V23–V27 — JWT introspection, polling/retry orchestration, transaction state interpretation. Several depend on new API fields not yet scoped. +- V29–V33 — hardcoded asset config, date constants, default language. Tracked but not blocking. diff --git a/docs/api-authority-plan.md b/docs/api-authority-plan.md new file mode 100644 index 00000000..6b570cb4 --- /dev/null +++ b/docs/api-authority-plan.md @@ -0,0 +1,423 @@ +# API Authority — Implementation Plan + +Sequenced, pair-PR plan to enforce the *"API as Decision Authority"* rule +defined in [`CONTRIBUTING.md`](../CONTRIBUTING.md). Companion to the violation +inventory in [`api-authority-audit.md`](api-authority-audit.md). + +**Source of truth:** the DFX API (`DFXswiss/api`, branch `develop`). +**Consumer:** this app (`DFXswiss/realunit-app`, branch `develop`). +**Author:** generated from a 4-stream subagent scan + 3-stream API-side gap analysis (2026-05-21). All file:line citations were verified against the cloned repos. + +--- + +## Executive summary + +All counts below are the **canonical numbers** for this PR; the audit file lists every V-ID. Numbers are derived from a single recount of `api-authority-audit.md` on 2026-05-21. + +| | Count | Notes | +|---|---|---| +| Distinct violations in audit (P0–P2 bulleted) | 50 | 16 P0 + 12 P1 + 22 P2 (post-initial-review pass added V41–V49) | +| Of those, **documented exceptions** (rule does not apply) | 5 | V13b (BitBox backup), V25 (401 refresh), V28 (network mode), V30 (default assets), V33 (BIP-39) | +| **Actionable** P0–P2 (recounted from audit) | 45 | what the waves below close | +| Closed by Wave 1 (app-only, ships today) | 11 across 6 items | V4, V7, V8, V10a, V10b, V10c, V11, V12, V13, V13c, V34 | +| Closed by Wave 2 (KYC routing collapse) | 7 | V1, V2, V3, V5, V21, V22, V45 (session-gate positions move under the API; V45 is the parallel `_continueKyc` loop) | +| Closed by Wave 3 (capability flags) | 9 | V6a, V6b, V6c, V6d, V9, V15, V16, V43, V46 | +| Closed by Wave 4 (new endpoints) | 8 | V14, V17, V18, V19, V41, V42, V44, V49 | +| Closed by Wave 5 (remaining P1/P2) | 10 | V20, V23, V24, V26, V27, V29, V31, V32, V47, V48 | +| P3 (DTO mirroring, informational) | 4 | V35–V38, not counted as actionable | +| P4 (already addressed) | 2 | V39, V40 | + +**Effort estimate:** 5 sequenced waves over ~7 sprints (14 weeks at 1 dev), or 4-5 weeks if 2 devs work in parallel (one API, one App). + +**Risk envelope:** all API changes are additive (new optional fields, new optional status values, new endpoints). No breaking changes — the audit is closed by *enriching* the API, not by *changing* it. Old clients continue to work. + +--- + +## Operating principles + +These principles govern every PR generated from this plan. Violating them undoes the audit. + +### 1. Pair-PR convention + +For each violation that requires an API change: + +- **API PR** lands first on `develop` (DFXswiss/api): adds the field/endpoint, fully tested, additive only. +- **App PR** opens within 1 week of API PR merge: consumes the new field and **deletes the corresponding local logic in the same PR**. +- The app PR title references the API PR (`Closes DFXswiss/api#NNNN`). + +An API field that ships without a consuming app PR within 4 weeks is a regression — track in [`api-authority-audit.md`](api-authority-audit.md) and address in the next sprint. + +### 2. Additive-only API changes + +- New fields: `@ApiPropertyOptional`, `nullable: true` in TypeScript, default to safe value in mapper. +- New enum values: append to existing enums, never reorder or remove. +- New endpoints: live alongside old ones; deprecate old endpoints only in a separate later PR. +- DB migrations: see `api/CONTRIBUTING.md:16` (preferred TypeORM-generated, fallback hand-written; immutable once on develop). + +### 3. Delete local logic in the same PR + +When the app PR consumes a new API field, it **must** delete the local business logic it replaces. Half-migrations (both local and API logic running) are explicitly forbidden — they become permanent. + +### 4. Stop adding new violations + +PR reviews must check this list. Any new `if`/`switch`/`.filter()` over status / level / capability data needs justification. The lint pass on `CONTRIBUTING.md`'s test rule ("Wer entscheidet?") applies to all new code. + +--- + +## Wave 1 — Quick wins (app-only, ships immediately) + +**6 items closing 11 audit findings**, all unblocked **today** — the API already returns what's needed. The app just isn't using it. Smallest, lowest-risk wins; ship first to build confidence in the rule. + +### W1.1 — Buy/Sell min-amount validation comes from quote + +| | | +|---|---| +| Closes | V7, V8 | +| API change | none — `BuyQuoteDto.minVolume`, `SellQuoteDto.minVolume` already exist (verified: `api/src/subdomains/core/buy-crypto/.../buy-quote.dto.ts:29-39`, `api/src/.../sell-quote.dto.ts:29-39`) | +| App change | Remove `_minAmountChf` constants and `validateMinAmount()`; submit any amount; render error from API | +| Files touched | `lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart` (delete lines 16, 50-60) `lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart` (delete lines 17, 82-112) `lib/screens/sell/widgets/sell_button.dart` (drop `SellPaymentInfoMinAmountNotMet` state if dependent) | +| Acceptance | App sends amount=1 CHF → API returns 400 with `errors: [{ error: 'AMOUNT_TOO_LOW', limit: minVolume }]` → snackbar renders that error verbatim | +| Test plan | Widget test: submit amount=1, mock API 400 with `limit: 100`, assert snackbar shows "100" | +| Risk | Low — error is already structured, only the source of the limit changes | +| Effort | XS (~2h) | + +### W1.2 — Bank-account default selection + +| | | +|---|---| +| Closes | V11 | +| API change | none — `BankAccountDto.default: boolean` already exists (verified: `api/src/.../bank-account.dto.ts:17-22`) AND the app's own `BankAccountDto` already exposes it as `isDefault` (verified: `lib/packages/service/dfx/models/bank_account/dto/bank_account_dto.dart:6, 22`). No parsing work needed. | +| App change | `sell_bank_account_field.dart:41-44` → `state.accounts.firstWhereOrNull((a) => a.isDefault)` instead of `.lastWhereOrNull((a) => a.isActive)`. One-line change. | +| Acceptance | App auto-selects the account flagged `default: true` by the API. If multiple defaults (shouldn't happen) → first wins. If no default → no auto-selection. | +| Test plan | Unit test on the field selection logic with three account shapes (one default, no default, multiple defaults) | +| Risk | Low | +| Effort | XS (~30 min) | + +### W1.3 — Currency list from `/v1/fiat` + +| | | +|---|---| +| Closes | V12 (enum source) + V10a, V10b, V10c (all three `Currency.values` renderers) | +| API change | none for basic list — `GET /v1/fiat` returns `FiatDetailDto[]` with `buyable/sellable` flags (verified: `api/src/shared/models/fiat/fiat.controller.ts:10-34`) | +| App change | Delete `lib/styles/currency.dart` enum. Replace `Currency.values` calls in `payment_converter.dart:83`, `sell_converter.dart:201`, and `settings_currencies_page.dart:26` with a `FiatRepository` that caches the API response. Filter by `buyable: true` for buy converter, `sellable: true` for sell converter; full list for settings picker. | +| Acceptance | New fiats added in the backend appear in the converter without an app release | +| Test plan | Widget tests with mocked `/v1/fiat` response containing CHF, EUR, USD — verify all appear in dropdown | +| Risk | Low — fallback to local enum if `/v1/fiat` fails (degrade gracefully on first launch with no network) | +| Effort | S (~half day) | + +### W1.4 — Language list from `/v1/language` + +| | | +|---|---| +| Closes | V13 (enum source) + V13c (settings_languages_page renderer) | +| API change | none — `GET /v1/language` returns `LanguageDto[]` (verified: `api/src/shared/models/language/language.controller.ts:8-17`) | +| App change | Replace `lib/styles/language.dart` enum with API-driven list. `settings_languages_page.dart:24` reads from API instead of `Language.values`. | +| Acceptance | New language enabled server-side appears in app within one API refresh cycle | +| Test plan | Same pattern as W1.3 | +| Risk | Low | +| Effort | S | + +### W1.5 — Render `TFA_REQUIRED` from response body, not status code + +| | | +|---|---| +| Closes | V4 (clean it up — works today, but app reads status code) | +| API change | none — `TfaRequiredException` already returns `{code: 'TFA_REQUIRED', level, message}` in body (verified: `api/src/.../tfa-required.exception.ts`) | +| App change | `kyc_cubit.dart:179-182` → match on `e.code == 'TFA_REQUIRED'` only; remove the `statusCode == 403` part. Long-term the API should also expose this via a body field on the regular `GET /v2/kyc` so the exception path is unnecessary, but that's Wave 3 | +| Acceptance | App routes to 2FA step on body code, regardless of HTTP status | +| Risk | Low | +| Effort | XS | + +### W1.6 — `softwareTermsAccepted` gate moves off the boot path + +| | | +|---|---| +| Closes | V34 | +| API change | none — local UI state, but the gate must not block the user from reaching any *API-allowed* state | +| App change | `lib/main.dart:120` → drop the `if (!homeState.softwareTermsAccepted)` boot-time gate. Show terms as a one-time overlay on Dashboard if not accepted. App-startup is never gated by terms. | +| Acceptance | First-run user with terms unaccepted lands on Dashboard with the overlay; API-driven KYC routing fires before any local UI gate | +| Risk | Low | +| Effort | S | + +**Wave 1 total effort:** ~1.5–2 dev-days. Closes 11 audit findings across 6 items with zero backend dependency. + +--- + +## Wave 2 — KYC routing collapse (one API PR unlocks the central misroute) + +This is the single highest-impact wave. **One API PR + one App PR** rewrites the core of `kyc_cubit.dart` and closes the 2026-05-21 ident-misroute that started this audit. + +### W2.1 (API PR) — KYC step + level capabilities + +| | | +|---|---| +| Closes | V1, V2, V3, V5 (foundation for Wave 2 app work — `currentStep` already exists; V5 needs no field, just the cubit rewrite that V1/V2/V3 enable) | +| Repo | DFXswiss/api, branch `feat/kyc-decision-fields` | +| Changes | 1. `KycStepDto` (`src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts:60-63`): add `@ApiProperty() isRequired: boolean` 2. `UserKycDto` (`src/subdomains/generic/user/models/user/dto/user-v2.dto.ts:116-134`): add `@ApiProperty() canTrade: boolean` (computed: `kycLevel >= LEVEL_30 && all required steps Completed && no Outdated/InProgress on Ident/FinancialData`) 3. `KycLevelDto`: add `@ApiProperty({enum: KycProcessStatus}) processStatus: KycProcessStatus` where `KycProcessStatus = 'InProgress' \| 'PendingReview' \| 'Completed' \| 'Failed'` 4. `KycStepMapper.toStep` + `KycInfoMapper.toDto` populate the new fields | +| Acceptance | `GET /v2/kyc` response: every step has `isRequired`; top-level has `processStatus`. `GET /v2/user` response: `kyc.canTrade` is correctly computed for the level-50-with-outdated-ident edge case | +| Test plan | New tests in `kyc-info.mapper.spec.ts`: user_data 338759 fixture (level 53, ident outdated, ident-seq-1 in-progress) → `canTrade: false`, `processStatus: InProgress`. Level-50-clean fixture → `canTrade: true`, `processStatus: Completed` | +| Risk | Low (additive). Care: `canTrade` semantics must match the existing implicit rule in the *app today* exactly, otherwise the migration changes behavior for users in the wild | +| Effort | M (~1 day) | + +### W2.2 (App PR) — Render `currentStep` from API, delete the cubit's business logic + +| | | +|---|---| +| Closes | V1, V2, V3, V5, V21, V22, V45, original 2026-05-21 ident-misroute report | +| Repo | DFXswiss/realunit-app, branch `refactor/kyc-cubit-api-driven` | +| Changes | Rewrite `lib/screens/kyc/cubits/kyc/kyc_cubit.dart:_runCheckKyc`. Specifically: 1. Delete `_requiredStepNames` (line 16-22) and `_minLevelForActions` (line 24) 2. Delete `actionableStatuses`, `pendingStatuses`, the iteration at lines 134-168 3. Read `processStatus` and `canTrade` from API response 4. Route purely from `currentStep`: present → render the matching page. Absent → check `processStatus` ∈ {Completed, PendingReview} → emit `KycCompleted` / `KycPending` 5. `_legalDisclaimerAccepted` / `_registrationSignProduced` stay as session security gates BUT no longer drive routing — they only let the app respond when API tells it to show the registration page 6. Drop the `requiredLevel` constructor parameter — it's dead once `canTrade` exists 7. Delete the same `kycSteps.firstWhere(step.isCurrent)` loop in `_continueKyc` (line 208) — that's V45, the parallel path called after registration completes; same `currentStep`-driven rewrite applies | +| Acceptance | Replay the 2026-05-21 reproduction: user 338759 with InProgress Ident step opens app → API returns `currentStep: ident`. App renders KycIdentPage. **No local computation needed.** Hand-test: clear the InProgress Ident in DB → API returns no currentStep + `canTrade: true` → app lands on Dashboard | +| Test plan | Cubit tests: 1) Mock API: `canTrade: true, currentStep: null, processStatus: Completed` → emit `KycCompleted`. 2) Mock API: `currentStep: {name: 'Ident', status: 'InProgress'}` → emit `KycSuccess(KycStep.ident)`. 3) Mock API: `processStatus: PendingReview, currentStep: null` → emit `KycPending`. Verify cubit code length drops by ~80% | +| Risk | Med — central routing logic. Mitigate with extensive cubit tests covering every state the API can return | +| Effort | M (~1–1.5 days including test rewrite) | + +**Wave 2 total:** ~2.5 dev-days. **Closes the original incident** + structurally removes 7 audit findings (V1, V2, V3, V5, V21, V22, V45). + +--- + +## Wave 3 — Capability flags + structured registration response + +Closes the rest of the P0/P1 KYC-adjacent items. + +### W3.1 (API PR) — User capabilities + structured registration error + +| | | +|---|---| +| Closes | V6a, V6b, V6c, V6d, V9, V15, V16, V43, V46 | +| Repo | DFXswiss/api, branch `feat/user-capabilities` | +| Changes | 1. `UserV2Dto`: add `capabilities: UserCapabilitiesDto { canEditName: bool, canEditMail: bool, canEditPhone: bool, canEditAddress: bool, supportAvailable: bool, availableUserTypes: string[] }`. Computed from KYC step states and other server-side conditions. `availableUserTypes` lets the registration screen render only the account types this branded app exposes instead of hardcoding `RegistrationUserType.values.first`. (Closes V6a, V6b, V6c, V9, V46.) Additionally expose a `category` on `KycStepDto` (e.g. `'changeRequest' | 'registration' | 'verification'`) so the app can filter change-flow steps without a hardcoded `_changeStepNames` set. (Closes V6d.) 2. `RealUnitRegistrationStatus` (`src/.../realunit-registration.dto.ts:26-30`): add `ALREADY_REGISTERED`. Change `realunit.service.ts:608, 670` from `throw BadRequestException` to `return RealUnitRegistrationStatus.ALREADY_REGISTERED` (different-signature path still throws — [`DFXswiss/api#3731`](https://github.com/DFXswiss/api/pull/3731) handles that nuance). (Closes V15.) 3. `SellPaymentInfoDto`: add `@ApiPropertyOptional() requiredWorkflow?: 'standard' \| 'sellBitbox' \| 'gasless'`. Compute server-side based on user wallet type + asset chain. (Closes V16.) 4. `KycFinancialQuestion` DTO: add `link?: { url?: string, action?: 'support' \| 'webview' }` so the financial-data questions page can render the right action target from the DTO instead of switching on `question.key`. (Closes V43.) | +| Acceptance | `GET /v2/user` includes `capabilities`. `POST /realunit/register/wallet` for already-registered same-wallet returns 201 + `ALREADY_REGISTERED` (not 400). `PUT /sell/paymentInfos` includes `requiredWorkflow` | +| Test plan | New unit tests for the mapper (`user.mapper.spec.ts`): InReview KYC user → `canEditName: false`. No-mail user → `supportAvailable: false`. Realunit service test for `ALREADY_REGISTERED` path. Sell-payment-info service: BitBox-credentials → `requiredWorkflow: 'sellBitbox'` | +| Risk | Med — `capabilities` is a new contract; missing a flag means apps stay on local logic. Be exhaustive | +| Effort | M (~1.5 days) | + +### W3.2 (App PR) — Consume capabilities + ALREADY_REGISTERED + requiredWorkflow + +| | | +|---|---| +| Closes | V6a, V6b, V6c, V6d, V9, V15, V16, V43, V46 | +| Repo | DFXswiss/realunit-app, branch `refactor/use-user-capabilities` | +| Changes | 1. `settings_user_data_page.dart:239` + `settings_edit_name_cubit.dart:22` + `settings_edit_address_cubit.dart:22`: drop status interpretation → `user.capabilities.canEditName` / `canEditAddress` (V6a/V6b/V6c) 2. `settings_user_data_cubit.dart:18-22`: delete `_changeStepNames` set → use the new step `category` flag or API capabilities to filter change-flow steps (V6d) 3. `settings_contact_page.dart:54-67` + `settings_contact_cubit.dart:22`: drop email check (both the cubit's `emailSet` computation and the page's read of it) → `user.capabilities.supportAvailable` (V9) 4. `kyc_registration_submit_cubit.dart:76, 92`: drop the "treat error as success" hack — handle `ALREADY_REGISTERED` as an explicit success status returned by the API (V15) 5. `sell_button.dart:60-62`: drop `isBitbox` local check → `state.paymentInfo.requiredWorkflow == 'sellBitbox' ? AppRoutes.sellBitbox : AppRoutes.sell` (V16) 6. `kyc_financial_data_questions_page.dart:110-122`: render the question's `link` metadata instead of switching on `question.key` (V43) 7. `kyc_registration_personal_step.dart:50-53`: read `user.capabilities.availableUserTypes` instead of `RegistrationUserType.values.first` (V46) | +| Acceptance | All four spots stop interpreting status / wallet type / exception body | +| Risk | Low (App-side, mechanical) | +| Effort | M (~1 day) | + +**Wave 3 total:** ~2.5 dev-days. + +--- + +## Wave 4 — New endpoints (legal docs, company info, country priority) + +The remaining P2 items that require entirely new API surface. Lower urgency — no user is blocked today by these. + +### W4.1 (API PR) — `GET /v1/legal-document` + +| | | +|---|---| +| Closes | V17, V44 | +| Repo | DFXswiss/api, branch `feat/legal-document-endpoint` | +| Changes | New module `src/shared/models/legal-document/`: entity (`type, language, version, url, enabled`), repository, service, controller (`GET /v1/legal-document`, optional `?type=` and `?language=` filters), DTO. Migration creates `legal_document` table with seed data from the current `lib/packages/config/legal_documents_config.dart` hardcoded URLs **plus** the `dfx.swiss/terms-and-conditions` URL hardcoded in `lib/screens/kyc/steps/financial_data/constants/kyc_financial_data_links.dart` (V44). Admin endpoint to update URLs (compliance role) | +| Acceptance | `GET /v1/legal-document?type=registrationAgreement&language=de` returns the agreement PDF URL + current version | +| Risk | Low — net-new module, no existing behavior touched | +| Effort | M (~2 days, mostly seed-data + admin endpoints) | + +### W4.2 (API PR) — `GET /v1/company-info` + +| | | +|---|---| +| Closes | V18 | +| Repo | DFXswiss/api, same branch as W4.1 or separate | +| Changes | New module + endpoint returning company contact info for the realunit-app brand. Public (no auth). Future-proofs white-labeling | +| Effort | S (~half day) | + +### W4.3 (API PR) — Country priority + recommended language + recommended currency + +| | | +|---|---| +| Closes | V14, V19, V41, V42 | +| Repo | DFXswiss/api, branch `feat/country-priority-recommended-language` | +| Changes | 1. `GET /v1/country` accepts `?priorityForRegion=CH` query → returns countries sorted with Swiss-preferred ones first. Or simpler: add `displayOrder: int` to the country entity for backend-configurable priority 2. `UserV2Dto` (or new endpoint `GET /v1/language/recommended?ip=…&acceptLanguage=…`): return a recommended language code (V19) **and** a recommended currency (V42 — same shape, paired so the app picks both defaults in one round-trip) 3. The realunit-registration endpoint derives the account language from the `Accept-Language` header (or the user's settings-language sent in the body), so the app no longer needs to send `lang: 'DE'` (V41) | +| Effort | S | + +### W4.5 (API PR) — `GET /v1/support/issue-types` + +| | | +|---|---| +| Closes | V49 | +| Repo | DFXswiss/api, branch `feat/support-issue-types-endpoint` | +| Changes | New `GET /v1/support/issue-types` endpoint returning `{ key, icon, label }[]` (or i18n-key-only with the app holding the translations). Per-brand: realunit returns a curated subset, other apps can expose different subsets. Replaces the hardcoded `SupportIssueType` tile list + the English-only `_getTicketName` labels in the app | +| Acceptance | `GET /v1/support/issue-types` returns a list keyed to the existing `SupportIssueType` values; new categories appear without an app release | +| Effort | S | + +### W4.4 (App PR) — Consume W4.1–W4.3, W4.5 + +| | | +|---|---| +| Closes | V14, V17, V18, V19, V41, V42, V44, V49 | +| Changes | 1. Delete `lib/packages/config/legal_documents_config.dart` hardcoded URLs **and** `lib/screens/kyc/steps/financial_data/constants/kyc_financial_data_links.dart` (V44) → call `/v1/legal-document`. Cache locally for offline 2. Delete hardcoded contact info in `settings_contact_page.dart:82-134` → render from `/v1/company-info`. Cache 3. Delete `priorityCountries` in `country_field.dart:65-79` → use API order 4. Replace `settings_repository.dart:18-24` (language fallback) **and** `settings_repository.dart:28` (currency fallback, V42) with API recommendations; system-locale fallback only on first-launch-no-network 5. Drop the hardcoded `lang: 'DE'` in `real_unit_registration_service.dart:117` (V41) → send the user's actual settings-language (or rely on the `Accept-Language` header) 6. Replace the hardcoded `SupportIssueType` tile list in `support_create_ticket_page.dart:85-110` and the English-only `_getTicketName` switch in `support_create_ticket_cubit.dart:48-57` with the API-returned list (V49) | +| Effort | M (~1.5 days) | + +**Wave 4 total:** ~4 dev-days. + +--- + +## Wave 5 — Remaining P1/P2 (JWT merge, polling, transaction state, account bounds, asset config) + +The tail items the first four waves don't cover. Lower urgency than Waves 1–3 but explicitly tracked so the audit can be driven to zero actionable items. + +### W5.1 (API PR) — Idempotent merge-detection + transaction status label + account bounds + auto-register + +| | | +|---|---| +| Closes | V20, V23, V24, V26, V27, V31, V32, V47, V48 | +| Repo | DFXswiss/api, branch `feat/api-authority-tail` | +| Changes | 1. `POST /v1/realunit/register/wallet` (or new `POST /v1/kyc/check-merge`): return `{ merged: bool, mergedAccountId?: string, propagated: bool }` directly. Server polls internally until propagated, so the client does not need its own generation/retry orchestration. (Closes V23, V26, V27.) 2. `Transaction` mapper: add `statusKey: string` and `statusLabel: string` so the app renders text instead of switching on `state == waitingForPayment`. (Closes V24.) 3. New `GET /v1/user/account-bounds` returning `{ firstTransactionDate, lastTransactionDate }` — both `transaction_history_page.dart:68-69, :82` and `settings_tax_report_page.dart:73` use it for `firstDate`. (Closes V31, V32.) 4. Auto-registration of email at `level < 10` becomes server-side: `PUT /v2/kyc` performs it before returning `currentStep`. App stops doing `if (level < 10) register()`. (Closes V20.) 5. `/v1/realunit/balance/pdf` accepts a `date` (not a timestamp); the server picks the evaluation moment (today → "now"; past date → end-of-day). The app stops computing `_getDateWithLatestTime`. (Closes V47.) 6. `SellPaymentInfoDto` exposes `needsFaucet: bool` and optionally `faucetPollingHint: int` (seconds), computed server-side from `ethBalance` vs `requiredGasEth`. The bitbox-sell cubit renders the boolean + uses the hint as the polling interval instead of comparing the two numbers locally. (Closes V48.) | +| Acceptance | (1) Client calls `check-merge` exactly once → no client-side 30s timeout needed. (2) `pending_transaction_row.dart` switches on `statusKey`, not on the typed enum. (3) Pickers' `firstDate` comes from API. (4) New-user flow shows `currentStep: 'email'` without the app pre-calling register. (5) Tax-report download sends a date, server picks the timestamp. (6) Bitbox-sell shows the faucet-pending UI when `needsFaucet: true` without comparing balances. | +| Risk | Med — V20 in particular changes when the email registration POST fires; needs careful regression testing. | +| Effort | M (~2.5 days) | + +### W5.2 (API PR) — Asset configuration endpoint + +| | | +|---|---| +| Closes | V29 | +| Repo | DFXswiss/api, branch `feat/realunit-asset-config` | +| Changes | `GET /v1/asset?app=realunit` returns the canonical RealUnit token configuration (address, chainId, decimals, mainnet + Sepolia). App reads this on boot and caches per network mode. Replaces hardcoded `ApiConfig` constants. | +| Acceptance | App calls `/v1/asset?app=realunit` after `NetworkMode` is resolved (the boot-time API host is still local), and uses the returned config for chainId/address/decimals. | +| Risk | Low (read-only endpoint, additive) | +| Effort | S (~half day) | + +### W5.3 (App PR) — Consume W5.1 + W5.2 + +| | | +|---|---| +| Closes | V20, V23, V24, V26, V27, V29, V31, V32, V47, V48 | +| Repo | DFXswiss/realunit-app, branch `refactor/api-authority-tail` | +| Changes | Apply the per-item changes from W5.1 + W5.2 in the app: delete `_runGeneration` (kyc_cubit.dart:42-69), `_mergeDetected` orchestration (kyc_email_verification_cubit.dart:24-37), JWT-decode merge detection (kyc_email_verification_cubit.dart:49-63), the `level < 10 → register` branch (kyc_cubit.dart:88-104), the pending-row state switch (pending_transaction_row.dart:49-51), the picker `firstDate` constants (transaction_history_page.dart:68-69, :82 and settings_tax_report_page.dart:73), the hardcoded `ApiConfig` token block (api_config.dart:19-22), the `_getDateWithLatestTime` transform in `settings_tax_report_cubit.dart:53-64` (V47), and the local ETH-balance-vs-required-gas comparisons in `sell_bitbox_cubit.dart:51, :81` (V48 — replace with `_paymentInfo.needsFaucet`). | +| Risk | Med — JWT decode and polling are critical paths; cover with cubit tests before merge. | +| Effort | M (~2 days) | + +### Out of scope — explicitly accepted boundary cases + +These items appear in the audit but are **not** closed by any wave. Future PRs should not try to "fix" them. + +| Item | V-ID | Why out of scope | +|---|---|---| +| `lib/packages/utils/default_assets.dart:3-22` (ETH/ZCHF asset IDs) | V30 | The app **is** the RealUnit wallet; the default asset list is part of the product identity, not a backend decision. Revisit only if a multi-asset use case emerges. | +| `lib/packages/config/network_mode.dart` (`mainnet` / `testnet`) | V28 | Determines *which* API host the app talks to — cannot itself be API-driven (chicken-and-egg). | +| BIP-39 12-word check (`settings_seed_view.dart:98`) | V33 | Structural crypto invariant. | +| `WalletType == software` for backup visibility (`settings_page.dart:100`) | V13b | Device-capability fact; BitBox cannot expose its seed. | +| `401 → token refresh` (`dfx_auth_service.dart:233-239`) | V25 | HTTP-standard convention; the 401 contract is contractual. | + +**Wave 5 total:** ~4.5 dev-days. + +--- + +## Documented exceptions — the rule explicitly does not apply + +These are listed in the audit ([`api-authority-audit.md`](api-authority-audit.md) P3/P4) and documented here as accepted exceptions. Future maintainers should not "fix" these into the audit again. + +| Exception | Reason | +|---|---| +| `NetworkMode { mainnet, testnet }` (`api_config.dart`) | Chicken-and-egg: the network mode determines *which* API the app calls. Cannot itself come from the API | +| BIP-39 seed = 12 words (`settings_seed_view.dart:98`) | Structural crypto invariant, not a business rule | +| `WalletType == software` check for backup visibility | Physical reality: a BitBox cannot expose its seed; this is a device-capability fact, not a backend policy | +| `401 → token refresh` (`dfx_auth_service.dart:233-239`) | HTTP-standard convention; the 401 contract is well-defined and external | +| PIN validation, wallet lock, BitBox connection state | Local security boundary; API has no view of device-side security state | +| EIP-712 signing, hashing, key derivation | Must run locally on the user's device | +| DTO mirroring (`KycLevel`, `KycStepName`, etc. enums) | Type safety; values must stay in sync but mirroring is acceptable boilerplate | + +--- + +## Sequencing & dependencies + +``` +Wave 1 (app-only, no API dep) ── ship first, validates mechanics +Wave 2 W2.1 API ──► W2.2 App ─┐ +Wave 3 W3.1 API ──► W3.2 App ─┤ Waves 2/3/4/5 independent +Wave 4 W4.1–W4.3 API ──► W4.4 App ─┤ of each other — +Wave 5 W5.1 + W5.2 API ──► W5.3 App ─┘ ship in parallel if 2 devs +``` + +- Wave 1 has no API dependency; ship it first to validate the pair-PR mechanics on something low-risk. +- Within each later wave, the API PR strictly blocks the App PR (`──►` arrow). +- Waves 2, 3, 4, 5 are **independent of each other** — they touch disjoint API surface and disjoint app surface, so any two devs can pick two waves and run them concurrently after Wave 1 lands. + +**Aggressive timeline (2 devs, parallel):** ~2.5 weeks calendar. +**Conservative timeline (1 dev, sequential):** ~7 sprints / 14 weeks calendar. + +--- + +## Risk register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| API field added but never consumed (audit regresses) | Med | High | Pair-PR convention enforced in PR review checklist | +| `canTrade` semantics drift from current implicit rule | Med | High | Fixture-based mapper tests covering edge cases (level 53 + outdated ident) BEFORE Wave 2.2 ships | +| Old app version + new API: missing field crashes | Low | Med | All API additions are optional / nullable; app handles `null` gracefully | +| Compliance objection to `canTrade` based on level only | Low | High | `canTrade` is computed server-side from the *same* signals the app uses today (level + step state) — semantic identity, not relaxation. Loop in compliance before W2.1 merge | +| Legal-document migration data error | Med | Low | Seed script runs in a dry-run + diff-review mode first; admin endpoint allows hotfix | + +--- + +## Forward contract + +Once this plan is executed: + +- New PRs that introduce a violation pattern (local `_requiredX`, status enum interpretation, hardcoded business limit) are **blocked** by review until the API field exists. +- The CONTRIBUTING.md rule is enforceable: every Reviewer can point at this plan and ask *"which wave does your change belong to?"* If the answer is "none — I added a new local decision", the PR doesn't merge. +- Audit ([`api-authority-audit.md`](api-authority-audit.md)) becomes a regression test: ideally only shrinks PR by PR. + +--- + +## What to do next (concrete first action) + +1. **Now:** review this plan + the audit doc. Decide if any item is mis-prioritized. +2. **Day 1:** open W1.1 + W1.2 + W1.5 as small App-only PRs. They're the lowest-risk validation that the pair-PR mechanics work. +3. **Day 2:** open W2.1 (API) as the first paired wave. Loop in compliance on the `canTrade` semantics before merge. +4. **Day 3-4:** open W2.2 (App) once W2.1 is on `develop`. +5. **Day 5+:** schedule Wave 3, Wave 4, and Wave 5 in the regular sprint planning. Waves 2–5 are independent and can run concurrently if staffed. + +--- + +## Lessons learned — Wave 3 reset (2026-05-26) + +Wave 3 went through four iterations before landing in a stable shape. The pattern is generalisable to every future capability we add — if you're tempted to expose anything richer than a bool flag, read this section first. + +### The PR sequence + +| PR | Direction | Outcome | Why | +|---|---|---|---| +| [api#3733](https://github.com/DFXswiss/api/pull/3733) | API: `+supportAvailable: bool` | merged | First Wave-3 cut — added a bool to `UserCapabilitiesDto` per the original plan | +| [app#497](https://github.com/DFXswiss/realunit-app/pull/497) | App: consume `supportAvailable` bool | merged | Companion app PR; tile-visibility tied to the bool | +| [app#588](https://github.com/DFXswiss/realunit-app/pull/588) | App: unconditional Support tile | merged | UX feedback: tile must stay visible pre-signin for discoverability, hiding it on `!supportAvailable` was wrong | +| [api#3761](https://github.com/DFXswiss/api/pull/3761) | API: `-supportAvailable: bool` | merged | After #588 the bool had no consumer; deleted backend-side | +| [api#3767](https://github.com/DFXswiss/api/pull/3767) | API: `+ActionCapability` tree (4 DTOs, HttpMethod enum, 170 LOC) | **closed without merge** | First attempt at the structured replacement; @davidleomay correctly flagged the over-engineering — static endpoint paths don't belong in dynamic responses | +| [api#3772](https://github.com/DFXswiss/api/pull/3772) | API: `+createSupportTicket: { available, missingPrerequisite? }` (91 LOC) | merged | Minimum compromise — per-user runtime info only, static paths stay in Swagger via `@ApiBadRequestResponse` | + +Net result: V9 closed end-to-end with **less LOC than the original bool-only path** plus the proper discoverable UX. But it took five days and six PRs. + +### What we'd do differently next time + +1. **Specify the UX requirement before designing the capability shape.** The bool was correct *for the original UX* (tile hides when unavailable). The UX changed (tile must stay visible) and we didn't re-derive the schema from the new requirement — we just deleted the bool. Re-deriving would have produced the discriminator shape directly. +2. **Push back on capability complexity at PR time, not after merge.** David's review on #3767 caught the over-engineering before merge — that's the model. If a capability shape isn't justified by the UX requirement, the reviewer flags it; reductions are easier than rollbacks. +3. **Static info goes in Swagger.** This is the most important takeaway. `@ApiBadRequestResponse` decorators let the API document the remediation path without shipping it on every request. The `createSupportTicket` capability ships **only** the per-user runtime state (`available` + the prerequisite discriminator) — paths are documented, not transmitted. + +### The eight binding rules + +Both repos now document the eight rules synthesised from this exercise. Read them before adding any new capability: + +- API side: [`DFXswiss/api:CONTRIBUTING.md`](https://github.com/DFXswiss/api/blob/develop/CONTRIBUTING.md) → "API Capability Design". +- App side: [`CONTRIBUTING.md`](../CONTRIBUTING.md) → "Consuming API capabilities — eight rules" inside the "API as Decision Authority" section. + +Rules 2 (static info in Swagger), 3 (YAGNI for enum members), 6 (pair-PR with documented trade-off), and 8 (reduction before extension) are directly attributable to @davidleomay's review pressure on this Wave. + +### Forward — what this means for Waves 4 and 5 + +- Wave 4 (`legal-document`, `company-info`, `support-issue-types`, country priority) is mostly **list / config endpoints** — these don't need capability shapes. Plain DTOs. +- Wave 5 (JWT merge, polling, transaction state, account bounds, asset config) has at least one discoverable-action shape (account-merge-on-email-conflict) — that one should use the `{ available, missingPrerequisite? }` pattern from Wave 3, not invent a new shape. +- Future capabilities not yet planned: default to the heterogeneous rule — `bool` for hide-able, struct only when the user must be guided through a prerequisite. + +--- + +*Generated 2026-05-21. Companion to [`api-authority-audit.md`](api-authority-audit.md) and the rule definition in [`CONTRIBUTING.md`](../CONTRIBUTING.md#api-as-decision-authority--critical). Wave-3 lessons-learned added 2026-05-27.* diff --git a/docs/handbook/README.md b/docs/handbook/README.md new file mode 100644 index 00000000..4dd4951c --- /dev/null +++ b/docs/handbook/README.md @@ -0,0 +1,181 @@ +# RealUnit Handbook + +User-Guide + Test-Doku in einem. Jeder Screenshot ist eine Visual-Regression- +Golden-Baseline aus `test/goldens/screens/`; das Handbook bleibt nur dann +visuell korrekt, wenn die App es auch ist — eine Drift im echten Seitenrendering +flippt den entsprechenden Golden-Test rot, bevor das Handbook-Image gebaut wird. + +## Lokal lesen + +```bash +# 1. Screenshots aus Goldens lokal in docs/handbook/screenshots/ assemblieren +# (Verzeichnis ist gitignored; wird nur fürs lokale Preview befüllt) +bash scripts/assemble-handbook-screenshots.sh docs/handbook/screenshots + +# 2. HTML im Browser öffnen — kein Web-Server nötig +open docs/handbook/de/index.html +``` + +Den gleichen Multi-Stage-Build macht `Dockerfile.handbook` automatisch beim +deployten Image (`handbook.realunit.app` / `dev-handbook.realunit.app`). + +## Screenshots regenerieren + +Es gibt keinen separaten Regeneration-Schritt: Die 52 Handbook-Screenshots +sind direkt die Golden-Baselines unter `test/goldens/screens/` (gemappt in +`scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der +gemappten Pages produziert beim `flutter test test/goldens` einen Diff — +diesen via `golden-regenerate.yaml` auf dfx01 regenerieren lassen, und der +nächste Handbook-Deploy zeigt das aktualisierte Bild. + +Workflow: + +1. Page in `lib/screens/**/*_page.dart` ändern +2. `flutter test test/goldens/screens/` läuft rot mit Diff-Artefakt +3. `gh workflow run golden-regenerate.yaml --ref ` — der Workflow + regeneriert auf dfx01 und committet die neuen PNGs als + `github-actions[bot]` zurück auf den Branch (siehe + [`../visual-regression-tests.md`](../visual-regression-tests.md)) +4. Pullen → der nächste Handbook-Deploy zeigt die neue Baseline automatisch + (Push auf `staging` → DEV bzw. `develop` → PRD). + +## Selektive Läufe (Teilmenge) + +`scripts/run-handbook-flows.sh` akzeptiert optionale Argumente: Glob-Muster auf +die Flow-Namen. Ohne Argument laufen alle Flows, mit Argument nur die passende +Teilmenge: + +```bash +# Nur diesen einen Flow +scripts/run-handbook-flows.sh 25-restore-wallet + +# Die ganze 20er-Serie (20..26) +scripts/run-handbook-flows.sh '2*' +``` + +Achtung: Die handbook-Flows sind eine **sequentielle Kette** und teilen sich den +App-State — jeder Flow greift den Zustand auf, wo der vorherige ihn hingelegt +hat. Ein Flow aus der Mitte der Kette schlägt einzeln fehl, sofern er nicht +selbst mit einem `launchApp` beginnt. Der Flow `26-terms` ist **eigenständig** +(eigener `launchApp`) und kann daher gefahrlos allein laufen. + +Auch der Tier-3-GitHub-Workflow hat dafür einen `flows`-`workflow_dispatch`-Input +— so lässt sich in der CI gezielt eine Teilmenge der Flows als Navigation-Smoke +neu laufen lassen. (Die Screenshots zieht das Handbook aus den Goldens, nicht +mehr aus diesen Maestro-Läufen.) + +## Einen neuen Handbook-Eintrag hinzufügen + +1. **Page + Golden-Test**: `lib/screens//_page.dart` + zugehörigen + Golden-Test unter `test/goldens/screens//`. Pattern siehe + [`../visual-regression-tests.md`](../visual-regression-tests.md) und bestehende + Tests in `test/goldens/screens/`. +2. **Screenshot-Mapping**: in `scripts/assemble-handbook-screenshots.sh` eine neue + Zeile in der `MAPPING`-Tabelle ergänzen — `"NN-=/goldens/macos/.png"`. + Die Nummer NN ist der Sortierschlüssel im Handbook (keine direkte Bindung mehr + an einen Maestro-Flow). +3. **HTML**: in `docs/handbook/de/index.html` einen neuen `
`-Block + in die thematisch passende `
`-Sektion + einfügen (Muster siehe spec-01). Die Screenshots sind in wenige thematische + spec-Sektionen gruppiert — ein neuer Eintrag kommt meist in eine bestehende. +4. **Nur bei einem neuen Thema**: eine neue `
` + anlegen und den Anker `#spec-NN` in `
) or smuggle + a