diff --git a/BUILD.md b/BUILD.md index 21097da3..71c13acf 100644 --- a/BUILD.md +++ b/BUILD.md @@ -33,7 +33,7 @@ mkdir -p apk DOCKER_BUILDKIT=1 docker build -f android/Dockerfile -o apk . ``` - +Note: This might take a while (from 20min up to 45-50min). # Verify an existing APK @@ -53,4 +53,3 @@ ```shell tools/verify-apollo.sh ``` - diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index af4256d8..58761db1 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -6,6 +6,17 @@ follow [https://changelog.md/](https://changelog.md/) guidelines. ## [Unreleased] +## [55.8] - 2026-04-15 + +### FIXED + +- Edge to edge support on certain screens +- Emergency kit key rotation when adding a new recovery code +- Biometrics support for Android < 9 +- Submarine swap being created twice on activity reconstruction +- Reproducible builds +- Various crash fixes + ## [55.7] - 2026-03-25 ### ADDED diff --git a/android/CLAUDE.md b/android/CLAUDE.md new file mode 100644 index 00000000..f525fd9d --- /dev/null +++ b/android/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This directory contains Muun's self-custody wallet implementation for android (aka called Apollo). + +# Apollo + +## Overview +Apollo is the android app implementation of Muun's self-custody wallet for Bitcoin & Lightning. It enables users to send/receive btc over mainnet and lightning networks. + +## Architecture + +### Original architecture (what you will find in most of the codebase) +- **Architecture pattern**: Clean architecture (data/domain/presentation) & MVP (model-view-presenter) +- **Language**: Java +- **Reactive framework**: RxJava 1.X +- **Object oriented programming**: Heavy dependency on inheritance +- **UI framework**: Android view system (aka XMLs) + +### Target architecture (where we are working towards) +- **Architecture pattern**: Clean architecture (data/domain/presentation) & MVVM (model-view-viewmodel) +- **Language**: Kotlin +- **Reactive framework**: RxJava 1.X +- **Object oriented programming**: Avoid inheritance hell (favor composition over inheritance), top-level/extension functions for sharable behaviour +- **UI framework**: Android view system (aka XMLs) + +## Libwallet +Libwallet is a shared library written in Golang that contains domain logic shared between Apollo and Falcon (Muun's self-custody wallet implementation for iOS). Module :libwallet contains the .aar generated while building the libwallet project located at ../libwallet. Apollo & Libwallet communication is made through gRPC. + +## Commands +```bash +./tools/libwallet-android.sh # MUST run before first build +./gradlew :android:apolloui:assembleLocalDebug +./gradlew :android:apolloui:assembleDogfoodDebug +``` + +## Token Economics + +**CRITICAL**: All file operations must be token-efficient: + +- **Be concise**: Remove verbosity, keep technical accuracy +- **Avoid repetition**: Don't repeat context already established +- **No code examples in CLAUDE.md**: All examples belong in REFERENCES.md +- **Direct communication**: Skip filler phrases +- **Efficient updates**: Only modify necessary sections +- **Prefer references**: Link to existing examples instead of duplicating + +**Apply to all files**: Code, documentation, comments, commit messages, AI responses. + +## Code Style +- IMPORTANT: All new code MUST be Kotlin (not Java) +- NEVER use ButterKnife (@BindView); prefer ViewBinding +- YOU MUST use MVVM for new screens (not MVP) +- ViewModels: StateFlow for continuous state, SharedFlow for one-time events + +## Code Quality +- Pre-commit hook runs Checkstyle/linters on all commits (style, imports, line length, whitespace) +- YOU MUST fix all linter errors before committing (hook will fail otherwise) +- Common issues: unused imports, lines >100 chars, whitespace, brace style +- See @android/ai-rules/SKILLS.md for full pre-commit hook details and how to fix failures + +## Documentation +- **SKILLS.md** - Debugging, testing, code review, refactoring, performance, security (@android/ai-rules/SKILLS.md) +- **LEARNINGS.md** - Common mistakes, deprecation history, codebase gotchas (@android/ai-rules/LEARNINGS.md) +- **FEEDBACK.md** - How to present code changes, errors, options, progress (@android/ai-rules/FEEDBACK.md) +- **REFERENCES.md** - MVVM, AsyncAction, migrations, custom views, RecyclerView, navigation (@android/ai-rules/REFERENCES.md) + +YOU MUST **proactively** update these files when you discover new patterns, gotchas, or better presentation techniques — do it immediately as part of the task, don't wait to be asked. This is how knowledge persists across sessions. + +## Critical: always read files completely + +When instructed to read a file as input (e.g. deployment_input.md), read the ENTIRE file using multiple Read calls with offset/limit if needed. Never start analyzing until every line has been read. diff --git a/android/ai-rules/FEEDBACK.md b/android/ai-rules/FEEDBACK.md new file mode 100644 index 00000000..a1a65e77 --- /dev/null +++ b/android/ai-rules/FEEDBACK.md @@ -0,0 +1,100 @@ +# Apollo Feedback Guide + +## Reporting Technical Decisions + +**Format:** +``` +I'm using [PATTERN] because [REASON]. + +Example implementation: [FILE] in [CLASS/FUNCTION] +``` + +**Example:** +``` +I'm using StateFlow instead of LiveData because the codebase is migrating to Coroutines Flow. + +Reference: NfcReaderViewModel.kt (viewState property) +``` + +## Presenting Code Changes + +**DO:** +- Use file and function/class references (e.g., `MyActivity.kt in onCreate()`) +- Show only changed sections, not entire files +- Explain WHY, not just WHAT +- Link to reference implementations in codebase + +**DON'T:** +- Dump entire file contents +- Explain basic Kotlin/Android concepts (assume user knows) +- Over-explain trivial changes + +## Error Reporting + +**Build errors:** +``` +Build failed at [TASK]: +[ERROR MESSAGE] + +Likely cause: [HYPOTHESIS] +Fix: [ACTION] +``` + +**Runtime errors:** +``` +Crash in [ACTIVITY/VIEWMODEL]: +[STACK TRACE - relevant lines only] + +Root cause: [ANALYSIS] +Fix: [CHANGES MADE] +``` + +## Asking for Clarification + +**When unclear:** +- Architecture decision (MVVM vs MVP for specific case) +- UX behavior (navigation flow, error handling) +- Data model changes (affects DB schema) + +**DON'T ask about:** +- Code style (follow existing patterns) +- Naming conventions (follow codebase conventions) + +## Presenting Options + +**Format:** +``` +Option 1: [APPROACH] +Pros: [...] +Cons: [...] +Example: @path/to/reference + +Option 2: [APPROACH] +Pros: [...] +Cons: [...] +Example: @path/to/reference + +Recommendation: Option [X] because [REASON] +``` + +## Progress Updates + +**For multi-step tasks:** +1. List steps upfront +2. Mark completed steps +3. Report blockers immediately +4. Summarize at end + +**Example:** +``` +Completed: +✓ Created ViewModel with ViewState/ViewCommand +✓ Created Activity with ViewBinding +✓ Added English strings + +In progress: +→ Adding Spanish translations + +Pending: +- Testing navigation flow +``` diff --git a/android/ai-rules/LEARNINGS.md b/android/ai-rules/LEARNINGS.md new file mode 100644 index 00000000..67ad8f7c --- /dev/null +++ b/android/ai-rules/LEARNINGS.md @@ -0,0 +1,108 @@ +# Apollo Learnings & Gotchas + +## Common Mistakes + +**ViewState location:** +- ❌ Separate file: `sealed interface ViewState` in ViewState.kt +- ✅ Inside ViewModel: `sealed interface ViewState` inside MyViewModel.kt + +**Activity base class:** +- ❌ Extending BaseActivity (deprecated) +- ❌ Extending ExtensibleActivity (deprecated) +- ✅ Extending AppCompatActivity directly + +**View binding:** +- ❌ `private lateinit var binding: MyBinding` +- ✅ `private val binding by lazy { MyBinding.inflate(layoutInflater) }` + +**ViewModel instantiation:** +- ❌ `private val viewModel = MyViewModel()` (no DI) +- ✅ `private val viewModel: MyViewModel by viewModels()` (Dagger) + +**Flow collection:** +- ❌ `viewModel.state.collect {}` in onCreate (leaks) +- ✅ `lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.state.collectLatest {} } }` + +**ButterKnife → ViewBinding migration:** +- Remove `tools:viewBindingIgnore="true"` from layout XML +- Remove `@BindView` annotations and butterknife imports +- Add `bindingInflater()` override returning lambda with `MyBinding::inflate` +- Keep `getLayoutResource()` returning 0 with TODO comment (abstract method, will be removed after full migration) +- Access views via `binding.viewId` instead of direct field reference +- DON'T remove `getLayoutResource()` (causes compile error, it's abstract in BaseFragment) + +## Deprecation History + +**Why BaseActivity is deprecated:** +- Massive god class with 50+ methods +- Tight coupling to MVP pattern +- Hard to test, hard to understand +- Blocks migration to MVVM + +**Why P2P/Contacts is deprecated:** +- Feature never gained traction +- Maintenance burden too high +- Focus shifting to core wallet functionality +- Will be removed in Q2 2026 + +**Why ButterKnife is deprecated:** +- ViewBinding is official Android solution +- Better null safety, compile-time checking +- No reflection overhead + +## Codebase Gotchas + +**Flavor-specific behavior:** +- Certificate pins differ per flavor (@android/apolloui/houston.gradle) +- Local uses localhost:8080, regtest uses remote, prod uses api.muun.com +- DON'T hardcode URLs, use BuildConfig + +**Database migrations:** +- SQLDelight migrations are NOT auto-applied +- MUST add migration in @data/db/migrations/ (next number in sequence) +- Test migration with `./gradlew :apolloui:verifySqlDelightMigration` + +**Libwallet changes:** +- If you change @libwallet/, MUST rebuild: `./tools/libwallet-android.sh` +- Changes won't reflect until rebuilt and gradle sync'd +- Takes ~5 minutes to build + +**MuunHeader:** +- Custom view for consistent header across app +- Use `binding.header.attachToActivity(this)` in Activity.onCreate +- Navigation modes: BACK, NONE, EXIT +- Reference: @android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunHeader.java + +## Crashlytics Investigation + +**Always pull at least 10 events across all variants:** +- 2 events gave incomplete picture (ANR appeared low-end-only, but Pixel 7a was affected too) +- More events reveal patterns: device diversity, processState, OS version clustering +- Use `crashlytics_batch_get_events` with sample events from all variants, at least 10 total +- Prioritize events from different variants for maximum diversity + +**Device fingerprint red flags:** +- `locale: UNSET` + `bigQueryPseudoId: null` → likely automated/non-legitimate environment +- `installSource-initiatingPackage: com.miui.huanji` → Xiaomi phone migration tool (copies SharedPreferences but NOT Android Keystore) + +## Android/Kotlin Gotchas + +**SharedFlow vs StateFlow:** +- StateFlow: Continuous state (always has current value) +- SharedFlow: One-time events (navigation, toasts, errors) +- DON'T use StateFlow for one-time events (will replay on config change) + +**repeatOnLifecycle:** +- STARTED: Collect while visible (correct for most UI updates) +- RESUMED: Collect while focused (use for analytics) +- CREATED: Collect while alive (leaks, don't use) + +**ViewBinding naming:** +- activity_my_screen.xml → ActivityMyScreenBinding +- fragment_my_screen.xml → FragmentMyScreenBinding +- view_my_widget.xml → ViewMyWidgetBinding + +**Kotlin scope functions:** +- Use `apply` when calling multiple methods on same object (avoid repetition) +- ❌ `binding.header.attachToActivity(this); binding.header.setTitle(...)` +- ✅ `binding.header.apply { attachToActivity(this@onCreate); setTitle(...) }` diff --git a/android/ai-rules/REFERENCES.md b/android/ai-rules/REFERENCES.md new file mode 100644 index 00000000..1030af07 --- /dev/null +++ b/android/ai-rules/REFERENCES.md @@ -0,0 +1,147 @@ +# Apollo Reference Implementations + +## MVVM Pattern + +**ViewModel:** +```kotlin +class MyViewModel @Inject constructor() : ViewModel() { + sealed interface ViewState { + data class Data(val x: String) : ViewState + data object Loading : ViewState + } + sealed interface ViewCommand { object Navigate : ViewCommand } + + private val _viewState = MutableStateFlow(ViewState.Loading) + val viewState = _viewState.asStateFlow() + private val _viewCommand = MutableSharedFlow(replay = 0) + val viewCommand = _viewCommand.asSharedFlow() +} +``` + +**Activity:** +```kotlin +class MyActivity : AppCompatActivity() { + private val binding by lazy { MyBinding.inflate(layoutInflater) } + private val viewModel: MyViewModel by viewModels() + @Inject lateinit var navigator: Navigator + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + (applicationContext as ApolloApplication).applicationComponent.activityComponent().inject(this) + lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.viewState.collectLatest(::handleState) } } + } + private fun handleState(state: MyViewState) { when(state) { /* ... */ } } +} +``` + +**Real examples in codebase:** +- @android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt + +## AsyncAction Pattern + +```kotlin +@Singleton +class FetchUserAction @Inject constructor( + private val userRepository: UserRepository +) : AsyncAction() { + + override fun action(params: Unit): Observable { + return userRepository.fetchUser() + } +} +``` + +**Usage in ViewModel:** +```kotlin +class MyViewModel @Inject constructor( + private val fetchUser: FetchUserAction +) : ViewModel() { + + fun loadUser() { + viewModelScope.launch { + fetchUser.run(Unit) + .toFlow() + .collectLatest { user -> + _viewState.value = ViewState.Data(user) + } + } + } +} +``` + +**Real examples:** +- @domain/action/base/AsyncAction.java +- @domain/action/user/FetchUserAction.kt + +## Database Migrations + +**Creating a new migration:** +```sql +-- 39.sqm (next in sequence) +ALTER TABLE operation ADD COLUMN nfc_card_id TEXT; +CREATE INDEX idx_operation_nfc_card_id ON operation(nfc_card_id); +``` + +**Testing migration:** +```bash +./gradlew :apolloui:verifySqlDelightMigration +``` + +**Real examples:** +- @data/db/migrations/ (use next number in sequence) + +## Custom Views + +**MuunHeader setup:** +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + binding.header.apply { + attachToActivity(this@onCreate) + setNavigation(MuunHeader.Navigation.BACK) + setTitle("My Screen") + } +} +``` + +**Real examples:** +- @android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunHeader.java + +## Navigation + +```kotlin +// Navigation handled by Activity + +@Inject lateinit var navigator: Navigator + +private fun navigateToNextScreen() { + navigator.navigateToHome(this) +} +``` + +```kotlin +// Navigation handled by ViewModel + +// In ViewModel - emit command +private val _viewCommand = MutableSharedFlow(replay = 0) +val viewCommand = _viewCommand.asSharedFlow() + +fun onContinueClicked() { + _viewCommand.tryEmit(ViewCommand.NavigateToHome) +} + +// In Activity - handle command +private fun handleViewCommand(command: ViewCommand) { + when (command) { + ViewCommand.NavigateToHome -> { + navigator.navigateToHome(this) + } + } +} +``` + +**Real examples:** +- @android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Navigator.kt diff --git a/android/ai-rules/REGRESSION.md b/android/ai-rules/REGRESSION.md new file mode 100644 index 00000000..d855c3dc --- /dev/null +++ b/android/ai-rules/REGRESSION.md @@ -0,0 +1,608 @@ +# Apollo Android Regression Test Suite + +This file is designed for an AI agent running regressions on an Android emulator using mobile automation tools (e.g., mobile-mcp). Each test case includes precise steps and pass criteria. + +## Agent Setup + +- **App package:** `io.muun.apollo.debug` (adjust flavor: `.local`, `.regtest`, `.dogfood` as needed) +- **Main activity:** `io.muun.apollo.presentation.ui.launch.LaunchActivity` +- **Before each test:** Take a screenshot to confirm current screen state +- **After each test:** Mark as PASS or FAIL with a brief note on what was observed +- **Between tests:** The app state carries over unless stated otherwise + +### ADB helpers +```bash +# Launch app +adb shell am start -n io.muun.apollo.debug/io.muun.apollo.presentation.ui.launch.LaunchActivity + +# Force stop (use only when a test requires fresh state) +adb shell am force-stop io.muun.apollo.debug + +# Clear app data (use only when test requires clean install) +adb shell pm clear io.muun.apollo.debug +``` + +--- + +## Prerequisites + +Some tests require: +- **Two simultaneous wallets** (Muun-to-Muun tests): Run two emulators or use a second physical device with the same app flavor and environment. +- **External LN wallet**: A lightning wallet (e.g., Phoenix, Breez) connected to the same regtest/dogfood environment. +- **Google Drive access**: A Google account configured on the emulator. +- **Email access**: Ability to check inbox for the test account's email. +- **Funds in wallet**: Some tests assume the wallet already has a balance. Run receive tests before spend tests. + +--- + +## Test Cases + +--- + +### TC-01: Create Wallet + +**Platform:** Apollo (Android) +**Requires:** Clean install or logged-out state + +**Steps:** +1. Launch the app. +2. On the welcome screen, tap **"Create a new wallet"**. +3. Wait for the PIN setup screen to appear. +4. Enter a 4-digit PIN (e.g., `1234`). +5. Re-enter the same PIN to confirm. +6. Wait for the home screen to load. + +**Pass criteria:** Home screen is displayed showing a welcoming message (e.g., "Welcome to Muun") and a zero balance. + +--- + +### TC-02: Set Up Email and Password + +**Platform:** Apollo (Android) +**Requires:** Logged-in wallet (TC-01 completed) + +**Steps:** +1. From the home screen, tap the **shield/security icon** (bottom navigation or top area). +2. Locate the **"Email and password"** step card. +3. Tap the card. +4. Enter a valid email address for the test account. +5. Check the inbox and enter the verification code received. +6. Enter a strong password. +7. Confirm the password. +8. Tap **"Set up"** or **"Continue"**. + +**Pass criteria:** The email step card on the security screen turns green/complete. + +--- + +### TC-03: Skip Email Step + +**Platform:** Apollo (Android) +**Requires:** Logged-in wallet where email has not been set up + +**Steps:** +1. From the home screen, tap the **shield/security icon**. +2. Locate the **"Email and password"** step card. +3. Tap on it. +4. Tap **"Skip"** or **"Not now"** (look for a skip/dismiss option). +5. Confirm the skip if a confirmation dialog appears. + +**Pass criteria:** The email step card on the security screen is marked as skipped (distinct visual state, not green). + +--- + +### TC-04: Set Up a Recovery Code (RC) + +**Platform:** Apollo (Android) +**Requires:** Logged-in wallet + +**Steps:** +1. From the home screen, tap the **shield/security icon**. +2. Locate the **"Recovery code"** step card. +3. Tap the card. +4. Follow the setup flow: read the instructions and tap **"Continue"**. +5. Note down the displayed 8-word recovery code (write it down for use in sign-in tests). +6. Confirm you have saved the code (tap checkboxes or confirmation button). +7. Enter the code on the verification screen to prove you saved it. + +**Pass criteria:** The recovery code step card on the security screen turns green/complete. + +--- + +### TC-05: Change User Password + +**Platform:** Apollo (Android) +**Requires:** Email and password set up (TC-02 completed) + +**Steps:** +1. From the home screen, tap the **hamburger menu** or **settings gear** icon. +2. Navigate to **"Security"** > **"Change password"** (or similar option). +3. Enter the current password. +4. Enter a new password. +5. Confirm the new password. +6. Tap **"Change"** or **"Save"**. +7. Log out of the app (Settings > **"Log out"**). +8. On the sign-in screen, tap **"Sign in"**, enter the email and the **new** password. +9. Enter the PIN when prompted. + +**Pass criteria:** After logging in with the new password, the app advances to the PIN screen successfully and then to the home screen. + +--- + +### TC-06: Sign In Using Email + Password + +**Platform:** Apollo (Android) +**Requires:** Account with email and password set up; currently logged out + +**Steps:** +1. If logged in: go to Settings > **"Log out"**, confirm log out. +2. On the welcome screen, tap **"Log in"** or **"Sign in"**. +3. Choose **"Use email"** or enter email directly. +4. Enter the test account email. +5. Enter the password. +6. Enter the PIN when prompted. + +**Pass criteria:** App advances to the PIN screen, then to the home screen. + +--- + +### TC-07: Sign In Using Email + RC + +**Platform:** Apollo (Android) +**Requires:** Account with email and RC set up; currently logged out + +**Steps:** +1. If logged in: go to Settings > **"Log out"**, confirm log out. +2. On the welcome screen, tap **"Log in"** or **"Sign in"**. +3. Choose the email sign-in option. +4. Enter the test account email. +5. When prompted for authentication, look for **"Use recovery code"** option. +6. Enter the recovery code (8 words separated by spaces or dashes). +7. Enter the PIN when prompted. + +**Pass criteria:** App advances to the PIN screen successfully. + +--- + +### TC-08: Sign In Using RC + +**Platform:** Apollo (Android) +**Requires:** Account with RC set up; currently logged out + +**Steps:** +1. If logged in: go to Settings > **"Log out"**, confirm log out. +2. On the welcome screen, tap **"Log in"** or **"Sign in"**. +3. Look for **"Use recovery code"** option (may be below email field). +4. Enter the full recovery code. +5. Enter the PIN when prompted. + +**Pass criteria:** App advances to the PIN screen and then to home screen after the loading completes. + +--- + +### TC-09: Log Out, Sign In Using RC + Email Confirmation + +**Platform:** Apollo (Android) +**Requires:** Account with email set up; currently logged in + +**Steps:** +1. Go to Settings > **"Log out"**, confirm log out. +2. On the welcome screen, tap **"Log in"** or **"Sign in"**. +3. Enter the test account email. +4. When prompted, select the **"Recovery code"** path (not password). +5. Enter the recovery code. +6. The app should send a confirmation email. Check the inbox. +7. Tap the confirmation link or enter the confirmation code in the app. +8. Enter the PIN when prompted. + +**Pass criteria:** App advances to the PIN screen after email confirmation. + +--- + +### TC-10: Sign In With RC Using an Old User + +**Platform:** Apollo (Android) +**Requires:** Specific test account credentials (RC version 1) + +**Credentials:** +- **Email:** Set via `TEST_OLD_USER_EMAIL` env var +- **RC:** Set via `TEST_OLD_USER_RC` env var +- **Note:** Password was reset via "Forgot password" flow + +**Steps:** +1. If logged in: go to Settings > **"Log out"**, confirm log out. +2. On the welcome screen, tap **"Log in"** or **"Sign in"**. +3. Choose **"Use recovery code"**. +4. Enter the RC from `TEST_OLD_USER_RC` env var. +5. If prompted for email, enter the value from `TEST_OLD_USER_EMAIL` env var. +6. Complete any additional verification steps (email confirmation if required). +7. Enter the PIN when prompted. + +**Pass criteria:** App advances to the PIN screen successfully. + +--- + +### TC-11: Receive from Another Wallet to a Legacy Address + +**Platform:** Apollo (Android) +**Requires:** External wallet with funds; logged in + +**Steps:** +1. From the home screen, tap the **receive** button (down arrow icon). +2. On the receive screen, look for an address type selector. +3. Select **"Legacy"** address type (if selectable), or find the legacy address option. +4. Copy the displayed Bitcoin legacy address (starts with `1`). +5. In the external wallet, initiate a send to this address. +6. Return to the Apollo app and wait for the transaction to appear. + +**Pass criteria:** A notification appears and/or a new operation is shown in the home screen with the received amount. + +--- + +### TC-12: Receive from Another Wallet to a Segwit Address (Muun → Muun) + +**Platform:** Apollo (Android) +**Requires:** Second Muun wallet instance; logged in on both + +**Steps:** +1. On **Wallet A** (receiving): tap the **receive** button. +2. Select or confirm **segwit** address type (starts with `3` or `bc1`). +3. Set a fixed amount using the **"Set amount"** option (required for Bitcoin URI). +4. Copy the Bitcoin URI or address. +5. On **Wallet B** (sending): tap **"Send"**, paste the Bitcoin URI. +6. Confirm the amount is pre-filled. +7. Complete the send on Wallet B. +8. On **Wallet A**: watch the home screen for the incoming transaction. + +**Pass criteria:** Balance on Wallet A is updated to match the sent amount. + +--- + +### TC-13: Receive from a LN Wallet on an Invoice With Amount (Other Wallet → Muun) + +**Platform:** Apollo (Android) +**Requires:** External lightning wallet; logged in +**Note:** Log out invalidates invoices — do not log out between generating invoice and paying it. + +**Steps:** +1. On Apollo, tap the **receive** button. +2. Select **"Lightning"** or let the app generate a lightning invoice. +3. Set a specific amount (e.g., 10,000 sats). +4. Copy the lightning invoice (BOLT11 string starting with `lnbc...`). +5. On the external LN wallet, paste the invoice and send. +6. Return to Apollo and wait for the balance to update. + +**Pass criteria:** Balance is updated to match the sent amount. + +--- + +### TC-14: Receive from Another Muun Wallet on an Invoice With Amount (Muun → Muun) + +**Platform:** Apollo (Android) +**Requires:** Two Muun wallet instances on same environment +**Note:** Log out invalidates invoices — do not log out during this test. + +**Steps:** +1. On **Wallet A** (receiving): tap **receive**, select **Lightning**, set an amount (e.g., 5,000 sats), copy the invoice. +2. On **Wallet B** (sending): tap **send**, paste the lightning invoice, confirm amount, complete payment. +3. On **Wallet A**: wait for balance to update. + +**Pass criteria:** Balance on Wallet A is updated to match the invoiced amount. + +--- + +### TC-15: Send Money from a Lightning Wallet to an Amountless Invoice (Other Wallet → Muun) + +**Platform:** Apollo (Android) +**Requires:** External lightning wallet; logged in +**Note:** Use amount > 10,000 sats (e.g., 10,001 sats). Do not log out. + +**Steps:** +1. On Apollo, tap the **receive** button. +2. Select **"Lightning"**. +3. Generate an invoice **without** a fixed amount (leave amount blank). +4. Copy the invoice. +5. On the external LN wallet, paste the invoice, enter amount (e.g., 10,001 sats), and send. +6. Return to Apollo and wait for the balance update. + +**Pass criteria:** Balance is updated to reflect the amount sent by the external wallet. + +--- + +### TC-16: Send Money from Another Muun Wallet, Amountless Invoice (Muun → Muun) + +**Platform:** Apollo (Android) +**Requires:** Two Muun wallet instances on same environment +**Note:** Do not log out during this test. + +**Steps:** +1. On **Wallet A** (receiving): tap **receive**, select **Lightning**, generate an invoice with **no amount**, copy it. +2. On **Wallet B** (sending): tap **send**, paste the invoice, enter an amount, complete payment. +3. On **Wallet A**: wait for balance to update. + +**Pass criteria:** Balance on Wallet A is updated to match the amount sent. + +--- + +### TC-17: Monitor Confirmations + +**Platform:** Apollo (Android) +**Requires:** A recent on-chain transaction (completed in a prior test) + +**Steps:** +1. From the home screen, tap on a recent on-chain operation. +2. Note the current confirmation count (likely 0 or low). +3. Wait for the network to mine new blocks (in regtest: manually mine; in dogfood: wait). +4. Periodically pull-to-refresh or check the operation detail screen. + +**Pass criteria:** Confirmation count on the operation increases over time (reaches at least 1 confirmation). + +--- + +### TC-18: Try Spending All Funds Received + +**Platform:** Apollo (Android) +**Requires:** Wallet with a positive balance + +**Steps:** +1. From the home screen, tap **"Send"** or the send button. +2. Enter a destination address or lightning invoice. +3. On the amount screen, tap **"Use all funds"** (or a similar "max" button). +4. Confirm that the amount field is filled with the total available balance. +5. Confirm the fee estimate is shown. +6. Tap **"Continue"** and confirm the payment. + +**Pass criteria:** Payment is sent successfully; balance approaches zero (accounting for fees). + +--- + +### TC-19: Try Spending Every UTXO Received + +**Platform:** Apollo (Android) +**Requires:** Wallet with multiple UTXOs (received multiple separate transactions) + +**Steps:** +1. For each individual UTXO/operation received: + a. Tap **"Send"**. + b. Enter a destination address. + c. Enter the amount of that specific UTXO (or use "Use all funds" for the last one). + d. Confirm the send. +2. After each send, verify the operation appears in history. +3. Repeat until all UTXOs are spent. + +**Pass criteria:** All individual UTXOs are successfully sent; balance reaches zero. + +--- + +### TC-20: Try to Delete Wallet With Money + +**Platform:** Apollo (Android) +**Requires:** Wallet with a positive balance + +**Steps:** +1. From the home screen, tap the **settings** icon. +2. Scroll down to find **"Delete wallet"** or **"Logout and delete wallet"** option. +3. Tap the delete option. +4. Observe the dialog or alert that appears. + +**Pass criteria:** An alert is displayed stating the wallet cannot be deleted because it has funds (e.g., "Empty your wallet first"). The wallet does **not** log out or delete. + +--- + +### TC-21: Export a Kit Manually + +**Platform:** Apollo (Android) +**Requires:** Logged-in wallet with email and RC set up + +**Steps:** +1. From the home screen, tap the **shield/security icon**. +2. Tap **"Emergency kit"** or **"Export kit"** option. +3. Choose **"Export manually"** (not email or cloud storage). +4. Read and continue through any informational screens. +5. On the verification code screen, enter the displayed code to confirm you can access it. +6. The app generates a PDF or file of the kit. + +**Pass criteria:** The verification code is accepted. The receiving app (file manager, share sheet) is able to handle the kit file. + +--- + +### TC-22: (Apollo) Export a Kit With Self-Email + +**Platform:** Apollo (Android) only + +**Steps:** +1. From the home screen, tap the **shield/security icon**. +2. Tap **"Emergency kit"** or **"Export kit"** option. +3. Choose **"Send to my email"** option. +4. Confirm the email address shown is the account email. +5. Tap **"Send"**. +6. Check the email inbox for the kit email. +7. Verify the email has the correct subject line, body text, and a valid attachment. + +**Pass criteria:** Email is received. The attachment is a valid emergency kit file. Subject and body match the expected format. + +--- + +### TC-23: Export a Kit With Google Drive + +**Platform:** Apollo (Android) +**Requires:** Google account configured on the emulator + +**Steps:** +1. Go to Google Drive on the emulator and delete any existing Muun kits in the Muun folder. +2. In the emulator's account settings, remove Muun's Google Drive permission (optional for a clean test). +3. Return to Apollo. From the home screen, tap the **shield/security icon**. +4. Tap **"Emergency kit"** or **"Export kit"** option. +5. Choose **"Upload to Google Drive"**. +6. A Google account picker appears — select the account and accept permissions. +7. Wait for the automatic upload to complete. +8. Tap **"Open in Drive"** (if shown) or open Google Drive manually. +9. Navigate to the Muun folder. + +**Pass criteria:** The kit is correctly uploaded to the Muun folder in Google Drive. + +--- + +### TC-24: Unified QR — Setup + +**Platform:** Apollo (Android) + +**Steps:** +1. From the home screen, tap **Settings** (gear icon). +2. Navigate to **"Receive preferences"** or **"Bitcoin address type"** settings. +3. Test switching between **Lightning**, **On-chain**, and **Unified QR** options. +4. For each option, go back to the receive screen and generate a QR code. +5. Verify that: + - Lightning → QR shows a Lightning invoice. + - On-chain → QR shows a Bitcoin address. + - Unified QR → QR shows a BIP-21 URI with both address and Lightning invoice. + +**Pass criteria:** The QR changes correctly based on the setting. With Unified QR, the remaining capacity/amount info is visible. + +--- + +### TC-25: Unified QR — On-Chain (Send From External Wallet) + +**Platform:** Apollo (Android) +**Requires:** External wallet that supports BIP-21 unified QRs + +**Steps:** +1. In Apollo settings, enable **Unified QR** mode. +2. Go to the receive screen and set a fixed amount. +3. Display the QR code. +4. On the external wallet, scan or paste the QR/URI. +5. Verify that the external wallet correctly parses: + - The Bitcoin address. + - The pre-filled amount. +6. The external wallet sends on-chain. +7. Wait for Apollo to receive the transaction. + +**Pass criteria:** External wallet correctly displays the address and auto-fills the amount. + +--- + +### TC-26: Unified QR — Off-Chain, Muun to External (M2E) + +**Platform:** Apollo (Android) +**Requires:** External lightning wallet (e.g., Phoenix) + +**Steps:** +1. In Apollo settings, enable **Unified QR** mode. +2. Go to the receive screen and set an amount. +3. Display the unified QR code. +4. On the external lightning wallet (e.g., Phoenix), scan the QR. +5. The external wallet should offer a choice of send type (Lightning or on-chain). +6. Choose Lightning. +7. Verify the amount is pre-filled. +8. Complete the send from the external wallet. +9. Check Apollo for the received funds. + +**Pass criteria:** External wallet can see both address and amount. Payment completes successfully. + +--- + +### TC-27: Unified QR — Off-Chain, Muun to Muun (M2M) + +**Platform:** Apollo (Android) +**Requires:** Two Muun wallet instances + +**Steps:** +1. On **Wallet A** (receiving): enable Unified QR in settings. Set an amount. Go to receive screen. +2. On **Wallet B** (sending): tap **send**, scan or paste the QR from Wallet A. +3. Wallet B should default to sending via Lightning. +4. Confirm amount and complete the payment. +5. On Wallet A: wait for balance to update. + +**Pass criteria:** Wallet B defaults to Lightning payment. Balance on Wallet A is updated. + +--- + +### TC-28: Verify Balance Is Updated Correctly + +**Platform:** Apollo (Android) +**Requires:** Run this after completing send/receive tests + +**Steps:** +1. On the home screen, note the displayed balance. +2. Open the operation history (tap "Activity" or similar). +3. Sum up all received amounts minus all sent amounts and fees. +4. Compare the calculated balance with the displayed balance. +5. Also verify the balance displays correctly in both BTC and local currency. + +**Pass criteria:** Displayed balance matches the sum of all operations. Currency conversion is shown correctly. + +--- + +### TC-29: Update App + +**Platform:** Apollo (Android) +**Requires:** Previous app version installed with a logged-in user + +**Steps:** +1. Ensure the app has a previous version installed with a logged-in user and some operations in history. +2. Note the current balance and last operation. +3. Install the new version of the APK: + ```bash + adb install -r path/to/new-version.apk + ``` +4. Launch the app. +5. Navigate to the home screen. + +**Pass criteria:** The app launches successfully without crashes. Balance and operation history are intact. No data migration errors are shown. + +--- + +### TC-30: Use the Recovery Tool to Recover Funds + +**Platform:** Apollo (Android) +**Requires:** RC and Emergency Kit (both keys from the EK); access to Muun Recovery Tool + +**Steps:** +1. Obtain the user's Recovery Code (from TC-04 setup). +2. Obtain the Emergency Kit file (exported in TC-21 or TC-22). +3. Open the Muun Recovery Tool (standalone app or web tool at the Muun recovery URL). +4. Follow the tool's instructions: + a. Input the Recovery Code. + b. Upload or provide both keys from the Emergency Kit. +5. The tool should display the user's funds and allow recovery. +6. Initiate the recovery transaction to a destination address. +7. Compare the amount shown in the recovery tool against the Apollo app balance. + +**Pass criteria:** A recovery transaction is made successfully. Funds shown in the recovery tool are greater than or equal to the funds shown in the Apollo app. + +--- + +## Test Execution Order (Recommended) + +Run tests in this order to build on prerequisite state: + +1. TC-01 Create Wallet +2. TC-02 Set Up Email and Password +3. TC-04 Set Up RC +4. TC-03 Skip Email Step *(requires fresh wallet — run independently)* +5. TC-05 Change User Password +6. TC-11 Receive to Legacy Address +7. TC-12 Receive Segwit (Muun → Muun) +8. TC-13 Receive LN Invoice With Amount (External → Muun) +9. TC-14 Receive LN Invoice With Amount (Muun → Muun) +10. TC-15 Receive Amountless LN Invoice (External → Muun) +11. TC-16 Receive Amountless LN Invoice (Muun → Muun) +12. TC-28 Verify Balance +13. TC-17 Monitor Confirmations +14. TC-18 Try Spending All Funds +15. TC-19 Try Spending Every UTXO +16. TC-20 Try to Delete Wallet With Money *(run before spending all funds)* +17. TC-21 Export Kit Manually +18. TC-22 Export Kit via Self-Email +19. TC-23 Export Kit via Google Drive +20. TC-24 Unified QR Setup +21. TC-25 Unified QR On-Chain +22. TC-26 Unified QR M2E +23. TC-27 Unified QR M2M +24. TC-06 Sign In Email + Password +25. TC-07 Sign In Email + RC +26. TC-08 Sign In RC +27. TC-09 Log Out + Sign In RC + Email Confirmation +28. TC-10 Sign In Old User with RC +29. TC-29 Update App *(requires two APK versions)* +30. TC-30 Recovery Tool diff --git a/android/ai-rules/SKILLS.md b/android/ai-rules/SKILLS.md new file mode 100644 index 00000000..344092e3 --- /dev/null +++ b/android/ai-rules/SKILLS.md @@ -0,0 +1,92 @@ +# Apollo Skills Guide + +## Debugging Android Apps + +**Quick check:** +```bash +./gradlew :android:apolloui:assembleLocalDebug +adb logcat | grep "apollo" +adb shell am start -n io.muun.apollo.debug.local/io.muun.apollo.presentation.ui.home.HomeActivity +``` + +**Common issues:** +- Libwallet not built → Run `./tools/libwallet-android.sh` first +- Houston not running → Check `docker compose ps houston` +- Certificate pinning fails → Use correct flavor (local/regtest/prod) + +## Testing Patterns + +**Pure tests (fast):** +```bash +./gradlew :android:apolloui:test +``` + +**Reference:** @android/apolloui/src/test/ for test structure + +## Code Review Checklist + +- [ ] No BaseActivity/BaseFragment/BasePresenter usage +- [ ] No ButterKnife (@BindView) +- [ ] No P2P/Contacts code added +- [ ] Kotlin (not Java) for new code +- [ ] ViewBinding used (not findViewById) +- [ ] MVVM pattern for new screens +- [ ] DB migration added if schema changed (@data/db/migrations/) +- [ ] Strings added to values/strings.xml AND values-es/strings.xml +- [ ] No security vulnerabilities (SQL injection, XSS, command injection) + +## Pre-commit Hook + +**What it does:** +- Runs automatically on `git commit` +- Lints only staged files (Python linter in @linters/pre-commit-linter.py) +- Checks: Checkstyle (Java/Kotlin), flake8 (Python), JSON, Dockerfile, Rust, custom linters +- Uses temporary commits to isolate staged changes, then reverts + +**Checkstyle checks (Java/Kotlin):** +- UnusedImports (catches unused imports automatically) +- Line length (100 chars max) +- Whitespace, braces, indentation +- Modifier order, naming conventions +- Config: @linters/checkstyle/config.xml + +**If pre-commit fails:** +- Fix the reported issues +- Re-stage changed files: `git add ` +- Commit again + +## Refactoring Legacy Code + +**DO:** +- Keep existing MVP screens as MVP (don't mix patterns) +- Extract logic to domain actions when possible +- Add tests before refactoring +- Use @Deprecated annotation with migration path + +**DON'T:** +- Refactor BaseActivity screens to ExtensibleActivity (both deprecated) +- Mix MVP + MVVM in same screen +- Remove P2P code (will be removed in coordinated effort) + +## Performance Optimization + +**Check:** +- @android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/ - RecyclerView patterns +- Use ViewHolder pattern, avoid nested RecyclerViews +- Lazy load images with Picasso +- Use SQLDelight queries efficiently (avoid N+1) + +**Profile:** +```bash +./gradlew :android:apolloui:assembleLocalDebug +# Android Studio → Profiler → CPU/Memory +``` + +## Security Considerations + +**CRITICAL:** +- NEVER log sensitive data (keys, seeds, passwords) +- NEVER commit .env files or credentials +- Always validate user input before database queries +- Use HTTPS for all network calls (certificate pinning configured per flavor) +- Check @android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/ for crypto operations diff --git a/android/apolloui/.gitignore b/android/apolloui/.gitignore index 064284d0..f1710804 100644 --- a/android/apolloui/.gitignore +++ b/android/apolloui/.gitignore @@ -5,6 +5,4 @@ *serviceaccount-key.json # Fastlane -fastlane/report.xml - -src/local/google-services.json +fastlane/report.xml \ No newline at end of file diff --git a/android/apolloui/build.gradle b/android/apolloui/build.gradle index 2c8d5a0f..08065a40 100644 --- a/android/apolloui/build.gradle +++ b/android/apolloui/build.gradle @@ -1,3 +1,5 @@ +import com.android.build.api.variant.FilterConfiguration + import java.util.regex.Pattern buildscript { @@ -23,6 +25,7 @@ apply plugin: 'com.google.firebase.crashlytics' apply plugin: 'kotlin-kapt' apply plugin: "androidx.navigation.safeargs" apply plugin: 'com.squareup.sqldelight' +apply plugin: 'kotlin-parcelize' apply from: 'houston.gradle' apply from: "${project.rootDir}/linters/checkstyle/check-android.gradle" @@ -101,8 +104,8 @@ android { applicationId "io.muun.apollo" minSdk 21 targetSdk 35 - versionCode 1507 - versionName "55.7" + versionCode 1508 + versionName "55.8" // Use default Proguard file, bundled with Android Gradle Plugin // See: https://issuetracker.google.com/issues/126772206 @@ -257,6 +260,7 @@ android { dogfood { // naming + // Note: if we ever change smth about this suffix our CI/CD needs to patched versionNameSuffix "-" + commitTag() applicationIdSuffix ".internal.beta" resValue "string", "app_name", "Muun (Dogfood)" @@ -416,9 +420,9 @@ dependencies { implementation 'org.javamoney:moneta-bp:1.0' implementation 'org.zalando:jackson-datatype-money:0.12.0' - // Firebase: - // Import the Firebase BoM - implementation platform('com.google.firebase:firebase-bom:32.6.0') + // Firebase: Import the Firebase BoM + // Note this is the max BOM while using minSdk 21. Next verison requires minSdk 23 + implementation platform('com.google.firebase:firebase-bom:33.16.0') // When using the BoM, you don't specify versions in Firebase library dependencies // Push Notifications: @@ -598,35 +602,43 @@ androidComponents { onVariants(selector().withName(Pattern.compile(".*dogfoodRelease.*"))) { variant -> variant.signingConfig?.setConfig(android.signingConfigs.dogfood) } -} -android.applicationVariants.configureEach { variant -> - variant.outputs.configureEach { output -> - def baseVersionCode = versionCode as int + onVariants(selector().all()) { variant -> + def baseVersionCode = android.defaultConfig.versionCode as int def buildVersionSuffix = project.hasProperty('buildSuffix') ? project.buildSuffix : "000" + println "baseVersionCode: $baseVersionCode" + println "buildVersionSuffix: $buildVersionSuffix" + if (!buildVersionSuffix.matches("\\d{3}")) { throw new GradleException("buildSuffix must be a number with 3 digits: '$buildVersionSuffix'") } def abiVersionCodeMap = ["dogfood": 1, "x86": 2, "x86_64": 3, "armeabi-v7a": 4, "arm64-v8a": 5] - def abi = output.getFilter(com.android.build.OutputFile.ABI) - def abiCode = 0 - if (variant.name.containsIgnoreCase('dogfood')) { - abiCode = 1 - } else if (abi != null) { - abiCode = abiVersionCodeMap.get(abi) ?: 0 - } + variant.outputs.each { output -> + def abiCode = 0 + def abiIdentifier = 'universal' - println "baseVersionCode: $baseVersionCode" - println "buildVersionSuffix: $buildVersionSuffix" - println "abiCode: $abiCode" + if (variant.name.containsIgnoreCase('dogfood')) { + abiCode = 1 + } else { + def abiFilter = output.filters.find { + it.filterType == FilterConfiguration.FilterType.ABI + } + if (abiFilter != null) { + abiCode = abiVersionCodeMap.get(abiFilter.identifier) ?: 0 + abiIdentifier = abiFilter.identifier + } + } + println "abiCode: $abiCode" - output.versionCodeOverride = (baseVersionCode.toString() + buildVersionSuffix + abiCode.toString()) as int + def finalVersionCode = (baseVersionCode.toString() + buildVersionSuffix + abiCode.toString()) as int + output.versionCode.set(finalVersionCode) - if (System.getenv("CI")) { - println "ABI: ${abi ?: 'universal'}, Final versionCode: ${output.versionCodeOverride}" + if (System.getenv("CI")) { + println "ABI: ${abiIdentifier}, Final versionCode: ${finalVersionCode}" + } } } } diff --git a/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt b/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt new file mode 100644 index 00000000..19b654eb --- /dev/null +++ b/android/apolloui/src/androidTest/java/io/muun/apollo/data/afs/MetricsProviderTimingTest.kt @@ -0,0 +1,238 @@ +package io.muun.apollo.data.afs + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.muun.apollo.domain.action.session.IsRootedDeviceAction +import io.muun.apollo.presentation.app.ApolloApplication +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MetricsProviderTimingTest { + + companion object { + private const val TAG = "MetricsProviderTiming" + } + + private lateinit var metricsProvider: MetricsProvider + private lateinit var isRootedDeviceAction: IsRootedDeviceAction + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val app = context.applicationContext as ApolloApplication + metricsProvider = app.dataComponent.metricsProvider() + isRootedDeviceAction = IsRootedDeviceAction(context) + } + + private fun measure( + name: String, + timings: MutableList>, + block: () -> T, + ): T { + val startNanoSecs = System.nanoTime() + val result = block() + val elapsedMicroSecs = (System.nanoTime() - startNanoSecs) / 1_000 + timings.add(name to elapsedMicroSecs) + return result + } + + @Test + fun measureAllPropertyTimings() { + val timingsInMicroSeconds = mutableListOf>() + + // ActivityManagerInfoProvider + measure("isLowRamDevice", timingsInMicroSeconds) { metricsProvider.isLowRamDevice } + measure( + "isBackgroundRestricted", + timingsInMicroSeconds + ) { metricsProvider.isBackgroundRestricted } + measure( + "isLowMemoryKillReportSupported", + timingsInMicroSeconds + ) { metricsProvider.isLowMemoryKillReportSupported } + measure("exitReasons", timingsInMicroSeconds) { metricsProvider.exitReasons } + + // TelephonyInfoProvider + measure("dataState", timingsInMicroSeconds) { metricsProvider.dataState } + measure("simStates", timingsInMicroSeconds) { metricsProvider.simStates } + measure( + "telephonyNetworkRegion", + timingsInMicroSeconds + ) { metricsProvider.telephonyNetworkRegion } + measure("simRegion", timingsInMicroSeconds) { metricsProvider.simRegion } + measure("mobileRoaming", timingsInMicroSeconds) { metricsProvider.mobileRoaming } + measure("mobileDataStatus", timingsInMicroSeconds) { metricsProvider.mobileDataStatus } + measure("mobileRadioType", timingsInMicroSeconds) { metricsProvider.mobileRadioType } + + // HardwareCapabilitiesProvider + measure("androidId", timingsInMicroSeconds) { metricsProvider.androidId } + measure("drmClientIds", timingsInMicroSeconds) { metricsProvider.drmClientIds } + measure("bootCount", timingsInMicroSeconds) { metricsProvider.bootCount } + measure("glEsVersion", timingsInMicroSeconds) { metricsProvider.glEsVersion } + measure( + "totalInternalStorageInBytes", + timingsInMicroSeconds + ) { metricsProvider.totalInternalStorageInBytes } + measure( + "totalExternalStorageInBytes", + timingsInMicroSeconds + ) { metricsProvider.totalExternalStorageInBytes } + measure("totalRamInBytes", timingsInMicroSeconds) { metricsProvider.totalRamInBytes } + measure("initOffset", timingsInMicroSeconds) { metricsProvider.bootOffset } + + // PackageManagerInfoProvider + measure( + "installSourceInfo", + timingsInMicroSeconds + ) { metricsProvider.installSourceInfo } + measure("appInfo", timingsInMicroSeconds) { metricsProvider.appInfo } + measure("deviceFeatures", timingsInMicroSeconds) { metricsProvider.deviceFeatures } + measure("signatureHash", timingsInMicroSeconds) { metricsProvider.signatureHash } + measure( + "firstInstallTimeInMs", + timingsInMicroSeconds + ) { metricsProvider.firstInstallTimeInMs } + measure("applicationId", timingsInMicroSeconds) { metricsProvider.applicationId } + + // BuildInfoProvider + measure("buildInfo", timingsInMicroSeconds) { metricsProvider.buildInfo } + measure("deviceName", timingsInMicroSeconds) { metricsProvider.deviceName } + measure("deviceModel", timingsInMicroSeconds) { metricsProvider.deviceModel } + + // FileInfoProvider + measure("quickEmProps", timingsInMicroSeconds) { metricsProvider.quickEmProps } + measure("emArchitecture", timingsInMicroSeconds) { metricsProvider.emArchitecture } + measure("appSize", timingsInMicroSeconds) { metricsProvider.appSize } + measure("initId", timingsInMicroSeconds) { metricsProvider.bootId } + measure("defaultFsDate", timingsInMicroSeconds) { metricsProvider.defaultFsDate } + measure("androidFsDate", timingsInMicroSeconds) { metricsProvider.androidFsDate } + measure( + "hasUniqueBaseDateInExternalStorage", + timingsInMicroSeconds + ) { metricsProvider.hasUniqueBaseDateInExternalStorage } + measure( + "externalStorageMinDate", + timingsInMicroSeconds + ) { metricsProvider.externalStorageMinDate } + measure( + "hasNewEntriesInAppExternalStorage", + timingsInMicroSeconds + ) { metricsProvider.hasNewEntriesInAppExternalStorage } + + // SystemCapabilitiesProvider + measure( + "securityEnhancedBuild", + timingsInMicroSeconds + ) { metricsProvider.securityEnhancedBuild } + measure( + "bridgeRootService", + timingsInMicroSeconds + ) { metricsProvider.bridgeRootService } + measure("vbMeta", timingsInMicroSeconds) { metricsProvider.vbMeta } + measure("usbConnected", timingsInMicroSeconds) { metricsProvider.usbConnected } + measure( + "usbPersistConfig", + timingsInMicroSeconds + ) { metricsProvider.usbPersistConfig } + measure("bridgeEnabled", timingsInMicroSeconds) { metricsProvider.bridgeEnabled } + measure( + "bridgeDaemonStatus", + timingsInMicroSeconds + ) { metricsProvider.bridgeDaemonStatus } + measure( + "developerEnabled", + timingsInMicroSeconds + ) { metricsProvider.developerEnabled } + measure("internalLevel", timingsInMicroSeconds) { metricsProvider.internalLevel } + + // AppInfoProvider + measure("appDatadir", timingsInMicroSeconds) { metricsProvider.appDatadir } + measure( + "latestBackgroundTimes", + timingsInMicroSeconds + ) { metricsProvider.latestBackgroundTimes } + + // ConnectivityInfoProvider + measure( + "currentNetworkTransport", + timingsInMicroSeconds + ) { metricsProvider.currentNetworkTransport } + measure("vpnState", timingsInMicroSeconds) { metricsProvider.vpnState } + measure("proxyHttpType", timingsInMicroSeconds) { metricsProvider.proxyHttpType } + measure("proxyHttpsType", timingsInMicroSeconds) { metricsProvider.proxyHttpsType } + measure("proxySocksType", timingsInMicroSeconds) { metricsProvider.proxySocksType } + measure("networkLink", timingsInMicroSeconds) { metricsProvider.networkLink } + + // DateTimeZoneProvider + measure( + "timeZoneOffsetSeconds", + timingsInMicroSeconds + ) { metricsProvider.timeZoneOffsetSeconds } + measure("autoDateTime", timingsInMicroSeconds) { metricsProvider.autoDateTime } + measure("autoTimeZone", timingsInMicroSeconds) { metricsProvider.autoTimeZone } + measure("timeZoneId", timingsInMicroSeconds) { metricsProvider.timeZoneId } + + // LocaleInfoProvider + measure("language", timingsInMicroSeconds) { metricsProvider.language } + measure("regionCode", timingsInMicroSeconds) { metricsProvider.regionCode } + + // TrafficStatsInfoProvider + measure( + "androidMobileRxTraffic", + timingsInMicroSeconds + ) { metricsProvider.androidMobileRxTraffic } + + // NfcProvider + measure("hasNfcFeature", timingsInMicroSeconds) { metricsProvider.hasNfcFeature } + measure("hasNfcAdapter", timingsInMicroSeconds) { metricsProvider.hasNfcAdapter } + measure("isNfcEnabled", timingsInMicroSeconds) { metricsProvider.isNfcEnabled } + measure( + "nfcAntennaPosition", + timingsInMicroSeconds + ) { metricsProvider.nfcAntennaPosition } + measure("deviceSizeInMm", timingsInMicroSeconds) { metricsProvider.deviceSizeInMm } + measure( + "isDeviceFoldable", + timingsInMicroSeconds + ) { metricsProvider.isDeviceFoldable } + + // BatteryInfoProvider + measure("batteryLevel", timingsInMicroSeconds) { metricsProvider.batteryLevel } + measure("batteryStatus", timingsInMicroSeconds) { metricsProvider.batteryStatus } + measure( + "batteryRemainState", + timingsInMicroSeconds + ) { metricsProvider.batteryRemainState } + measure("isCharging", timingsInMicroSeconds) { metricsProvider.isCharging } + + // SystemInfoProvider + measure( + "currentTimeMillis", + timingsInMicroSeconds + ) { metricsProvider.currentTimeMillis } + measure("uptimeMillis", timingsInMicroSeconds) { metricsProvider.uptimeMillis } + measure("elapsedRealtime", timingsInMicroSeconds) { metricsProvider.elapsedRealtime } + + // RootHint + measure("RootHint", timingsInMicroSeconds) { isRootedDeviceAction.isRooted() } + + // Print summary sorted by elapsed time (slowest first) + val totalUs = timingsInMicroSeconds.sumOf { it.second } + + Log.d(TAG, "=".repeat(60)) + Log.d(TAG, "MetricsProvider property timing (sorted slowest first)") + Log.d(TAG, "=".repeat(60)) + + timingsInMicroSeconds.sortedByDescending { it.second }.forEach { (name, us) -> + val ms = us / 1_000.0 + Log.d(TAG, "%-40s %8d μs (%6.2f ms)".format(name, us, ms)) + } + + Log.d(TAG, "-".repeat(60)) + Log.d(TAG, "%-40s %8d μs (%6.2f ms)".format("TOTAL", totalUs, totalUs / 1_000.0)) + Log.d(TAG, "=".repeat(60)) + } +} diff --git a/android/apolloui/src/main/AndroidManifest.xml b/android/apolloui/src/main/AndroidManifest.xml index c727f146..9ed2fe73 100644 --- a/android/apolloui/src/main/AndroidManifest.xml +++ b/android/apolloui/src/main/AndroidManifest.xml @@ -399,6 +399,22 @@ android:name=".presentation.ui.high_fees.HighFeesExplanationActivity" /> + + + + + + + + diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt index 4ddab090..92a89326 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/ActivityManagerInfoProvider.kt @@ -56,7 +56,11 @@ class ActivityManagerInfoProvider(context: Context) { val isLowMemoryKillReportSupported: Boolean get() { return if (OS.supportsLowMemoryKillReport()) { - ActivityManager.isLowMemoryKillReportSupported() + try { + ActivityManager.isLowMemoryKillReportSupported() + } catch (e: Exception) { + false + } } else { false } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt index 801608db..6c49d34b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/BackgroundExecutionMetricsProvider.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class BackgroundExecutionMetricsProvider @Inject constructor( private val metricsProvider: MetricsProvider, - private val googlePlayServicesHelper: GooglePlayServicesHelper, + googlePlayServicesHelper: GooglePlayServicesHelper, private val googlePlayHelper: GooglePlayHelper, ) { @@ -72,7 +72,12 @@ class BackgroundExecutionMetricsProvider @Inject constructor( playServicesInfo.clientVersionCode, googlePlayHelper.versionCode, googlePlayHelper.versionName, - metricsProvider.buildInfo + metricsProvider.buildInfo, + metricsProvider.bootOffset, + metricsProvider.bootId, + metricsProvider.vbMeta, + metricsProvider.bridgeRootService, + metricsProvider.appSize, ) @Suppress("ArrayInDataClass") @@ -134,6 +139,11 @@ class BackgroundExecutionMetricsProvider @Inject constructor( private var googlePlayServicesClientVersionCode: Int, private var googlePlayVersionCode: Long, private var googlePlayVersionName: String, - private var buildInfo: BuildInfo + private var buildInfo: BuildInfo, + private var bootOffset: Int, + private var bootId: String, + private val vbMeta: String, + private val bridgeRootService: String, + private val appSize: Long ) } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/FileInfoProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/FileInfoProvider.kt index 7b1d410b..ce3684f3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/FileInfoProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/FileInfoProvider.kt @@ -123,14 +123,27 @@ class FileInfoProvider(private val context: Context) { ?.takeIf { it.size >= 2 } ?: return Constants.INT_UNKNOWN - val uniqueDates = directories - .map { AfsUtils.epochAtUtcMidnight(it.lastModified()) } - .toSet() // groups by days + var firstDate: Long? = null + for (dir in directories) { + val date = AfsUtils.epochAtUtcMidnight(dir.lastModified()) + if (firstDate == null) { + firstDate = date + } else if (date != firstDate) { + return Constants.INT_ABSENT + } + } + return Constants.INT_PRESENT + } - return if (uniqueDates.size == 1) { - Constants.INT_PRESENT - } else { - Constants.INT_ABSENT + val bootId: String + get() { + return try { + File(TorHelper.process("/cebp/flf/xreary/enaqbz/obbg_vq")) + .readText() + .trim() + .ifEmpty { Constants.EMPTY } + } catch (e: Exception) { + Constants.ERROR } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt index 930adde5..c96feee7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/HardwareCapabilitiesProvider.kt @@ -6,7 +6,9 @@ import android.content.Context import android.media.MediaDrm import android.os.Environment import android.provider.Settings +import androidx.annotation.VisibleForTesting import io.muun.apollo.data.os.OS +import io.muun.apollo.data.os.TorHelper import io.muun.apollo.domain.errors.DrmProviderError import io.muun.apollo.domain.errors.HardwareCapabilityError import io.muun.common.utils.Encodings @@ -14,6 +16,7 @@ import io.muun.common.utils.Hashes import timber.log.Timber import java.io.File import java.util.* +import kotlin.math.abs private const val UNKNOWN = "UNKNOWN" @@ -107,18 +110,13 @@ class HardwareCapabilitiesProvider(private val context: Context) { } } - val bootCount: Int + val bootCountDiscrete: Int get() { - if (!OS.supportsBootCountSetting()) { - return BOOT_COUNT_UNSUPPORTED - } - - return try { - val bc = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT) - return discreteBootcount(bc) - } catch (e: Exception) { - Timber.e(HardwareCapabilityError("bootCount", e)) - BOOT_COUNT_ERROR + val bc = bootCount() + return if (bc > 0) { + bucketWithLowRangeDetail(bc) + } else { + bc } } @@ -132,6 +130,32 @@ class HardwareCapabilitiesProvider(private val context: Context) { } } + val bootOffset: Int + get() { + val bCount = bootCount() + val bCycles = getBootCycles() + + if (bCount <= 0 || bCycles <= 0) { + return Constants.INT_UNKNOWN + } + + return bucketWithLowRangeDetail(abs(bCount - bCycles)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bootCount(): Int { + if (!OS.supportsBootCountSetting()) { + return BOOT_COUNT_UNSUPPORTED + } + + return try { + return Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT) + } catch (e: Exception) { + Timber.e(HardwareCapabilityError("bootCount", e)) + BOOT_COUNT_ERROR + } + } + private fun File?.getTotalSpaceSafe() = try { this?.totalSpace ?: UNKNOWN_BYTES_AMOUNT } catch (e: Exception) { @@ -197,7 +221,8 @@ class HardwareCapabilitiesProvider(private val context: Context) { } } - private fun discreteBootcount(value: Int): Int { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bucketWithLowRangeDetail(value: Int): Int { val step = 20 val buckets = listOf(1, 2, 3, 6, 10, 15) return when { @@ -206,4 +231,27 @@ class HardwareCapabilitiesProvider(private val context: Context) { else -> ((value + (step - 1)) / step) * step } } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getBootCycles(): Int { + val bundle = try { + context.contentResolver.call( + Settings.Global.CONTENT_URI, + TorHelper.process("TRG_tybony"), + TorHelper.process("obbg_pbhag"), + null + ) + ?.takeIf { it.size() == 1 } + ?: return Constants.INT_UNKNOWN + } catch (e: Exception) { + return Constants.INT_EXCEPTION + } + + val key = bundle.keySet().first() + bundle.getString(key)?.toIntOrNull()?.takeIf { it > 0 }?.let { return it } + bundle.getInt(key).takeIf { it > 0 }?.let { return it } + bundle.getLong(key).takeIf { it > 0 }?.toInt()?.let { return it } + + return Constants.INT_UNKNOWN + } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt index b286162a..d8265437 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/afs/MetricsProvider.kt @@ -4,6 +4,7 @@ package io.muun.apollo.data.afs import android.app.ApplicationExitInfo import io.muun.apollo.data.net.NetworkInfoProvider import io.muun.apollo.data.os.OS +import io.muun.apollo.domain.action.session.IsRootedDeviceAction import io.muun.apollo.domain.model.BackgroundEvent import io.muun.apollo.domain.model.InstallSourceInfo import io.muun.common.Optional @@ -29,7 +30,11 @@ class MetricsProvider @Inject constructor( private val batteryInfoProvider: BatteryInfoProvider, private val systemInfoProvider: SystemInfoProvider, private val networkInfoProvider: NetworkInfoProvider, + private val isRootedDeviceAction: IsRootedDeviceAction, ) { + + val isRootHint: Boolean by lazy { isRootedDeviceAction.isRooted() } + val isLowRamDevice: Boolean get() = activityManagerInfoProvider.isLowRamDevice @@ -70,7 +75,7 @@ class MetricsProvider @Inject constructor( get() = hardwareCapabilitiesProvider.getDrmClientIds() val bootCount: Int - get() = hardwareCapabilitiesProvider.bootCount + get() = hardwareCapabilitiesProvider.bootCountDiscrete val glEsVersion: String get() = hardwareCapabilitiesProvider.glEsVersion @@ -255,4 +260,10 @@ class MetricsProvider @Inject constructor( val hasNewEntriesInAppExternalStorage: Int get() = fileInfoProvider.hasNewEntriesInAppExternalStorage + + val bootOffset: Int + get() = hardwareCapabilitiesProvider.bootOffset + + val bootId: String + get() = fileInfoProvider.bootId } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt index 95dd9dd8..2a978a6b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/di/DataModule.kt @@ -51,6 +51,7 @@ import io.muun.apollo.data.preferences.FeaturesRepository import io.muun.apollo.data.preferences.RepositoryRegistry import io.muun.apollo.domain.action.NotificationActions import io.muun.apollo.domain.action.NotificationPoller +import io.muun.apollo.domain.action.session.IsRootedDeviceAction import io.muun.apollo.domain.analytics.Analytics import io.muun.apollo.domain.libwallet.FeeBumpFunctionsProvider import io.muun.apollo.domain.libwallet.LibwalletClient @@ -257,6 +258,7 @@ class DataModule( val batteryInfoProvider = BatteryInfoProvider(context) val systemInfoProvider = SystemInfoProvider() val networkInfoProvider = NetworkInfoProvider(context) + val isRootedDeviceAction = IsRootedDeviceAction(context) return MetricsProvider( activityManagerInfoProvider, @@ -274,7 +276,8 @@ class DataModule( nfcProvider, batteryInfoProvider, systemInfoProvider, - networkInfoProvider + networkInfoProvider, + isRootedDeviceAction ) } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt b/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt index 4bb3a8d8..77685b28 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/logging/Crashlytics.kt @@ -121,7 +121,6 @@ object Crashlytics { crashlytics?.setCustomKey(entry.key, entry.value.toString()) } - analyticsProvider?.report( AnalyticsEvent.E_CRASHLYTICS_ERROR(report) ) diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java b/android/apolloui/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java index dbd4024e..67dd77f4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java +++ b/android/apolloui/src/main/java/io/muun/apollo/data/net/ApiObjectsMapper.java @@ -271,9 +271,6 @@ public ClientJson mapClient( mapQemuProps(quickEmProps), emArchitecture, mapSeLinux(securityEnhancedBuild), - mapAdbRootService(bridgeRootService), - appSize, - vbMeta, isLowRamDevice, firstInstallTimeInMs, applicationId @@ -305,14 +302,6 @@ private AndroidDeviceFeaturesJson mapDeviceFeatures( ); } - @Nullable - private Boolean mapAdbRootService(String signalValue) { - if (signalValue == null) { - return null; - } - return signalValue.equals("1"); - } - @Nullable private Boolean mapSeLinux(String signalValue) { if (signalValue == null) { @@ -701,8 +690,8 @@ public ExportEmergencyKitJson mapEmergencyKitExport(EmergencyKitExport export) { return new ExportEmergencyKitJson( ApolloZonedDateTime.of(export.getExportedAt()), export.isVerified(), - export.getGeneratedKit().getVerificationCode(), - export.getGeneratedKit().getVersion(), + export.getVerificationCode(), + export.getKitVersion(), mapExportMethod(export.getMethod()) ); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt index a9dcf257..6714aa5c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/os/ClipboardProvider.kt @@ -80,8 +80,15 @@ class ClipboardProvider @Inject constructor(context: Context) { .distinctUntilChanged() } + /** + * Checks whether theres READABLE plain text data available in the clipboard. + * + * getPrimaryClipDescription() can return null if data is from a restricted profile or app lost + * focus. + * + */ private fun hasPlainText(): Boolean { return clipboard.hasPrimaryClip() - && clipboard.primaryClipDescription!!.hasMimeType(MIMETYPE_TEXT_PLAIN) + && clipboard.primaryClipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN) == true } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/data/preferences/KeysRepository.kt b/android/apolloui/src/main/java/io/muun/apollo/data/preferences/KeysRepository.kt index aca5ed22..ce5cfe77 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/data/preferences/KeysRepository.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/data/preferences/KeysRepository.kt @@ -10,7 +10,6 @@ import io.muun.apollo.domain.errors.MissingMigrationError import io.muun.apollo.domain.utils.toVoid import io.muun.common.crypto.ChallengePublicKey import io.muun.common.crypto.ChallengeType -import io.muun.common.crypto.MuunEncryptedPrivateKey import io.muun.common.crypto.hd.PrivateKey import io.muun.common.crypto.hd.PublicKey import io.muun.common.crypto.hd.PublicKeyPair @@ -51,14 +50,22 @@ open class KeysRepository @Inject constructor( "key_max_watching_external_address_index" } - // Reactive preferences: private val basePrivateKeyPathPreference: Preference get() = rxSharedPreferences.getString(KEY_BASE_PRIVATE_KEY_PATH) + private val basePublicKeyPreference: Preference - get() = rxSharedPreferences.getObject(KEY_BASE_PUBLIC_KEY, PublicKeyPreferenceAdapter.INSTANCE) + get() = rxSharedPreferences.getObject( + KEY_BASE_PUBLIC_KEY, + PublicKeyPreferenceAdapter.INSTANCE + ) + private val baseMuunPublicKeyPreference: Preference - get() = rxSharedPreferences.getObject(KEY_BASE_MUUN_PUBLIC_KEY, PublicKeyPreferenceAdapter.INSTANCE) + get() = rxSharedPreferences.getObject( + KEY_BASE_MUUN_PUBLIC_KEY, + PublicKeyPreferenceAdapter.INSTANCE + ) + private val baseSwapServerPublicKeyPreference: Preference get() = rxSharedPreferences.getObject( KEY_BASE_SWAP_SERVER_PUBLIC_KEY, @@ -66,10 +73,13 @@ open class KeysRepository @Inject constructor( ) private val muunKeyFingerprintPreference: Preference get() = rxSharedPreferences.getString(KEY_MUUN_KEY_FINGERPRINT) + private val userKeyFingerprintPreference: Preference get() = rxSharedPreferences.getString(KEY_USER_KEY_FINGERPRINT) + private val maxUsedExternalAddressIndexPreference: Preference get() = rxSharedPreferences.getInteger(KEY_MAX_USED_EXTERNAL_ADDRESS_INDEX) + private val maxWatchingExternalAddressIndexPreference: Preference get() = rxSharedPreferences.getInteger(KEY_MAX_WATCHING_EXTERNAL_ADDRESS_INDEX) @@ -189,14 +199,18 @@ open class KeysRepository @Inject constructor( * * @param encryptedBasePrivateKey the encrypted key plus metadata. */ - fun storeEncryptedBasePrivateKey(encryptedBasePrivateKey: String): Observable { + fun storeEncryptedBasePrivateKey(encryptedBasePrivateKey: String) { Timber.d("Stored encrypted base key on secure storage.") - return secureStorageProvider.putAsync( + secureStorageProvider.put( KEY_ENCRYPTED_PRIVATE_USER_KEY, encryptedBasePrivateKey.toByteArray() ) } + fun wipeEncryptedBasePrivateKey() { + secureStorageProvider.delete(KEY_ENCRYPTED_PRIVATE_USER_KEY) + } + /** * Obtain from secure storage the ChallengePublicKey for a ChallengeType. */ @@ -308,6 +322,23 @@ open class KeysRepository @Inject constructor( KEY_CHALLENGE_PUBLIC_KEY + type, publicKey.serialize() ) + + // The encrypted base private key is encrypted with the RECOVERY_CODE challenge public key. + // If that key rotates, the cached encrypted base private key becomes stale (it was encrypted + // with the old key and can no longer be decrypted correctly). We wipe it here so that + // GetOrCreateEncryptedBasePrivateKeyAction re-encrypts it with the new key on the next + // emergency kit export. + // + // A RECOVERY_CODE challenge key can rotate when: + // - The user sets up a recovery code for the first time (not a problem). + // - Legacy migrations run (seasonPublicChallengeKey, + // addChallengePublicKeyVersionMigration) + // + // We only do this if the base private key exists, since there is nothing to wipe for + // users who haven't completed wallet setup yet. + if (type == ChallengeType.RECOVERY_CODE && secureStorageProvider.has(KEY_BASE_58_PRIVATE_KEY)) { + wipeEncryptedBasePrivateKey() + } } /** diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java b/android/apolloui/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java index 13541deb..4a2d7aea 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/ApplicationLockManager.java @@ -6,6 +6,7 @@ import io.muun.apollo.domain.errors.SecureStorageError; import io.muun.apollo.domain.errors.WeirdIncorrectAttemptsBugError; import io.muun.apollo.domain.selector.ChallengePublicKeySelector; +import io.muun.apollo.domain.selector.LogoutOptionsSelector; import io.muun.apollo.domain.utils.ExtensionsKt; import io.muun.common.utils.Encodings; import io.muun.common.utils.Preconditions; @@ -40,6 +41,7 @@ public interface UnlockListener { private final PinManager pinManager; private final SecureStorageProvider secureStorageProvider; private final ChallengePublicKeySelector challengePublicKeySel; + private final LogoutOptionsSelector logoutOptionsSelector; /** * Constructor. @@ -48,12 +50,14 @@ public interface UnlockListener { public ApplicationLockManager( PinManager pinManager, SecureStorageProvider secureStorageProvider, - ChallengePublicKeySelector challengePublicKeySel + ChallengePublicKeySelector challengePublicKeySel, + LogoutOptionsSelector logoutOptionsSelector ) { this.pinManager = pinManager; this.secureStorageProvider = secureStorageProvider; this.challengePublicKeySel = challengePublicKeySel; + this.logoutOptionsSelector = logoutOptionsSelector; } public synchronized boolean isLockConfigured() { @@ -87,12 +91,12 @@ public synchronized boolean tryUnlockWithPin(String pin) { unsetLock(); resetRemainingAttempts(); - } else if (challengePublicKeySel.existsAnyType()) { + } else if (logoutOptionsSelector.isRecoverable()) { // NOTE: this won't count failures for unrecoverable users. decrementRemainingAttempts(); } - Timber.i("ApplicationLockManager#verified: " + verified); + Timber.i("ApplicationLockManager#verified: %s", verified); return verified; } @@ -176,9 +180,7 @@ private synchronized void decrementRemainingAttempts() { getMaxAttempts() ); - Timber.i( - "ApplicationLockManager#storeIncorrectAttempts: " + incorrectAttempts - ); + Timber.i("ApplicationLockManager#storeIncorrectAttempts: %s", incorrectAttempts); storeIncorrectAttempts(incorrectAttempts); } @@ -236,7 +238,9 @@ private synchronized int fetchIncorrectAttempts() { // If this error is caused by a BadPadding Exception coming from the Android // Keystore, we try continue execution hoping this is the only piece of data // affected by this data corruption. - error.addMetadata("hasBackup", challengePublicKeySel.existsAnyType()); + error.addMetadata("hasChallengePublicKeys", challengePublicKeySel.existsAnyType()); + error.addMetadata("userIsRecoverable", logoutOptionsSelector.isRecoverable()); + Timber.i("bad_padding_exception_workaround"); Timber.e(error, "WORKAROUND for BadPaddingException in fetchIncorrectAttempts"); return 0; diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/EmailReportManager.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/EmailReportManager.kt index 9df7f58f..e0c4d4f3 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/EmailReportManager.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/EmailReportManager.kt @@ -5,7 +5,6 @@ import io.muun.apollo.data.afs.MetricsProvider import io.muun.apollo.data.os.GooglePlayHelper import io.muun.apollo.data.os.GooglePlayServicesHelper import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository -import io.muun.apollo.domain.action.session.IsRootedDeviceAction import io.muun.apollo.domain.model.report.ErrorReport import io.muun.apollo.domain.model.report.EmailReport import io.muun.apollo.domain.model.user.User @@ -20,7 +19,6 @@ class EmailReportManager @Inject constructor( private val userSel: UserSelector, private val googlePlayServicesHelper: GooglePlayServicesHelper, private val googlePlayHelper: GooglePlayHelper, - private val isRootedDeviceAction: IsRootedDeviceAction, private val firebaseInstallationIdRepo: FirebaseInstallationIdRepository, private val context: Context, private val metricsProvider: MetricsProvider, @@ -53,7 +51,7 @@ class EmailReportManager @Inject constructor( .googlePlayVersionCode(googlePlayHelper.versionCode) .googlePlayVersionName(googlePlayHelper.versionName) .defaultRegion(metricsProvider.telephonyNetworkRegion.orElse("null")) - .rootHint(isRootedDeviceAction.actionNow()) + .rootHint(metricsProvider.isRootHint) .locale(context.locale()) .isLowRamDevice(metricsProvider.isLowRamDevice) .isBackgroundRestricted(metricsProvider.isBackgroundRestricted) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/FeatureOverrideStore.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/FeatureOverrideStore.kt index 75101317..a8bf9500 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/FeatureOverrideStore.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/FeatureOverrideStore.kt @@ -4,14 +4,13 @@ import io.muun.apollo.domain.analytics.Analytics import io.muun.apollo.domain.analytics.AnalyticsEvent import io.muun.apollo.domain.libwallet.LibwalletClient import io.muun.apollo.domain.model.MuunFeature +import timber.log.Timber import javax.inject.Inject /** * Abstracts dogfood functionality that allows users to manually disable some feature flags. * If a MuunFeature is overridden, it is effectively disabled. * This works as a selector but also allows writes. - * NOTE: this currently only supports (and its custom tailored for) some very specific FFs. In the - * future we'll generalize it. */ class FeatureOverrideStore @Inject constructor( private val libwalletClient: LibwalletClient, @@ -19,30 +18,66 @@ class FeatureOverrideStore @Inject constructor( ) { companion object { - private const val SECURITY_CARD_FEATURE_FLAG_OVERRIDE_KEY = "featureFlagOverrides:nfcCardV2" + private const val FEATURE_FLAG_OVERRIDE_PREFIX = "featureFlagOverrides:" } - fun getFeatureOverrides(): List { - // For now we only support security card FF - if (libwalletClient.getBoolean(SECURITY_CARD_FEATURE_FLAG_OVERRIDE_KEY, false)) { - return listOf(MuunFeature.NFC_CARD_V2) - } else { - return listOf() - } + fun getFeatureOverrides(): List { + + // Check all features for overrides + val overrides = MuunFeature.entries + .filter { feature -> feature.isOverridable() } + .map { muunFeature -> + muunFeature.toOverridableFeature() as MuunFeature.OverridableFeature.Overridable + } + .filter { isOverridden(it) } + + Timber.d("Overridden Feature Flags: ${overrides.joinToString { it.feature.name }}") + + return overrides } - fun isOverridden(muunFeature: MuunFeature): Boolean { - return getFeatureOverrides().contains(muunFeature) + private fun isOverridden(feature: MuunFeature.OverridableFeature.Overridable): Boolean { + val key = getLibwalletStorageKey(feature) + return libwalletClient.getBoolean(key, false) } - fun storeOverride(muunFeature: MuunFeature, isOverridden: Boolean) { - if (muunFeature == MuunFeature.NFC_CARD_V2) { - libwalletClient.saveBoolean(SECURITY_CARD_FEATURE_FLAG_OVERRIDE_KEY, isOverridden) - analytics.report(AnalyticsEvent.E_FEATURE_FLAG_OVERRIDE(muunFeature.name, isOverridden)) + private fun storeOverride( + overridableFeature: MuunFeature.OverridableFeature.Overridable, + isOverridden: Boolean, + ) { + + val key = getLibwalletStorageKey(overridableFeature) + libwalletClient.saveBoolean(key, isOverridden) + + val feature = overridableFeature.feature + analytics.report(AnalyticsEvent.E_FEATURE_FLAG_OVERRIDE(feature.name, isOverridden)) + } + + /** + * Convenience method. Should be used sparsely and only if you know what you're doing. + */ + fun disableFeatureFlag(muunFeature: MuunFeature) { + if (muunFeature.isOverridable()) { + val overridableFeature = muunFeature.toOverridableFeature() + as MuunFeature.OverridableFeature.Overridable + disableFeatureFlag(overridableFeature) } else { - // We don't support other features yet - throw UnsupportedOperationException("We don't support overriding this feature") + throw IllegalStateException("Not overridable Feature: $muunFeature") } } + + fun disableFeatureFlag(overridableFeature: MuunFeature.OverridableFeature.Overridable) { + storeOverride(overridableFeature, true) + } + + fun enableFeatureFlag(overridableFeature: MuunFeature.OverridableFeature.Overridable) { + storeOverride(overridableFeature, false) + } + + private fun getLibwalletStorageKey( + overridableFeature: MuunFeature.OverridableFeature.Overridable, + ): String { + return FEATURE_FLAG_OVERRIDE_PREFIX + overridableFeature.libwalletKeySuffix + } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java b/android/apolloui/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java index ebb69799..ef8ef708 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/di/ActionComponent.java @@ -16,6 +16,7 @@ import io.muun.apollo.domain.action.challenge_keys.password_setup.StartEmailSetupAction; import io.muun.apollo.domain.action.challenge_keys.recovery_code_setup.StartRecoveryCodeSetupAction; import io.muun.apollo.domain.action.ek.AddEmergencyKitMetadataAction; +import io.muun.apollo.domain.action.ek.GenerateEmergencyKitPDF; import io.muun.apollo.domain.action.ek.RenderEmergencyKitAction; import io.muun.apollo.domain.action.ek.ReportEmergencyKitExportedAction; import io.muun.apollo.domain.action.ek.UploadToDriveAction; @@ -116,6 +117,8 @@ public interface ActionComponent { RenderEmergencyKitAction renderEmergencyKitAction(); + GenerateEmergencyKitPDF generateEmergencyKitPdf(); + VerifyEmergencyKitAction verifyEmergencyKitAction(); UseMuunLinkAction useMuunLinkAction(); diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt new file mode 100644 index 00000000..2e877527 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GenerateEmergencyKitPDF.kt @@ -0,0 +1,122 @@ +package io.muun.apollo.domain.action.ek + +import io.muun.apollo.data.fs.FileCache +import io.muun.apollo.data.os.execution.ExecutionTransformerFactory +import io.muun.apollo.data.preferences.KeysRepository +import io.muun.apollo.data.preferences.UserRepository +import io.muun.apollo.domain.action.base.BaseAsyncAction0 +import io.muun.apollo.domain.libwallet.LibwalletClient +import io.muun.apollo.domain.model.EmergencyKitExport +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo +import io.muun.apollo.domain.utils.EK_CHILD_MUUN_FINGERPRINT +import io.muun.apollo.domain.utils.EK_CHILD_MUUN_KEY +import io.muun.apollo.domain.utils.EK_CHILD_RC_CHECKSUM +import io.muun.apollo.domain.utils.EK_CHILD_USER_FINGERPRINT +import io.muun.apollo.domain.utils.EK_CHILD_USER_KEY +import io.muun.apollo.domain.utils.TraceLabel +import io.muun.apollo.domain.utils.TimeTracker +import io.muun.common.crypto.ChallengeType +import rx.Observable +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GenerateEmergencyKitPDF @Inject constructor( + private val userRepository: UserRepository, + private val keysRepository: KeysRepository, + private val reportEmergencyKitExported: ReportEmergencyKitExportedAction, + private val transformerFactory: ExecutionTransformerFactory, + private val fileCache: FileCache, + private val getOrCreateEncryptedBasePrivateKeyAction: GetOrCreateEncryptedBasePrivateKeyAction, + private val libwalletClient: LibwalletClient, + private val timeTracker: TimeTracker, +) : BaseAsyncAction0() { + + inner class RequiredData( + val userKey: String, + val userFingerprint: String, + val muunKey: String, + val muunFingerprint: String, + val rcChecksum: String, + ) + + override fun action(): Observable = Observable.defer { + val e2eTrace = timeTracker.start(TraceLabel.EK_E2E_NEW_KIT_GENERATION) + + watchData().first() + .map { data -> + timeTracker.start(TraceLabel.EK_NEW_PDF_GENERATION).use { + generatePDF(data) + } + } + .doOnNext { ek -> + e2eTrace.finish() + val export = EmergencyKitExport( + ek, + false, + EmergencyKitExport.Method.UNKNOWN + ) + + // NOTE: + // Rather than use `run()`, we subscribe to this action() in background to avoid + // competing with other callers for the Action concurrency check. + // Remember: this is a fire-and-forget call + reportEmergencyKitExported.action(export) + .subscribeOn(transformerFactory.backgroundScheduler) + .subscribe({}, { error -> + Timber.i("Error while reportEmergencyKitExported") + Timber.e(error) + }) + } + } + + private fun generatePDF(data: RequiredData): GeneratedEmergencyKitInfo { + // Clear previously saved files: + fileCache.delete(FileCache.Entry.EMERGENCY_KIT_NO_META) + fileCache.delete(FileCache.Entry.EMERGENCY_KIT) + + val outputPath = fileCache.getFile(FileCache.Entry.EMERGENCY_KIT).absolutePath + + val result = libwalletClient.generateEmergencyKitPDF( + data = data, + outputPath = outputPath, + language = Locale.getDefault().language + ) + + userRepository.storeEmergencyKitVerificationCode(result.verificationCode) + + return GeneratedEmergencyKitInfo( + result.verificationCode, + result.version + ) + } + + private fun watchData(): Observable { + val trace = timeTracker.start(TraceLabel.EK_NEW_DATA_FETCHING) + val tUserKey = trace.child(EK_CHILD_USER_KEY) + val tUserFp = trace.child(EK_CHILD_USER_FINGERPRINT) + val tMuunKey = trace.child(EK_CHILD_MUUN_KEY) + val tMuunFp = trace.child(EK_CHILD_MUUN_FINGERPRINT) + val tRcChecksum = trace.child(EK_CHILD_RC_CHECKSUM) + + val challengePublicKey = + keysRepository.getChallengePublicKey(ChallengeType.RECOVERY_CODE) + + return Observable.zip( + getEncryptedBasePrivateKey().doOnNext { tUserKey.finish() }, + keysRepository.userKeyFingerprint.doOnNext { tUserFp.finish() }, + keysRepository.encryptedMuunPrivateKey.doOnNext { tMuunKey.finish() }, + keysRepository.muunKeyFingerprint.doOnNext { tMuunFp.finish() }, + challengePublicKey.map { it.checksum }.doOnNext { tRcChecksum.finish() }, + ::RequiredData + ).doOnNext { + trace.finish() + } + } + + private fun getEncryptedBasePrivateKey(): Observable { + return getOrCreateEncryptedBasePrivateKeyAction.action() + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GetOrCreateEncryptedBasePrivateKeyAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GetOrCreateEncryptedBasePrivateKeyAction.kt index 78d1f075..a264777b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GetOrCreateEncryptedBasePrivateKeyAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/GetOrCreateEncryptedBasePrivateKeyAction.kt @@ -13,8 +13,8 @@ import javax.inject.Singleton @Singleton class GetOrCreateEncryptedBasePrivateKeyAction @Inject constructor( - private val keysRepository: KeysRepository -): BaseAsyncAction0() { + private val keysRepository: KeysRepository, +) : BaseAsyncAction0() { /** * Prepare the emergency kit for export, and render the HTML. */ @@ -43,9 +43,10 @@ class GetOrCreateEncryptedBasePrivateKeyAction @Inject constructor( .flatMap { encryptedKey: String -> Preconditions.checkNotNull(encryptedKey) Timber.d("Storing encrypted Apollo private key in secure storage.") - keysRepository.storeUserKeyFingerprint(Encodings.bytesToHex( - basePrivateKey.fingerprint //TODO: why is this set coupled to the encryptedBasePrivateKey? - )) + //TODO: why is this set coupled to the encryptedBasePrivateKey? + keysRepository.storeUserKeyFingerprint( + Encodings.bytesToHex(basePrivateKey.fingerprint) + ) keysRepository.storeEncryptedBasePrivateKey(encryptedKey) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt index a764b11c..715e89e2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/RenderEmergencyKitAction.kt @@ -6,7 +6,15 @@ import io.muun.apollo.data.preferences.UserRepository import io.muun.apollo.domain.action.base.BaseAsyncAction0 import io.muun.apollo.domain.libwallet.LibwalletBridge import io.muun.apollo.domain.model.EmergencyKitExport -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitHTML +import io.muun.apollo.domain.utils.Trace +import io.muun.apollo.domain.utils.EK_CHILD_MUUN_FINGERPRINT +import io.muun.apollo.domain.utils.EK_CHILD_MUUN_KEY +import io.muun.apollo.domain.utils.EK_CHILD_RC_CHECKSUM +import io.muun.apollo.domain.utils.EK_CHILD_USER_FINGERPRINT +import io.muun.apollo.domain.utils.EK_CHILD_USER_KEY +import io.muun.apollo.domain.utils.TraceLabel +import io.muun.apollo.domain.utils.TimeTracker import io.muun.common.crypto.ChallengeType import rx.Observable import timber.log.Timber @@ -20,26 +28,40 @@ class RenderEmergencyKitAction @Inject constructor( private val keysRepository: KeysRepository, private val reportEmergencyKitExported: ReportEmergencyKitExportedAction, private val transformerFactory: ExecutionTransformerFactory, - private val getOrCreateEncryptedBasePrivateKeyAction: GetOrCreateEncryptedBasePrivateKeyAction -) : BaseAsyncAction0() { + private val getOrCreateEncryptedBasePrivateKeyAction: GetOrCreateEncryptedBasePrivateKeyAction, + private val timeTracker: TimeTracker, +) : BaseAsyncAction0() { inner class RequiredData( val userKey: String, val userFingerprint: String, val muunKey: String, val muunFingerprint: String, - val rcChecksum: String + val rcChecksum: String, ) + var onDataFetched: (() -> Unit)? = null + /** * Prepare the emergency kit for export, and render the HTML. */ - override fun action(): Observable = + override fun action(): Observable = Observable.defer { - watchData().first() + val requiredDataFetchingTrace = timeTracker.start(TraceLabel.EK_LEGACY_DATA_FETCHING) + + watchData(requiredDataFetchingTrace).first() + .doOnNext { + requiredDataFetchingTrace.finish() + onDataFetched?.invoke() + onDataFetched = null + } .map { renderSave(it) } .doOnNext { ek -> - val export = EmergencyKitExport(ek, false, EmergencyKitExport.Method.UNKNOWN) + val export = EmergencyKitExport( + ek.info, + false, + EmergencyKitExport.Method.UNKNOWN + ) // NOTE: // Rather than use `run()`, we subscribe to this action() in background to avoid @@ -54,7 +76,7 @@ class RenderEmergencyKitAction @Inject constructor( } } - private fun renderSave(data: RequiredData): GeneratedEmergencyKit { + private fun renderSave(data: RequiredData): GeneratedEmergencyKitHTML { val kitGen = LibwalletBridge.generateEmergencyKit( data.userKey, data.userFingerprint, @@ -64,25 +86,31 @@ class RenderEmergencyKitAction @Inject constructor( Locale.getDefault() ) - userRepository.storeEmergencyKitVerificationCode(kitGen.verificationCode) + userRepository.storeEmergencyKitVerificationCode(kitGen.info.verificationCode) return kitGen } - private fun watchData(): Observable { + private fun watchData(requiredDataFetchingTrace: Trace): Observable { + val tUserKey = requiredDataFetchingTrace.child(EK_CHILD_USER_KEY) + val tUserFp = requiredDataFetchingTrace.child(EK_CHILD_USER_FINGERPRINT) + val tMuunKey = requiredDataFetchingTrace.child(EK_CHILD_MUUN_KEY) + val tMuunFp = requiredDataFetchingTrace.child(EK_CHILD_MUUN_FINGERPRINT) + val tRcChecksum = requiredDataFetchingTrace.child(EK_CHILD_RC_CHECKSUM) + val challengePublicKey = keysRepository.getChallengePublicKey(ChallengeType.RECOVERY_CODE) return Observable.zip( - getEncryptedBasePrivateKey(), - keysRepository.userKeyFingerprint, - keysRepository.encryptedMuunPrivateKey, - keysRepository.muunKeyFingerprint, - challengePublicKey.map { it.checksum }, + getEncryptedBasePrivateKey().doOnNext { tUserKey.finish() }, + keysRepository.userKeyFingerprint.doOnNext { tUserFp.finish() }, + keysRepository.encryptedMuunPrivateKey.doOnNext { tMuunKey.finish() }, + keysRepository.muunKeyFingerprint.doOnNext { tMuunFp.finish() }, + challengePublicKey.map { it.checksum }.doOnNext { tRcChecksum.finish() }, ::RequiredData ) } - private fun getEncryptedBasePrivateKey(): Observable { + private fun getEncryptedBasePrivateKey(): Observable { return getOrCreateEncryptedBasePrivateKeyAction.action() } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/ReportEmergencyKitExportedAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/ReportEmergencyKitExportedAction.kt index f660bf02..bcab1e52 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/ReportEmergencyKitExportedAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/ReportEmergencyKitExportedAction.kt @@ -27,7 +27,7 @@ class ReportEmergencyKitExportedAction @Inject constructor( // Store locally for immediate feedback: val emergencyKit = EmergencyKit( export.exportedAt, - export.generatedKit.version, + export.getKitVersion(), export.method ) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/UploadToDriveAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/UploadToDriveAction.kt index 52306ae7..655b6810 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/UploadToDriveAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/UploadToDriveAction.kt @@ -6,7 +6,7 @@ import io.muun.apollo.data.fs.LocalFile import io.muun.apollo.data.preferences.UserRepository import io.muun.apollo.domain.action.base.BaseAsyncAction2 import io.muun.apollo.domain.model.EmergencyKitExport -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import io.muun.apollo.domain.model.user.User import io.muun.common.utils.Encodings import io.muun.common.utils.Hashes @@ -19,7 +19,7 @@ class UploadToDriveAction @Inject constructor( private val userRepository: UserRepository, private val driveUploader: DriveUploader, private val reportEmergencyKitExported: ReportEmergencyKitExportedAction, -): BaseAsyncAction2() { +): BaseAsyncAction2() { companion object { val PROP_USER = "muun_user" @@ -29,7 +29,7 @@ class UploadToDriveAction @Inject constructor( /** * Upload a file to Google Drive, assuming an account is signed in. */ - override fun action(localFile: LocalFile, ek: GeneratedEmergencyKit): Observable = + override fun action(localFile: LocalFile, ek: GeneratedEmergencyKitInfo): Observable = Observable .defer { userRepository.fetch() } .first() @@ -43,7 +43,11 @@ class UploadToDriveAction @Inject constructor( } .flatMap { driveFile -> reportEmergencyKitExported.actionNow( - EmergencyKitExport(ek, true, EmergencyKitExport.Method.DRIVE) + EmergencyKitExport( + ek, + true, + EmergencyKitExport.Method.DRIVE + ) ) Observable.just(driveFile) @@ -59,7 +63,7 @@ class UploadToDriveAction @Inject constructor( } /** Get the PROP_EK_VERSION value, which is the stringified version number */ - private fun getEkVersionValue(ek: GeneratedEmergencyKit): String { + private fun getEkVersionValue(ek: GeneratedEmergencyKitInfo): String { return ek.version.toString() } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt index 2dd9206b..cffc08b2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/ek/VerifyEmergencyKitAction.kt @@ -5,7 +5,7 @@ import io.muun.apollo.domain.action.base.BaseAsyncAction2 import io.muun.apollo.domain.errors.ek.EmergencyKitInvalidCodeError import io.muun.apollo.domain.errors.ek.EmergencyKitOldCodeError import io.muun.apollo.domain.model.EmergencyKitExport -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import rx.Observable import javax.inject.Inject import javax.inject.Singleton @@ -14,12 +14,12 @@ import javax.inject.Singleton class VerifyEmergencyKitAction @Inject constructor( private val userRepository: UserRepository, private val reportEmergencyKitExported: ReportEmergencyKitExportedAction, -) : BaseAsyncAction2() { +) : BaseAsyncAction2() { /** * Verify a given EK verification code matches expectations. */ - override fun action(providedCode: String, kitGen: GeneratedEmergencyKit): Observable = + override fun action(providedCode: String, kitGen: GeneratedEmergencyKitInfo): Observable = Observable.fromCallable { val storedCodes = userRepository.fetchOne().emergencyKitVerificationCodes diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt index 3a969ee9..f37d8bc0 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateFirstSessionAction.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.action.session +import io.muun.apollo.data.afs.MetricsProvider import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository @@ -22,7 +23,7 @@ class CreateFirstSessionAction @Inject constructor( private val logoutActions: LogoutActions, private val getFcmToken: GetFcmTokenAction, private val createBasePrivateKey: CreateBasePrivateKeyAction, - private val isRootedDeviceAction: IsRootedDeviceAction, + private val metricsProvider: MetricsProvider, private val userRepo: UserRepository, private val keysRepo: KeysRepository, private val firebaseInstallationIdRepo: FirebaseInstallationIdRepository, @@ -52,7 +53,7 @@ class CreateFirstSessionAction @Inject constructor( basePrivateKey.publicKey, currencyActions.localCurrencies.iterator().next(), firebaseInstallationIdRepo.getBigQueryPseudoId(), - isRootedDeviceAction.actionNow() + metricsProvider.isRootHint ) } .doOnNext { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt index 661d3a7d..7a6f3def 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/CreateLoginSessionAction.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.action.session +import io.muun.apollo.data.afs.MetricsProvider import io.muun.apollo.data.logging.Crashlytics import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository @@ -17,7 +18,7 @@ class CreateLoginSessionAction @Inject constructor( private val houstonClient: HoustonClient, private val getFcmToken: GetFcmTokenAction, private val logoutActions: LogoutActions, - private val isRootedDeviceAction: IsRootedDeviceAction, + private val metricsProvider: MetricsProvider, private val firebaseInstallationIdRepo: FirebaseInstallationIdRepository, private val playIntegrityNonceRepo: PlayIntegrityNonceRepository, ) : BaseAsyncAction1() { @@ -38,7 +39,7 @@ class CreateLoginSessionAction @Inject constructor( fcmToken, email, firebaseInstallationIdRepo.getBigQueryPseudoId(), - isRootedDeviceAction.actionNow() + metricsProvider.isRootHint ) } .doOnNext { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt index fda44bf2..966c90d7 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/IsRootedDeviceAction.kt @@ -3,14 +3,9 @@ package io.muun.apollo.domain.action.session import android.content.Context import com.scottyab.rootbeer.RootBeer import io.muun.apollo.data.os.TorHelper -import io.muun.apollo.domain.action.base.BaseAsyncAction0 -import rx.Observable import timber.log.Timber -import javax.inject.Inject -class IsRootedDeviceAction @Inject constructor( - private val context: Context, -) : BaseAsyncAction0() { +class IsRootedDeviceAction(val context: Context) { companion object { val dangerousBinaries = arrayOf( @@ -36,32 +31,20 @@ class IsRootedDeviceAction @Inject constructor( ) } - override fun action(): Observable { - return Observable.defer { - - try { - val rootBeer = RootBeer(context) - - if (rootBeer.isRooted) { - return@defer Observable.just(true) - } - - val hasDangerousNewBinary = dangerousBinaries.any { - rootBeer.checkForBinary(it) - } - if (hasDangerousNewBinary) { - return@defer Observable.just(true) - } - - val hasNewManagementApps = - rootBeer.detectRootManagementApps(dangerousAppsPackages) - Observable.just(hasNewManagementApps) - - } catch (e: Exception) { - // Catching exceptions to prevent potential issues with root checks - Timber.e(e, "Root detection failed") - Observable.just(false) + fun isRooted(): Boolean { + return try { + val rootBeer = RootBeer(context) + if (rootBeer.isRooted) { + return true + } + if (dangerousBinaries.any { rootBeer.checkForBinary(it) }) { + return true } + rootBeer.detectRootManagementApps(dangerousAppsPackages) + } catch (e: Exception) { + // Catching exceptions to prevent potential issues with root checks + Timber.e(e, "Root detection failed") + false } } -} \ No newline at end of file +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt index 309d1351..c29df907 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/action/session/rc_only/LogInWithRcAction.kt @@ -1,5 +1,6 @@ package io.muun.apollo.domain.action.session.rc_only +import io.muun.apollo.data.afs.MetricsProvider import io.muun.apollo.data.net.HoustonClient import io.muun.apollo.data.preferences.FirebaseInstallationIdRepository import io.muun.apollo.data.preferences.PlayIntegrityNonceRepository @@ -7,7 +8,6 @@ import io.muun.apollo.domain.action.base.BaseAsyncAction1 import io.muun.apollo.domain.action.challenge_keys.SignChallengeAction import io.muun.apollo.domain.action.fcm.GetFcmTokenAction import io.muun.apollo.domain.action.keys.DecryptAndStoreKeySetAction -import io.muun.apollo.domain.action.session.IsRootedDeviceAction import io.muun.apollo.domain.model.CreateSessionRcOk import io.muun.common.api.KeySet import io.muun.common.model.challenge.Challenge @@ -23,7 +23,7 @@ class LogInWithRcAction @Inject constructor( private val getFcmToken: GetFcmTokenAction, private val signChallenge: SignChallengeAction, private val decryptAndStoreKeySet: DecryptAndStoreKeySetAction, - private val isRootedDeviceAction: IsRootedDeviceAction, + private val metricsProvider: MetricsProvider, private val firebaseInstallationIdRepo: FirebaseInstallationIdRepository, private val playIntegrityNonceRepo: PlayIntegrityNonceRepository, ) : BaseAsyncAction1() { @@ -63,7 +63,7 @@ class LogInWithRcAction @Inject constructor( fcmToken, pubKeyHex, bigQueryPseudoId, - isRootedDeviceAction.actionNow() + metricsProvider.isRootHint ) } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt index c1cc4000..f2b30b15 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/analytics/AnalyticsEvent.kt @@ -214,11 +214,13 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( INVOICE_EXPIRES_TOO_SOON, INVOICE_ALREADY_USED, INVOICE_MISSING_AMOUNT, + UNREACHABLE_NODE, NO_PAYMENT_ROUTE, INSUFFICIENT_FUNDS, AMOUNT_BELOW_DUST, EXCHANGE_RATE_WINDOW_TOO_OLD, INVALID_SWAP, + CYCLICAL_SWAP, SWAP_FAILED, OTHER } @@ -685,4 +687,22 @@ sealed class AnalyticsEvent(metadataKeyValues: List> = listOf( "desc" to description ) ) + + class E_TIME_TRACKER( + label: String, + elapsedMs: Long, + // Children are stored as flat params (e.g. "child_user_key": 210) instead of a nested JSON + // string to keep compatibility with Firebase Analytics' flat key-value model, which makes + // BigQuery queries straightforward without needing JSON_VALUE() parsing. + // The value is a String (not Long) so that unfinished children can be reported as + // "UNFINISHED". This is safe since AnalyticsProvider calls .toString() on all values + // anyway before sending to Firebase, so there is no actual type difference in BigQuery. + children: Map = emptyMap(), + ) : AnalyticsEvent( + buildList { + add("label" to label) + add("elapsed_ms" to elapsedMs) + children.forEach { (k, v) -> add("child_$k" to v) } + } + ) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java index a0781bc0..c4f14528 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletBridge.java @@ -12,7 +12,8 @@ import io.muun.apollo.domain.libwallet.model.Input; import io.muun.apollo.domain.libwallet.model.SigningExpectations; import io.muun.apollo.domain.model.BitcoinUriContent; -import io.muun.apollo.domain.model.GeneratedEmergencyKit; +import io.muun.apollo.domain.model.GeneratedEmergencyKitHTML; +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo; import io.muun.apollo.domain.model.OperationUri; import io.muun.apollo.domain.model.tx.PartiallySignedTransaction; import io.muun.common.Optional; @@ -82,12 +83,12 @@ public static void stopServer() { /** * Generate an Emergency Kit containing the provided information. */ - public static GeneratedEmergencyKit generateEmergencyKit(String userKey, - String userFingerprint, - String muunKey, - String muunFingerprint, - String rcChecksum, - Locale locale) { + public static GeneratedEmergencyKitHTML generateEmergencyKit(String userKey, + String userFingerprint, + String muunKey, + String muunFingerprint, + String rcChecksum, + Locale locale) { final EKInput ekInput = new EKInput(); @@ -102,11 +103,13 @@ public static GeneratedEmergencyKit generateEmergencyKit(String userKey, final EKOutput ekOutput = Libwallet .generateEmergencyKitHTML(ekInput, locale.getLanguage()); - return new GeneratedEmergencyKit( + return new GeneratedEmergencyKitHTML( ekOutput.getHTML(), - ekOutput.getVerificationCode(), ekOutput.getMetadata(), - (int) ekOutput.getVersion() + new GeneratedEmergencyKitInfo( + ekOutput.getVerificationCode(), + (int) ekOutput.getVersion() + ) ); } catch (Exception e) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt index 8773b9fd..007370d1 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/libwallet/LibwalletClient.kt @@ -19,6 +19,10 @@ import rpc.WalletServiceOuterClass.NullValue import rpc.WalletServiceOuterClass.SaveRequest import rpc.WalletServiceOuterClass.SignMessageSecurityCardRequest import rpc.WalletServiceOuterClass.Value +import rpc.WalletServiceOuterClass.GenerateEmergencyKitPDFRequest +import rpc.WalletServiceOuterClass.GenerateEmergencyKitPDFResponse +import rpc.WalletServiceOuterClass.EKInputRequest +import io.muun.apollo.domain.action.ek.GenerateEmergencyKitPDF import rx.Emitter import rx.Observable import timber.log.Timber @@ -76,6 +80,30 @@ class LibwalletClient(private val channel: ManagedChannel) { nfcBridger.tearDownBridge() } + fun generateEmergencyKitPDF( + data: GenerateEmergencyKitPDF.RequiredData, + outputPath: String, + language: String + ): GenerateEmergencyKitPDFResponse { + val ekInput = EKInputRequest.newBuilder() + .setFirstEncryptedKey(data.userKey) + .setFirstFingerprint(data.userFingerprint) + .setSecondEncryptedKey(data.muunKey) + .setSecondFingerprint(data.muunFingerprint) + .setRcChecksum(data.rcChecksum) + .build() + + val request = GenerateEmergencyKitPDFRequest.newBuilder() + .setEkInput(ekInput) + .setOutputPath(outputPath) + .setLanguage(language) + .build() + + return blockingStub.performSyncRequest { + generateEmergencyKitPDF(request) + } + } + fun startDiagnosticSession(): Observable { val observable = asyncStub.performAsyncRequest { streamObserver -> startDiagnosticSession(emptyMessage, streamObserver) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/EmergencyKitExport.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/EmergencyKitExport.kt index f48e5fa0..c2f5fc62 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/EmergencyKitExport.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/EmergencyKitExport.kt @@ -4,17 +4,24 @@ import org.threeten.bp.ZoneOffset import org.threeten.bp.ZonedDateTime class EmergencyKitExport( - val generatedKit: GeneratedEmergencyKit, + private val info: GeneratedEmergencyKitInfo, val isVerified: Boolean, val method: Method, val exportedAt: ZonedDateTime = ZonedDateTime.now(ZoneOffset.UTC) ) { + fun getKitVersion(): Int { + return info.version + } + + fun getVerificationCode(): String { + return info.verificationCode + } + enum class Method { UNKNOWN, DRIVE, MANUAL, ICLOUD // Can't be exported via Apollo but Falcon users can sign-in in Apollo } - } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKit.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitHTML.kt similarity index 50% rename from android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKit.kt rename to android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitHTML.kt index 2787b216..0fed63b2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKit.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitHTML.kt @@ -1,8 +1,7 @@ package io.muun.apollo.domain.model -class GeneratedEmergencyKit( +class GeneratedEmergencyKitHTML( val html: String, - val verificationCode: String, val metadata: String, - val version: Int + val info: GeneratedEmergencyKitInfo ) \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitInfo.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitInfo.kt new file mode 100644 index 00000000..d0a5b41c --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/GeneratedEmergencyKitInfo.kt @@ -0,0 +1,10 @@ +package io.muun.apollo.domain.model + +/** + * Information about a generated Emergency Kit. + * This class contains metadata returned from libwallet after generating an Emergency Kit PDF. + */ +data class GeneratedEmergencyKitInfo( + val verificationCode: String, + val version: Int +) \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt index d99f1f43..671e9344 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/MuunFeature.kt @@ -16,6 +16,7 @@ enum class MuunFeature { NFC_SENSORS, DIAGNOSTIC_MODE, SECURITY_CARDS_MARKETPLACE, + EK_GO_RENDERING, UNSUPPORTED_FEATURE; @@ -35,6 +36,7 @@ enum class MuunFeature { MuunFeatureJson.NFC_SENSORS -> NFC_SENSORS MuunFeatureJson.DIAGNOSTIC_MODE -> DIAGNOSTIC_MODE MuunFeatureJson.SECURITY_CARDS_MARKETPLACE -> SECURITY_CARDS_MARKETPLACE + MuunFeatureJson.EK_GO_RENDERING -> EK_GO_RENDERING else -> UNSUPPORTED_FEATURE } @@ -53,6 +55,7 @@ enum class MuunFeature { Libwallet.BackendFeatureNfcSensors -> NFC_SENSORS Libwallet.BackendFeatureDiagnosticMode -> DIAGNOSTIC_MODE Libwallet.BackendFeatureSecurityCardsMarketplace -> SECURITY_CARDS_MARKETPLACE + Libwallet.BackendFeatureEkGoRendering -> EK_GO_RENDERING else -> UNSUPPORTED_FEATURE } @@ -72,6 +75,7 @@ enum class MuunFeature { NFC_SENSORS -> MuunFeatureJson.NFC_SENSORS DIAGNOSTIC_MODE -> MuunFeatureJson.DIAGNOSTIC_MODE SECURITY_CARDS_MARKETPLACE -> MuunFeatureJson.SECURITY_CARDS_MARKETPLACE + EK_GO_RENDERING -> MuunFeatureJson.EK_GO_RENDERING UNSUPPORTED_FEATURE -> MuunFeatureJson.UNSUPPORTED_FEATURE } @@ -90,7 +94,67 @@ enum class MuunFeature { NFC_SENSORS -> Libwallet.BackendFeatureNfcSensors DIAGNOSTIC_MODE -> Libwallet.BackendFeatureDiagnosticMode SECURITY_CARDS_MARKETPLACE -> Libwallet.BackendFeatureSecurityCardsMarketplace + EK_GO_RENDERING -> Libwallet.BackendFeatureEkGoRendering UNSUPPORTED_FEATURE -> Libwallet.BackendFeatureUnsupported } + + fun isOverridable(): Boolean = toOverridableFeature().isOverridable() + + /** + * Use this mapping to define whether a feature flag can be overridden locally. + * - Return `NotOverridable` for flags that must not be overridden locally. + * - Return `Overridable` for flags that support local overrides. + * When using `Overridable`, you MUST provide: + * - A human-readable description (shown in the Disable Feature Flags screen) + * - A libwallet key name that matches exactly the storage key suffix used by libwallet + * (see libwallet/storage/schema.go). The `featureFlagOverrides:` prefix + * is automatically added by the storage repository. + */ + fun toOverridableFeature(): OverridableFeature = + when (this) { + TAPROOT -> OverridableFeature.NotOverridable + TAPROOT_PREACTIVATION -> OverridableFeature.NotOverridable + APOLLO_BIOMETRICS -> OverridableFeature.NotOverridable + HIGH_FEES_HOME_BANNER -> OverridableFeature.NotOverridable + HIGH_FEES_RECEIVE_FLOW -> OverridableFeature.NotOverridable + EFFECTIVE_FEES_CALCULATION -> OverridableFeature.NotOverridable + OS_VERSION_DEPRECATED_FLOW -> OverridableFeature.NotOverridable + NFC_CARD -> OverridableFeature.NotOverridable + NFC_CARD_V2 -> OverridableFeature.Overridable( + this, + "Enables NFC Security Card V2 support", + "nfcCardV2" + ) + + NFC_SENSORS -> OverridableFeature.NotOverridable + DIAGNOSTIC_MODE -> OverridableFeature.NotOverridable + SECURITY_CARDS_MARKETPLACE -> OverridableFeature.NotOverridable + EK_GO_RENDERING -> OverridableFeature.Overridable( + this, + "Enables Go-based emergency kit rendering", + "ekGoRendering" + ) + + UNSUPPORTED_FEATURE -> OverridableFeature.NotOverridable + } + + sealed class OverridableFeature { + + // IMPORTANT: + // If you mark a flag as overridable, you MUST ensure that a matching + // override key exists in Libwallet Storage and matches exactly (case-sensitive). + // See libwallet/storage/schema.go. + // Otherwise, the Disable Feature Flags screen will crash. + + data class Overridable( + val feature: MuunFeature, + val humanReadableDesc: String, + val libwalletKeySuffix: String, + ) : OverridableFeature() + + object NotOverridable : OverridableFeature() + + fun isOverridable(): Boolean = this is Overridable + } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/ErrorReport.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/ErrorReport.kt index 35a15c3d..a1c4d763 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/ErrorReport.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/model/report/ErrorReport.kt @@ -3,6 +3,7 @@ package io.muun.apollo.domain.model.report import android.util.Log import java.io.Serializable import java.util.UUID +import java.util.WeakHashMap import kotlin.math.min /** @@ -20,7 +21,19 @@ data class ErrorReport( val metadata: MutableMap, ) { - val uniqueId = UUID.randomUUID().toString() + val uniqueId = getOrCreateId(originalError ?: error) + + companion object { + private val idCache = WeakHashMap() + + /** + * Return a stable UUID for the given Throwable instance so that every ErrorReport + * built from the same Throwable shares the same ID across reporting channels. + */ + @Synchronized + private fun getOrCreateId(error: Throwable): String = + idCache.getOrPut(error) { UUID.randomUUID().toString() } + } fun print(abridged: Boolean): String { val error = printError(abridged) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/BlockchainHeightSelector.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/BlockchainHeightSelector.kt index e673f6f5..48a2f131 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/BlockchainHeightSelector.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/BlockchainHeightSelector.kt @@ -10,12 +10,10 @@ import javax.inject.Inject import kotlin.math.max class BlockchainHeightSelector @Inject constructor( - private val blockchainHeightRepository: BlockchainHeightRepository + private val blockchainHeightRepository: BlockchainHeightRepository, ) { companion object { - var DEBUG_BLOCKS_TO_TAPROOT: Int? = null - fun getBlocksInHours(blocks: Int) = if (blocks == 0) { 0 // separate case, because estimations are rounded up to 1 @@ -34,10 +32,6 @@ class BlockchainHeightSelector @Inject constructor( watch().toBlocking().first() private fun calcBlocksToTaproot(blockchainHeight: Int): Int { - if (DEBUG_BLOCKS_TO_TAPROOT != null) { - return DEBUG_BLOCKS_TO_TAPROOT!! - } - val taprootHeight = Libwallet.getUserActivatedFeatureTaproot() .blockheight(Globals.INSTANCE.network.toLibwallet()) diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt index 7879d44c..e736a8b2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/FeatureSelector.kt @@ -4,6 +4,7 @@ import io.muun.apollo.data.preferences.FeaturesRepository import io.muun.apollo.domain.FeatureOverrideStore import io.muun.apollo.domain.model.MuunFeature import rx.Observable +import timber.log.Timber import javax.inject.Inject class FeatureSelector @Inject constructor( @@ -15,7 +16,11 @@ class FeatureSelector @Inject constructor( return fetchWithoutOverrides() .map { list -> val newList = list.toMutableList() - newList.removeAll(featureOverrideStore.getFeatureOverrides()) + val overrides = featureOverrideStore.getFeatureOverrides() + .map { overridableFeature -> + overridableFeature.feature + } + newList.removeAll(overrides) newList } } @@ -36,15 +41,24 @@ class FeatureSelector @Inject constructor( * Avoid using unless you REALLY know what you're doing. You probably just want to use the * fetch with overrides. */ - fun fetchWithoutOverrides(): Observable> { + private fun fetchWithoutOverrides(): Observable> { return featuresRepository.fetch() + .doOnNext { features -> + Timber.d("ALL Feature Flags: ${features.joinToString { it.name }}") + } } - /** - * Avoid using unless you REALLY know what you're doing. You probably just want to use the - * fetch with overrides. - */ - fun getWithoutOverrides(feature: MuunFeature): Boolean { - return fetchWithoutOverrides().toBlocking().first().contains(feature) + fun fetchOverridableFlags(): Observable> { + return fetchWithoutOverrides() + .map { list -> + list.filter { feature -> feature.isOverridable() } + .map { feature -> + feature.toOverridableFeature() as MuunFeature.OverridableFeature.Overridable + } + } + .doOnNext { features -> + Timber.d("Overridable Feature Flags: ${features.joinToString { it.feature.name }}") + } } + } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/LogoutOptionsSelector.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/LogoutOptionsSelector.kt index 15b7d5ed..205a261b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/LogoutOptionsSelector.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/LogoutOptionsSelector.kt @@ -5,7 +5,8 @@ import io.muun.common.utils.Preconditions import rx.Observable import javax.inject.Inject -class LogoutOptionsSelector @Inject constructor( +// open so mockito can mock/spy +open class LogoutOptionsSelector @Inject constructor( private val userSel: UserSelector, private val paymentContextSel: PaymentContextSelector, private val operationSel: OperationSelector, @@ -54,7 +55,8 @@ class LogoutOptionsSelector @Inject constructor( fun get(): LogoutOptions = watch().toBlocking().first() - fun isRecoverable(): Boolean { + // open so mockito can mock/spy + open fun isRecoverable(): Boolean { if (userSel.getOptional().isPresent) { try { return get().isRecoverable() diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/UserActivatedFeatureStatusSelector.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/UserActivatedFeatureStatusSelector.kt index e50313f1..79932052 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/domain/selector/UserActivatedFeatureStatusSelector.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/selector/UserActivatedFeatureStatusSelector.kt @@ -3,7 +3,6 @@ package io.muun.apollo.domain.selector import io.muun.apollo.data.external.Globals import io.muun.apollo.data.preferences.BlockchainHeightRepository import io.muun.apollo.data.preferences.UserRepository -import io.muun.apollo.domain.libwallet.isEqualTo import io.muun.apollo.domain.libwallet.toLibwallet import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.model.UserActivatedFeatureStatus @@ -18,11 +17,10 @@ import javax.inject.Inject class UserActivatedFeatureStatusSelector @Inject constructor( private val userRepository: UserRepository, private val blockchainHeightRepository: BlockchainHeightRepository, - private val featureSelector: FeatureSelector + private val featureSelector: FeatureSelector, ) { companion object { - var DEBUG_TAPROOT_STATUS: UserActivatedFeatureStatus? = null val UAF_TAPROOT: UserActivatedFeature = Libwallet.getUserActivatedFeatureTaproot() } @@ -44,13 +42,9 @@ class UserActivatedFeatureStatusSelector @Inject constructor( user: User, blockHeight: Int, backendFeatures: List, - wantedFeature: UserActivatedFeature + wantedFeature: UserActivatedFeature, ): UserActivatedFeatureStatus { - if (wantedFeature.isEqualTo(UAF_TAPROOT) && DEBUG_TAPROOT_STATUS != null) { - return DEBUG_TAPROOT_STATUS!! - } - val uafStatus = Libwallet.determineUserActivatedFeatureStatus( wantedFeature, blockHeight.toLong(), diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/ChildTrace.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/ChildTrace.kt new file mode 100644 index 00000000..2f0496f0 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/ChildTrace.kt @@ -0,0 +1,25 @@ +package io.muun.apollo.domain.utils + +import io.muun.apollo.BuildConfig +import timber.log.Timber + +class ChildTrace internal constructor( + internal val label: String, +) { + private val startTime: Long = System.currentTimeMillis() + private var elapsedMs: Long? = null + + internal fun result(): Pair = + label to (elapsedMs?.toString() ?: "UNFINISHED") + + fun finish() { + if (elapsedMs != null) { + Timber.e("[TIMING] $label finished more than once") + if (BuildConfig.DEBUG) { + error("[TIMING] $label finished more than once") + } + return + } + elapsedMs = System.currentTimeMillis() - startTime + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt new file mode 100644 index 00000000..4d643c08 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/TimeTracker.kt @@ -0,0 +1,41 @@ +package io.muun.apollo.domain.utils + +import io.muun.apollo.domain.analytics.Analytics +import javax.inject.Inject + +/** + * List of traces added into the app. The format is PREFIX_CONST("PREFIX_ string") + */ +enum class TraceLabel(val value: String) { + /** E2E go kit generation*/ + EK_E2E_NEW_KIT_GENERATION("EK_ E2E new kit generation"), + + /** The go rendering when we already have the data.*/ + EK_NEW_PDF_GENERATION("EK_ New Emergency Kit PDF generation"), + + /** Fetching the ekit data for the go kit*/ + EK_NEW_DATA_FETCHING("EK_ New Emergency Kit data fetching"), + + /** Fetching the ekit data for the css/html kit*/ + EK_LEGACY_DATA_FETCHING("EK_ Legacy Emergency Kit data fetching"), + + /** The css/html rendering when we already have the data.*/ + EK_LEGACY_PDF_GENERATION("EK_ LibwalletBridge.generateEmergencyKit"), + + /** E2E CSS/HTML kit generation*/ + EK_E2E_LEGACY_KIT_GENERATION("EK_ E2E legacy kit generation"), +} + +// Child label constants for emergency kit data fetching traces: +const val EK_CHILD_USER_KEY = "user_key" +const val EK_CHILD_USER_FINGERPRINT = "user_fp" +const val EK_CHILD_MUUN_KEY = "muun_key" +const val EK_CHILD_MUUN_FINGERPRINT = "muun_fp" +const val EK_CHILD_RC_CHECKSUM = "rc_checksum" + +/** Factory for timing traces. Inject this and call [start] to begin measuring. */ +class TimeTracker @Inject constructor(private val analytics: Analytics) { + + /** Start a new trace for [label]. Call [Trace.finish] (or use `use {}`) to report. */ + fun start(label: TraceLabel): Trace = Trace(label.value, analytics) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt new file mode 100644 index 00000000..fa59a37e --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/domain/utils/Trace.kt @@ -0,0 +1,41 @@ +package io.muun.apollo.domain.utils + +import io.muun.apollo.BuildConfig +import io.muun.apollo.domain.analytics.Analytics +import io.muun.apollo.domain.analytics.AnalyticsEvent +import timber.log.Timber + +/** A running timing measurement. Call [finish] or use `use {}` to report the elapsed time. */ +class Trace internal constructor( + private val label: String, + private val analytics: Analytics, +) : AutoCloseable { + + private val startTime: Long = System.currentTimeMillis() + private var finished = false + + private val children = mutableListOf() + + /** Create a child trace. Call [ChildTrace.finish] when the child operation completes. */ + fun child(label: String): ChildTrace { + return ChildTrace(label).also { children.add(it) } + } + + /** Report the elapsed time to analytics. */ + fun finish() { + if (finished) { + Timber.e("[TIMING] $label finished more than once") + if (BuildConfig.DEBUG) { + error("[TIMING] $label finished more than once") + } + return + } + + finished = true + val elapsed = System.currentTimeMillis() - startTime + val childMap = children.associate { it.result() } + analytics.report(AnalyticsEvent.E_TIME_TRACKER(label, elapsed, childMap)) + } + + override fun close() = finish() +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java index 7cca52e6..dc24de51 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/app/Navigator.java @@ -34,6 +34,14 @@ import io.muun.apollo.presentation.ui.recovery_tool.RecoveryToolActivity; import io.muun.apollo.presentation.ui.scan_qr.LnUrlFlow; import io.muun.apollo.presentation.ui.scan_qr.ScanQrActivity; +import io.muun.apollo.presentation.ui.security_cards_card_detail.CardDetailActivity; +import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity; +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo; +import io.muun.apollo.presentation.ui.security_cards_marketplace.SecurityCardsMarketplaceActivity; +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter; +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard; +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider; +import io.muun.apollo.presentation.ui.security_cards_onboarding.SecurityCardsOnboardingActivity; import io.muun.apollo.presentation.ui.security_logout.SecurityLogoutActivity; import io.muun.apollo.presentation.ui.select_bitcoin_unit.SelectBitcoinUnitActivity; import io.muun.apollo.presentation.ui.select_night_mode.SelectNightModeActivity; @@ -61,6 +69,7 @@ import android.provider.Settings; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; import timber.log.Timber; import javax.inject.Inject; @@ -673,4 +682,47 @@ public void navigateToNfcReaderActivityForResult( NfcReaderActivity.Companion.getStartActivityIntent(context) ); } + + public void navigateToSecurityCardsMarketplace( + @NotNull Context context, + @NotNull CountryInfo countryInfo + ) { + final Intent intent = + SecurityCardsMarketplaceActivity.Companion.getIntent(context, countryInfo); + context.startActivity(intent); + } + + public void navigateToCardDetail( + @NotNull Context context, + @NotNull SecurityCardProvider provider, + @NotNull SecurityCard card, + @NotNull MarketplaceFooter footer + ) { + final Intent intent = + CardDetailActivity.Companion.getIntent(context, provider, card, footer); + context.startActivity(intent); + } + + /** + * Navigates to country picker activity. + */ + public void navigateToCountryPickerForResult( + @NotNull Context context, + @Nullable String selectedCountryCode, + @NotNull ActivityResultLauncher activityLauncher + ) { + activityLauncher.launch(CountryPickerActivity.Companion.getIntent( + context, + selectedCountryCode + )); + } + + /** + * . + */ + public void navigateToSecurityCardsMarketplaceOnboarding( + @NotNull Context context + ) { + context.startActivity(SecurityCardsOnboardingActivity.Companion.getIntent(context)); + } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/biometrics/BiometricsControllerImpl.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/biometrics/BiometricsControllerImpl.kt index c5386b3b..54bd9e84 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/biometrics/BiometricsControllerImpl.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/biometrics/BiometricsControllerImpl.kt @@ -6,6 +6,8 @@ import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.PromptInfo import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import io.muun.apollo.R import io.muun.apollo.data.preferences.BiometricsRepository import io.muun.apollo.domain.analytics.Analytics @@ -72,9 +74,17 @@ class BiometricsControllerImpl @Inject constructor( } val executor = ContextCompat.getMainExecutor(applicationContext) - val biometricPrompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() { + var biometricPrompt: BiometricPrompt? = null + val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) { + biometricPrompt?.cancelAuthentication() + owner.lifecycle.removeObserver(this) + } + } + biometricPrompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + activity.lifecycle.removeObserver(lifecycleObserver) repository.setUserOptInBiometrics(true) onSuccess() analytics.report(E_BIOMETRICS_AUTH_SUCCESS()) @@ -82,6 +92,7 @@ class BiometricsControllerImpl @Inject constructor( override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) { super.onAuthenticationError(errorCode, errorString) + activity.lifecycle.removeObserver(lifecycleObserver) if (shouldReportFailure(errorCode)) { onFailure(BiometricAuthenticationError(errorCode.toAuthenticationFailedReason())) analytics.report(E_BIOMETRICS_AUTH_ERROR(errorCode.toString(), errorString.toString())) @@ -96,6 +107,7 @@ class BiometricsControllerImpl @Inject constructor( .build() biometricPrompt.authenticate(promptInfo) analytics.report(S_BIOMETRICS_AUTH()) + activity.lifecycle.addObserver(lifecycleObserver) } /** diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/ItemAdapter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/ItemAdapter.java index 3e0314ad..51802420 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/ItemAdapter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/ItemAdapter.java @@ -63,7 +63,10 @@ public void onBindViewHolder(BaseViewHolder holder, int position) final ItemViewModel item = items.get(position); holder.bind(item); - holder.itemView.setOnClickListener(view -> onItemClick(item)); + holder.itemView.setOnClickListener(view -> { + onItemClick(item); + holder.onItemClick(); + }); } @Override diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.java deleted file mode 100644 index 617ccd7e..00000000 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.muun.apollo.presentation.ui.adapter.holder; - -import android.view.View; -import androidx.recyclerview.widget.RecyclerView; - -public abstract class BaseViewHolder extends RecyclerView.ViewHolder { - - public BaseViewHolder(View itemView) { - super(itemView); - } - - public abstract void bind(T item); -} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.kt new file mode 100644 index 00000000..2f6146ad --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/BaseViewHolder.kt @@ -0,0 +1,13 @@ +package io.muun.apollo.presentation.ui.adapter.holder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +abstract class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + abstract fun bind(viewModel: T) + + open fun onItemClick() { + // Override to apply visual/state changes upon click + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/FeatureFlagViewHolder.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/FeatureFlagViewHolder.kt new file mode 100644 index 00000000..ea688de4 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/FeatureFlagViewHolder.kt @@ -0,0 +1,26 @@ +package io.muun.apollo.presentation.ui.adapter.holder + +import android.view.View +import io.muun.apollo.databinding.ItemFeatureFlagBinding +import io.muun.apollo.presentation.ui.adapter.viewmodel.FeatureFlagViewModel + +class FeatureFlagViewHolder(itemView: View) : BaseViewHolder(itemView) { + + private val binding = ItemFeatureFlagBinding.bind(itemView) + + override fun bind(viewModel: FeatureFlagViewModel) { + + binding.featureFlagTitle.text = viewModel.overridableFeature.feature.toString() + binding.featureFlagDescription.text = viewModel.overridableFeature.humanReadableDesc + + // Set initial state - switch is ON when feature is enabled (not overridden) + binding.featureFlagSwitch.isChecked = viewModel.state == FeatureFlagViewModel.State.ENABLED + + // Clear any existing listener to prevent unwanted triggers + binding.featureFlagSwitch.setOnCheckedChangeListener(null) + } + + override fun onItemClick() { + binding.featureFlagSwitch.isChecked = !binding.featureFlagSwitch.isChecked + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java index bf3cc3cf..64456f9c 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/holder/ViewHolderFactory.java @@ -4,6 +4,7 @@ import io.muun.apollo.domain.model.Contact; import io.muun.apollo.presentation.model.CurrencyItem; import io.muun.apollo.presentation.model.UiOperation; +import io.muun.apollo.presentation.ui.adapter.viewmodel.FeatureFlagViewModel; import io.muun.apollo.presentation.ui.adapter.viewmodel.SectionHeaderViewModel.SectionHeader; import android.view.View; @@ -26,6 +27,10 @@ public int getLayoutRes(UiOperation model) { return R.layout.home_operations_item; } + public int getLayoutRes(FeatureFlagViewModel featureFlag) { + return R.layout.item_feature_flag; + } + /** * Maps viewType to ViewHolder type. This way all the information about adapter's viewTypes * is encapsulated in this class. (Using layout ids as recommended by Google). @@ -44,6 +49,9 @@ public BaseViewHolder create(int viewType, View view) { case R.layout.home_operations_item: return new OperationViewHolder(view); + case R.layout.item_feature_flag: + return new FeatureFlagViewHolder(view); + default: throw new RuntimeException("Illegal view type"); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/FeatureFlagViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/FeatureFlagViewModel.kt new file mode 100644 index 00000000..590e1ce1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/FeatureFlagViewModel.kt @@ -0,0 +1,19 @@ +package io.muun.apollo.presentation.ui.adapter.viewmodel + +import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.presentation.ui.adapter.holder.ViewHolderFactory + +class FeatureFlagViewModel( + val overridableFeature: MuunFeature.OverridableFeature.Overridable, + val state: State, +) : ItemViewModel { + + enum class State { + ENABLED, + DISABLED + } + + override fun type(typeFactory: ViewHolderFactory): Int { + return typeFactory.getLayoutRes(this) + } +} \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.java deleted file mode 100644 index 9175e498..00000000 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.muun.apollo.presentation.ui.adapter.viewmodel; - -import io.muun.apollo.presentation.ui.adapter.holder.ViewHolderFactory; - -public interface ItemViewModel { - - int type(ViewHolderFactory typeFactory); -} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.kt new file mode 100644 index 00000000..3987b5f2 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/adapter/viewmodel/ItemViewModel.kt @@ -0,0 +1,8 @@ +package io.muun.apollo.presentation.ui.adapter.viewmodel + +import io.muun.apollo.presentation.ui.adapter.holder.ViewHolderFactory + +interface ItemViewModel { + + fun type(typeFactory: ViewHolderFactory): Int +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java index e70850e3..5a965b12 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseActivity.java @@ -21,6 +21,7 @@ import io.muun.apollo.presentation.ui.activity.extension.ShakeToDebugExtension; import io.muun.apollo.presentation.ui.activity.extension.SnackBarExtension; import io.muun.apollo.presentation.ui.base.di.ActivityComponent; +import io.muun.apollo.presentation.ui.utils.BundleSizeLogger; import io.muun.apollo.presentation.ui.utils.LinkBuilder; import io.muun.apollo.presentation.ui.utils.OS; import io.muun.apollo.presentation.ui.utils.UiUtils; @@ -29,17 +30,13 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.os.Looper; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; -import android.view.View; -import android.view.WindowManager; import android.widget.Toast; -import androidx.activity.EdgeToEdge; import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.MenuRes; @@ -47,11 +44,6 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.core.view.WindowInsetsControllerCompat; import androidx.fragment.app.DialogFragment; import androidx.viewbinding.ViewBinding; import butterknife.ButterKnife; @@ -174,7 +166,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { // setWindowInsets() should be called before setContentView() (inside setUpLayout()) to // prevent redrawing - setWindowInsets(); + io.muun.apollo.presentation.ui.utils.ExtensionsKt.setWindowInsetsCompat(this); setUpLayout(); initializePresenter(savedInstanceState); initializeUi(); @@ -202,63 +194,6 @@ protected void onDestroy() { super.onDestroy(); } - /** - * Configures window insets to allow drawing behind system bars and dynamically applies padding - * to the root view based on system UI elements like the status bar, navigation bar, and IME. - * This ensures proper layout behavior when system UI visibility changes (e.g., keyboard shown). - */ - protected void setWindowInsets() { - if (OS.supportsEdgeToEdge()) { - EdgeToEdge.enable(this); - } else { - WindowCompat.setDecorFitsSystemWindows(this.getWindow(), false); - getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - } - - final View rootView = getWindow().getDecorView().getRootView(); - - setStatusBarIconsColor(); - - ViewCompat.setOnApplyWindowInsetsListener( - rootView, - (view, insets) -> { - final Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()); - final Insets navInsets = insets.getInsets( - WindowInsetsCompat.Type.navigationBars() - ); - final int topInset = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top; - - final int bottomInset = Math.max(imeInsets.bottom, navInsets.bottom); - - view.setPadding( - navInsets.left, - topInset, - navInsets.right, - bottomInset - ); - - return WindowInsetsCompat.CONSUMED; - } - ); - } - - /** - * Sets the status bar icon color based on the current UI mode. - * Displays the status bar and adjusts icon appearance for visibility - * in light or dark themes. - */ - private void setStatusBarIconsColor() { - final int nightModeFlags = - getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - final boolean isDarkMode = nightModeFlags == Configuration.UI_MODE_NIGHT_YES; - - final WindowInsetsControllerCompat controller = - WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); - - controller.show(WindowInsetsCompat.Type.statusBars()); - controller.setAppearanceLightStatusBars(!isDarkMode); - } - /** * Override this method to add any activity initialization logic. */ @@ -299,6 +234,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); presenter.saveState(outState); + BundleSizeLogger.INSTANCE.logBundleBreakdown(getClass().getSimpleName(), outState); } @Override diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java index 734715d7..45d6c6ec 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/BaseFragment.java @@ -8,6 +8,7 @@ import io.muun.apollo.presentation.ui.activity.extension.PermissionManagerExtension; import io.muun.apollo.presentation.ui.activity.extension.PermissionManagerExtension.PermissionRequester; import io.muun.apollo.presentation.ui.base.di.FragmentComponent; +import io.muun.apollo.presentation.ui.utils.BundleSizeLogger; import io.muun.apollo.presentation.ui.utils.UiUtils; import io.muun.common.Optional; @@ -249,6 +250,7 @@ public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); presenter.saveState(outState); + BundleSizeLogger.INSTANCE.logBundleBreakdown(getClass().getSimpleName(), outState); } @Override diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java index dac6c6e8..225c6eef 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/ActivityComponent.java @@ -20,6 +20,10 @@ import io.muun.apollo.presentation.ui.recovery_code.SetupRecoveryCodeActivity; import io.muun.apollo.presentation.ui.recovery_tool.RecoveryToolActivity; import io.muun.apollo.presentation.ui.scan_qr.ScanQrActivity; +import io.muun.apollo.presentation.ui.security_cards_card_detail.CardDetailActivity; +import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity; +import io.muun.apollo.presentation.ui.security_cards_marketplace.SecurityCardsMarketplaceActivity; +import io.muun.apollo.presentation.ui.security_cards_onboarding.SecurityCardsOnboardingActivity; import io.muun.apollo.presentation.ui.security_logout.SecurityLogoutActivity; import io.muun.apollo.presentation.ui.select_amount.SelectAmountActivity; import io.muun.apollo.presentation.ui.select_bitcoin_unit.SelectBitcoinUnitActivity; @@ -121,4 +125,12 @@ public interface ActivityComponent { void inject(DiagnosticActivity diagnosticActivity); void inject(NfcReaderActivity nfcReaderActivity); + + void inject(SecurityCardsMarketplaceActivity securityCardsMarketplaceActivity); + + void inject(CountryPickerActivity countryPickerActivity); + + void inject(SecurityCardsOnboardingActivity securityCardsOnboardingActivity); + + void inject(CardDetailActivity cardDetailActivity); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/FragmentComponent.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/FragmentComponent.java index dd83014e..0b1dd443 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/FragmentComponent.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/base/di/FragmentComponent.java @@ -45,6 +45,7 @@ import io.muun.apollo.presentation.ui.recovery_code.show.ShowRecoveryCodeFragment; import io.muun.apollo.presentation.ui.recovery_code.success.SuccessRecoveryCodeFragment; import io.muun.apollo.presentation.ui.recovery_code.verify.VerifyRecoveryCodeFragment; +import io.muun.apollo.presentation.ui.security_cards_marketplace.SecurityCardProviderFragment; import io.muun.apollo.presentation.ui.settings.EmailWaitFragment; import io.muun.apollo.presentation.ui.settings.OldPasswordFragment; import io.muun.apollo.presentation.ui.settings.RecoveryCodeFragment; @@ -180,4 +181,6 @@ public interface FragmentComponent { void inject(TaprootIntroFragment taprootIntroFragment); void inject(ShowUnifiedQrFragment showUnifiedQrFragment); + + void inject(SecurityCardProviderFragment securityCardProviderFragment); } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/diagnostic/DiagnosticActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/diagnostic/DiagnosticActivity.kt index a109091a..efae7208 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/diagnostic/DiagnosticActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/diagnostic/DiagnosticActivity.kt @@ -12,6 +12,7 @@ import io.muun.apollo.databinding.ActivityDiagnosticBinding import io.muun.apollo.domain.libwallet.LibwalletClient import io.muun.apollo.presentation.app.ApolloApplication import io.muun.apollo.presentation.ui.base.di.ActivityComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat import rx.android.schedulers.AndroidSchedulers import javax.inject.Inject @@ -35,6 +36,7 @@ class DiagnosticActivity : AppCompatActivity() { private var currentValue: Long = 0 override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() super.onCreate(savedInstanceState) component.inject(this) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt index 263589d0..d6c4aeda 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/export_keys/EmergencyKitPresenter.kt @@ -5,7 +5,7 @@ import io.muun.apollo.data.apis.DriveFile import io.muun.apollo.data.apis.DriveUploader import io.muun.apollo.domain.analytics.AnalyticsEvent import io.muun.apollo.domain.errors.ChallengeKeyMigrationError -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import io.muun.apollo.presentation.ui.base.BasePresenter import io.muun.apollo.presentation.ui.base.di.PerActivity import io.muun.apollo.presentation.ui.fragments.ek_save.EmergencyKitSaveParentPresenter @@ -29,7 +29,7 @@ class EmergencyKitPresenter @Inject constructor( var uploadedFile: DriveFile? = null - private var generatedEK: GeneratedEmergencyKit? = null + private var generatedEK: GeneratedEmergencyKitInfo? = null override fun setUp(arguments: Bundle) { super.setUp(arguments) @@ -51,11 +51,11 @@ class EmergencyKitPresenter @Inject constructor( view.refreshToolbar() } - override fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKit) { + override fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKitInfo) { generatedEK = kitGen } - override fun getGeneratedEmergencyKit(): GeneratedEmergencyKit = + override fun getGeneratedEmergencyKit(): GeneratedEmergencyKitInfo = generatedEK!! override fun confirmEmergencyKitUploaded(driveFile: DriveFile) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveParentPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveParentPresenter.kt index 8e19261c..9bde44d4 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveParentPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSaveParentPresenter.kt @@ -1,14 +1,14 @@ package io.muun.apollo.presentation.ui.fragments.ek_save import io.muun.apollo.data.apis.DriveFile -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import io.muun.apollo.presentation.ui.base.ParentPresenter interface EmergencyKitSaveParentPresenter : ParentPresenter { - fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKit) + fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKitInfo) - fun getGeneratedEmergencyKit(): GeneratedEmergencyKit + fun getGeneratedEmergencyKit(): GeneratedEmergencyKitInfo fun confirmEmergencyKitUploaded(driveFile: DriveFile) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSavePresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSavePresenter.kt index 8d08e236..4e0b5e3a 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSavePresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_save/EmergencyKitSavePresenter.kt @@ -13,8 +13,10 @@ import io.muun.apollo.data.apis.DriveFile import io.muun.apollo.data.fs.FileCache import io.muun.apollo.domain.action.UserActions import io.muun.apollo.domain.action.ek.AddEmergencyKitMetadataAction +import io.muun.apollo.domain.action.ek.GenerateEmergencyKitPDF import io.muun.apollo.domain.action.ek.RenderEmergencyKitAction import io.muun.apollo.domain.action.ek.UploadToDriveAction +import io.muun.apollo.domain.analytics.Analytics import io.muun.apollo.domain.analytics.AnalyticsEvent import io.muun.apollo.domain.analytics.AnalyticsEvent.ERROR_TYPE import io.muun.apollo.domain.analytics.AnalyticsEvent.E_DRIVE_TYPE @@ -31,7 +33,13 @@ import io.muun.apollo.domain.analytics.AnalyticsEvent.S_EMERGENCY_KIT_MANUAL_ADV import io.muun.apollo.domain.analytics.PdfFontIssueTracker import io.muun.apollo.domain.errors.ek.SaveEkToDiskError import io.muun.apollo.domain.model.FeedbackCategory -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitHTML +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo +import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.domain.selector.FeatureSelector +import io.muun.apollo.domain.utils.Trace +import io.muun.apollo.domain.utils.TraceLabel +import io.muun.apollo.domain.utils.TimeTracker import io.muun.apollo.presentation.export.PdfExportError import io.muun.apollo.presentation.export.PdfExporter import io.muun.apollo.presentation.export.SaveToDiskExporter @@ -43,27 +51,44 @@ import javax.inject.Inject @PerFragment class EmergencyKitSavePresenter @Inject constructor( private val fileCache: FileCache, + // New EKit Generation + private val generateEmergencyKitPdf: GenerateEmergencyKitPDF, + // Legacy EKit generation ==>> private val renderEmergencyKit: RenderEmergencyKitAction, private val addEmergencyKitMetadata: AddEmergencyKitMetadataAction, + // <<== private val uploadToDrive: UploadToDriveAction, private val driveAuthenticator: DriveAuthenticator, private val userActions: UserActions, + private val featureSelector: FeatureSelector, + private val analytics: Analytics, + private val timeTracker: TimeTracker, ) : SingleFragmentPresenter() { - var isExportingPdf = false + private var isExportingPdf = false + + private var e2eLegacyKitGenerationTrace: Trace? = null + private var legacyKitGenerationTrace: Trace? = null override fun setUp(arguments: Bundle) { super.setUp(arguments) - renderEmergencyKit.state - .compose(handleStates(null, this::handleError)) - .doOnNext(this::onRenderResult) - .let(this::subscribeTo) - - addEmergencyKitMetadata.state - .compose(handleStates(null, this::handleError)) - .doOnNext { onMetadataAdded() } - .let(this::subscribeTo) + if (featureSelector.get(MuunFeature.EK_GO_RENDERING)) { + generateEmergencyKitPdf.state + .compose(handleStates(null, this::handleError)) + .doOnNext(this::onPDFGenerationFinished) + .let(this::subscribeTo) + } else { + renderEmergencyKit.state + .compose(handleStates(null, this::handleError)) + .doOnNext(this::onRenderResult) + .let(this::subscribeTo) + + addEmergencyKitMetadata.state + .compose(handleStates(null, this::handleError)) + .doOnNext { onMetadataAdded() } + .let(this::subscribeTo) + } uploadToDrive.state .compose(handleStates(view::setDriveUploading, this::handleError)) @@ -82,8 +107,20 @@ class EmergencyKitSavePresenter @Inject constructor( } isExportingPdf = true - // Cool, proceed: - renderEmergencyKit.run() + if (featureSelector.get(MuunFeature.EK_GO_RENDERING)) { + try { + generateEmergencyKitPdf.run() + } catch (e: Exception) { + Timber.e(e, "EK_GO_RENDERING failed, falling back to legacy flow") + e2eLegacyKitGenerationTrace = timeTracker.start(TraceLabel.EK_E2E_LEGACY_KIT_GENERATION) + renderEmergencyKit.onDataFetched = { legacyKitGenerationTrace = timeTracker.start(TraceLabel.EK_LEGACY_PDF_GENERATION) } + renderEmergencyKit.run() + } + } else { + e2eLegacyKitGenerationTrace = timeTracker.start(TraceLabel.EK_E2E_LEGACY_KIT_GENERATION) + renderEmergencyKit.onDataFetched = { legacyKitGenerationTrace = timeTracker.start(TraceLabel.EK_LEGACY_PDF_GENERATION) } + renderEmergencyKit.run() + } } fun goBack() { @@ -148,7 +185,7 @@ class EmergencyKitSavePresenter @Inject constructor( userActions.submitFeedbackAction.run(FeedbackCategory.CLOUD_REQUEST, cloudName) } - private fun onRenderResult(kitGen: GeneratedEmergencyKit) { + private fun onRenderResult(kitGen: GeneratedEmergencyKitHTML) { // Clear previously saved files: fileCache.delete(FileCache.Entry.EMERGENCY_KIT_NO_META) fileCache.delete(FileCache.Entry.EMERGENCY_KIT) @@ -178,7 +215,7 @@ class EmergencyKitSavePresenter @Inject constructor( parentPresenter.confirmEmergencyKitUploaded(driveFile) } - private fun onPdfExportFinished(kitGen: GeneratedEmergencyKit, error: PdfExportError?) { + private fun onPdfExportFinished(kitGen: GeneratedEmergencyKitHTML, error: PdfExportError?) { isExportingPdf = false if (error != null) { @@ -186,7 +223,7 @@ class EmergencyKitSavePresenter @Inject constructor( return } - parentPresenter.setGeneratedEmergencyKit(kitGen) + parentPresenter.setGeneratedEmergencyKit(kitGen.info) addEmergencyKitMetadata.run(kitGen.metadata) @@ -194,7 +231,17 @@ class EmergencyKitSavePresenter @Inject constructor( .track(AnalyticsEvent.PDF_FONT_ISSUE_TYPE.PDF_EXPORTED) } + private fun onPDFGenerationFinished(kitGen: GeneratedEmergencyKitInfo) { + isExportingPdf = false + parentPresenter.setGeneratedEmergencyKit(kitGen) + + val localFile = fileCache.get(FileCache.Entry.EMERGENCY_KIT) + view.onEmergencyKitExported(localFile) + } + private fun onMetadataAdded() { + legacyKitGenerationTrace?.finish() + e2eLegacyKitGenerationTrace?.finish() val localFile = fileCache.get(FileCache.Entry.EMERGENCY_KIT) view.onEmergencyKitExported(localFile) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify/EmergencyKitVerifyParentPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify/EmergencyKitVerifyParentPresenter.kt index 81f4c822..35428f93 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify/EmergencyKitVerifyParentPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/ek_verify/EmergencyKitVerifyParentPresenter.kt @@ -1,11 +1,11 @@ package io.muun.apollo.presentation.ui.fragments.ek_verify -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import io.muun.apollo.presentation.ui.base.ParentPresenter interface EmergencyKitVerifyParentPresenter: ParentPresenter { - fun getGeneratedEmergencyKit(): GeneratedEmergencyKit + fun getGeneratedEmergencyKit(): GeneratedEmergencyKitInfo fun refreshToolbar() diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt index 3ae999e2..4cbd9bda 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsFragment.kt @@ -7,7 +7,6 @@ import android.view.Menu import android.view.MenuInflater import android.view.View import android.widget.TextView -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import butterknife.BindView import io.muun.apollo.R @@ -19,15 +18,10 @@ import io.muun.apollo.domain.model.ExchangeRateWindow import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.domain.model.NightMode import io.muun.apollo.domain.model.UserActivatedFeatureStatus.ACTIVE -import io.muun.apollo.domain.model.UserActivatedFeatureStatus.CAN_ACTIVATE -import io.muun.apollo.domain.model.UserActivatedFeatureStatus.CAN_PREACTIVATE -import io.muun.apollo.domain.model.UserActivatedFeatureStatus.OFF import io.muun.apollo.domain.model.UserActivatedFeatureStatus.PREACTIVATED import io.muun.apollo.domain.model.UserActivatedFeatureStatus.SCHEDULED_ACTIVATION import io.muun.apollo.domain.model.user.User import io.muun.apollo.domain.model.user.UserProfile -import io.muun.apollo.domain.selector.BlockchainHeightSelector -import io.muun.apollo.domain.selector.UserActivatedFeatureStatusSelector import io.muun.apollo.presentation.biometrics.BiometricsController import io.muun.apollo.presentation.ui.activity.extension.MuunDialog import io.muun.apollo.presentation.ui.base.SingleFragment @@ -41,7 +35,6 @@ import io.muun.apollo.presentation.ui.view.MuunIconButton import io.muun.apollo.presentation.ui.view.MuunPictureInput import io.muun.apollo.presentation.ui.view.MuunSettingItem import io.muun.apollo.presentation.ui.view.MuunSwitchSettingItem -import io.muun.apollo.presentation.ui.view.RichText import io.muun.common.model.Currency import javax.inject.Inject @@ -97,6 +90,13 @@ open class SettingsFragment : SingleFragment(), SettingsView @BindView(R.id.settings_biometrics) lateinit var biometricsSettingsItem: MuunSwitchSettingItem + + @BindView(R.id.internal_debugging_section) + lateinit var featureFlagsSection: View + + @BindView(R.id.settings_disable_feature_flags) + lateinit var featureFlagsSettingsItem: MuunSettingItem + @BindView(R.id.recovery_section) lateinit var recoverySection: View @@ -112,12 +112,6 @@ open class SettingsFragment : SingleFragment(), SettingsView @BindView(R.id.settings_version_code) lateinit var versionCode: TextView - @BindView(R.id.settings_disable_feature_flags) - lateinit var disableFeatureFlags: TextView - - @BindView(R.id.settings_feature_flag) - lateinit var featureFlagLabel: TextView - /** Whether the loading dialog is currently on screen. Required due to limitations of * AlertDialogExtensions. It can't handle multiple dismissDialogs() calls prompted by * handleStates(). */ @@ -149,8 +143,6 @@ open class SettingsFragment : SingleFragment(), SettingsView diagnosticSettingsItem.setOnClickListener { goToDiagnosticMode() } if (Globals.INSTANCE.isDebug) { - // TEMP: code for Taproot QA: -// versionCode.setOnClickListener { rotateDebugTaprootStatusForQa() } versionCode.setOnClickListener { presenter.openDebugPanel() } } else if (Globals.INSTANCE.isDogfood) { @@ -197,43 +189,13 @@ open class SettingsFragment : SingleFragment(), SettingsView // Helper code for internal builds if (Globals.INSTANCE.isDogfood || Globals.INSTANCE.isDebug) { - if (state.features.contains(MuunFeature.NFC_CARD_V2)) { - disableFeatureFlags.visibility = View.VISIBLE - disableFeatureFlags.setOnClickListener { + if (state.overridableFeatures.isNotEmpty()) { + featureFlagsSection.visibility = View.VISIBLE + featureFlagsSettingsItem.visibility = View.VISIBLE + featureFlagsSettingsItem.setOnClickListener { presenter.navigateToDisableFeatureFlags() } - - } else { - disableFeatureFlags.visibility = View.GONE - } - - var textForDisplay = RichText() - val featureFlagsForDisplay = state.features - .filter { it != MuunFeature.TAPROOT } - .filter { it != MuunFeature.TAPROOT_PREACTIVATION } - .filter { it != MuunFeature.EFFECTIVE_FEES_CALCULATION } - - featureFlagsForDisplay.forEach { feature -> - val isDisabled = state.featureOverrides.contains(feature) - val status = if (isDisabled) { - "DISABLED" - } else { - "ENABLED" - } - - val textColor = if (isDisabled) { - ContextCompat.getColor(requireContext(), R.color.gray_light) - } else { - ContextCompat.getColor(requireContext(), R.color.red) - } - - textForDisplay = textForDisplay.concat( - RichText("$feature: $status\n") - .setForegroundColor(textColor) - ) } - featureFlagLabel.visibility = View.VISIBLE - featureFlagLabel.text = textForDisplay } } @@ -561,43 +523,4 @@ open class SettingsFragment : SingleFragment(), SettingsView "$versionName ($flavor-$buildType-$commit-$branchName)" } } - - private fun rotateDebugTaprootStatusForQa() { - val nextStatus = when (UserActivatedFeatureStatusSelector.DEBUG_TAPROOT_STATUS) { - null -> OFF - OFF -> CAN_PREACTIVATE - CAN_PREACTIVATE -> CAN_ACTIVATE - CAN_ACTIVATE -> PREACTIVATED - PREACTIVATED -> SCHEDULED_ACTIVATION - SCHEDULED_ACTIVATION -> ACTIVE - ACTIVE -> null - } - - val nextBlocksToTaproot = when (nextStatus) { - null -> null - OFF -> 1111 - CAN_PREACTIVATE -> 1112 - CAN_ACTIVATE -> 1113 - PREACTIVATED -> 1114 - SCHEDULED_ACTIVATION -> 1115 - ACTIVE -> 0 - } - - val nextToast = if (nextStatus != null) { - "Taproot: ${nextStatus.name} / $nextBlocksToTaproot blocks" - } else { - "Taproot debug disabled" - } - - UserActivatedFeatureStatusSelector.DEBUG_TAPROOT_STATUS = nextStatus - BlockchainHeightSelector.DEBUG_BLOCKS_TO_TAPROOT = nextBlocksToTaproot - - showTextToast(nextToast) - - when (nextStatus) { - CAN_PREACTIVATE -> presenter.showPreactivationNotification() - ACTIVE -> presenter.showActivatedNotification() - else -> {} - } - } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt index cf110614..d18ee192 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/settings/SettingsPresenter.kt @@ -3,7 +3,6 @@ package io.muun.apollo.presentation.ui.fragments.settings import android.net.Uri import android.os.Bundle import io.muun.apollo.data.external.NotificationService -import io.muun.apollo.domain.FeatureOverrideStore import io.muun.apollo.domain.NightModeManager import io.muun.apollo.domain.action.UserActions import io.muun.apollo.domain.action.base.ActionState @@ -33,7 +32,6 @@ import io.muun.apollo.presentation.ui.settings.bitcoin.BitcoinSettingsFragment import io.muun.apollo.presentation.ui.settings.flags.DisableFeatureFlagsFragment import io.muun.apollo.presentation.ui.settings.lightning.LightningSettingsFragment import io.muun.common.Optional -import io.muun.common.api.messages.EventCommunicationMessage.Event import io.muun.common.utils.Preconditions import rx.Observable import timber.log.Timber @@ -52,7 +50,6 @@ class SettingsPresenter @Inject constructor( private val nightModeManager: NightModeManager, private val notificationService: NotificationService, private val featureSelector: FeatureSelector, - private val overrideStore: FeatureOverrideStore, private val biometricsController: BiometricsController, ) : SingleFragmentPresenter() { @@ -62,7 +59,7 @@ class SettingsPresenter @Inject constructor( val exchangeRateWindow: ExchangeRateWindow, val taprootFeatureStatus: UserActivatedFeatureStatus, val features: List, - val featureOverrides: List, + val overridableFeatures: List, ) override fun setUp(arguments: Bundle) { @@ -81,8 +78,8 @@ class SettingsPresenter @Inject constructor( bitcoinUnitSel.watch(), exchangeRateSelector.watchLatestWindow(), userActivatedFeatureStatusSel.watchTaproot(), - featureSelector.fetchWithoutOverrides(), - Observable.just(overrideStore.getFeatureOverrides()), + featureSelector.fetch(), + featureSelector.fetchOverridableFlags(), ::SettingsState ) .doOnNext { state -> @@ -268,14 +265,6 @@ class SettingsPresenter @Inject constructor( navigator.navigateToFragment(context, DisableFeatureFlagsFragment::class.java) } - fun showPreactivationNotification() { - notificationService.showEventCommunication(Event.TAPROOT_PREACTIVATION) - } - - fun showActivatedNotification() { - notificationService.showEventCommunication(Event.TAPROOT_ACTIVATED) - } - fun openDebugPanel() { navigator.navigateToDebugPanel(context) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/sync/SyncPresenter.java b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/sync/SyncPresenter.java index bf786256..03598512 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/sync/SyncPresenter.java +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/fragments/sync/SyncPresenter.java @@ -8,6 +8,7 @@ import io.muun.apollo.domain.model.LoginWithRc; import io.muun.apollo.domain.model.SignupDraft; import io.muun.apollo.domain.model.SignupStep; +import io.muun.apollo.domain.model.user.User; import io.muun.apollo.presentation.ui.base.SingleFragmentPresenter; import io.muun.apollo.presentation.ui.base.di.PerFragment; import io.muun.apollo.presentation.ui.signup.SignupPresenter; @@ -134,6 +135,10 @@ private void finishSignupIfReady() { } private void reportSyncComplete(boolean isExistingUser) { + final User user = userSel.get(); + + analytics.setUserProperties(user); + if (isExistingUser) { analytics.report(new AnalyticsEvent.E_SIGN_IN_SUCCESSFUL(getLoginType())); } else { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/helper/MoneyHelper.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/helper/MoneyHelper.kt index 5f66bd37..16733639 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/helper/MoneyHelper.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/helper/MoneyHelper.kt @@ -13,7 +13,7 @@ import org.javamoney.moneta.Money import org.javamoney.moneta.format.AmountFormatParams import java.math.BigDecimal import java.math.RoundingMode -import java.util.* +import java.util.Locale import javax.money.CurrencyUnit import javax.money.Monetary import javax.money.MonetaryAmount @@ -182,7 +182,7 @@ object MoneyHelper { return if (currencyCode == "BTC" && bitcoinUnit == BitcoinUnit.SATS) { "SAT" } else { - currencyCode.toUpperCase() + currencyCode.uppercase(Locale.getDefault()) } } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt index 7ca601f8..454c436b 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationActivity.kt @@ -64,6 +64,7 @@ import newop.EnterAmountState import newop.EnterDescriptionState import newop.PaymentIntent import timber.log.Timber +import java.util.Locale import javax.inject.Inject import javax.money.MonetaryAmount @@ -765,7 +766,7 @@ class NewOperationActivity : SingleFragmentActivity(), ) } - val linkText = getString(R.string.see_in_node_explorer).toUpperCase() + val linkText = getString(R.string.see_in_node_explorer).uppercase(Locale.getDefault()) return TextUtils.concat( publicKeyText, diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationErrorType.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationErrorType.kt index 8eff994c..14f02b14 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationErrorType.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationErrorType.kt @@ -21,8 +21,10 @@ enum class NewOperationErrorType { fun toAnalyticsEvent(): S_NEW_OP_ERROR_TYPE = when (this) { + INVALID_ADDRESS -> S_NEW_OP_ERROR_TYPE.INVALID_ADDRESS AMOUNT_TOO_SMALL -> S_NEW_OP_ERROR_TYPE.AMOUNT_BELOW_DUST INSUFFICIENT_FUNDS -> S_NEW_OP_ERROR_TYPE.INSUFFICIENT_FUNDS + INVOICE_UNREACHABLE_NODE -> S_NEW_OP_ERROR_TYPE.UNREACHABLE_NODE INVOICE_NO_ROUTE -> S_NEW_OP_ERROR_TYPE.NO_PAYMENT_ROUTE INVOICE_EXPIRED -> S_NEW_OP_ERROR_TYPE.EXPIRED_INVOICE INVOICE_WILL_EXPIRE_SOON -> S_NEW_OP_ERROR_TYPE.INVOICE_EXPIRES_TOO_SOON @@ -31,7 +33,8 @@ enum class NewOperationErrorType { INVOICE_MISSING_AMOUNT -> S_NEW_OP_ERROR_TYPE.INVOICE_MISSING_AMOUNT EXCHANGE_RATE_WINDOW_TOO_OLD -> S_NEW_OP_ERROR_TYPE.EXCHANGE_RATE_WINDOW_TOO_OLD INVALID_SWAP -> S_NEW_OP_ERROR_TYPE.INVALID_SWAP + CYCLICAL_SWAP -> S_NEW_OP_ERROR_TYPE.CYCLICAL_SWAP SWAP_FAILED -> S_NEW_OP_ERROR_TYPE.SWAP_FAILED - else -> S_NEW_OP_ERROR_TYPE.OTHER + GENERIC -> S_NEW_OP_ERROR_TYPE.OTHER } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt index b5eae6f5..36d174ca 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/new_operation/NewOperationPresenter.kt @@ -568,14 +568,21 @@ class NewOperationPresenter @Inject constructor( // If the data is fresh: simply returns null to complete loading on this screen. preloadFeeData.runIfDataIsInvalidated(FeeBumpRefreshPolicy.NEW_OP_BLOCKINGLY) - // This is still needed because we need to: - // - resolveLnInvoice for submarine swaps TODO mv this to libwallet - // - resolveMuunUri for P2P/Contacts legacy feature TODO refactor this? - resolveOperationUri.run(OperationUri.fromString(uri), origin) + val isActivityRecreation = stateMachine.value() as? StartState == null + + // Only resolve on first run — resolving creates swaps server-side, and on Activity + // recreation the previous resolve already completed, so re-running would create a + // duplicate swap (the isRunning() guard won't help since the first one finished). + if (!isActivityRecreation) { + // This is still needed because we need to: + // - resolveLnInvoice for submarine swaps TODO mv this to libwallet + // - resolveMuunUri for P2P/Contacts legacy feature TODO refactor this? + resolveOperationUri.run(OperationUri.fromString(uri), origin) + } view.setInitialBitcoinUnit(bitcoinUnitSel.get()) - return stateMachine.value() as? StartState == null + return isActivityRecreation } private fun onPaymentContextChanged(newPayCtx: PaymentContext, payReq: PaymentRequest?) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt index 2aa14823..b71762ef 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt @@ -273,7 +273,7 @@ class NfcReaderViewModel @Inject constructor( } internal fun disableSecurityCardFF() { - featureOverrideStore.storeOverride(MuunFeature.NFC_CARD_V2, true) + featureOverrideStore.disableFeatureFlag(MuunFeature.NFC_CARD_V2) analytics.report(AnalyticsEvent.E_NEW_OP_ACTION(E_NEW_OP_ACTION_TYPE.DISABLE_FLAG)) } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt new file mode 100644 index 00000000..0c0d1751 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailActivity.kt @@ -0,0 +1,198 @@ +package io.muun.apollo.presentation.ui.security_cards_card_detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import io.muun.apollo.R +import io.muun.apollo.databinding.ActivityCardDetailBinding +import io.muun.apollo.databinding.ItemCardSpecBinding +import io.muun.apollo.domain.model.BitcoinUnit +import io.muun.apollo.domain.selector.ExchangeRateSelector +import io.muun.apollo.domain.selector.UserSelector +import io.muun.apollo.presentation.app.ApolloApplication +import io.muun.apollo.presentation.ui.new_operation.toRichText +import io.muun.apollo.presentation.ui.security_cards_marketplace.CurrencySelectionSharedViewModel +import io.muun.apollo.presentation.ui.security_cards_marketplace.CurrencySelectionSharedViewModelFactory +import io.muun.apollo.presentation.ui.security_cards_marketplace.inBtc +import io.muun.apollo.presentation.ui.security_cards_marketplace.inCurrencyUnit +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import io.muun.apollo.presentation.ui.view.MuunHeader +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CardDetailActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_PROVIDER = "provider" + private const val EXTRA_CARD = "card" + private const val EXTRA_FOOTER = "footer" + + fun getIntent( + context: Context, + provider: SecurityCardProvider, + card: SecurityCard, + footer: MarketplaceFooter, + ) = Intent(context, CardDetailActivity::class.java).apply { + putExtra(EXTRA_PROVIDER, provider) + putExtra(EXTRA_CARD, card) + putExtra(EXTRA_FOOTER, footer) + } + } + + private val binding: ActivityCardDetailBinding by lazy { + ActivityCardDetailBinding.inflate(layoutInflater) + } + + @Inject + lateinit var viewModelFactory: CardDetailViewModel.Factory + + private val viewModel: CardDetailViewModel by viewModels { + CardDetailViewModelFactory( + provider = requireNotNull(intent.getParcelableExtra(EXTRA_PROVIDER)), + card = requireNotNull(intent.getParcelableExtra(EXTRA_CARD)), + footer = requireNotNull(intent.getParcelableExtra(EXTRA_FOOTER)), + assistedFactory = viewModelFactory, + ) + } + + @Inject + lateinit var currencySelectionSharedViewModelFactory: CurrencySelectionSharedViewModel.Factory + + private val currencySelectionSharedViewModel: CurrencySelectionSharedViewModel by viewModels { + CurrencySelectionSharedViewModelFactory( + initialCurrentCurrency = requireNotNull(intent.getParcelableExtra(EXTRA_FOOTER)).currentCurrency, + assistedFactory = currencySelectionSharedViewModelFactory, + ) + } + + @Inject + lateinit var exchangeRateSelector: ExchangeRateSelector + + @Inject + lateinit var userSel: UserSelector + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + (applicationContext as ApolloApplication).getApplicationComponent().activityComponent().inject(this) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.viewState.collectLatest(::handleViewState) + } + + launch { + currencySelectionSharedViewModel.selectedCurrency.collectLatest { + viewModel.rotateCurrencyInFooterAmounts(it) + } + } + } + } + + setupHeader() + setupFullSpecs() + setupFooter() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun setupHeader() { + binding.header.attachToActivity(this) + binding.header.setNavigation(MuunHeader.Navigation.BACK) + } + + private fun setupFullSpecs() { + binding.textViewSeeFullSpecs.setOnClickListener { + navigateToFullSpecs() + } + } + + private fun setupFooter() { + binding.viewFooter.setOnClickListener { + currencySelectionSharedViewModel.rotateCurrencySelection() + } + binding.buttonContinue.setOnClickListener { navigateToProviderWebsite() } + } + + private fun handleViewState(viewState: CardDetailViewModel.ViewState) { + binding.textViewTitle.text = viewState.provider.name + binding.textViewDescription.text = viewState.provider.description + binding.imageViewCard.setImageResource(viewState.card.imageRes) + + binding.specsContainer.removeAllViews() + viewState.card.primarySpecs.forEach { spec -> + val specBinding = ItemCardSpecBinding.inflate(layoutInflater, binding.specsContainer, false) + specBinding.imageViewIcon.setImageResource(spec.iconRes) + specBinding.textViewLabel.text = spec.label + specBinding.textViewValue.text = spec.value + binding.specsContainer.addView(specBinding.root) + } + + val rateProvider = exchangeRateSelector.watchLatest() + .toBlocking() + .first() + + binding.textViewFooterCardCost.text = getString( + R.string.security_cards_marketplace_card_cost, + toRichText( + amt = when (viewState.footer.currentCurrency) { + CurrentCurrency.PRIMARY -> viewState.footer.cardCost.inCurrencyUnit( + rateProvider, + userSel.get().getPrimaryCurrency(rateProvider) + ) + CurrentCurrency.BTC -> viewState.footer.cardCost.inBtc(rateProvider) + }, + btcUnit = BitcoinUnit.BTC, + isValid = true, + ) + ) + binding.textViewFooterShippingCost.text = getString( + R.string.security_cards_marketplace_shipping_and_taxes_cost, + toRichText( + amt = when (viewState.footer.currentCurrency) { + CurrentCurrency.PRIMARY -> viewState.footer.shippingAndTaxesCost.inCurrencyUnit( + rateProvider, + userSel.get().getPrimaryCurrency(rateProvider) + ) + CurrentCurrency.BTC -> viewState.footer.shippingAndTaxesCost.inBtc( + rateProvider + ) + }, + btcUnit = BitcoinUnit.BTC, + isValid = true, + ) + ) + + binding.buttonContinue.text = getString( + R.string.security_cards_card_detail_go_to_provider, + viewState.provider.name.uppercase(), + ) + } + + private fun navigateToFullSpecs() { + // TODO: Navigate to full specs activity + } + + private fun navigateToProviderWebsite() { + // TODO: Navigate to provider website + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModel.kt new file mode 100644 index 00000000..34b2ce97 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModel.kt @@ -0,0 +1,47 @@ +package io.muun.apollo.presentation.ui.security_cards_card_detail + +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class CardDetailViewModel @AssistedInject constructor( + @Assisted val provider: SecurityCardProvider, + @Assisted val card: SecurityCard, + @Assisted val footer: MarketplaceFooter, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create( + provider: SecurityCardProvider, + card: SecurityCard, + footer: MarketplaceFooter, + ): CardDetailViewModel + } + + data class ViewState( + val provider: SecurityCardProvider, + val card: SecurityCard, + val footer: MarketplaceFooter, + ) + + private val _viewState = MutableStateFlow(ViewState(provider, card, footer)) + val viewState: StateFlow = _viewState + + fun rotateCurrencyInFooterAmounts(currencySelected: CurrentCurrency) { + val data = _viewState.value + + _viewState.tryEmit(data.copy( + footer = data.footer.copy( + currentCurrency = currencySelected + ) + )) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModelFactory.kt new file mode 100644 index 00000000..15ea6031 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_card_detail/CardDetailViewModelFactory.kt @@ -0,0 +1,24 @@ +package io.muun.apollo.presentation.ui.security_cards_card_detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider + +class CardDetailViewModelFactory( + private val provider: SecurityCardProvider, + private val card: SecurityCard, + private val footer: MarketplaceFooter, + private val assistedFactory: CardDetailViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + provider = provider, + card = card, + footer = footer, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerActivity.kt new file mode 100644 index 00000000..54d69d11 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerActivity.kt @@ -0,0 +1,140 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import io.muun.apollo.R +import io.muun.apollo.databinding.ActivityCountryPickerBinding +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import io.muun.apollo.presentation.ui.view.MuunHeader +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CountryPickerActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_SELECTED_COUNTRY_CODE = "selected_country_code" + private const val RESULT_COUNTRY = "result_country" + + fun getIntent(context: Context, selectedCountryCode: String?): Intent = + Intent(context, CountryPickerActivity::class.java).apply { + putExtra(EXTRA_SELECTED_COUNTRY_CODE, selectedCountryCode) + } + + fun getResult(data: Intent): CountryInfo = + requireNotNull(data.getParcelableExtra(RESULT_COUNTRY)) + } + + private val binding: ActivityCountryPickerBinding by lazy { + ActivityCountryPickerBinding.inflate(layoutInflater) + } + + @Inject + lateinit var viewModelFactory: CountryPickerViewModel.Factory + + private val viewModel: CountryPickerViewModel by viewModels { + CountryPickerViewModelFactory( + selectedCountryCode = intent.getStringExtra(EXTRA_SELECTED_COUNTRY_CODE), + assistedFactory = viewModelFactory, + ) + } + + private val recyclerViewAdapter: CountryPickerRecyclerViewAdapter by lazy { + CountryPickerRecyclerViewAdapter(onCountryClick = ::onCountrySelected) + } + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + getComponent().inject(this) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collectLatest(::handleViewState) + } + } + + setupHeader() + setupRecyclerView() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_country_picker, menu) + + val searchView = menu.findItem(R.id.search).actionView as SearchView + searchView.queryHint = getString(R.string.security_cards_country_picker_search_hint) + searchView.maxWidth = Int.MAX_VALUE + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String) = false + override fun onQueryTextChange(newText: String): Boolean { + viewModel.setQuery(newText) + return true + } + }) + + searchView.setOnCloseListener { + viewModel.setQuery("") + false + } + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun setupHeader() { + binding.header.attachToActivity(this) + binding.header.showTitle(R.string.security_cards_country_picker_title) + binding.header.setNavigation(MuunHeader.Navigation.BACK) + } + + private fun setupRecyclerView() { + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = recyclerViewAdapter + } + + private fun handleViewState(viewState: CountryPickerViewModel.ViewState) { + when (viewState) { + is CountryPickerViewModel.ViewState.Data -> { + binding.recyclerView.visibility = View.VISIBLE + binding.textViewEmptyState.visibility = View.GONE + recyclerViewAdapter.submitList(viewState.countries) + } + is CountryPickerViewModel.ViewState.NoData -> { + binding.recyclerView.visibility = View.GONE + binding.textViewEmptyState.visibility = View.VISIBLE + binding.textViewEmptyState.text = + getString(R.string.security_cards_country_picker_no_results, viewState.query) + } + } + } + + private fun onCountrySelected(selection: CountryInfo) { + val resultIntent = Intent().apply { + putExtra(RESULT_COUNTRY, selection) + } + setResult(RESULT_OK, resultIntent) + finish() + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerRecyclerViewAdapter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerRecyclerViewAdapter.kt new file mode 100644 index 00000000..d9a349d9 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerRecyclerViewAdapter.kt @@ -0,0 +1,67 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.muun.apollo.R +import io.muun.apollo.databinding.ItemCountryBinding +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.SelectableCountryInfo + +class CountryPickerRecyclerViewAdapter( + private val onCountryClick: (CountryInfo) -> Unit, +) : ListAdapter(DiffCallback()) { + + override fun submitList(list: List?) { + super.submitList(null) + super.submitList(list) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemCountryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ViewHolder( + private val binding: ItemCountryBinding + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = adapterPosition + if (position != RecyclerView.NO_ID.toInt()) { + onCountryClick(getItem(position).countryInfo) + } + } + } + + fun bind(item: SelectableCountryInfo) { + binding.textViewFlag.text = item.countryInfo.flagEmoji + binding.textViewName.text = item.countryInfo.name + binding.textViewName.setTextColor( + ContextCompat.getColor( + binding.root.context, + if (item.isSelected) R.color.blue_buttons else R.color.text_primary_color, + ) + ) + binding.imageViewCheckmark.visibility = + if (item.isSelected) View.VISIBLE else View.INVISIBLE + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SelectableCountryInfo, newItem: SelectableCountryInfo) = + oldItem.countryInfo.code == newItem.countryInfo.code + + override fun areContentsTheSame(oldItem: SelectableCountryInfo, newItem: SelectableCountryInfo) = + oldItem == newItem + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModel.kt new file mode 100644 index 00000000..663870b4 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModel.kt @@ -0,0 +1,91 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.SelectableCountryInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +class CountryPickerViewModel @AssistedInject constructor( + @Assisted val selectedCountryCode: String?, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(selectedCountryCode: String?): CountryPickerViewModel + } + + sealed interface ViewState { + + data class Data( + val countries: List, + ) : ViewState + + data class NoData( + val query: String, + ) : ViewState + } + + private val countries: List = mockCountriesData(selectedCountryCode) + + private val _query = MutableStateFlow("") + + val viewState: StateFlow = combine( + _query, + ) { (query) -> + val filteredCountries = countries + .filter { selectableCountryInfo -> + query.isEmpty() || selectableCountryInfo.countryInfo.name.contains( + query, + ignoreCase = true + ) + } + + if (filteredCountries.isEmpty()) { + ViewState.NoData(query) + } else { + ViewState.Data(countries = filteredCountries) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = ViewState.Data( + countries = countries, + ), + ) + + fun setQuery(query: String) { + _query.value = query + } +} + +// Mocking stuff, not real implementation. +private fun mockCountriesData(selectedCountryCode: String?): List = listOf( + CountryInfo("AR", "Argentina", "\uD83C\uDDE6\uD83C\uDDF7"), + CountryInfo("AU", "Australia", "\uD83C\uDDE6\uD83C\uDDFA"), + CountryInfo("AT", "Austria", "\uD83C\uDDE6\uD83C\uDDF9"), + CountryInfo("BE", "Belgium", "\uD83C\uDDE7\uD83C\uDDEA"), + CountryInfo("BR", "Brazil", "\uD83C\uDDE7\uD83C\uDDF7"), + CountryInfo("CA", "Canada", "\uD83C\uDDE8\uD83C\uDDE6"), + CountryInfo("CO", "Colombia", "\uD83C\uDDE8\uD83C\uDDF4"), + CountryInfo("CZ", "Czech Republic", "\uD83C\uDDE8\uD83C\uDDFF"), + CountryInfo("FR", "France", "\uD83C\uDDEB\uD83C\uDDF7"), + CountryInfo("DE", "Germany", "\uD83C\uDDE9\uD83C\uDDEA"), + CountryInfo("HU", "Hungary", "\uD83C\uDDED\uD83C\uDDFA"), + CountryInfo("IT", "Italy", "\uD83C\uDDEE\uD83C\uDDF9"), + CountryInfo("MX", "Mexico", "\uD83C\uDDF2\uD83C\uDDFD"), + CountryInfo("NL", "Netherlands", "\uD83C\uDDF3\uD83C\uDDF1"), + CountryInfo("ES", "Spain", "\uD83C\uDDEA\uD83C\uDDF8"), + CountryInfo("CH", "Switzerland", "\uD83C\uDDE8\uD83C\uDDED"), + CountryInfo("GB", "United Kingdom", "\uD83C\uDDEC\uD83C\uDDE7"), + CountryInfo("US", "United States", "\uD83C\uDDFA\uD83C\uDDF8"), +) + .map { SelectableCountryInfo(countryInfo = it, isSelected = it.code == selectedCountryCode) } + .sortedBy { it.countryInfo.name } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModelFactory.kt new file mode 100644 index 00000000..19993a43 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/CountryPickerViewModelFactory.kt @@ -0,0 +1,17 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class CountryPickerViewModelFactory( + private val selectedCountryCode: String?, + private val assistedFactory: CountryPickerViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + selectedCountryCode = selectedCountryCode, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/CountryInfo.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/CountryInfo.kt new file mode 100644 index 00000000..c8b23fa8 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/CountryInfo.kt @@ -0,0 +1,11 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CountryInfo( + val code: String, + val name: String, + val flagEmoji: String, +) : Parcelable diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/SelectableCountryInfo.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/SelectableCountryInfo.kt new file mode 100644 index 00000000..f9bbd658 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_country_picker/models/SelectableCountryInfo.kt @@ -0,0 +1,6 @@ +package io.muun.apollo.presentation.ui.security_cards_country_picker.models + +data class SelectableCountryInfo( + val countryInfo: CountryInfo, + val isSelected: Boolean = false, +) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModel.kt new file mode 100644 index 00000000..4d63d7e4 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModel.kt @@ -0,0 +1,27 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.utils.rotate +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class CurrencySelectionSharedViewModel @AssistedInject constructor( + @Assisted val initialCurrentCurrency: CurrentCurrency, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(initialCurrentCurrency: CurrentCurrency): CurrencySelectionSharedViewModel + } + + private val _selectedCurrency = MutableStateFlow(initialCurrentCurrency) + val selectedCurrency: StateFlow = _selectedCurrency + + fun rotateCurrencySelection() { + _selectedCurrency.tryEmit(_selectedCurrency.value.rotate()) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModelFactory.kt new file mode 100644 index 00000000..9008990b --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/CurrencySelectionSharedViewModelFactory.kt @@ -0,0 +1,18 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency + +class CurrencySelectionSharedViewModelFactory( + private val initialCurrentCurrency: CurrentCurrency, + private val assistedFactory: CurrencySelectionSharedViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + initialCurrentCurrency = initialCurrentCurrency, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/MarketplaceViewPagerAdapter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/MarketplaceViewPagerAdapter.kt new file mode 100644 index 00000000..a2db39f3 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/MarketplaceViewPagerAdapter.kt @@ -0,0 +1,58 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider + +class MarketplaceViewPagerAdapter( + activity: FragmentActivity, +) : FragmentStateAdapter(activity) { + + private var data: List = emptyList() + + override fun createFragment(position: Int) = SecurityCardProviderFragment.newInstance(data[position]) + + override fun getItemCount() = data.size + + override fun getItemId(position: Int): Long { + return data[position].hashCode().toLong() + } + + override fun containsItem(itemId: Long): Boolean { + return data.singleOrNull { dataItem -> dataItem.hashCode().toLong() == itemId } != null + } + + fun setData(newData: List) { + val diffCallback = MarketplaceViewPagerDiffUtil(oldList = data, newList = newData) + val diff = DiffUtil.calculateDiff(diffCallback) + + data = newData + + diff.dispatchUpdatesTo(this) + } + + fun getPageName(index: Int): String { + return data[index].name + } + + private class MarketplaceViewPagerDiffUtil( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ) = true + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ) = oldList[oldItemPosition] == newList[newItemPosition] + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderFragment.kt new file mode 100644 index 00000000..75b04f67 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderFragment.kt @@ -0,0 +1,312 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import android.content.Context +import android.graphics.Rect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.PagerSnapHelper +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SnapHelper +import io.muun.apollo.R +import io.muun.apollo.databinding.FragmentSecurityCardProviderBinding +import io.muun.apollo.domain.model.BitcoinUnit +import io.muun.apollo.domain.selector.ExchangeRateSelector +import io.muun.apollo.domain.selector.UserSelector +import io.muun.apollo.presentation.ui.new_operation.toRichText +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.common.model.ExchangeRateProvider +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.money.CurrencyUnit +import javax.money.MonetaryAmount +import kotlin.math.abs + +class SecurityCardProviderFragment : Fragment() { + + companion object { + private const val KEY_PROVIDER = "KEY_PROVIDER" + + fun newInstance(provider: SecurityCardProvider) = SecurityCardProviderFragment() + .apply { + arguments = Bundle().apply { + putParcelable(KEY_PROVIDER, provider) + } + } + } + + @Inject + lateinit var viewModelFactory: SecurityCardProviderViewModel.Factory + + @Inject + lateinit var userSel: UserSelector + + @Inject + lateinit var exchangeRateSelector: ExchangeRateSelector + + @Inject + lateinit var currencySelectionSharedViewModelFactory: CurrencySelectionSharedViewModel.Factory + + private val currencySelectionSharedViewModel: CurrencySelectionSharedViewModel by activityViewModels { + CurrencySelectionSharedViewModelFactory( + initialCurrentCurrency = CurrentCurrency.PRIMARY, + assistedFactory = currencySelectionSharedViewModelFactory, + ) + } + + private val viewModel: SecurityCardProviderViewModel by viewModels { + SecurityCardProviderViewModelFactory( + provider = requireNotNull(requireArguments().getParcelable(KEY_PROVIDER)), + currencySelection = currencySelectionSharedViewModel.selectedCurrency.value, + assistedFactory = viewModelFactory, + ) + } + + private var _binding: FragmentSecurityCardProviderBinding? = null + private val binding: FragmentSecurityCardProviderBinding + get() = _binding as FragmentSecurityCardProviderBinding + + private val recyclerViewAdapter: SecurityCardProviderRecyclerViewAdapter by lazy { + SecurityCardProviderRecyclerViewAdapter { securityCard -> + val viewState = + viewModel.viewState.value as? SecurityCardProviderViewModel.ViewState.Data ?: error( + "This shouldn't happen" + ) + + listener?.onSecurityCardClicked( + provider = viewState.provider, + securityCard = securityCard, + footer = viewState.footer + ) + } + } + + private val snapHelper: SnapHelper by lazy { + PagerSnapHelper().apply { + attachToRecyclerView(binding.recyclerView) + } + } + + private var listener: Listener? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + getComponent().inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = FragmentSecurityCardProviderBinding.inflate(layoutInflater, container, false).also { + _binding = it + }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.setupRecyclerView() + binding.setupFooter() + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.viewState.collectLatest(::handleViewState) + } + launch { + currencySelectionSharedViewModel.selectedCurrency.collectLatest { + viewModel.rotateCurrencyInFooterAmounts(it) + } + } + } + } + } + + private fun FragmentSecurityCardProviderBinding.setupRecyclerView() { + recyclerView.addItemDecoration(VerticalAspectPaddingDecoration()) + recyclerView.adapter = recyclerViewAdapter + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + private var lastSelectedPosition = RecyclerView.NO_POSITION + + override fun onScrolled( + recyclerView: RecyclerView, + dx: Int, + dy: Int, + ) { + val centerY = recyclerView.height / 2f + + for (i in 0 until recyclerView.childCount) { + val child = recyclerView.getChildAt(i) + + val childCenterY = (child.top + child.bottom) / 2f + val distance = abs(centerY - childCenterY) + + val maxDistance = recyclerView.height / 2f + val fraction = (distance / maxDistance).coerceIn(0f, 1f) + + // Interpolate + val scale = 1f - fraction * (1f - 0.75f) + val alpha = 1f - fraction * (1f - 0.60f) + + child.scaleX = scale + child.scaleY = scale + child.alpha = alpha + } + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + + val layoutManager = recyclerView.layoutManager ?: return + val snapView = snapHelper.findSnapView(layoutManager) ?: return + + val adapterPosition = layoutManager.getPosition(snapView) + + if (adapterPosition in recyclerViewAdapter.currentList.indices && + adapterPosition != lastSelectedPosition + ) { + lastSelectedPosition = adapterPosition + viewModel.preselectProviderSecurityCard(cardIndex = adapterPosition) + } + } + } + }) + } + + private fun FragmentSecurityCardProviderBinding.setupFooter() { + viewFooter.setOnClickListener { + currencySelectionSharedViewModel.rotateCurrencySelection() + } + } + + private fun handleViewState(viewState: SecurityCardProviderViewModel.ViewState) { + if (viewState is SecurityCardProviderViewModel.ViewState.Data) { + binding.handleData(viewState) + } + } + + private fun FragmentSecurityCardProviderBinding.handleData(data: SecurityCardProviderViewModel.ViewState.Data) { + recyclerViewAdapter.submitList(data.provider.securityCards) + + val rateProvider = exchangeRateSelector.watchLatest() + .toBlocking() + .first() + + textViewFooterCardCost.text = getString( + R.string.security_cards_marketplace_card_cost, + requireActivity().toRichText( + amt = when (data.footer.currentCurrency) { + CurrentCurrency.PRIMARY -> data.footer.cardCost.inCurrencyUnit( + rateProvider, + userSel.get().getPrimaryCurrency(rateProvider) + ) + CurrentCurrency.BTC -> data.footer.cardCost.inBtc(rateProvider) + }, + btcUnit = BitcoinUnit.BTC, + isValid = true, + ) + ) + textViewFooterShippingCost.text = getString( + R.string.security_cards_marketplace_shipping_and_taxes_cost, + requireActivity().toRichText( + amt = when (data.footer.currentCurrency) { + CurrentCurrency.PRIMARY -> data.footer.shippingAndTaxesCost.inCurrencyUnit( + rateProvider, + userSel.get().getPrimaryCurrency(rateProvider) + ) + CurrentCurrency.BTC -> data.footer.shippingAndTaxesCost.inBtc( + rateProvider + ) + }, + btcUnit = BitcoinUnit.BTC, + isValid = true, + ) + ) + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + listener = null + } + + interface Listener { + fun onSecurityCardClicked( + provider: SecurityCardProvider, + securityCard: SecurityCard, + footer: MarketplaceFooter, + ) + } +} + +/** + * This RecyclerView.ItemDecoration is necessary to create the spacers needed for centering the + * first and last card in the security cards carousel. As the cards size depends on device's screen + * size (width), the vertical padding can't be hardcoded in the xml and need to be computed + * programmatically, also we're using a decoration because changing the RecyclerView's padding mess + * with the SnapHelper's behaviour. + */ +private class VerticalAspectPaddingDecoration private constructor( + private val securityCardAspectRatio: Float, + private val securityCardItemMargin: Float, +) : RecyclerView.ItemDecoration() { + + constructor() : this( + // Corresponds to item_security_cards_marketplace_card.xml card aspect ratio + securityCardAspectRatio = 0.632f, + // Corresponds to item_security_cards_marketplace_card.xml card vertical margin + securityCardItemMargin = 32f, + ) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + if (parent.width == 0) return + + val itemHeight = (parent.width * securityCardAspectRatio) + securityCardItemMargin + val verticalPadding = ((parent.height - itemHeight) / 2f).toInt().coerceAtLeast(0) + + val position = parent.getChildAdapterPosition(view) + + if (position == 0) { + outRect.top = verticalPadding + } + + if (position == state.itemCount - 1) { + outRect.bottom = verticalPadding + } + } +} + +// TODO: This should be moved into ViewModel (or even libwallet), the exchange rate window should be +// fixed for the whole flow so prices don't change while the user is navigating the marketplace +fun MonetaryAmount.inBtc( + rateProvider: ExchangeRateProvider, +): MonetaryAmount = rateProvider.convert(this, BitcoinUnit.BTC.name) + +fun MonetaryAmount.inCurrencyUnit( + rateProvider: ExchangeRateProvider, + currencyUnit: CurrencyUnit, +): MonetaryAmount = rateProvider.convert(this, currencyUnit) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderRecyclerViewAdapter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderRecyclerViewAdapter.kt new file mode 100644 index 00000000..4a3bb0a1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderRecyclerViewAdapter.kt @@ -0,0 +1,46 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.muun.apollo.R +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard + +class SecurityCardProviderRecyclerViewAdapter( + private val onSecurityCardClick: (SecurityCard) -> Unit +) : ListAdapter( + SecurityCardProviderDiffUtil() +) { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val view: View = view.findViewById(R.id.securityCard) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + LayoutInflater.from(parent.context) + .inflate(R.layout.item_security_cards_marketplace_card, parent, false) + .let(::ViewHolder) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.view.setBackgroundResource(currentList[position].imageRes) + holder.itemView.setOnClickListener { onSecurityCardClick(currentList[position]) } + } + + override fun getItemCount() = currentList.size + + private class SecurityCardProviderDiffUtil : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: SecurityCard, + newItem: SecurityCard, + ) = true + + override fun areContentsTheSame( + oldItem: SecurityCard, + newItem: SecurityCard, + ) = oldItem == newItem + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModel.kt new file mode 100644 index 00000000..3a7e0ed9 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModel.kt @@ -0,0 +1,73 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.cardCost +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.shippingAndTaxesCost +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SecurityCardProviderViewModel @AssistedInject constructor( + @Assisted private val provider: SecurityCardProvider, + @Assisted private val currencySelection: CurrentCurrency, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create( + provider: SecurityCardProvider, + currencySelection: CurrentCurrency, + ): SecurityCardProviderViewModel + } + + sealed interface ViewState { + + data class Data( + val provider: SecurityCardProvider, + val footer: MarketplaceFooter + ) : ViewState + } + + private val _viewState = MutableStateFlow(ViewState.Data( + provider = provider, + footer = MarketplaceFooter( + cardCost = provider.cardCost(provider.securityCards.first()), + shippingAndTaxesCost = provider.shippingAndTaxesCost(provider.securityCards.first()), + currentCurrency = currencySelection, + ) + )) + + val viewState: StateFlow = _viewState + + fun preselectProviderSecurityCard(cardIndex: Int) { + val data = _viewState.value + + _viewState.tryEmit( + data.copy( + footer = data.footer.copy( + cardCost = data.provider.cardCost( + data.provider.securityCards[cardIndex] + ), + shippingAndTaxesCost = data.provider.shippingAndTaxesCost( + data.provider.securityCards[cardIndex] + ), + ) + ) + ) + } + + fun rotateCurrencyInFooterAmounts(currencySelected: CurrentCurrency) { + val data = _viewState.value + + _viewState.tryEmit(data.copy( + footer = data.footer.copy( + currentCurrency = currencySelected + ) + )) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModelFactory.kt new file mode 100644 index 00000000..5585f1b8 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardProviderViewModelFactory.kt @@ -0,0 +1,21 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter.CurrentCurrency +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider + +class SecurityCardProviderViewModelFactory( + private val provider: SecurityCardProvider, + private val currencySelection: CurrentCurrency, + private val assistedFactory: SecurityCardProviderViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + provider = provider, + currencySelection = currencySelection, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt new file mode 100644 index 00000000..3aedf33b --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceActivity.kt @@ -0,0 +1,212 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.tabs.TabLayoutMediator +import io.muun.apollo.R +import io.muun.apollo.databinding.ActivitySecurityCardsMarketplaceBinding +import io.muun.apollo.presentation.app.Navigator +import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import io.muun.apollo.presentation.ui.utils.StyledStringRes +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SecurityCardsMarketplaceActivity : AppCompatActivity(), SecurityCardProviderFragment.Listener { + + companion object { + + private const val EXTRA_INITIAL_COUNTRY_INFO = "initial_country_info" + + fun getIntent(context: Context, initialCountryInfo: CountryInfo) = + Intent(context, SecurityCardsMarketplaceActivity::class.java) + .putExtra(EXTRA_INITIAL_COUNTRY_INFO, initialCountryInfo) + } + + private val binding: ActivitySecurityCardsMarketplaceBinding by lazy { + ActivitySecurityCardsMarketplaceBinding.inflate(layoutInflater) + } + + @Inject + lateinit var viewModelFactory: SecurityCardsMarketplaceViewModel.Factory + + private val viewModel: SecurityCardsMarketplaceViewModel by viewModels { + SecurityCardsMarketplaceViewModelFactory( + initialCountryInfo = requireNotNull(intent.getParcelableExtra(EXTRA_INITIAL_COUNTRY_INFO)), + assistedFactory = viewModelFactory, + ) + } + + private val viewPagerAdapter: MarketplaceViewPagerAdapter by lazy { + MarketplaceViewPagerAdapter(this) + } + + @Inject + lateinit var navigator: Navigator + + private lateinit var countryPickerMenuItem: MenuItem + + private val countryPickerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val pickedCountry = CountryPickerActivity.getResult(requireNotNull(result.data)) + viewModel.changeCountry(newCountry = pickedCountry) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + getComponent().inject(this) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.viewState.collectLatest(::handleViewState) + } + + launch { + viewModel.viewEvent.collectLatest(::handleViewEvent) + } + } + } + + setupHeader() + setupViewPager() + setupTabLayout() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_security_cards_marketplace, menu) + + countryPickerMenuItem = menu.findItem(R.id.country_picker) + + return true + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val countryPickerMenuItem = menu.findItem(R.id.country_picker) + val country = when (val viewState = viewModel.viewState.value) { + is SecurityCardsMarketplaceViewModel.ViewState.Data -> viewState.country + is SecurityCardsMarketplaceViewModel.ViewState.NoData -> viewState.country + } + countryPickerMenuItem.title = country.flagEmoji + countryPickerMenuItem.setOnMenuItemClickListener { + navigator.navigateToCountryPickerForResult( + this, + country.code, + countryPickerLauncher, + ) + + return@setOnMenuItemClickListener true + } + + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun setupHeader() { + binding.header.attachToActivity(this) + binding.header.showTitle("Marketplace") + } + + private fun setupViewPager() { + binding.viewPager.adapter = viewPagerAdapter + } + + private fun setupTabLayout() { + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> + tab.text = viewPagerAdapter.getPageName(position) + }.attach() + } + + private fun handleViewState(viewState: SecurityCardsMarketplaceViewModel.ViewState) { + when (viewState) { + is SecurityCardsMarketplaceViewModel.ViewState.Data -> binding.handleData(viewState) + is SecurityCardsMarketplaceViewModel.ViewState.NoData -> binding.handleNoData(viewState) + } + } + + private fun ActivitySecurityCardsMarketplaceBinding.handleData(data: SecurityCardsMarketplaceViewModel.ViewState.Data) { + viewGroupNoData.visibility = View.INVISIBLE + invalidateOptionsMenu() + + viewPagerAdapter.setData(data.providers) + } + + private fun ActivitySecurityCardsMarketplaceBinding.handleNoData(data: SecurityCardsMarketplaceViewModel.ViewState.NoData) { + viewGroupNoData.visibility = View.VISIBLE + invalidateOptionsMenu() + + val styledRes = StyledStringRes( + context = this@SecurityCardsMarketplaceActivity, + resId = R.string.security_cards_marketplace_no_data_description, + onLinkClick = this@SecurityCardsMarketplaceActivity::onNoDataContactUsClick + ) + + textViewNoDataDescription.movementMethod = LinkMovementMethod.getInstance() + textViewNoDataDescription.text = styledRes.toCharSequence(data.country.name) + + buttonNoDataSelectCountry.setOnClickListener { + navigator.navigateToCountryPickerForResult( + this@SecurityCardsMarketplaceActivity, + data.country.code, + countryPickerLauncher + ) + } + } + + private fun handleViewEvent(viewEvent: SecurityCardsMarketplaceViewModel.ViewEvent) { + when (viewEvent) { + is SecurityCardsMarketplaceViewModel.ViewEvent.NavigateToCardDetail -> navigator.navigateToCardDetail( + this, + viewEvent.provider, + viewEvent.securityCard, + viewEvent.footer, + ) + } + } + + private fun onNoDataContactUsClick(id: String) { + navigator.navigateToSendGenericFeedback(this) + } + + // region SecurityCardProviderFragment.Listener + override fun onSecurityCardClicked( + provider: SecurityCardProvider, + securityCard: SecurityCard, + footer: MarketplaceFooter, + ) { + viewModel.continueWithSecurityCard( + provider = provider, + securityCard = securityCard, + footer = footer, + ) + } + // endregion +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt new file mode 100644 index 00000000..d1b21751 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModel.kt @@ -0,0 +1,154 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.muun.apollo.R +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.CardSpec +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.MarketplaceFooter +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCard +import io.muun.apollo.presentation.ui.security_cards_marketplace.models.SecurityCardProvider +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +class SecurityCardsMarketplaceViewModel @AssistedInject constructor( + @Assisted val initialCountryInfo: CountryInfo, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(initialCountryInfo: CountryInfo): SecurityCardsMarketplaceViewModel + } + + sealed interface ViewState { + + data class Data( + val country: CountryInfo, + val providers: List, + ) : ViewState + + data class NoData( + val country: CountryInfo, + ) : ViewState + } + + sealed interface ViewEvent { + + data class NavigateToCardDetail( + val provider: SecurityCardProvider, + val securityCard: SecurityCard, + val footer: MarketplaceFooter, + ) : ViewEvent + } + + private val _viewState = + MutableStateFlow( + mockCardsData(initialCountryInfo).let { securityCards -> + if (securityCards.isEmpty()) { + ViewState.NoData(country = initialCountryInfo) + } else { + ViewState.Data( + country = initialCountryInfo, + providers = securityCards, + ) + } + } + ) + val viewState: StateFlow = _viewState + + private val _viewEvent = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val viewEvent: SharedFlow = _viewEvent + + + fun changeCountry(newCountry: CountryInfo) { + val providers = mockCardsData(newCountry) + + if (providers.isNotEmpty()) { + _viewState.tryEmit(ViewState.Data( + country = newCountry, + providers = providers, + )) + } else { + _viewState.tryEmit(ViewState.NoData( + country = newCountry, + )) + } + } + + fun continueWithSecurityCard( + provider: SecurityCardProvider, + securityCard: SecurityCard, + footer: MarketplaceFooter, + ) { + _viewEvent.tryEmit(ViewEvent.NavigateToCardDetail( + provider = provider, + securityCard = securityCard, + footer = footer, + )) + } +} + +private fun mockCardsData( + country: CountryInfo, +) = mapOf( + "BE" to listOf( + SecurityCardProvider( + name = "Constellations", + description = "Constellations", + securityCards = listOf( + SecurityCard(imageRes = R.drawable.sc_constellations_scorpius, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_constellations_gemini, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_constellations_sagitarius, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_constellations_virgo, primarySpecs = mockCardSpecs()), + ), + currencyCode = "EUR", + ), + SecurityCardProvider( + name = "Numbers", + description = "Numbers", + securityCards = listOf( + SecurityCard(imageRes = R.drawable.sc_numbers_1, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_2, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_3, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_4, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_5, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_6, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_7, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_8, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_numbers_9, primarySpecs = mockCardSpecs()), + ), + currencyCode = "USD", + ), + SecurityCardProvider( + name = "Planets", + description = "Planets", + securityCards = listOf( + SecurityCard(imageRes = R.drawable.sc_planets_earth, primarySpecs = mockCardSpecs()), + SecurityCard(imageRes = R.drawable.sc_planets_mars, primarySpecs = mockCardSpecs()), + ), + currencyCode = "USD", + ), + ) +)[country.code] ?: emptyList() + +private fun mockCardSpecs() = listOf( + CardSpec( + iconRes = R.drawable.ic_clock, + label = "Delivers in", + value = "15-30 days", + ), + CardSpec( + iconRes = R.drawable.ic_circle_off_outline_24, + label = "Ships from", + value = "Belgium", + ), + CardSpec( + iconRes = R.drawable.ic_circle_off_outline_24, + label = "Material", + value = "Plastic", + ), +) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModelFactory.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModelFactory.kt new file mode 100644 index 00000000..0d9cb751 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/SecurityCardsMarketplaceViewModelFactory.kt @@ -0,0 +1,18 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo + +class SecurityCardsMarketplaceViewModelFactory( + private val initialCountryInfo: CountryInfo, + private val assistedFactory: SecurityCardsMarketplaceViewModel.Factory, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create( + initialCountryInfo = initialCountryInfo, + ) as T + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt new file mode 100644 index 00000000..b23b7169 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/CardSpec.kt @@ -0,0 +1,12 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace.models + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CardSpec( + @DrawableRes val iconRes: Int, + val label: String, + val value: String, +) : Parcelable diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/MarketplaceFooter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/MarketplaceFooter.kt new file mode 100644 index 00000000..dc11f120 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/MarketplaceFooter.kt @@ -0,0 +1,37 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace.models + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import org.javamoney.moneta.Money +import java.math.BigDecimal +import javax.money.MonetaryAmount + +@Parcelize +@TypeParceler() +data class MarketplaceFooter( + val cardCost: MonetaryAmount, + val shippingAndTaxesCost: MonetaryAmount, + val currentCurrency: CurrentCurrency +) : Parcelable { + + enum class CurrentCurrency { + PRIMARY, BTC; + } +} + +object MonetaryAmountParceler : Parceler { + + override fun create(parcel: Parcel): MonetaryAmount? { + val currency = parcel.readString() ?: return null + val amount = parcel.readSerializable() as BigDecimal + return Money.of(amount, currency) + } + + override fun MonetaryAmount?.write(parcel: Parcel, flags: Int) { + parcel.writeString(this?.currency?.currencyCode) + parcel.writeSerializable(this?.number?.numberValue(BigDecimal::class.java)) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt new file mode 100644 index 00000000..180b62ab --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCard.kt @@ -0,0 +1,12 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace.models + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SecurityCard( + @DrawableRes + val imageRes: Int, + val primarySpecs: List, +) : Parcelable diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCardProvider.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCardProvider.kt new file mode 100644 index 00000000..9f0d35db --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_marketplace/models/SecurityCardProvider.kt @@ -0,0 +1,26 @@ +package io.muun.apollo.presentation.ui.security_cards_marketplace.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.javamoney.moneta.Money +import java.math.BigDecimal +import javax.money.MonetaryAmount + +@Parcelize +data class SecurityCardProvider( + val name: String, + val description: String, + val securityCards: List, + val currencyCode: String, +) : Parcelable + +// Mocking stuff, not real implementation. +fun SecurityCardProvider.cardCost(card: SecurityCard): MonetaryAmount { + val cardPosition = securityCards.indexOf(card) + 1 + return Money.of(BigDecimal.valueOf(cardPosition * 10_000L), currencyCode) +} + +fun SecurityCardProvider.shippingAndTaxesCost(card: SecurityCard): MonetaryAmount { + val cardPosition = securityCards.indexOf(card) + 1 + return Money.of(BigDecimal.valueOf(cardPosition * 1_500L), currencyCode) +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/CountrySelectionSharedViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/CountrySelectionSharedViewModel.kt new file mode 100644 index 00000000..e8b1a9e0 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/CountrySelectionSharedViewModel.kt @@ -0,0 +1,16 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import androidx.lifecycle.ViewModel +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class CountrySelectionSharedViewModel : ViewModel() { + + private val _selectedCountryInfo = MutableStateFlow(null) + val selectedCountryInfo: StateFlow = _selectedCountryInfo + + fun onCountrySelected(countryInfo: CountryInfo) { + _selectedCountryInfo.value = countryInfo + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingCountrySelectorSlideFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingCountrySelectorSlideFragment.kt new file mode 100644 index 00000000..ee961cc1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingCountrySelectorSlideFragment.kt @@ -0,0 +1,84 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import io.muun.apollo.databinding.FragmentOnboardingCountrySelectorSlideBinding +import io.muun.apollo.presentation.ui.security_cards_country_picker.models.CountryInfo +import io.muun.apollo.presentation.ui.security_cards_onboarding.models.OnboardingSlide +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class OnboardingCountrySelectorSlideFragment : Fragment() { + + interface Listener { + fun onCountryPickerClick() + } + + companion object { + private const val ARG_TITLE = "arg_title" + private const val ARG_DESCRIPTION = "arg_description" + + fun newInstance(slide: OnboardingSlide.CountrySelector) = + OnboardingCountrySelectorSlideFragment().apply { + arguments = Bundle().apply { + putInt(ARG_TITLE, slide.titleRes) + putInt(ARG_DESCRIPTION, slide.descriptionRes) + } + } + } + + private var _binding: FragmentOnboardingCountrySelectorSlideBinding? = null + private val binding get() = _binding!! + + private val countrySelectionViewModel: CountrySelectionSharedViewModel by activityViewModels() + + private var listener: Listener? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentOnboardingCountrySelectorSlideBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.textViewTitle.setText(requireArguments().getInt(ARG_TITLE)) + binding.textViewDescription.setText(requireArguments().getInt(ARG_DESCRIPTION)) + binding.viewTextInputLayoutCountryOverlay.setOnClickListener { + listener?.onCountryPickerClick() + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + countrySelectionViewModel.selectedCountryInfo.collectLatest(::handleSelectedCountry) + } + } + } + + private fun handleSelectedCountry(countryInfo: CountryInfo?) { + binding.textInputEditTextCountry.setText(countryInfo?.let { "${countryInfo.flagEmoji} ${countryInfo.name}" }) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + listener = null + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingExplanatorySlideFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingExplanatorySlideFragment.kt new file mode 100644 index 00000000..33f6d9d1 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingExplanatorySlideFragment.kt @@ -0,0 +1,49 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import io.muun.apollo.databinding.FragmentOnboardingExplanatorySlideBinding +import io.muun.apollo.presentation.ui.security_cards_onboarding.models.OnboardingSlide + +class OnboardingExplanatorySlideFragment : Fragment() { + + companion object { + private const val ARG_TITLE = "arg_title" + private const val ARG_DESCRIPTION = "arg_description" + + fun newInstance(slide: OnboardingSlide.Explanatory) = + OnboardingExplanatorySlideFragment().apply { + arguments = Bundle().apply { + putInt(ARG_TITLE, slide.titleRes) + putInt(ARG_DESCRIPTION, slide.descriptionRes) + } + } + } + + private var _binding: FragmentOnboardingExplanatorySlideBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentOnboardingExplanatorySlideBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.textViewTitle.setText(requireArguments().getInt(ARG_TITLE)) + binding.textViewDescription.setText(requireArguments().getInt(ARG_DESCRIPTION)) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingViewPagerAdapter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingViewPagerAdapter.kt new file mode 100644 index 00000000..ea626c34 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/OnboardingViewPagerAdapter.kt @@ -0,0 +1,54 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.muun.apollo.presentation.ui.security_cards_onboarding.models.OnboardingSlide + +class OnboardingViewPagerAdapter( + activity: FragmentActivity, +) : FragmentStateAdapter(activity) { + + private var data: List = emptyList() + + override fun createFragment(position: Int) = when (val slide = data[position]) { + is OnboardingSlide.Explanatory -> OnboardingExplanatorySlideFragment.newInstance(slide) + is OnboardingSlide.CountrySelector -> OnboardingCountrySelectorSlideFragment.newInstance(slide) + } + + override fun getItemCount() = data.size + + override fun getItemId(position: Int) = data[position].hashCode().toLong() + + override fun containsItem(itemId: Long) = + data.singleOrNull { dataItem -> dataItem.hashCode().toLong() == itemId } != null + + fun setData(newData: List) { + val diffCallback = OnboardingPagerDiffCallback(oldList = data, newList = newData) + val diff = DiffUtil.calculateDiff(diffCallback) + + data = newData + + diff.dispatchUpdatesTo(this) + } + + private class OnboardingPagerDiffCallback( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ) = oldList[oldItemPosition]::class == newList[newItemPosition]::class + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ) = oldList[oldItemPosition] == newList[newItemPosition] + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingActivity.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingActivity.kt new file mode 100644 index 00000000..a9b195ac --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingActivity.kt @@ -0,0 +1,125 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayoutMediator +import io.muun.apollo.R +import io.muun.apollo.databinding.ActivitySecurityCardsOnboardingBinding +import io.muun.apollo.presentation.app.Navigator +import io.muun.apollo.presentation.ui.security_cards_country_picker.CountryPickerActivity +import io.muun.apollo.presentation.ui.utils.getComponent +import io.muun.apollo.presentation.ui.utils.setWindowInsetsCompat +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SecurityCardsOnboardingActivity : + AppCompatActivity(), + OnboardingCountrySelectorSlideFragment.Listener { + + companion object { + fun getIntent(context: Context) = + Intent(context, SecurityCardsOnboardingActivity::class.java) + } + + private val binding: ActivitySecurityCardsOnboardingBinding by lazy { + ActivitySecurityCardsOnboardingBinding.inflate(layoutInflater) + } + + private val viewModel: SecurityCardsOnboardingViewModel by viewModels() + private val countrySelectionViewModel: CountrySelectionSharedViewModel by viewModels() + + private val viewPagerAdapter: OnboardingViewPagerAdapter by lazy { + OnboardingViewPagerAdapter(this) + } + + private val countryPickerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val pickedCountry = CountryPickerActivity.getResult(requireNotNull(result.data)) + countrySelectionViewModel.onCountrySelected(countryInfo = pickedCountry) + } + } + + @Inject + lateinit var navigator: Navigator + + override fun onCreate(savedInstanceState: Bundle?) { + setWindowInsetsCompat() + super.onCreate(savedInstanceState) + setContentView(binding.root) + getComponent().inject(this) + + setupHeader() + setupViewPager() + setupTabLayout() + setupContinueButton() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collectLatest(::handleViewState) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + countrySelectionViewModel.selectedCountryInfo.collectLatest { selectedCountry -> + val isCountrySelected = selectedCountry != null + binding.buttonContinue.isEnabled = isCountrySelected + } + } + } + } + + private fun setupHeader() { + binding.header.attachToActivity(this) + binding.header.showTitle(R.string.security_cards_onboarding_title) + } + + private fun setupViewPager() { + binding.viewPager.adapter = viewPagerAdapter + binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + val isFirstPage = position == 0 + val isLastPage = position == viewPagerAdapter.itemCount - 1 + binding.textViewFootnote.visibility = if (isFirstPage && !isLastPage) View.VISIBLE else View.INVISIBLE + binding.buttonContinue.visibility = if (isLastPage) View.VISIBLE else View.INVISIBLE + } + }) + } + + private fun setupTabLayout() { + TabLayoutMediator(binding.tabLayout, binding.viewPager) { _, _ -> }.attach() + } + + private fun setupContinueButton() { + binding.buttonContinue.isEnabled = false + binding.buttonContinue.setOnClickListener { + val selectedCountryInfo = requireNotNull(countrySelectionViewModel.selectedCountryInfo.value) + navigator.navigateToSecurityCardsMarketplace(this, selectedCountryInfo) + } + } + + private fun handleViewState(viewState: SecurityCardsOnboardingViewModel.ViewState) { + viewPagerAdapter.setData(viewState.slides) + } + + // region SecurityCardsOnboardingLocationFragment.Listener + override fun onCountryPickerClick() { + navigator.navigateToCountryPickerForResult( + this, + countrySelectionViewModel.selectedCountryInfo.value?.code, + countryPickerLauncher + ) + } + // endregion +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingViewModel.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingViewModel.kt new file mode 100644 index 00000000..af2d95fe --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/SecurityCardsOnboardingViewModel.kt @@ -0,0 +1,36 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding + +import androidx.lifecycle.ViewModel +import io.muun.apollo.R +import io.muun.apollo.presentation.ui.security_cards_onboarding.models.OnboardingSlide +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SecurityCardsOnboardingViewModel : ViewModel() { + + data class ViewState( + val slides: List, + ) + + private val _viewState = MutableStateFlow(ViewState(slides = buildSlides())) + val viewState: StateFlow = _viewState +} + +private fun buildSlides() = listOf( + OnboardingSlide.Explanatory( + titleRes = R.string.security_cards_onboarding_slide_1_title, + descriptionRes = R.string.security_cards_onboarding_slide_1_description, + ), + OnboardingSlide.Explanatory( + titleRes = R.string.security_cards_onboarding_slide_2_title, + descriptionRes = R.string.security_cards_onboarding_slide_2_description, + ), + OnboardingSlide.Explanatory( + titleRes = R.string.security_cards_onboarding_slide_3_title, + descriptionRes = R.string.security_cards_onboarding_slide_3_description, + ), + OnboardingSlide.CountrySelector( + titleRes = R.string.security_cards_onboarding_location_title, + descriptionRes = R.string.security_cards_onboarding_location_description, + ), +) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/models/OnboardingSlide.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/models/OnboardingSlide.kt new file mode 100644 index 00000000..c868de0f --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/security_cards_onboarding/models/OnboardingSlide.kt @@ -0,0 +1,16 @@ +package io.muun.apollo.presentation.ui.security_cards_onboarding.models + +import androidx.annotation.StringRes + +sealed interface OnboardingSlide { + + data class Explanatory( + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int, + ) : OnboardingSlide + + data class CountrySelector( + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int, + ) : OnboardingSlide +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsFragment.kt index af706f59..2469b7b9 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsFragment.kt @@ -3,9 +3,15 @@ package io.muun.apollo.presentation.ui.settings.flags import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewbinding.ViewBinding import io.muun.apollo.R import io.muun.apollo.databinding.FragmentDisableFeatureFlagsBinding +import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.presentation.ui.adapter.ItemAdapter +import io.muun.apollo.presentation.ui.adapter.holder.ViewHolderFactory +import io.muun.apollo.presentation.ui.adapter.viewmodel.FeatureFlagViewModel +import io.muun.apollo.presentation.ui.adapter.viewmodel.ItemViewModel import io.muun.apollo.presentation.ui.base.SingleFragment import io.muun.apollo.presentation.ui.view.MuunHeader @@ -15,6 +21,8 @@ class DisableFeatureFlagsFragment : SingleFragment private val binding: FragmentDisableFeatureFlagsBinding get() = getBinding() as FragmentDisableFeatureFlagsBinding + private lateinit var adapter: ItemAdapter + override fun inject() { component.inject(this) } @@ -36,16 +44,38 @@ class DisableFeatureFlagsFragment : SingleFragment override fun initializeUi(view: View?) { super.initializeUi(view) - // UI switch represents whether user has FF enabled or not. - // If switch is ON/checked -> FF is enabled - // If switch is OFF/!checked -> FF is disabled - // Hence, unchecking the switch means the user disables the FF. - binding.disableFeatureFlagsSecurityCardSwitch.setOnCheckedChangeListener { _, isChecked -> - presenter.disableSecurityCardFeatureFlag(!isChecked) + adapter = ItemAdapter(ViewHolderFactory()) + adapter.setOnItemClickListener { viewModel: ItemViewModel? -> + this.onItemClick(viewModel) + } + + binding.featureFlagsRecyclerView.apply { + layoutManager = LinearLayoutManager(context) + adapter = this@DisableFeatureFlagsFragment.adapter } } - override fun setSecurityCardFlagEnabled(isEnabled: Boolean) { - binding.disableFeatureFlagsSecurityCardSwitch.isChecked = isEnabled + override fun setState( + features: List, + featureOverrides: List + ) { + + val featureFlagViewModels = features + .map { feature -> + val state = if (featureOverrides.contains(feature)) { + FeatureFlagViewModel.State.DISABLED + } else { + FeatureFlagViewModel.State.ENABLED + } + FeatureFlagViewModel(feature, state) + } + + adapter.setItems(featureFlagViewModels) + } + + private fun onItemClick(viewModel: ItemViewModel?) { + if (viewModel is FeatureFlagViewModel) { + presenter.toggleFeatureFlag(viewModel.overridableFeature, viewModel.state) + } } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsPresenter.kt index cbbec941..6fe44667 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsPresenter.kt @@ -3,22 +3,35 @@ package io.muun.apollo.presentation.ui.settings.flags import android.os.Bundle import io.muun.apollo.domain.FeatureOverrideStore import io.muun.apollo.domain.model.MuunFeature +import io.muun.apollo.domain.selector.FeatureSelector +import io.muun.apollo.presentation.ui.adapter.viewmodel.FeatureFlagViewModel import io.muun.apollo.presentation.ui.base.ParentPresenter import io.muun.apollo.presentation.ui.base.SingleFragmentPresenter import javax.inject.Inject class DisableFeatureFlagsPresenter @Inject constructor( + private val featureSelector: FeatureSelector, private val featureOverrideStore: FeatureOverrideStore, ) : SingleFragmentPresenter() { override fun setUp(arguments: Bundle) { super.setUp(arguments) - val isSecurityCardDisabled = featureOverrideStore.isOverridden(MuunFeature.NFC_CARD_V2) - view.setSecurityCardFlagEnabled(!isSecurityCardDisabled) + view.setState( + featureSelector.fetchOverridableFlags().toBlocking().first(), + featureOverrideStore.getFeatureOverrides() + ) } - fun disableSecurityCardFeatureFlag(disabled: Boolean) { - featureOverrideStore.storeOverride(MuunFeature.NFC_CARD_V2, disabled) + fun toggleFeatureFlag( + overridableFeature: MuunFeature.OverridableFeature.Overridable, + state: FeatureFlagViewModel.State, + ) { + // If state was ENABLED -> disable, if state was DISABLED -> enable + if (state == FeatureFlagViewModel.State.ENABLED) { + featureOverrideStore.disableFeatureFlag(overridableFeature) + } else { + featureOverrideStore.enableFeatureFlag(overridableFeature) + } } } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsView.kt index 7ae567cd..87837738 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/settings/flags/DisableFeatureFlagsView.kt @@ -1,9 +1,13 @@ package io.muun.apollo.presentation.ui.settings.flags +import io.muun.apollo.domain.model.MuunFeature import io.muun.apollo.presentation.ui.base.BaseView interface DisableFeatureFlagsView : BaseView { - fun setSecurityCardFlagEnabled(isEnabled: Boolean) + fun setState( + features: List, + featureOverrides: List, + ) } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/bitcoin/BitcoinAddressQrPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/bitcoin/BitcoinAddressQrPresenter.kt index 3a0a1805..da3115bc 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/bitcoin/BitcoinAddressQrPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/bitcoin/BitcoinAddressQrPresenter.kt @@ -16,6 +16,7 @@ import io.muun.apollo.presentation.ui.bundler.BitcoinAmountBundler import io.muun.apollo.presentation.ui.show_qr.QrPresenter import io.muun.common.bitcoinj.BitcoinUri import rx.Observable +import java.util.Locale import javax.inject.Inject open class BitcoinAddressQrPresenter @Inject constructor( @@ -134,7 +135,7 @@ open class BitcoinAddressQrPresenter @Inject constructor( @SuppressLint("DefaultLocale") private fun getDefaultAddressType() = try { - AddressType.valueOf(userPreferencesSel.get().defaultAddressType.toUpperCase()) + AddressType.valueOf(userPreferencesSel.get().defaultAddressType.uppercase(Locale.getDefault())) } catch (e: Throwable) { AddressType.SEGWIT } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt index c2f5cf16..b659ff41 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/ln/LnInvoiceQrFragment.kt @@ -20,6 +20,7 @@ import io.muun.apollo.presentation.ui.utils.ReceiveLnInvoiceFormatter import io.muun.apollo.presentation.ui.view.ExpirationTimeItem import io.muun.apollo.presentation.ui.view.HiddenSection import io.muun.apollo.presentation.ui.view.LoadingView +import java.util.Locale import javax.money.MonetaryAmount @@ -120,7 +121,7 @@ class LnInvoiceQrFragment : QrFragment(), override fun setInvoice(invoice: DecodedInvoice, amount: MonetaryAmount?) { // Enable extra QR compression mode. Uppercase bech32 strings are more efficiently encoded - super.setQrContent(invoice.original, invoice.original.toUpperCase()) + super.setQrContent(invoice.original, invoice.original.uppercase(Locale.getDefault())) stopTimer() countdownTimer = MuunCountdownTimer(invoice.remainingMillis(), this) diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/unified/ShowUnifiedQrPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/unified/ShowUnifiedQrPresenter.kt index 2f339a01..179fe5ef 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/unified/ShowUnifiedQrPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/show_qr/unified/ShowUnifiedQrPresenter.kt @@ -21,6 +21,7 @@ import io.muun.apollo.presentation.ui.bundler.BitcoinAmountBundler import io.muun.apollo.presentation.ui.show_qr.QrPresenter import io.muun.common.bitcoinj.BitcoinUri import org.bitcoinj.core.NetworkParameters +import java.util.Locale import javax.inject.Inject @PerFragment @@ -139,7 +140,7 @@ class ShowUnifiedQrPresenter @Inject constructor( @SuppressLint("DefaultLocale") private fun getDefaultAddressType() = try { - AddressType.valueOf(userPreferencesSel.get().defaultAddressType.toUpperCase()) + AddressType.valueOf(userPreferencesSel.get().defaultAddressType.uppercase(Locale.getDefault())) } catch (e: Throwable) { AddressType.SEGWIT } diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/taproot_setup/TaprootSetupPresenter.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/taproot_setup/TaprootSetupPresenter.kt index 1c46bde4..80ade0f2 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/taproot_setup/TaprootSetupPresenter.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/taproot_setup/TaprootSetupPresenter.kt @@ -4,7 +4,7 @@ import android.os.Bundle import io.muun.apollo.data.apis.DriveFile import io.muun.apollo.data.apis.DriveUploader import io.muun.apollo.domain.analytics.AnalyticsEvent.E_TAPROOT_SLIDES_ABORTED -import io.muun.apollo.domain.model.GeneratedEmergencyKit +import io.muun.apollo.domain.model.GeneratedEmergencyKitInfo import io.muun.apollo.presentation.ui.base.BasePresenter import io.muun.apollo.presentation.ui.base.di.PerActivity import io.muun.apollo.presentation.ui.fragments.ek_save.EmergencyKitSaveParentPresenter @@ -30,7 +30,7 @@ class TaprootSetupPresenter @Inject constructor( var uploadedFile: DriveFile? = null - private var generatedEK: GeneratedEmergencyKit? = null + private var generatedEK: GeneratedEmergencyKitInfo? = null override fun setUp(arguments: Bundle) { super.setUp(arguments) @@ -50,11 +50,11 @@ class TaprootSetupPresenter @Inject constructor( view.finishActivity() } - override fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKit) { + override fun setGeneratedEmergencyKit(kitGen: GeneratedEmergencyKitInfo) { generatedEK = kitGen } - override fun getGeneratedEmergencyKit(): GeneratedEmergencyKit = + override fun getGeneratedEmergencyKit(): GeneratedEmergencyKitInfo = generatedEK!! override fun confirmEmergencyKitUploaded(driveFile: DriveFile) { diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/BundleSizeLogger.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/BundleSizeLogger.kt new file mode 100644 index 00000000..30bfaeef --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/BundleSizeLogger.kt @@ -0,0 +1,50 @@ +package io.muun.apollo.presentation.ui.utils + +import android.os.Bundle +import android.os.Parcel +import timber.log.Timber + +object BundleSizeLogger { + + private const val TAG = "BundleSize" + private const val MIN_KEY_SIZE_BYTES = 1024 + + fun logBundleBreakdown(label: String, bundle: Bundle) { + val totalSize = measureBundleSize(bundle) + Timber.tag(TAG).d("%s total: %dB", label, totalSize) + + bundle.keySet() + .map { key -> key to measureKeySize(bundle, key) } + .filter { it.second > MIN_KEY_SIZE_BYTES } + .sortedByDescending { it.second } + .forEach { (key, size) -> + Timber.tag(TAG).d(" %s key '%s': %dB", label, key, size) + } + } + + private fun measureBundleSize(bundle: Bundle): Int { + val parcel = Parcel.obtain() + try { + parcel.writeBundle(bundle) + return parcel.dataSize() + } finally { + parcel.recycle() + } + } + + @Suppress("DEPRECATION") + private fun measureKeySize(source: Bundle, key: String): Int { + val single = Bundle() + val value = source.get(key) ?: return 0 + when (value) { + is Bundle -> single.putBundle(key, value) + is android.os.Parcelable -> single.putParcelable(key, value) + is String -> single.putString(key, value) + is Int -> single.putInt(key, value) + is Boolean -> single.putBoolean(key, value) + is java.io.Serializable -> single.putSerializable(key, value) + else -> return 0 + } + return measureBundleSize(single) + } +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/EnumExtensions.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/EnumExtensions.kt new file mode 100644 index 00000000..7e551a04 --- /dev/null +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/EnumExtensions.kt @@ -0,0 +1,9 @@ +package io.muun.apollo.presentation.ui.utils + +/** + * Rotates enum values cyclically. Given A, B, C, then A.rotate() = B, B.rotate() = C, C.rotate() = A. + */ +inline fun > T.rotate(): T { + val values = enumValues() + return values[(ordinal + 1) % values.size] +} diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt index f3d01109..1a2b3b5f 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/Extensions.kt @@ -15,21 +15,36 @@ import android.os.Vibrator import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver +import android.view.WindowManager import android.view.animation.Animation import android.widget.TextView import android.widget.Toast -import androidx.annotation.* +import androidx.activity.enableEdgeToEdge +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isNotEmpty import androidx.core.widget.TextViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import io.muun.apollo.R import io.muun.apollo.domain.utils.locale +import io.muun.apollo.presentation.app.ApolloApplication import io.muun.apollo.presentation.ui.base.ExtensibleActivity +import io.muun.apollo.presentation.ui.base.di.ActivityComponent +import io.muun.apollo.presentation.ui.base.di.FragmentComponent +import io.muun.apollo.presentation.ui.utils.OS.supportsEdgeToEdge import timber.log.Timber -import androidx.core.view.isNotEmpty import java.text.DecimalFormatSymbols import java.util.Locale +import kotlin.math.max val ViewGroup.children: List get() = @@ -379,4 +394,56 @@ fun FragmentTransaction.safelyCommitNow(activity: ExtensibleActivity) { commitNow() } } +} + +/** + * Configures window insets to allow drawing behind system bars and dynamically applies padding + * to the root view based on system UI elements like the status bar, navigation bar, and IME. + * This ensures proper layout behavior when system UI visibility changes (e.g., keyboard shown). + */ +fun AppCompatActivity.setWindowInsetsCompat() { + enableEdgeToEdge() + if (!supportsEdgeToEdge()) { + @Suppress("DEPRECATION") + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + + val rootView = window.decorView.rootView + + setStatusBarIconsColor() + + ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val topInset = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + val bottomInset = max(imeInsets.bottom, navInsets.bottom) + + view.setPadding(navInsets.left, topInset, navInsets.right, bottomInset) + WindowInsetsCompat.CONSUMED + } +} + +/** + * Sets the status bar icon color based on the current UI mode. + * Displays the status bar and adjusts icon appearance for visibility + * in light or dark themes. + */ +private fun AppCompatActivity.setStatusBarIconsColor() { + val nightModeFlags: Int = + getResources().configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + val isDarkMode = nightModeFlags == Configuration.UI_MODE_NIGHT_YES + + val controller = + WindowCompat.getInsetsController(window, window.decorView) + + controller.show(WindowInsetsCompat.Type.statusBars()) + controller.isAppearanceLightStatusBars = !isDarkMode +} + +fun AppCompatActivity.getComponent(): ActivityComponent { + return (application as ApolloApplication).getApplicationComponent().activityComponent() +} + +fun Fragment.getComponent(): FragmentComponent { + return (requireActivity().application as ApolloApplication).getApplicationComponent().fragmentComponent() } \ No newline at end of file diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/StyledStringRes.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/StyledStringRes.kt index 3bfdacdf..5f3cd541 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/StyledStringRes.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/utils/StyledStringRes.kt @@ -35,6 +35,7 @@ class StyledStringRes( private const val FONT_COLOR = "fontColor" private const val FONT_COLOR_BLUE = "muunBlue" private const val FONT_COLOR_BLACK = "muunBlack" + private const val FONT_COLOR_TEXT_SECONDARY = "muunTextSecondaryColor" private const val FONT_STYLE = "fontStyle" private const val FONT_STYLE_NORMAL = "normal" @@ -130,6 +131,7 @@ class StyledStringRes( val colorRes = when (a.value) { FONT_COLOR_BLUE -> R.color.blue FONT_COLOR_BLACK -> R.color.text_primary_color + FONT_COLOR_TEXT_SECONDARY -> R.color.text_secondary_color else -> throw IllegalArgumentException("Color ${a.value} not supported") diff --git a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt index 823de88c..1157d638 100644 --- a/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt +++ b/android/apolloui/src/main/java/io/muun/apollo/presentation/ui/view/MuunView.kt @@ -22,6 +22,7 @@ import io.muun.apollo.presentation.ui.activity.extension.ExternalResultExtension import io.muun.apollo.presentation.ui.activity.extension.PermissionManagerExtension import io.muun.apollo.presentation.ui.base.BaseActivity import io.muun.apollo.presentation.ui.base.di.ViewComponent +import io.muun.apollo.presentation.ui.utils.BundleSizeLogger import io.muun.apollo.presentation.ui.utils.locale import timber.log.Timber import java.util.LinkedList @@ -186,9 +187,11 @@ abstract class MuunView : FrameLayout, for (i in 0 until childCount) { getChildAt(i).saveHierarchyState(childState) } - state.putParcelable(OWN_STATE, ownState) state.putSparseParcelableArray(CHILD_STATE, childState) + + BundleSizeLogger.logBundleBreakdown(javaClass.simpleName, state) + return state } diff --git a/android/apolloui/src/main/res/drawable/bg_security_cards_onboarding_placeholder.xml b/android/apolloui/src/main/res/drawable/bg_security_cards_onboarding_placeholder.xml new file mode 100644 index 00000000..91b29a4c --- /dev/null +++ b/android/apolloui/src/main/res/drawable/bg_security_cards_onboarding_placeholder.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/android/apolloui/src/main/res/drawable/ic_circle_off_outline_24.xml b/android/apolloui/src/main/res/drawable/ic_circle_off_outline_24.xml new file mode 100644 index 00000000..0db0196d --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_circle_off_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/ic_language.xml b/android/apolloui/src/main/res/drawable/ic_language.xml new file mode 100644 index 00000000..4e16a67b --- /dev/null +++ b/android/apolloui/src/main/res/drawable/ic_language.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/apolloui/src/main/res/drawable/sc_constellations_gemini.webp b/android/apolloui/src/main/res/drawable/sc_constellations_gemini.webp new file mode 100644 index 00000000..afee2054 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_constellations_gemini.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_constellations_sagitarius.webp b/android/apolloui/src/main/res/drawable/sc_constellations_sagitarius.webp new file mode 100644 index 00000000..da443f41 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_constellations_sagitarius.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_constellations_scorpius.webp b/android/apolloui/src/main/res/drawable/sc_constellations_scorpius.webp new file mode 100644 index 00000000..1752bd24 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_constellations_scorpius.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_constellations_virgo.webp b/android/apolloui/src/main/res/drawable/sc_constellations_virgo.webp new file mode 100644 index 00000000..69865c76 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_constellations_virgo.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_1.webp b/android/apolloui/src/main/res/drawable/sc_numbers_1.webp new file mode 100644 index 00000000..21e9b5a2 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_1.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_2.webp b/android/apolloui/src/main/res/drawable/sc_numbers_2.webp new file mode 100644 index 00000000..70e5dd59 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_2.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_3.webp b/android/apolloui/src/main/res/drawable/sc_numbers_3.webp new file mode 100644 index 00000000..ce381333 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_3.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_4.webp b/android/apolloui/src/main/res/drawable/sc_numbers_4.webp new file mode 100644 index 00000000..e9f7f88f Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_4.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_5.webp b/android/apolloui/src/main/res/drawable/sc_numbers_5.webp new file mode 100644 index 00000000..534c2987 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_5.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_6.webp b/android/apolloui/src/main/res/drawable/sc_numbers_6.webp new file mode 100644 index 00000000..fcff3419 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_6.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_7.webp b/android/apolloui/src/main/res/drawable/sc_numbers_7.webp new file mode 100644 index 00000000..55bc6c0c Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_7.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_8.webp b/android/apolloui/src/main/res/drawable/sc_numbers_8.webp new file mode 100644 index 00000000..c03267c4 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_8.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_numbers_9.webp b/android/apolloui/src/main/res/drawable/sc_numbers_9.webp new file mode 100644 index 00000000..4b2bf681 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_numbers_9.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_planets_earth.webp b/android/apolloui/src/main/res/drawable/sc_planets_earth.webp new file mode 100644 index 00000000..e3bdf4ad Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_planets_earth.webp differ diff --git a/android/apolloui/src/main/res/drawable/sc_planets_mars.webp b/android/apolloui/src/main/res/drawable/sc_planets_mars.webp new file mode 100644 index 00000000..96a99237 Binary files /dev/null and b/android/apolloui/src/main/res/drawable/sc_planets_mars.webp differ diff --git a/android/apolloui/src/main/res/layout-land/fragment_settings.xml b/android/apolloui/src/main/res/layout-land/fragment_settings.xml index e610c4e6..afc3dc58 100644 --- a/android/apolloui/src/main/res/layout-land/fragment_settings.xml +++ b/android/apolloui/src/main/res/layout-land/fragment_settings.xml @@ -193,6 +193,29 @@ + + + + + + + + + + + + - - - - \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/activity_card_detail.xml b/android/apolloui/src/main/res/layout/activity_card_detail.xml new file mode 100644 index 00000000..9e78ced5 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_card_detail.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/activity_country_picker.xml b/android/apolloui/src/main/res/layout/activity_country_picker.xml new file mode 100644 index 00000000..f857565a --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_country_picker.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/activity_security_cards_marketplace.xml b/android/apolloui/src/main/res/layout/activity_security_cards_marketplace.xml new file mode 100644 index 00000000..6d385527 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_security_cards_marketplace.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/activity_security_cards_onboarding.xml b/android/apolloui/src/main/res/layout/activity_security_cards_onboarding.xml new file mode 100644 index 00000000..a18be388 --- /dev/null +++ b/android/apolloui/src/main/res/layout/activity_security_cards_onboarding.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/fragment_disable_feature_flags.xml b/android/apolloui/src/main/res/layout/fragment_disable_feature_flags.xml index 51bebe04..9da60173 100644 --- a/android/apolloui/src/main/res/layout/fragment_disable_feature_flags.xml +++ b/android/apolloui/src/main/res/layout/fragment_disable_feature_flags.xml @@ -2,24 +2,14 @@ + android:orientation="vertical"> - - - + android:scrollbars="vertical" /> \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/fragment_onboarding_country_selector_slide.xml b/android/apolloui/src/main/res/layout/fragment_onboarding_country_selector_slide.xml new file mode 100644 index 00000000..3594edd9 --- /dev/null +++ b/android/apolloui/src/main/res/layout/fragment_onboarding_country_selector_slide.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/fragment_onboarding_explanatory_slide.xml b/android/apolloui/src/main/res/layout/fragment_onboarding_explanatory_slide.xml new file mode 100644 index 00000000..2eb4034b --- /dev/null +++ b/android/apolloui/src/main/res/layout/fragment_onboarding_explanatory_slide.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/fragment_security_card_provider.xml b/android/apolloui/src/main/res/layout/fragment_security_card_provider.xml new file mode 100644 index 00000000..ff4882d4 --- /dev/null +++ b/android/apolloui/src/main/res/layout/fragment_security_card_provider.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/fragment_settings.xml b/android/apolloui/src/main/res/layout/fragment_settings.xml index bb1fcbda..7f7b13ff 100644 --- a/android/apolloui/src/main/res/layout/fragment_settings.xml +++ b/android/apolloui/src/main/res/layout/fragment_settings.xml @@ -192,6 +192,28 @@ + + + + + + + + + + + - - - - \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/item_card_spec.xml b/android/apolloui/src/main/res/layout/item_card_spec.xml new file mode 100644 index 00000000..0185a1c3 --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_card_spec.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/item_country.xml b/android/apolloui/src/main/res/layout/item_country.xml new file mode 100644 index 00000000..bf4a1096 --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_country.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/android/apolloui/src/main/res/layout/item_feature_flag.xml b/android/apolloui/src/main/res/layout/item_feature_flag.xml new file mode 100644 index 00000000..e07ef8e8 --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_feature_flag.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/apolloui/src/main/res/layout/item_security_cards_marketplace_card.xml b/android/apolloui/src/main/res/layout/item_security_cards_marketplace_card.xml new file mode 100644 index 00000000..ce3306fe --- /dev/null +++ b/android/apolloui/src/main/res/layout/item_security_cards_marketplace_card.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/android/apolloui/src/main/res/menu/menu_country_picker.xml b/android/apolloui/src/main/res/menu/menu_country_picker.xml new file mode 100644 index 00000000..2ba717de --- /dev/null +++ b/android/apolloui/src/main/res/menu/menu_country_picker.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/apolloui/src/main/res/menu/menu_security_cards_marketplace.xml b/android/apolloui/src/main/res/menu/menu_security_cards_marketplace.xml new file mode 100644 index 00000000..9c2f2f4d --- /dev/null +++ b/android/apolloui/src/main/res/menu/menu_security_cards_marketplace.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/apolloui/src/main/res/values-es/strings.xml b/android/apolloui/src/main/res/values-es/strings.xml index d4e0d072..be8258f4 100644 --- a/android/apolloui/src/main/res/values-es/strings.xml +++ b/android/apolloui/src/main/res/values-es/strings.xml @@ -762,6 +762,7 @@ Unidad de bitcoin Cambiar contraseña Desbloqueo por huella + Configuración interna de debug Respaldo de Claves Privadas Configurar Código de Recuperación Herramienta de Recuperación @@ -798,13 +799,8 @@ Eliminar Cancelar - Desactivar feature flags + Tus feature flags Security Card - - Algunas de nuestras feature flags pueden activarse o desactivarse según sea necesario. - Estas son las que pueden modificarse actualmente. Desactiva la opción para deshabilitar la - función seleccionada. - Eliminaste tu monedero @@ -1798,4 +1794,41 @@ La autenticación con huella digital ha sido deshabilitada temporalmente debido a muchos intentos fallidos. Por favor espere un momento e intente de nuevo. La autenticación con huella digital ha sido deshabilitada temporalmente debido a muchos intentos fallidos. Para habilitarla, por favor desbloquee su celular nuevamente. + + + %s + %s shipping & taxes + + Unfortunately, no provider currently ships to COUNTRY. + Add your voice to help us prioritize which country we should add next. + + CHOOSE A DIFFERENT COUNTRY + + + + Country + Search + Ordered alphabetically + No countries found for \"%s\" + + + + Security cards + Physical protection + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Tap to unlock + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Curated for you + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Choose your location + Providers will depend on where you want to receive your card. + Select a country + Swipe to continue + Start exploring + + + + See full specs + GO TO %s + diff --git a/android/apolloui/src/main/res/values/strings.xml b/android/apolloui/src/main/res/values/strings.xml index 979c5176..f37cac6b 100644 --- a/android/apolloui/src/main/res/values/strings.xml +++ b/android/apolloui/src/main/res/values/strings.xml @@ -733,6 +733,7 @@ Bitcoin unit Change password Fingerprint unlock + Internal debug settings Private keys backup Set up a Recovery Code Recovery Tool @@ -769,12 +770,8 @@ Delete Cancel - Disable feature flags + Your feature flags Security Card - - Some of our feature flags can be turned off and on again at your request. These are the - ones you can currently modify. Turn the toggles off to disable the selected feature. - You deleted your wallet @@ -1745,4 +1742,37 @@ Fingerprint authentication has been disabled temporarily due to too many failed attempts. Please wait and try again. Fingerprint authentication has been disabled due to too many failed attempts. To enable it again, please unlock your phone again. + + %s + %s shipping & taxes + + Unfortunately, no provider currently ships to COUNTRY. + Add your voice to help us prioritize which country we should add next. + + CHOOSE A DIFFERENT COUNTRY + + + Country + Search + Ordered alphabetically + No countries found for \"%s\" + + + Security cards + Physical protection + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Tap to unlock + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Curated for you + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacinia tincidunt neque vitae volutpat. + Choose your location + Providers will depend on where you want to receive your card. + Select a country + Swipe to continue + Start exploring + + + See full specs + GO TO %s + diff --git a/android/apolloui/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java b/android/apolloui/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java index e22ec5ac..dca51baf 100644 --- a/android/apolloui/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java +++ b/android/apolloui/src/test/java/io/muun/apollo/application_lock/ApplicationLockTest.java @@ -9,6 +9,7 @@ import io.muun.apollo.domain.ApplicationLockManager; import io.muun.apollo.domain.errors.WeirdIncorrectAttemptsBugError; import io.muun.apollo.domain.selector.ChallengePublicKeySelector; +import io.muun.apollo.domain.selector.LogoutOptionsSelector; import org.junit.Before; import org.junit.Ignore; @@ -16,7 +17,6 @@ import org.mockito.Mock; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; @@ -31,6 +31,9 @@ public class ApplicationLockTest extends BaseTest { @Mock private ChallengePublicKeySelector challengePublicKeySel; + @Mock + private LogoutOptionsSelector logoutOptionsSelector; + private ApplicationLockManager lockManager; @Before @@ -43,13 +46,14 @@ public void setUp() { lockManager = new ApplicationLockManager( pinManager, secureStorageProvider, - challengePublicKeySel + challengePublicKeySel, + logoutOptionsSelector ); when(pinManager.verifyPin(CORRECT_PIN)).thenReturn(true); when(pinManager.verifyPin(INCORRECT_PIN)).thenReturn(false); - doReturn(false).when(challengePublicKeySel).exists(any()); + doReturn(false).when(logoutOptionsSelector).isRecoverable(); } @Test @@ -86,7 +90,7 @@ public void noUnlockWithIncorrectPin() { @Test public void decrementsRemainingAttemptsWhenRecoverable() { - doReturn(true).when(challengePublicKeySel).exists(any()); + doReturn(true).when(logoutOptionsSelector).isRecoverable(); final int maxAttempts = lockManager.getMaxAttempts(); assertThat(lockManager.getRemainingAttempts()).isEqualTo(maxAttempts); @@ -99,15 +103,13 @@ public void decrementsRemainingAttemptsWhenRecoverable() { @Test(expected = WeirdIncorrectAttemptsBugError.class) public void errorOnZeroRemainingAttemptsWhenRecoverable() { - doReturn(true).when(challengePublicKeySel).exists(any()); + doReturn(true).when(logoutOptionsSelector).isRecoverable(); burnRemainingAttempts(lockManager.getMaxAttempts() + 1); } @Test public void doesNotDecrementAttemptsWhenUnrecoverable() { - doReturn(false).when(challengePublicKeySel).exists(any()); - final int maxAttempts = lockManager.getMaxAttempts(); assertThat(lockManager.getRemainingAttempts()).isEqualTo(maxAttempts); diff --git a/android/apolloui/src/test/java/io/muun/apollo/data/afs/HardwareCapabilitiesProviderTest.kt b/android/apolloui/src/test/java/io/muun/apollo/data/afs/HardwareCapabilitiesProviderTest.kt new file mode 100644 index 00000000..6675729a --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/data/afs/HardwareCapabilitiesProviderTest.kt @@ -0,0 +1,58 @@ +package io.muun.apollo.data.afs + +import android.app.ActivityManager +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import kotlin.math.abs + +class HardwareCapabilitiesProviderTest { + private lateinit var provider: HardwareCapabilitiesProvider + + @Before + fun setup() { + val context = mockk() + val activityManager = mockk(relaxed = true) + + every { context.getSystemService(Context.ACTIVITY_SERVICE) } returns activityManager + + provider = spyk(HardwareCapabilitiesProvider(context)) + } + + @Test + fun initOffsetWithInitCycleUnknown() { + every { provider.getBootCycles() } returns Constants.INT_UNKNOWN + assertEquals(Constants.INT_UNKNOWN, provider.bootOffset) + } + + @Test + fun initOffsetWithBootCountUnknown() { + every { provider.bootCount() } returns Constants.INT_UNKNOWN + assertEquals(Constants.INT_UNKNOWN, provider.bootOffset) + } + + @Test + fun initOffsetWithNoOffset() { + val number = (1..100).random() + + every { provider.bootCount() } returns number + every { provider.getBootCycles() } returns number + + assertEquals(0, provider.bootOffset) + } + + @Test + fun initOffsetWithOffset() { + val number = (1..100).random() + val otherNumber = number + (1..100).random() + + every { provider.bootCount() } returns number + every { provider.getBootCycles() } returns otherNumber + + assertEquals(provider.bucketWithLowRangeDetail(abs(number - otherNumber)), provider.bootOffset) + } +} \ No newline at end of file diff --git a/android/apolloui/src/test/java/io/muun/apollo/domain/model/report/ErrorReportIdTest.kt b/android/apolloui/src/test/java/io/muun/apollo/domain/model/report/ErrorReportIdTest.kt new file mode 100644 index 00000000..8e69a83c --- /dev/null +++ b/android/apolloui/src/test/java/io/muun/apollo/domain/model/report/ErrorReportIdTest.kt @@ -0,0 +1,31 @@ +package io.muun.apollo.domain.model.report + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ErrorReportIdTest { + + @Test + fun sameThrowableInstance_producesSameUniqueId() { + val error = RuntimeException("boom") + val report1 = ErrorReportBuilder.build(error) + val report2 = ErrorReportBuilder.build(error) + assertEquals(report1.uniqueId, report2.uniqueId) + } + + @Test + fun differentThrowableInstances_produceDifferentUniqueIds() { + val report1 = ErrorReportBuilder.build(RuntimeException("boom")) + val report2 = ErrorReportBuilder.build(RuntimeException("boom")) + assertNotEquals(report1.uniqueId, report2.uniqueId) + } + + @Test + fun sameThrowableWithTagVariants_producesSameUniqueId() { + val error = RuntimeException("boom") + val report1 = ErrorReportBuilder.build("TagA", "msg", error) + val report2 = ErrorReportBuilder.build(error) + assertEquals(report1.uniqueId, report2.uniqueId) + } +} diff --git a/build.gradle b/build.gradle index 36464cdc..af0e368d 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ buildscript { } ext { - global_kotlin_version = '1.8.20' + global_kotlin_version = '2.1.0' } } diff --git a/common/build.gradle b/common/build.gradle index 137b1afc..474f0008 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -78,7 +78,7 @@ dependencies { testImplementation "org.mockito:mockito-core:$global_version_mockito" // Apply signature that matches :android:apolloui minSdk - signature 'net.sf.androidscents.signature:android-api-level-19:4.4.2_r4@signature' + signature 'net.sf.androidscents.signature:android-api-level-21:5.0.1_r2@signature' } // This forces our builds to be reproducible @@ -88,7 +88,7 @@ tasks.withType(AbstractArchiveTask) { } // Workaround to fix false positive in java.nio.ByteBuffer#rewind, a best effort could be to create -// a fully signature based on Android api level 19. +// a fully signature based on Android api level 21. animalsniffer { ignore 'java.nio.ByteBuffer' } \ No newline at end of file diff --git a/common/src/main/java/io/muun/common/api/AndroidBuildInfoJson.java b/common/src/main/java/io/muun/common/api/AndroidBuildInfoJson.java index 8baef95c..d9da1418 100644 --- a/common/src/main/java/io/muun/common/api/AndroidBuildInfoJson.java +++ b/common/src/main/java/io/muun/common/api/AndroidBuildInfoJson.java @@ -6,6 +6,20 @@ import java.util.List; import javax.annotation.Nullable; +/** + * Android Build Information JSON for `device` (a.k.a static pipeline) + * + *

This class represents the first version of Android Build Information JSON. + * It reflects the previous reporting strategy, where this signal was emitted only + * at specific session creation points. + * + *

The newer reporting strategy {@code houston/presentation/models/AndroidBuildInfoJson.java} + * uses a dynamic counterpart, which reports repeatedly a conceptually equivalent signal + * (with minor field-level differences) as part of the BackgroundExecutionMetrics flow. + * + *

This JSON model is retained for backward compatibility with older Apollo versions. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class AndroidBuildInfoJson { @@ -58,9 +72,6 @@ public class AndroidBuildInfoJson { @Nullable public String release; - @Nullable - public Long date; - /** * Json constructor. */ @@ -68,38 +79,5 @@ public class AndroidBuildInfoJson { public AndroidBuildInfoJson() { } - /** - * Code constructor. - */ - public AndroidBuildInfoJson( - @Nullable List abis, - @Nullable String fingerprint, - @Nullable String bootloader, - @Nullable String manufacturer, - @Nullable String brand, - @Nullable String display, - @Nullable String host, - @Nullable String type, - @Nullable String radioVersion, - @Nullable String securityPatch, - @Nullable String model, - @Nullable String product, - @Nullable String release, - @Nullable Long date - ) { - this.abis = abis; - this.fingerprint = fingerprint; - this.bootloader = bootloader; - this.manufacturer = manufacturer; - this.brand = brand; - this.display = display; - this.host = host; - this.type = type; - this.radioVersion = radioVersion; - this.securityPatch = securityPatch; - this.model = model; - this.product = product; - this.release = release; - this.date = date; - } + // Constructor removed: Apollo moved this flow to the dynamic pipeline. } diff --git a/common/src/main/java/io/muun/common/api/ClientJson.java b/common/src/main/java/io/muun/common/api/ClientJson.java index 6c9a7f84..56e63ec0 100644 --- a/common/src/main/java/io/muun/common/api/ClientJson.java +++ b/common/src/main/java/io/muun/common/api/ClientJson.java @@ -249,9 +249,6 @@ public ClientJson( @Nullable final Boolean androidQuickEmuProps, @Nullable final Integer androidEmachineArchitecture, @Nullable final Boolean androidSecurityEnhancedBuild, - @Nullable final Boolean androidBridgeRootService, - @Nullable final Long androidAppSize, - @Nullable final String androidVbMeta, @Nullable final Boolean androidIsLowRamDevice, @Nullable final Long androidFirstInstallTimeInMs, @Nullable final String applicationId @@ -289,9 +286,9 @@ public ClientJson( this.androidQuickEmuProps = androidQuickEmuProps; this.androidEmArchitecture = androidEmachineArchitecture; this.androidSecurityEnhancedBuild = androidSecurityEnhancedBuild; - this.androidBridgeRootService = androidBridgeRootService; - this.androidAppSize = androidAppSize; - this.androidVbMeta = androidVbMeta; + this.androidBridgeRootService = null; + this.androidAppSize = null; + this.androidVbMeta = null; this.androidIsLowRamDevice = androidIsLowRamDevice; this.androidFirstInstallTimeInMs = androidFirstInstallTimeInMs; this.androidApplicationId = applicationId; diff --git a/common/src/main/java/io/muun/common/api/MuunFeatureJson.java b/common/src/main/java/io/muun/common/api/MuunFeatureJson.java index 58be703b..67e375d8 100644 --- a/common/src/main/java/io/muun/common/api/MuunFeatureJson.java +++ b/common/src/main/java/io/muun/common/api/MuunFeatureJson.java @@ -21,5 +21,6 @@ public enum MuunFeatureJson { NFC_SENSORS, DIAGNOSTIC_MODE, SECURITY_CARDS_MARKETPLACE, - EXAMPLE_FLAG; + EXAMPLE_FLAG, + EK_GO_RENDERING; } diff --git a/common/src/main/java/io/muun/common/rx/SingleFn.java b/common/src/main/java/io/muun/common/rx/SingleFn.java index f859fe13..cdbf3d4c 100644 --- a/common/src/main/java/io/muun/common/rx/SingleFn.java +++ b/common/src/main/java/io/muun/common/rx/SingleFn.java @@ -101,4 +101,15 @@ public static Single.Transformer onHttpExceptionResumeNext( } }); } + + /** + * Like onHttpExceptionResumeNext, but specialized to return Optional#empty. + * Useful for 404 not found style errors. + */ + public static Single.Transformer, Optional> onHttpExceptionResumeEmpty( + final BaseErrorCode code + ) { + return onHttpExceptionResumeNext(code, error -> Single.just(Optional.empty())); + + } } diff --git a/libwallet/cmd/kv_migration_tool/main.go b/libwallet/cmd/kv_migration_tool/main.go new file mode 100644 index 00000000..f07ca96b --- /dev/null +++ b/libwallet/cmd/kv_migration_tool/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + + "github.com/muun/libwallet/internal/kvmigrationlock" + "github.com/muun/libwallet/storage" +) + +// Default paths assume the tool is run from the libwallet directory. +const defaultMigrationsFile = "storage/kv_migrations.go" +const defaultLockfile = "storage/testdata/kv_migrations.lock" + +func main() { + lockCmd := flag.NewFlagSet("lock", flag.ExitOnError) + lockMigrationsFile := lockCmd.String("migrations-file", defaultMigrationsFile, "path to the migrations Go source file") + lockLockfile := lockCmd.String("lockfile", defaultLockfile, "path to the lockfile to write") + + verifyCmd := flag.NewFlagSet("verify", flag.ExitOnError) + verifyMigrationsFile := verifyCmd.String("migrations-file", defaultMigrationsFile, "path to the migrations Go source file") + verifyLockfile := verifyCmd.String("lockfile", defaultLockfile, "path to the lockfile to read") + + if len(os.Args) < 2 { + log.Fatalf("Expected 'lock' or 'verify' subcommand.") + } + + switch os.Args[1] { + case "lock": + lockCmd.Parse(os.Args[2:]) + err := runLock(storage.BuildKVMigrationPlan(), *lockMigrationsFile, *lockLockfile) + if err != nil { + log.Fatal(err) + } + case "verify": + verifyCmd.Parse(os.Args[2:]) + err := runVerify(storage.BuildKVMigrationPlan(), *verifyMigrationsFile, *verifyLockfile) + if err != nil { + log.Fatal(err) + } + default: + log.Fatalf("Expected 'lock' or 'verify' subcommand.") + } +} + +func runLock(plan []storage.Migration, migrationsFile, lockfilePath string) error { + generatedLockFile, err := kvmigrationlock.Generate(plan, migrationsFile) + if err != nil { + return fmt.Errorf("failed to generate lockfile: %w", err) + } + + // Check if a committed lockfile exists. + existingData, err := os.ReadFile(lockfilePath) + if os.IsNotExist(err) { + // No lockfile yet, nothing to verify. + } else if err != nil { + return fmt.Errorf("failed to read existing lockfile: %w", err) + } else { + // If a lockfile already exists, verify that no existing migration was modified or deleted. + // Only appending new migrations is allowed. Rewriting history is not allowed. + var committedLockfile kvmigrationlock.Lockfile + err = json.Unmarshal(existingData, &committedLockfile) + if err != nil { + return fmt.Errorf("failed to parse existing lockfile: %w", err) + } + err = failIfHistoryIsModified(committedLockfile, generatedLockFile) + if err != nil { + return err + } + } + + data, err := json.MarshalIndent(generatedLockFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal lockfile: %w", err) + } + + err = os.WriteFile(lockfilePath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write lockfile: %w", err) + } + log.Printf("Successfully wrote %s", lockfilePath) + return nil +} + +func runVerify(plan []storage.Migration, migrationsFile, lockfilePath string) error { + generatedLockFile, err := kvmigrationlock.Generate(plan, migrationsFile) + if err != nil { + return fmt.Errorf("failed to generate lockfile for verification: %w", err) + } + + existingData, err := os.ReadFile(lockfilePath) + if err != nil { + return fmt.Errorf("failed to read lockfile. Run 'lock' first: %w", err) + } + + var committedLockFile kvmigrationlock.Lockfile + err = json.Unmarshal(existingData, &committedLockFile) + if err != nil { + return fmt.Errorf("failed to parse lockfile: %w", err) + } + + err = failIfHistoryIsModified(committedLockFile, generatedLockFile) + if err != nil { + return err + } + if len(committedLockFile.Migrations) < len(generatedLockFile.Migrations) { + return fmt.Errorf("lockfile has %d migrations but plan has %d, run 'lock' to update", + len(committedLockFile.Migrations), len(generatedLockFile.Migrations)) + } + log.Println("OK: all existing migrations are unmodified.") + return nil +} + +// failIfHistoryIsModified checks that no committed migration was modified or deleted. +func failIfHistoryIsModified(committedLockfile kvmigrationlock.Lockfile, generatedLockfile *kvmigrationlock.Lockfile) error { + if len(committedLockfile.Migrations) > len(generatedLockfile.Migrations) { + return fmt.Errorf("plan has %d migrations but lockfile has %d: deleting migrations is not allowed", + len(generatedLockfile.Migrations), len(committedLockfile.Migrations)) + } + for i, existing := range committedLockfile.Migrations { + if existing.Hash != generatedLockfile.Migrations[i].Hash { + return fmt.Errorf("migration %d (%q) was modified", i+1, existing.Description) + } + } + return nil +} diff --git a/libwallet/cmd/kv_migration_tool/main_test.go b/libwallet/cmd/kv_migration_tool/main_test.go new file mode 100644 index 00000000..49120170 --- /dev/null +++ b/libwallet/cmd/kv_migration_tool/main_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/muun/libwallet/internal/kvmigrationlock" + "github.com/muun/libwallet/storage" +) + +var planV1 = []storage.Migration{ + {Description: "Migration 1", Changes: []storage.Change{ + storage.Define("key1", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + }}, +} + +var planV2 = []storage.Migration{ + {Description: "Migration 1", Changes: []storage.Change{ + storage.Define("key1", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + }}, + {Description: "Migration 2", Changes: []storage.Change{ + storage.Define("key2", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + }}, +} + +// planV1Modified has the same description as planV1 but different content, so its hash differs. +var planV1Modified = []storage.Migration{ + {Description: "Migration 1", Changes: []storage.Change{ + storage.Define("key1_modified", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + }}, +} + +func TestLock(t *testing.T) { + + t.Run("creates lockfile when none exists", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(lockfile) + if err != nil { + t.Fatalf("lockfile was not created: %v", err) + } + var lf kvmigrationlock.Lockfile + err = json.Unmarshal(data, &lf) + if err != nil { + t.Fatalf("lockfile is not valid JSON: %v", err) + } + if len(lf.Migrations) != 1 { + t.Fatalf("expected 1 migration in lockfile, got %d", len(lf.Migrations)) + } + }) + + t.Run("idempotent when plan is unchanged", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("first lock failed: %v", err) + } + err = runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("second lock failed: %v", err) + } + }) + + t.Run("succeeds when plan has new migrations", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("v1 lock failed: %v", err) + } + err = runLock(planV2, migrationsFile, lockfile) + if err != nil { + t.Fatalf("v2 lock failed: %v", err) + } + + data, _ := os.ReadFile(lockfile) + var lf kvmigrationlock.Lockfile + json.Unmarshal(data, &lf) + if len(lf.Migrations) != 2 { + t.Fatalf("expected 2 migrations in lockfile, got %d", len(lf.Migrations)) + } + }) + + t.Run("fails when migration is modified", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("initial lock failed: %v", err) + } + err = runLock(planV1Modified, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error for modified migration, but got none") + } + }) + + t.Run("fails when migration is deleted", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV2, migrationsFile, lockfile) + if err != nil { + t.Fatalf("initial lock failed: %v", err) + } + err = runLock(planV1, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error for deleted migration, but got none") + } + }) +} + +func TestVerify(t *testing.T) { + t.Run("fails when no lockfile exists", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runVerify(planV1, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error when lockfile does not exist, but got none") + } + }) + + t.Run("succeeds when lockfile matches plan", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("lock failed: %v", err) + } + err = runVerify(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("fails when plan has unlocked migrations", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("lock failed: %v", err) + } + err = runVerify(planV2, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error for unlocked migrations, but got none") + } + }) + + t.Run("fails when migration is modified", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV1, migrationsFile, lockfile) + if err != nil { + t.Fatalf("lock failed: %v", err) + } + err = runVerify(planV1Modified, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error for modified migration, but got none") + } + }) + + t.Run("fails when migration is deleted", func(t *testing.T) { + migrationsFile := writeMigrationsFile(t) + lockfile := newLockfilePath(t) + + err := runLock(planV2, migrationsFile, lockfile) + if err != nil { + t.Fatalf("initial lock failed: %v", err) + } + err = runVerify(planV1, migrationsFile, lockfile) + if err == nil { + t.Fatal("expected error for deleted migration, but got none") + } + }) +} + +func writeMigrationsFile(t *testing.T) string { + t.Helper() + + // minimalGoFile is a valid Go source used as the migrations file. + // We need it since kvmigrationlock.Generate parses it to locate AddCustomChange literals. + // Our test plans only use Define, so any valid Go file suffices. + var minimalGoFile = "package test\n" + + path := filepath.Join(t.TempDir(), "migrations.go") + err := os.WriteFile(path, []byte(minimalGoFile), 0644) + if err != nil { + t.Fatalf("failed to write migrations file: %v", err) + } + return path +} + +func newLockfilePath(t *testing.T) string { + return filepath.Join(t.TempDir(), "kv_migrations.lock") +} diff --git a/libwallet/data/emergency_kit/pdf.go b/libwallet/data/emergency_kit/pdf.go new file mode 100644 index 00000000..233b83b1 --- /dev/null +++ b/libwallet/data/emergency_kit/pdf.go @@ -0,0 +1,441 @@ +package emergency_kit + +import ( + "bytes" + "github.com/muun/libwallet/data/emergency_kit/resources" + "math" + "strings" + + "github.com/phpdave11/gofpdf" +) + +type RenderingContext struct { + NonDrawableHorizontalMargins float64 + TextStyling TextStyling + Images []ImageAsset +} + +// TextStyling contains function pointers for styling text elements +type TextStyling struct { + SetBodyFont func(*gofpdf.Fpdf) + SetBodyColor func(*gofpdf.Fpdf) + SetLinkFont func(*gofpdf.Fpdf) + SetLinkColor func(*gofpdf.Fpdf) +} + +// ImageAsset contains embedded image data +type ImageAsset struct { + Name string + Format string + Data []byte +} + +// PdfExtensions wraps gofpdf.Fpdf to provide additional helper methods +type PdfExtensions struct { + *gofpdf.Fpdf + ctx RenderingContext +} + +// CreateAndSetupPdf creates a new PdfExtensions wrapper around a gofpdf.Fpdf instance +func CreateAndSetupPdf(ctx RenderingContext) *PdfExtensions { + // Custom page size: 138.3 × 388.1 mm (13.83 × 38.81 cm) + // Matches HTML/CSS PDF dimensions to ensure proper scaling in PDF viewers like Preview + pageSize := resources.XY{X: 138.3, Y: 388.1} + + pdf := gofpdf.NewCustom(&gofpdf.InitType{ + OrientationStr: "P", // Portrait + UnitStr: "mm", // Millimeters + Size: gofpdf.SizeType{Wd: pageSize.X, Ht: pageSize.Y}, + FontDirStr: "", + }) + pdfExt := &PdfExtensions{Fpdf: pdf, ctx: ctx} + registerFonts(pdfExt) + registerImages(pdf, ctx.Images) + + // Remove cell margins globally to prevent unwanted padding + pdf.SetCellMargin(0) + + // This PDF manages its own page layout via explicit AddPage() calls, so auto page break + // must be disabled to prevent gofpdf from inserting extra pages mid-render. + pdf.SetAutoPageBreak(false, 0) + + return pdfExt +} + +func registerFonts(pdf *PdfExtensions) { + // Add custom fonts from embedded binary data: + pdf.AddUTF8FontFromBytes("Roboto", "", resources.RobotoRegular) + pdf.AddUTF8FontFromBytes("Roboto", "M", resources.RobotoMedium) + pdf.AddUTF8FontFromBytes("RobotoMono", "", resources.RobotoMonoRegular) + pdf.AddUTF8FontFromBytes("RobotoMono", "M", resources.RobotoMonoMedium) +} + +func registerImages(pdf *gofpdf.Fpdf, images []ImageAsset) { + for _, img := range images { + pdf.RegisterImageReader(img.Name, img.Format, bytes.NewReader(img.Data)) + } +} + +func (p *PdfExtensions) AddComponentSeparator() { + p.SetY(p.GetY() + resources.Mm(58)) +} + +// TextPart represents a part of text with specific styling +type TextPart struct { + Text string + SetFont func(*gofpdf.Fpdf) + SetColor func(*gofpdf.Fpdf) +} + +// RenderMultiStyledText renders text with multiple styles and automatic word-level wrapping +// It handles mixed font styles (regular/bold) and colors within a single line, +// wrapping words to the next line as needed to fit within the available width. +// +// Parameters: +// - startX: X coordinate where text rendering starts +// - startY: Y coordinate where text rendering starts +// - availableWidth: Maximum width available for text +// - lineHeight: Height of each line +// - parts: Array of text parts, each with its own styling +// - minLines: Minimum number of lines to use (0 for no minimum) +// +// Returns: +// - endY: Y coordinate after rendering all text (useful for calculating next element position) +func (p *PdfExtensions) RenderMultiStyledText( + startX float64, + startY float64, + availableWidth float64, + lineHeight float64, + parts []TextPart, + letterSpacing float64, + minLines int, +) float64 { + styling := p.ctx.TextStyling + // Collect all words with their styling and widths + type styledWord struct { + text string + width float64 + partIdx int + isLast bool + hasSpace bool + spaceWidth float64 + } + + allWords := []styledWord{} + + for partIndex, part := range parts { + // Split part text into words + words := strings.Fields(part.Text) + + // Apply styling for this part to calculate widths + part.SetFont(p.Fpdf) + part.SetColor(p.Fpdf) + + // Collect words with their metadata + for wordIndex, word := range words { + isLastWord := partIndex == len(parts)-1 && wordIndex == len(words)-1 + + // Check if next part starts with punctuation (don't add space before punctuation) + nextPartStartsWithPunctuation := false + if partIndex < len(parts)-1 && wordIndex == len(words)-1 && len(parts[partIndex+1].Text) > 0 { + nextPartStartsWithPunctuation = isPunctuation(parts[partIndex+1].Text[0]) + } + + needsSpace := !isLastWord && !nextPartStartsWithPunctuation + + wordWidth := p.GetStringWidthWithLetterSpacing(word, letterSpacing) + spaceWidth := float64(0) + if needsSpace { + spaceWidth = p.GetStringWidth(" ") + } + + allWords = append(allWords, styledWord{ + text: word, + width: wordWidth, + partIdx: partIndex, + isLast: isLastWord, + hasSpace: needsSpace, + spaceWidth: spaceWidth, + }) + } + } + + // Calculate natural line breaks + currentX := startX + currentLine := 0 + forceWrapIndex := -1 + + for _, word := range allWords { + totalWidth := word.width + word.spaceWidth + if currentX+totalWidth > startX+availableWidth { + currentLine++ + currentX = startX + } + currentX += totalWidth + } + totalNaturalLines := currentLine + 1 + + // If we need to force minimum lines and text fits on fewer lines, + // force the last word to wrap to a new line + if minLines > 0 && totalNaturalLines < minLines && len(allWords) > 0 { + forceWrapIndex = len(allWords) - 1 + } + + // Render all words + currentX = startX + currentY := startY + + for i, word := range allWords { + totalWidth := word.width + word.spaceWidth + + // Force wrap if this is the designated word + if i == forceWrapIndex { + currentY += lineHeight + currentX = startX + } else if currentX+totalWidth > startX+availableWidth { + // Natural wrap + currentY += lineHeight + currentX = startX + } + + // Render word with its styling + parts[word.partIdx].SetFont(p.Fpdf) + parts[word.partIdx].SetColor(p.Fpdf) + if letterSpacing > 0 { + p.RenderTextWithLetterSpacing(currentX, currentY, word.text, letterSpacing, "L", "T", lineHeight) + } else { + p.SetXY(currentX, currentY) + p.Cell(word.width, lineHeight, word.text) + } + currentX += word.width + + // Render space in regular font (not underlined) + if word.hasSpace { + styling.SetBodyFont(p.Fpdf) + styling.SetBodyColor(p.Fpdf) + p.SetXY(currentX, currentY) + p.Cell(word.spaceWidth, lineHeight, " ") + currentX += word.spaceWidth + } + } + + return currentY + lineHeight +} + +// MultiCellWithLetterSpacing renders multi-line text using the current font/color with custom letter spacing. +// It mimics the behaviour of gofpdf's MultiCell but applies inter-character spacing. +// The caller must set X/Y position and font/color before calling, just like MultiCell. +// After rendering, the Y cursor is advanced to the bottom of the last line. +func (p *PdfExtensions) MultiCellWithLetterSpacing(availableWidth, lineHeight float64, text string, letterSpacing float64) { + startX := p.GetX() + currentX := startX + currentY := p.GetY() + + words := strings.Fields(text) + spaceWidth := p.GetStringWidth(" ") + + for i, word := range words { + wordWidth := p.GetStringWidthWithLetterSpacing(word, letterSpacing) + maxX := startX + availableWidth + + // 1. Move to a new line if the word doesn't fit on the current one. + // Guard: skip if already at line start to avoid creating an empty line. + if currentX > startX && currentX+wordWidth > maxX { + currentY += lineHeight + currentX = startX + } + + if wordWidth <= availableWidth { + // 2. Word fits on a single line — render it whole. + currentX = p.RenderTextWithLetterSpacing(currentX, currentY, word, letterSpacing, "L", "T", lineHeight) + } else { + // 3. Word is longer than the available width (e.g. an encrypted key) — split character by character. + lineStr := "" + for _, char := range word { + charStr := string(char) + testWidth := p.GetStringWidthWithLetterSpacing(lineStr+charStr, letterSpacing) + if lineStr != "" && currentX+testWidth > maxX { + p.RenderTextWithLetterSpacing(currentX, currentY, lineStr, letterSpacing, "L", "T", lineHeight) + currentY += lineHeight + currentX = startX + lineStr = charStr + } else { + lineStr += charStr + } + } + currentX = p.RenderTextWithLetterSpacing(currentX, currentY, lineStr, letterSpacing, "L", "T", lineHeight) + } + + // Add one space after each word + if i < len(words)-1 { + currentX += spaceWidth + } + } + + p.SetY(currentY + lineHeight) +} + +// LineCountWithLetterSpacing estimates the number of lines text will occupy when rendered +// with the given letter spacing, based on total text width divided by available width. +func (p *PdfExtensions) LineCountWithLetterSpacing(maxWidth float64, text string, letterSpacing float64) float64 { + if maxWidth <= 0 { + return 1 + } + textWidth := p.GetStringWidthWithLetterSpacing(text, letterSpacing) + if textWidth <= maxWidth { + return 1 + } + return math.Ceil(textWidth / maxWidth) +} + +// RenderTextWithLetterSpacing renders text with custom letter spacing +// letterSpacing is specified as a fraction of the font size in pixels (0.05 = 5% of font size) +// The spacing is converted from points to millimeters to match PDF coordinate system. +// Custom spacing was already being used in the html/css version so we had to replicate it. +// horizontalAlign controls horizontal positioning: "L" (left), "C" (center), "R" (right) +// verticalAlign controls vertical positioning: "T" (top), "M" (middle), "B" (bottom), "A" (baseline) +// lineHeight is optional - if not provided or <= 0, defaults to fontHeight * 1.2 +func (p *PdfExtensions) RenderTextWithLetterSpacing( + x float64, + y float64, + text string, + letterSpacing float64, + horizontalAlign string, + verticalAlign string, + lineHeight ...float64, +) float64 { + fontSizePt, _ := p.GetFontSize() + fontSizeMm := resources.PtToMm(fontSizePt) + + cellHeight := fontSizeMm * 1.2 + if len(lineHeight) > 0 && lineHeight[0] > 0 { + cellHeight = lineHeight[0] + } + + currentX := x + spacingAmount := fontSizeMm * letterSpacing + + for _, char := range text { + charStr := string(char) + charWidth := p.GetStringWidth(charStr) + p.SetXY(currentX, y) + p.CellFormat(charWidth, cellHeight, charStr, "", 0, horizontalAlign+verticalAlign, false, 0, "") + currentX += charWidth + spacingAmount + } + + // Return the final X position (useful for continuing text on the same line) + return currentX - spacingAmount // Remove the last spacing +} + +// GetStringWidthWithLetterSpacing calculates the width of text with custom letter spacing +// letterSpacing is specified as a fraction of the font size in pixels (0.05 = 5% of font size) +// The spacing is converted from points to millimeters to match PDF coordinate system +func (p *PdfExtensions) GetStringWidthWithLetterSpacing(text string, letterSpacing float64) float64 { + if text == "" { + return 0 + } + fontSizePt, _ := p.GetFontSize() + fontSizeMm := resources.PtToMm(fontSizePt) + spacingAmount := fontSizeMm * letterSpacing + + totalWidth := float64(0) + for _, char := range text { + charWidth := p.GetStringWidth(string(char)) + totalWidth += charWidth + spacingAmount + } + + // Remove the last spacing since there's no spacing after the last character + return totalWidth - spacingAmount +} + +// GetDrawablePageWidth returns the usable page width after subtracting the horizontal margins on both sides. +func (p *PdfExtensions) GetDrawablePageWidth() float64 { + pageWidth, _ := p.GetPageSize() + return pageWidth - 2*p.ctx.NonDrawableHorizontalMargins +} + +// ParseTextWithLinks splits text into parts, highlighting specified links in link color. +// Links are matched by exact string matching. +func (p *PdfExtensions) ParseTextWithLinks(text string, links []string) []TextPart { + styling := p.ctx.TextStyling + + if len(links) == 0 { + return []TextPart{ + {Text: text, SetFont: styling.SetBodyFont, SetColor: styling.SetBodyColor}, + } + } + + // Find first link occurrence + var foundLink string + linkStart := -1 + for _, link := range links { + index := strings.Index(text, link) + if index >= 0 { + linkStart = index + foundLink = link + break + } + } + + // No link found + if linkStart < 0 { + return []TextPart{ + {Text: text, SetFont: styling.SetBodyFont, SetColor: styling.SetBodyColor}, + } + } + + // Build parts: before link, link, after link + parts := []TextPart{} + + if linkStart > 0 { + parts = append(parts, TextPart{ + Text: text[:linkStart], + SetFont: styling.SetBodyFont, + SetColor: styling.SetBodyColor, + }) + } + + parts = append(parts, TextPart{ + Text: foundLink, + SetFont: styling.SetLinkFont, + SetColor: styling.SetLinkColor, + }) + + linkEnd := linkStart + len(foundLink) + if linkEnd < len(text) { + remainingText := text[linkEnd:] + // If remaining text starts with punctuation, create a separate non-spaced part for it + if len(remainingText) > 0 && isPunctuation(remainingText[0]) { + parts = append(parts, TextPart{ + Text: string(remainingText[0]), + SetFont: styling.SetBodyFont, + SetColor: styling.SetBodyColor, + }) + remainingText = remainingText[1:] + } + // Recursively parse remaining text for more links + if len(remainingText) > 0 { + remainingParts := p.ParseTextWithLinks(remainingText, links) + parts = append(parts, remainingParts...) + } + } + + return parts +} + +func (p *PdfExtensions) LineCount(maxWidth float64, text string) float64 { + if maxWidth <= 0 { + return 1 + } + + textWidth := p.GetStringWidth(text) + if textWidth <= maxWidth { + return 1 + } + + return math.Ceil(textWidth / maxWidth) +} + +func isPunctuation(char byte) bool { + return strings.ContainsRune(".,!?;:", rune(char)) +} diff --git a/libwallet/data/emergency_kit/resources/date_formatter.go b/libwallet/data/emergency_kit/resources/date_formatter.go new file mode 100644 index 00000000..36aaceca --- /dev/null +++ b/libwallet/data/emergency_kit/resources/date_formatter.go @@ -0,0 +1,31 @@ +package resources + +import ( + "fmt" + "time" +) + +var spanishMonthNames = []string{ + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +} + +// FormatDate returns a localized date string for the given time and language code. +// Supported languages: "es" (Spanish), defaults to English. +func FormatDate(t time.Time, lang string) string { + if lang == "es" { + year, month, day := t.Date() + return fmt.Sprintf("%d de %s, %d", day, spanishMonthNames[month-1], year) + } + return t.Format("January 2, 2006") +} diff --git a/libwallet/data/emergency_kit/resources/fonts.go b/libwallet/data/emergency_kit/resources/fonts.go new file mode 100644 index 00000000..4a463ee9 --- /dev/null +++ b/libwallet/data/emergency_kit/resources/fonts.go @@ -0,0 +1,39 @@ +package resources + +import ( + _ "embed" + "github.com/phpdave11/gofpdf" +) + +// Embed font files as binary data in the compiled binary +// This eliminates the need for external font files at runtime +// +// These fonts are licensed under the Apache License 2.0 - see LICENSE file in this directory + +//go:embed fonts/Roboto-Regular.ttf +var RobotoRegular []byte + +//go:embed fonts/Roboto-Medium.ttf +var RobotoMedium []byte + +//go:embed fonts/RobotoMono-Regular.ttf +var RobotoMonoRegular []byte + +//go:embed fonts/RobotoMono-Medium.ttf +var RobotoMonoMedium []byte + +func SetRobotoRegular(pdf *gofpdf.Fpdf, sizeInPixels float64) { + pdf.SetFont("Roboto", "", Pt(sizeInPixels)) +} + +func SetRobotoRegularUnderlined(pdf *gofpdf.Fpdf, sizeInPixels float64) { + pdf.SetFont("Roboto", "U", Pt(sizeInPixels)) +} + +func SetRobotoMedium(pdf *gofpdf.Fpdf, sizeInPixels float64) { + pdf.SetFont("Roboto", "M", Pt(sizeInPixels)) +} + +func SetRobotoMonoRegular(pdf *gofpdf.Fpdf, sizeInPixels float64) { + pdf.SetFont("RobotoMono", "", Pt(sizeInPixels)) +} diff --git a/libwallet/data/emergency_kit/resources/fonts/LICENSE b/libwallet/data/emergency_kit/resources/fonts/LICENSE new file mode 100644 index 00000000..dc5fbeb9 --- /dev/null +++ b/libwallet/data/emergency_kit/resources/fonts/LICENSE @@ -0,0 +1,15 @@ +The Roboto and Roboto Mono font families are licensed under the Apache License, Version 2.0. + +Copyright 2011 Google Inc. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/libwallet/data/emergency_kit/resources/fonts/Roboto-Bold.ttf b/libwallet/data/emergency_kit/resources/fonts/Roboto-Bold.ttf new file mode 100644 index 00000000..4658f9a6 Binary files /dev/null and b/libwallet/data/emergency_kit/resources/fonts/Roboto-Bold.ttf differ diff --git a/libwallet/data/emergency_kit/resources/fonts/Roboto-Medium.ttf b/libwallet/data/emergency_kit/resources/fonts/Roboto-Medium.ttf new file mode 100644 index 00000000..d629e984 Binary files /dev/null and b/libwallet/data/emergency_kit/resources/fonts/Roboto-Medium.ttf differ diff --git a/libwallet/data/emergency_kit/resources/fonts/Roboto-Regular.ttf b/libwallet/data/emergency_kit/resources/fonts/Roboto-Regular.ttf new file mode 100644 index 00000000..7e3bb2f8 Binary files /dev/null and b/libwallet/data/emergency_kit/resources/fonts/Roboto-Regular.ttf differ diff --git a/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Medium.ttf b/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Medium.ttf new file mode 100644 index 00000000..53fdd400 Binary files /dev/null and b/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Medium.ttf differ diff --git a/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Regular.ttf b/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Regular.ttf new file mode 100644 index 00000000..3806bfb1 Binary files /dev/null and b/libwallet/data/emergency_kit/resources/fonts/RobotoMono-Regular.ttf differ diff --git a/libwallet/data/emergency_kit/resources/units.go b/libwallet/data/emergency_kit/resources/units.go new file mode 100644 index 00000000..39472359 --- /dev/null +++ b/libwallet/data/emergency_kit/resources/units.go @@ -0,0 +1,51 @@ +package resources + +import ( + "github.com/phpdave11/gofpdf" +) + +const ( + // MillimetersPerInch is the number of millimeters in one inch + MillimetersPerInch = 25.4 + + // PixelsPerInch is the DPI for CSS pixels (web standard) + PixelsPerInch = 96 + + // PointsPerInch is the DPI for PDF points (PostScript/PDF standard) + PointsPerInch = 72 + + // PixelsToPointsRatio converts CSS pixels (96 DPI) to PDF points (72 DPI) + // Calculated as: PointsPerInch / PixelsPerInch + PixelsToPointsRatio = float64(PointsPerInch) / float64(PixelsPerInch) +) + +// Mm converts pixels to millimeters at 96 DPI +// Formula: pixels * (mm/inch) / (pixels/inch) = millimeters +func Mm(pixels float64) float64 { + return pixels * MillimetersPerInch / PixelsPerInch +} + +// Pt converts pixels to points (96 DPI: 1px = 0.75pt) +func Pt(pixels float64) float64 { + return pixels * PixelsToPointsRatio +} + +// PtToPixels converts points to pixels (96 DPI: 1pt ≈ 1.333px) +func PtToPixels(points float64) float64 { + return points / PixelsToPointsRatio +} + +// PtToMm converts points to millimeters (96 DPI: 1pt ≈ 0.353mm) +func PtToMm(points float64) float64 { + return Mm(PtToPixels(points)) +} + +type XY struct { + X float64 + Y float64 +} + +type Component interface { + Height(pdf *gofpdf.Fpdf) float64 + Render(pdf *gofpdf.Fpdf) +} diff --git a/libwallet/docker/builder.Dockerfile b/libwallet/docker/builder.Dockerfile index b7cd4f49..69194d75 100644 --- a/libwallet/docker/builder.Dockerfile +++ b/libwallet/docker/builder.Dockerfile @@ -3,4 +3,5 @@ FROM golang:1.24-bullseye ENV STATICCHECK_VERSION=2025.1.1 # install staticcheck (linter for go projects) -RUN go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" \ No newline at end of file +RUN go install "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" \ + && cp /go/bin/staticcheck /usr/local/bin/staticcheck \ No newline at end of file diff --git a/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go b/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go new file mode 100644 index 00000000..b7cb7b5b --- /dev/null +++ b/libwallet/domain/action/emergency_kit/generate_emergency_kit_pdf_action.go @@ -0,0 +1,98 @@ +package emergency_kit + +import ( + "encoding/json" + "fmt" + "github.com/muun/libwallet" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render" + "github.com/muun/libwallet/emergencykit" + "os" + "path/filepath" +) + +// GeneratedEKPDF is a model including the verificationCode and version +type GeneratedEKPDF struct { + VerificationCode string + Version int +} + +// GenerateEmergencyKitPDFAction action for generating emergency kit PDFs +type GenerateEmergencyKitPDFAction struct { + // No dependencies needed for this action currently +} + +func NewGenerateEmergencyKitPDFAction() *GenerateEmergencyKitPDFAction { + return &GenerateEmergencyKitPDFAction{} +} + +// Run generates an emergency kit PDF and returns a GeneratedEKPDF. +func (a *GenerateEmergencyKitPDFAction) Run( + ekParams *libwallet.EKInput, + outputPath string, + language string, +) (*GeneratedEKPDF, error) { + outputPath = stripFilePrefix(outputPath) + + outputDir := filepath.Dir(outputPath) + err := os.MkdirAll(outputDir, 0755) + if err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + ekInput := &emergencykit.Input{ + FirstEncryptedKey: ekParams.FirstEncryptedKey, + FirstFingerprint: ekParams.FirstFingerprint, + SecondEncryptedKey: ekParams.SecondEncryptedKey, + SecondFingerprint: ekParams.SecondFingerprint, + Version: libwallet.EkVersionCurrent, + } + + preMetadataPath := outputPath + ".tmp" + err = os.Remove(preMetadataPath) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + result, err := go_render.Render(ekInput, preMetadataPath, language) + if err != nil { + return nil, err + } + + metadata, err := libwallet.CreateEmergencyKitMetadata(ekParams) + if err != nil { + return nil, fmt.Errorf("GenerateEkHtml failed to create metadata: %w", err) + } + + metadataBytes, err := json.Marshal(&metadata) + if err != nil { + return nil, fmt.Errorf("GenerateEkHtml failed to marshal %s: %w", string(metadataBytes), err) + } + + err = os.Remove(outputPath) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + srcPath := stripFilePrefix(result.Path) + err = libwallet.AddEmergencyKitMetadata(string(metadataBytes), srcPath, outputPath) + if err != nil { + return nil, err + } + + err = os.Remove(preMetadataPath) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + return &GeneratedEKPDF{ + VerificationCode: result.VerificationCode, + Version: result.Version, + }, nil +} + +func stripFilePrefix(path string) string { + if len(path) > 7 && path[:7] == "file://" { + return path[7:] + } + return path +} diff --git a/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go b/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go new file mode 100644 index 00000000..6ff13c20 --- /dev/null +++ b/libwallet/domain/action/security_cards_marketplace/get_security_cards_marketplace_action.go @@ -0,0 +1,69 @@ +package security_cards_marketplace + +import ( + "github.com/muun/libwallet/domain/model/security_cards_marketplace" + "github.com/muun/libwallet/service" + "github.com/muun/libwallet/service/model" +) + +type GetSecurityCardsMarketplaceAction struct { +} + +func NewGetSecurityCardsMarketplaceAction() *GetSecurityCardsMarketplaceAction { + return &GetSecurityCardsMarketplaceAction{} +} + +func (ac *GetSecurityCardsMarketplaceAction) Run() (*security_cards_marketplace.Marketplace, error) { + marketplaceJson := model.SecurityCardsMarketplaceJson{ + Providers: []model.SecurityCardsProviderJson{ + { + Name: "Constellations", + ColorHex: "#B19B6A", + Material: "plastic", + Price: 37500, + ShippingCost: 30000, + CurrencyCode: "ARS", + SecurityCards: []model.SecurityCardJson{ + {Image: "sc_constellations_scorpius", Stock: 10}, + {Image: "sc_constellations_gemini", Stock: 10}, + {Image: "sc_constellations_sagitarius", Stock: 10}, + {Image: "sc_constellations_virgo", Stock: 10}, + }, + }, + { + Name: "Numbers", + ColorHex: "#D9DBDD", + Material: "plastic", + Price: 30000, + ShippingCost: 15000, + CurrencyCode: "ARS", + SecurityCards: []model.SecurityCardJson{ + {Image: "sc_numbers_1", Stock: 10}, + {Image: "sc_numbers_2", Stock: 10}, + {Image: "sc_numbers_3", Stock: 10}, + {Image: "sc_numbers_4", Stock: 10}, + {Image: "sc_numbers_5", Stock: 10}, + {Image: "sc_numbers_6", Stock: 10}, + {Image: "sc_numbers_7", Stock: 10}, + {Image: "sc_numbers_8", Stock: 10}, + {Image: "sc_numbers_9", Stock: 10}, + }, + }, + { + Name: "Planets", + ColorHex: "#158E5A", + Material: "plastic", + Price: 76485, + ShippingCost: 43500, + CurrencyCode: "ARS", + SecurityCards: []model.SecurityCardJson{ + {Image: "sc_planets_earth", Stock: 10}, + {Image: "sc_planets_mars", Stock: 10}, + }, + }, + }, + } + + marketplace, err := service.MapSecurityCardsMarketplace(marketplaceJson) + return marketplace, err +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/colors.go b/libwallet/domain/model/emergency_kit/go_render/assets/colors.go new file mode 100644 index 00000000..70b2bdec --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/colors.go @@ -0,0 +1,57 @@ +package assets + +import ( + "github.com/phpdave11/gofpdf" +) + +func SetTitleColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(24, 36, 73) +} + +func SetSecondaryTextColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(87, 101, 128) +} + +func SetHeaderBackgroundColor(pdf *gofpdf.Fpdf) { + pdf.SetFillColor(247, 251, 255) +} + +func SetBlueLightAltBackgroundColor(pdf *gofpdf.Fpdf) { + pdf.SetFillColor(246, 249, 255) +} + +func SetWhiteBackgroundColor(pdf *gofpdf.Fpdf) { + pdf.SetFillColor(255, 255, 255) +} + +func SetKeysBackgroundColor(pdf *gofpdf.Fpdf) { + pdf.SetFillColor(223, 236, 251) +} + +func SetInstructionNumberBackgroundColor(pdf *gofpdf.Fpdf) { + pdf.SetFillColor(36, 116, 205) +} + +func SetLinkColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(51, 124, 208) +} + +func SetWhiteTextColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(255, 255, 255) +} + +func SetDescriptorDefaultColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(87, 101, 128) +} + +func SetDescriptorFunctionColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(68, 123, 239) +} + +func SetDescriptorFingerprintColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(215, 74, 65) +} + +func SetDescriptorChecksumColor(pdf *gofpdf.Fpdf) { + pdf.SetTextColor(164, 47, 162) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go b/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go new file mode 100644 index 00000000..b03ca1f8 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/fonts.go @@ -0,0 +1,88 @@ +package assets + +import ( + _ "embed" + "github.com/muun/libwallet/data/emergency_kit/resources" + + "github.com/phpdave11/gofpdf" +) + +const ( + keysSectionTitleFontSize = 32 + sectionTitleFontSize = 25 + subtitleFontSize = 21 + verificationCodeFontSize = 20 + KeyBoxTitleFontSize = 18 + bodyParagraphFontSize = 17 + keysHeaderFontSize = 16 + instructionsBadgeFontSize = 14 + encryptedKeyFontSize = 14 + dateFontSize = 13 + descriptorFontSize = 10 + + BodyLetterSpacing = 0.01 +) + +func SetKeysSectionTitleFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, keysSectionTitleFontSize) +} + +func SetSectionTitleFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, sectionTitleFontSize) +} + +func SetSubtitleFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, subtitleFontSize) +} + +func SetVerificationCodeFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMonoRegular(pdf, verificationCodeFontSize) +} + +func SetKeyBoxTitleFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, KeyBoxTitleFontSize) +} + +func SetBodyParagraphFont(pdf *gofpdf.Fpdf) { + setBodyParagraphFont(pdf, false) +} + +func SetBodyParagraphFontUnderlined(pdf *gofpdf.Fpdf) { + setBodyParagraphFont(pdf, true) +} + +func setBodyParagraphFont(pdf *gofpdf.Fpdf, underlined bool) { + if underlined { + resources.SetRobotoRegularUnderlined(pdf, bodyParagraphFontSize) + } else { + resources.SetRobotoRegular(pdf, bodyParagraphFontSize) + } +} + +func SetKeysHeaderSubtitlesFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoRegular(pdf, keysHeaderFontSize) +} + +func SetKeysHeaderSubtitleBoldFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, keysHeaderFontSize) +} + +func SetInstructionsBadgeFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, instructionsBadgeFontSize) +} + +func SetEncryptedKeyFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMonoRegular(pdf, encryptedKeyFontSize) +} + +func SetDateLabelFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoRegular(pdf, dateFontSize) +} + +func SetDateValueFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMedium(pdf, dateFontSize) +} + +func SetDescriptorFont(pdf *gofpdf.Fpdf) { + resources.SetRobotoMonoRegular(pdf, descriptorFontSize) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/images.go b/libwallet/domain/model/emergency_kit/go_render/assets/images.go new file mode 100644 index 00000000..0c6ab57b --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/images.go @@ -0,0 +1,19 @@ +package assets + +import ( + _ "embed" +) + +// Embed image files as binary data in the compiled binary +// This eliminates the need for external image files at runtime + +//go:embed images/padlock.png +var PadlockPNG []byte + +//go:embed images/help.png +var HelpPNG []byte + +const ( + PadlockImageName = "padlock" + HelpImageName = "help" +) diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/images/help.png b/libwallet/domain/model/emergency_kit/go_render/assets/images/help.png new file mode 100644 index 00000000..a1f3d0e2 Binary files /dev/null and b/libwallet/domain/model/emergency_kit/go_render/assets/images/help.png differ diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/images/padlock.png b/libwallet/domain/model/emergency_kit/go_render/assets/images/padlock.png new file mode 100644 index 00000000..c4f9e1cd Binary files /dev/null and b/libwallet/domain/model/emergency_kit/go_render/assets/images/padlock.png differ diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go b/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go new file mode 100644 index 00000000..81f5deef --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable.go @@ -0,0 +1,108 @@ +package assets + +import ( + _ "embed" + "encoding/json" + "github.com/muun/libwallet/data/emergency_kit/resources" + "time" +) + +//go:embed localizable/en.json +var englishJSON []byte + +//go:embed localizable/es.json +var spanishJSON []byte + +type Language string + +const ( + English Language = "en" + Spanish Language = "es" +) + +// Translations contains all translatable strings for the Emergency Kit PDF. +// +// To add a new translatable keyword: +// 1. Add the new field to the appropriate nested struct below with a json tag +// Example: NewField string `json:"new_field"` +// 2. Add the corresponding key-value pair to all JSON files in localizable/ +// (en.json, es.json, and any other language files) +// 3. Use the new field in your component: +// translations.SectionName.NewField +// 4. Run tests to ensure consistency: +// go test ./emergencykit/go_render/assets -v +// +// To add a new language: +// 1. Create a new JSON file in localizable/ (e.g., localizable/fr.json) +// 2. Add an embed directive at the top of this file: +// //go:embed localizable/fr.json +// var frenchJSON []byte +// 3. Add a new Language constant: +// French Language = "fr" +// 4. Update LoadTranslations() function to handle the new language: +// } else if lang == French { +// data = frenchJSON +// 5. Translate all strings from en.json to the new language +// 6. Add the new language to the test arrays in localizable_test.go: +// - In TestTranslationCompleteness: add {French, frenchJSON} +// - In TestTranslationStructMatchesJSON: add {French, frenchJSON} +// 7. Run tests to ensure the new language is complete: +// go test ./emergencykit/go_render/assets -v +type Translations struct { + Lang Language + Header struct { + Title string `json:"title"` + VerificationPrefix string `json:"verification_prefix"` + } `json:"header"` + Keys struct { + EncryptedBackupTitle string `json:"encrypted_backup_title"` + EncryptedBackupDesc1 string `json:"encrypted_backup_desc1"` + EncryptedBackupDesc2 string `json:"encrypted_backup_desc2"` + FirstKeyLabel string `json:"first_key_label"` + SecondKeyLabel string `json:"second_key_label"` + CreatedOnPrefix string `json:"created_on_prefix"` + } `json:"keys"` + Instructions struct { + Title string `json:"title"` + Intro string `json:"intro"` + Step1Title string `json:"step1_title"` + Step1Desc string `json:"step1_desc"` + Step2Title string `json:"step2_title"` + Step2Desc string `json:"step2_desc"` + Step3Title string `json:"step3_title"` + Step3Desc string `json:"step3_desc"` + } `json:"instructions"` + Help struct { + Title string `json:"title"` + Description string `json:"description"` + } `json:"help"` + Advanced struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Intro string `json:"intro"` + Closing1 string `json:"closing1"` + Closing2 string `json:"closing2"` + } `json:"advanced"` +} + +func LoadTranslations(lang Language) (*Translations, error) { + var data []byte + if lang == Spanish { + data = spanishJSON + } else { + data = englishJSON + } + + var t Translations + err := json.Unmarshal(data, &t) + if err != nil { + return nil, err + } + t.Lang = lang + + return &t, nil +} + +func (t *Translations) LocalizedDate(date time.Time) string { + return resources.FormatDate(date, string(t.Lang)) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable/en.json b/libwallet/domain/model/emergency_kit/go_render/assets/localizable/en.json new file mode 100644 index 00000000..1ca9fe27 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable/en.json @@ -0,0 +1,35 @@ +{ + "header": { + "title": "Emergency Kit", + "verification_prefix": "VERIFICATION #" + }, + "keys": { + "encrypted_backup_title": "Encrypted backup", + "encrypted_backup_desc1": "It can only be decrypted using your", + "encrypted_backup_desc2": "Recovery Code.", + "first_key_label": "FIRST KEY", + "second_key_label": "SECOND KEY", + "created_on_prefix": "CREATED ON " + }, + "instructions": { + "title": "Instructions", + "intro": "This emergency procedure will help you recover your funds if you are unable to use Muun on your phone.", + "step1_title": "Find your Recovery Code", + "step1_desc": "You wrote this code on paper before creating your Emergency Kit. You'll need it later.", + "step2_title": "Download the Recovery Tool", + "step2_desc": "Go to github.com/muun/recovery and download the tool on your computer.", + "step3_title": "Recover your funds", + "step3_desc": "Run the Recovery Tool and follow the steps. It will safely transfer your funds to a Bitcoin address that you choose." + }, + "help": { + "title": "Need help?", + "description": "Contact us at support@muun.com. We're always there to help." + }, + "advanced": { + "title": "Advanced information", + "subtitle": "Output descriptors", + "intro": "These descriptors, combined with your keys, specify how to locate your wallet's funds on the Bitcoin blockchain.", + "closing1": "Output descriptors are part of a developing standard for Recovery that Muun intends to support and is helping grow. Since the standard is in a very early stage, the list above includes some non-standard elements.", + "closing2": "When descriptors reach a more mature stage, you'll be able to take your funds from one wallet to another with complete independence. Muun believes this freedom is at the core of Bitcoin's promise, and is working towards that goal." + } +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable/es.json b/libwallet/domain/model/emergency_kit/go_render/assets/localizable/es.json new file mode 100644 index 00000000..b01222e9 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable/es.json @@ -0,0 +1,35 @@ +{ + "header": { + "title": "Kit de Emergencia", + "verification_prefix": "VERIFICACIÓN #" + }, + "keys": { + "encrypted_backup_title": "Respaldo encriptado", + "encrypted_backup_desc1": "Sólo puede ser desencriptado con tu", + "encrypted_backup_desc2": "Código de Recuperación.", + "first_key_label": "PRIMERA CLAVE", + "second_key_label": "SEGUNDA CLAVE", + "created_on_prefix": "CREADO EL " + }, + "instructions": { + "title": "Instrucciones", + "intro": "Este procedimiento de emergencia te ayudará a recuperar tus fondos si no puedes usar Muun en tu teléfono.", + "step1_title": "Encuentra tu Código de Recuperación", + "step1_desc": "Lo escribiste en papel antes de crear tu Kit de Emergencia. Lo necesitarás después.", + "step2_title": "Descarga la Herramienta de Recuperación", + "step2_desc": "Ingresa en github.com/muun/recovery y descarga la herramienta en tu computadora.", + "step3_title": "Recupera tus fondos", + "step3_desc": "Ejecuta la Herramienta de Recuperación y sigue los pasos. Transferirá tus fondos a una dirección de Bitcoin que elijas." + }, + "help": { + "title": "¿Necesitas ayuda?", + "description": "Contáctanos en support@muun.com. Siempre estamos disponibles para ayudar." + }, + "advanced": { + "title": "Información Avanzada", + "subtitle": "Output descriptors", + "intro": "Estos descriptors, combinados con tus claves, indican cómo encontrar los fondos de tu billetera en la blockchain de Bitcoin.", + "closing1": "Los output descriptors son parte de un estándar de recuperación actualmente en desarrollo. Muun tiene la intención de soportar este estándar y apoyar su crecimiento. Dado que se encuentra en una etapa muy temprana, la siguiente lista incluye algunos elementos que aún no están estandarizados.", + "closing2": "Cuando los descriptors lleguen a una etapa más madura, podrás llevar tus fondos de una billetera a la otra con completa independencia. Muun cree que esta libertad es central a la promesa de Bitcoin, y está trabajando para que eso suceda." + } +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go b/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go new file mode 100644 index 00000000..3c66a67d --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/localizable_test.go @@ -0,0 +1,100 @@ +package assets + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestTranslationStructMatchesJSON ensures the Translations struct matches +// the JSON structure exactly - no missing or extra fields. +func TestTranslationStructMatchesJSON(t *testing.T) { + languages := []struct { + name Language + data []byte + }{ + {English, englishJSON}, + {Spanish, spanishJSON}, + } + + for _, lang := range languages { + t.Run(string(lang.name), func(t *testing.T) { + // Parse JSON into a generic map + var jsonMap map[string]interface{} + err := json.Unmarshal(lang.data, &jsonMap) + if err != nil { + t.Fatalf("Failed to unmarshal %s JSON: %v", lang.name, err) + } + + // Validate structure matches + translations := Translations{} + validateJSONStructMatch(t, string(lang.name), "", jsonMap, reflect.TypeOf(translations)) + }) + } +} + +// validateJSONStructMatch checks that JSON keys match struct fields +func validateJSONStructMatch( + t *testing.T, + lang string, + path string, + jsonMap map[string]interface{}, + structType reflect.Type, +) { + if structType.Kind() != reflect.Struct { + return + } + + // Build a map of json tags to field names + jsonTagToField := make(map[string]string) + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + jsonTagToField[jsonTag] = field.Name + } + } + + // Check all JSON keys have corresponding struct fields + for jsonKey, jsonValue := range jsonMap { + fullPath := path + "." + jsonKey + if path == "" { + fullPath = jsonKey + } + + fieldName, exists := jsonTagToField[jsonKey] + if !exists { + t.Errorf("[%s] JSON key '%s' has no corresponding struct field", lang, fullPath) + continue + } + + // Get the struct field + field, found := structType.FieldByName(fieldName) + if !found { + continue + } + + // If it's a nested object, recurse + if nestedMap, ok := jsonValue.(map[string]interface{}); ok { + validateJSONStructMatch(t, lang, fullPath, nestedMap, field.Type) + } + } + + // Check all struct fields have corresponding JSON keys + for jsonTag, fieldName := range jsonTagToField { + _, exists := jsonMap[jsonTag] + if !exists { + fullPath := path + "." + jsonTag + if path == "" { + fullPath = jsonTag + } + t.Errorf( + "[%s] Struct field '%s' (json:\"%s\") has no corresponding JSON key at %s", + lang, + fieldName, + jsonTag, + fullPath, + ) + } + } +} diff --git a/libwallet/domain/model/emergency_kit/go_render/assets/measures.go b/libwallet/domain/model/emergency_kit/go_render/assets/measures.go new file mode 100644 index 00000000..b82a283b --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/assets/measures.go @@ -0,0 +1,36 @@ +package assets + +import ( + "github.com/muun/libwallet/data/emergency_kit/resources" +) + +// Standard padding used throughout the emergency kit +var StandardHorizontalMargin = resources.Mm(16) + +// BodyParagraphLineHeight +// +// Line height are the real size each line requires considering its bottom and top space when +// they are multiline. It dependes on how big the font is, but it goes from fontSize*1.25 to fontSize*1.75 +// In the css all these values are explicit. +var BodyParagraphLineHeight = resources.Mm(24) + +var OutputDescriptorsLineHeight = resources.Mm(23) + +// Section title line height for 24pt medium text +var SectionTitleLineHeight = resources.Mm(36) + +// Subtitle line height for 20pt medium text +var SubtitleLineHeight = resources.Mm(32) + +// Keys section title line height for 32pt medium text +var KeysSectionTitleLineHeight = resources.Mm(42.5) + +var EncryptedKeysTextFontSize = resources.Mm(25.9) + +// Spacing between related elements within a component (e.g., title to description) +var IntraComponentSpacing = resources.Mm(11) + +// Standard icon size for all icons +var PadlockIconSize = resources.Mm(76) + +var HelpIconSize = resources.Mm(68) diff --git a/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go b/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go new file mode 100644 index 00000000..05a9ca9f --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/advanced/advanced_component.go @@ -0,0 +1,139 @@ +package advanced + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +type AdvancedComponent struct { + pdf *emergency_kit.PdfExtensions + Title string + Subtitle string + IntroParagraph string + Descriptors []DescriptorLine + ClosingParagraph1 string + ClosingParagraph2 string +} + +type DescriptorLine struct { + Segments []DescriptorSegment +} + +type SegmentType int + +const ( + SegmentDefault SegmentType = iota + SegmentFunction + SegmentFingerprint + SegmentChecksum +) + +type DescriptorSegment struct { + Text string + Type SegmentType +} + +func NewAdvancedComponent( + pdf *emergency_kit.PdfExtensions, + descriptors []string, + translations *assets.Translations, +) *AdvancedComponent { + parsedDescriptors := make([]DescriptorLine, len(descriptors)) + for i, desc := range descriptors { + parsedDescriptors[i] = parseDescriptor(desc) + } + + return &AdvancedComponent{ + pdf: pdf, + Title: translations.Advanced.Title, + Subtitle: translations.Advanced.Subtitle, + IntroParagraph: translations.Advanced.Intro, + Descriptors: parsedDescriptors, + ClosingParagraph1: translations.Advanced.Closing1, + ClosingParagraph2: translations.Advanced.Closing2, + } +} + +func (r *AdvancedComponent) Height() float64 { + sectionMarginTop := resources.Mm(40) + titleLineHeight := assets.SectionTitleLineHeight + subtitleMarginTop := resources.Mm(24) + subtitleLineHeight := assets.SubtitleLineHeight + introParagraphMarginTop := assets.IntraComponentSpacing + introParagraphLineHeight := assets.BodyParagraphLineHeight + descriptorsMarginTop := assets.StandardHorizontalMargin + descriptorsPadding := assets.StandardHorizontalMargin + descriptorLineHeight := resources.Pt(9) * 2.4 + closingMarginTop := assets.StandardHorizontalMargin + closingLineHeight := assets.BodyParagraphLineHeight + + innerWidth := r.pdf.GetDrawablePageWidth() + + assets.SetBodyParagraphFont(r.pdf.Fpdf) + introLines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.IntroParagraph, assets.BodyLetterSpacing) + + assets.SetDescriptorFont(r.pdf.Fpdf) + closing1Lines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.ClosingParagraph1, assets.BodyLetterSpacing) + closing2Lines := r.pdf.LineCountWithLetterSpacing(innerWidth, r.ClosingParagraph2, assets.BodyLetterSpacing) + + descriptorsHeight := descriptorsPadding + + float64(len(r.Descriptors))*descriptorLineHeight + + descriptorsPadding + + totalHeight := sectionMarginTop + + titleLineHeight + + subtitleMarginTop + subtitleLineHeight + + introParagraphMarginTop + introLines*introParagraphLineHeight + + descriptorsMarginTop + descriptorsHeight + + closingMarginTop + closing1Lines*closingLineHeight + + closingMarginTop + closing2Lines*closingLineHeight + + return totalHeight +} + +func (r *AdvancedComponent) Render() { + innerStartX := assets.StandardHorizontalMargin + innerWidth := r.pdf.GetDrawablePageWidth() + + // Render title "Advanced information" + assets.SetSectionTitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, r.pdf.GetY()+innerStartX) + r.pdf.Cell(0, assets.SectionTitleLineHeight, r.Title) + r.pdf.SetY(r.pdf.GetY() + assets.SectionTitleLineHeight) + + // Render subtitle "Output descriptors" + r.pdf.SetY(r.pdf.GetY() + resources.Mm(24)) + assets.SetSubtitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, r.pdf.GetY()) + r.pdf.Cell(0, assets.SubtitleLineHeight, r.Subtitle) + r.pdf.SetY(r.pdf.GetY() + assets.SubtitleLineHeight) + + // Render intro paragraph + r.pdf.SetY(r.pdf.GetY() + assets.IntraComponentSpacing) + assets.SetBodyParagraphFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, r.pdf.GetY()) + r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.IntroParagraph, assets.BodyLetterSpacing) + + // Render descriptors box + r.renderDescriptors(innerStartX, innerWidth) + + // Render closing paragraph 1 + r.pdf.SetY(r.pdf.GetY() + assets.StandardHorizontalMargin) + assets.SetBodyParagraphFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, r.pdf.GetY()) + r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.ClosingParagraph1, assets.BodyLetterSpacing) + + // Render closing paragraph 2 + r.pdf.SetY(r.pdf.GetY() + assets.StandardHorizontalMargin) + assets.SetBodyParagraphFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, r.pdf.GetY()) + r.pdf.MultiCellWithLetterSpacing(innerWidth, assets.BodyParagraphLineHeight, r.ClosingParagraph2, assets.BodyLetterSpacing) +} + +// renderDescriptors is implemented in descriptors_render.go diff --git a/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go b/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go new file mode 100644 index 00000000..2edd2fbb --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/advanced/descriptors_render.go @@ -0,0 +1,129 @@ +package advanced + +import ( + "encoding/hex" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +var ( + functions = []string{"musig", "multi", "wsh", "wpkh", "pkh", "sh", "tr"} +) + +const fingerprintLength = 8 + +func parseDescriptor(descriptor string) DescriptorLine { + line := DescriptorLine{} + currentText := "" + + for i := 0; i < len(descriptor); i++ { + if descriptor[i] == '#' { + line.addSegment(currentText, SegmentDefault) + currentText = "" + line.addSegment(descriptor[i:], SegmentChecksum) + break + } + + if matched, text := matchFunctionAt(descriptor, i); matched { + line.addSegment(currentText, SegmentDefault) + line.addSegment(text, SegmentFunction) + currentText = "" + i += len(text) - 1 + continue + } + + if matched, text := matchFingerprintAt(descriptor, i); matched { + line.addSegment(currentText, SegmentDefault) + line.addSegment(text, SegmentFingerprint) + currentText = "" + i += len(text) - 1 + continue + } + + currentText += string(descriptor[i]) + } + + line.addSegment(currentText, SegmentDefault) + return line +} + +func matchFunctionAt(text string, pos int) (bool, string) { + for _, fn := range functions { + if pos+len(fn) <= len(text) && text[pos:pos+len(fn)] == fn { + return true, fn + } + } + return false, "" +} + +func matchFingerprintAt(text string, pos int) (bool, string) { + if pos+fingerprintLength > len(text) { + return false, "" + } + + fingerprint := text[pos : pos+fingerprintLength] + _, err := hex.DecodeString(fingerprint) + if err == nil { + return true, fingerprint + } + + return false, "" +} + +func (line *DescriptorLine) addSegment(text string, segmentType SegmentType) { + if text == "" { + return + } + line.Segments = append(line.Segments, DescriptorSegment{ + Text: text, + Type: segmentType, + }) +} + +func (r *AdvancedComponent) renderDescriptors(startX float64, width float64) { + r.pdf.SetY(r.pdf.GetY() + assets.StandardHorizontalMargin) + boxStartY := r.pdf.GetY() + + padding := assets.StandardHorizontalMargin + lineHeight := assets.OutputDescriptorsLineHeight + boxHeight := padding + float64(len(r.Descriptors))*lineHeight + padding + + r.drawDescriptorBox(startX, boxStartY, width, boxHeight) + r.renderDescriptorLines(startX+padding, boxStartY+padding, lineHeight) + + r.pdf.SetY(boxStartY + boxHeight) +} + +func (r *AdvancedComponent) drawDescriptorBox(x float64, y float64, width float64, height float64) { + assets.SetBlueLightAltBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(x, y, width, height, "F") +} + +func (r *AdvancedComponent) renderDescriptorLines(x float64, y float64, lineHeight float64) { + assets.SetDescriptorFont(r.pdf.Fpdf) + currentY := y + + for _, line := range r.Descriptors { + r.pdf.SetXY(x, currentY) + r.renderDescriptorLine(line, lineHeight) + currentY += lineHeight + } +} + +func (r *AdvancedComponent) renderDescriptorLine(line DescriptorLine, lineHeight float64) { + currentX := r.pdf.GetX() + currentY := r.pdf.GetY() + + for _, segment := range line.Segments { + switch segment.Type { + case SegmentDefault: + assets.SetDescriptorDefaultColor(r.pdf.Fpdf) + case SegmentFunction: + assets.SetDescriptorFunctionColor(r.pdf.Fpdf) + case SegmentFingerprint: + assets.SetDescriptorFingerprintColor(r.pdf.Fpdf) + case SegmentChecksum: + assets.SetDescriptorChecksumColor(r.pdf.Fpdf) + } + currentX = r.pdf.RenderTextWithLetterSpacing(currentX, currentY, segment.Text, assets.BodyLetterSpacing, "L", "T", lineHeight) + } +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/header_component.go b/libwallet/domain/model/emergency_kit/go_render/components/header_component.go new file mode 100644 index 00000000..a9cba72c --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/header_component.go @@ -0,0 +1,57 @@ +package components + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +type HeaderComponent struct { + pdf *emergency_kit.PdfExtensions + TitleText string + VerificationText string +} + +func NewHeaderComponent(pdf *emergency_kit.PdfExtensions, verificationCode string, translations *assets.Translations) *HeaderComponent { + return &HeaderComponent{ + pdf: pdf, + TitleText: translations.Header.Title, + VerificationText: translations.Header.VerificationPrefix + verificationCode, + } +} + +func (r *HeaderComponent) Height() float64 { + // Height = line height + 20px top padding + 20px bottom padding + return assets.SectionTitleLineHeight + 2*resources.Mm(20) +} + +func (r *HeaderComponent) Render() { + startY := r.pdf.GetY() + innerStartX := assets.StandardHorizontalMargin + + componentWidth, _ := r.pdf.GetPageSize() + componentHeight := r.Height() + innerWidth := r.pdf.GetDrawablePageWidth() + + r.addBackgroundColor(componentWidth, componentHeight) + + // Render title on the left side + assets.SetSectionTitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, startY) + r.pdf.CellFormat(innerWidth, componentHeight, r.TitleText, "", 2, "LM", false, 0, "") // LM = Left, Middle + + // Render verification code on the right side + assets.SetVerificationCodeFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + r.pdf.SetXY(innerStartX, startY) + r.pdf.CellFormat(innerWidth, componentHeight, r.VerificationText, "", 2, "RM", false, 0, "") // RM = Right, Middle + + // Move cursor to end of header + r.pdf.SetXY(0, startY+componentHeight) +} + +func (r *HeaderComponent) addBackgroundColor(componentWidth float64, componentHeight float64) { + assets.SetHeaderBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(0, 0, componentWidth, componentHeight, "F") +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/help_component.go b/libwallet/domain/model/emergency_kit/go_render/components/help_component.go new file mode 100644 index 00000000..25982610 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/help_component.go @@ -0,0 +1,69 @@ +package components + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +var ( + helpSectionMarginTop = resources.Mm(40) + helpPaddingVertical = resources.Mm(32) + helpIconMarginLeft = resources.Mm(8) + helpIconMarginRight = resources.Mm(8) +) + +type HelpComponent struct { + pdf *emergency_kit.PdfExtensions + Title string + Description string +} + +func NewHelpComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *HelpComponent { + return &HelpComponent{ + pdf: pdf, + Title: translations.Help.Title, + Description: translations.Help.Description, + } +} + +func (r *HelpComponent) Height() float64 { + pageWidth, _ := r.pdf.GetPageSize() + textWidth := pageWidth - helpIconMarginLeft - assets.HelpIconSize - helpIconMarginRight - assets.StandardHorizontalMargin + + assets.SetBodyParagraphFont(r.pdf.Fpdf) + descLines := r.pdf.LineCountWithLetterSpacing(textWidth, r.Description, assets.BodyLetterSpacing) + + textContentHeight := assets.SubtitleLineHeight + assets.IntraComponentSpacing + descLines*assets.BodyParagraphLineHeight + + return helpSectionMarginTop + helpPaddingVertical + textContentHeight +} + +func (r *HelpComponent) Render() { + startY := r.pdf.GetY() + pageWidth, _ := r.pdf.GetPageSize() + height := r.Height() + + assets.SetBlueLightAltBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(0, startY, pageWidth, height, "F") + + iconX := helpIconMarginLeft + iconY := startY + helpPaddingVertical + r.pdf.Image(assets.HelpImageName, iconX, iconY, assets.HelpIconSize, assets.HelpIconSize, false, "", 0, "") + + textX := helpIconMarginLeft + assets.HelpIconSize + helpIconMarginRight + textY := startY + helpPaddingVertical + textWidth := pageWidth - textX - assets.StandardHorizontalMargin + + assets.SetSubtitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(textX, textY) + r.pdf.Cell(textWidth, assets.SubtitleLineHeight, r.Title) + + descY := textY + assets.SubtitleLineHeight + assets.IntraComponentSpacing + + parts := r.pdf.ParseTextWithLinks(r.Description, []string{"support@muun.com"}) + r.pdf.RenderMultiStyledText(textX, descY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 0) + + r.pdf.SetY(startY + height) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go b/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go new file mode 100644 index 00000000..203e3542 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/instructions_component.go @@ -0,0 +1,165 @@ +package components + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +var ( + itemMarginTop = resources.Mm(38) + numberCircleSize = resources.Mm(22) + numberVerticalAdjust = resources.Mm(0.75) + titleVerticalAdjust = resources.Mm(1.15) +) + +type InstructionsComponent struct { + pdf *emergency_kit.PdfExtensions + Title string + Intro string + Items []InstructionItem +} + +type InstructionItem struct { + Number string + Title string + Description string +} + +func NewInstructionsComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *InstructionsComponent { + return &InstructionsComponent{ + pdf: pdf, + Title: translations.Instructions.Title, + Intro: translations.Instructions.Intro, + Items: []InstructionItem{ + { + Number: "1", + Title: translations.Instructions.Step1Title, + Description: translations.Instructions.Step1Desc, + }, + { + Number: "2", + Title: translations.Instructions.Step2Title, + Description: translations.Instructions.Step2Desc, + }, + { + Number: "3", + Title: translations.Instructions.Step3Title, + Description: translations.Instructions.Step3Desc, + }, + }, + } +} + +func (r *InstructionsComponent) Height() float64 { + contentWidth := r.pdf.GetDrawablePageWidth() + + titleHeight := assets.SectionTitleLineHeight + introHeight := r.calculateIntroHeight(contentWidth) + allItemsHeight := r.calculateAllItemsHeight(contentWidth) + + return titleHeight + introHeight + allItemsHeight +} + +func (r *InstructionsComponent) calculateIntroHeight(contentWidth float64) float64 { + assets.SetBodyParagraphFont(r.pdf.Fpdf) + introLines := r.pdf.LineCountWithLetterSpacing(contentWidth, r.Intro, assets.BodyLetterSpacing) + return assets.IntraComponentSpacing + introLines*assets.BodyParagraphLineHeight +} + +func (r *InstructionsComponent) calculateAllItemsHeight(contentWidth float64) float64 { + textWidth := contentWidth - numberCircleSize - assets.StandardHorizontalMargin + totalHeight := itemMarginTop + + for i, item := range r.Items { + if i > 0 { + totalHeight += itemMarginTop + } + totalHeight += r.calculateItemHeight(textWidth, item) + } + + return totalHeight +} + +func (r *InstructionsComponent) calculateItemHeight(textWidth float64, item InstructionItem) float64 { + assets.SetBodyParagraphFont(r.pdf.Fpdf) + descriptionLines := r.pdf.LineCountWithLetterSpacing(textWidth, item.Description, assets.BodyLetterSpacing) + + return assets.SubtitleLineHeight + assets.IntraComponentSpacing + descriptionLines*assets.BodyParagraphLineHeight +} + +func (r *InstructionsComponent) Render() { + r.renderTitle() + r.renderIntro() + r.renderItems() +} + +func (r *InstructionsComponent) renderTitle() { + assets.SetSectionTitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(assets.StandardHorizontalMargin, r.pdf.GetY()) + r.pdf.Cell(0, assets.SectionTitleLineHeight, r.Title) + r.pdf.SetY(r.pdf.GetY() + assets.SectionTitleLineHeight) +} + +func (r *InstructionsComponent) renderIntro() { + r.pdf.SetY(r.pdf.GetY() + assets.IntraComponentSpacing) + assets.SetBodyParagraphFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + r.pdf.SetXY(assets.StandardHorizontalMargin, r.pdf.GetY()) + r.pdf.MultiCellWithLetterSpacing(r.pdf.GetDrawablePageWidth(), assets.BodyParagraphLineHeight, r.Intro, assets.BodyLetterSpacing) +} + +func (r *InstructionsComponent) renderItems() { + r.pdf.SetY(r.pdf.GetY() + itemMarginTop) + for i, item := range r.Items { + if i > 0 { + r.pdf.SetY(r.pdf.GetY() + itemMarginTop) + } + r.renderSingleItem(item) + } +} + +func (r *InstructionsComponent) renderSingleItem(item InstructionItem) { + itemY := r.pdf.GetY() + + r.renderNumberCircle(itemY, item.Number) + + textX := 2*assets.StandardHorizontalMargin + numberCircleSize + textWidth := r.pdf.GetDrawablePageWidth() - numberCircleSize - assets.StandardHorizontalMargin + + r.renderItemTitle(textX, itemY, textWidth, item.Title) + r.renderItemDescription(textX, itemY, textWidth, item.Description) + + r.pdf.SetY(itemY + r.calculateItemHeight(textWidth, item)) +} + +func (r *InstructionsComponent) renderNumberCircle(itemY float64, number string) { + circleRadius := numberCircleSize / 2 + circleX := assets.StandardHorizontalMargin + circleRadius + titleMidY := itemY + assets.SubtitleLineHeight/2 + circleTopY := titleMidY - circleRadius + + assets.SetInstructionNumberBackgroundColor(r.pdf.Fpdf) + r.pdf.Circle(circleX, titleMidY, circleRadius, "F") + + assets.SetInstructionsBadgeFont(r.pdf.Fpdf) + assets.SetWhiteTextColor(r.pdf.Fpdf) + + r.pdf.SetXY(assets.StandardHorizontalMargin, circleTopY+numberVerticalAdjust) + r.pdf.CellFormat(numberCircleSize, numberCircleSize, number, "", 0, "C", false, 0, "") +} + +func (r *InstructionsComponent) renderItemTitle(textX float64, itemY float64, textWidth float64, title string) { + assets.SetSubtitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(textX, itemY+titleVerticalAdjust) + r.pdf.CellFormat(textWidth, assets.SubtitleLineHeight, title, "", 0, "L", false, 0, "") +} + +func (r *InstructionsComponent) renderItemDescription(textX float64, itemY float64, textWidth float64, description string) { + descriptionY := itemY + assets.SubtitleLineHeight + assets.IntraComponentSpacing + + parts := r.pdf.ParseTextWithLinks(description, []string{"github.com/muun/recovery"}) + r.pdf.RenderMultiStyledText(textX, descriptionY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 0) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go new file mode 100644 index 00000000..331317eb --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/key_box_component.go @@ -0,0 +1,83 @@ +package keys + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +var ( + keyBoxHorizontalMargin = resources.Mm(20) + keyBoxTitleToKeySpacing = resources.Mm(12) + + keyBoxInnerTopVerticalMargin = resources.Mm(38) + KeyBoxInnerBottomMargin = resources.Mm(25.5) +) + +const ( + letterSpacing = 0.05 +) + +type singleKeyBox struct { + pdf *emergency_kit.PdfExtensions + TitleText string + KeyText string +} + +func newSingleKeyBox(pdf *emergency_kit.PdfExtensions, titleText string, keyText string) *singleKeyBox { + return &singleKeyBox{ + pdf: pdf, + TitleText: titleText, + KeyText: keyText, + } +} + +func (r *singleKeyBox) Height() float64 { + return keyBoxInnerTopVerticalMargin + r.calculateTitlePlusTextHeight() + KeyBoxInnerBottomMargin +} + +func (r *singleKeyBox) keyTextHeight() float64 { + assets.SetEncryptedKeyFont(r.pdf.Fpdf) + keyLines := r.pdf.LineCountWithLetterSpacing(r.getBoxContentWidth(), r.KeyText, assets.BodyLetterSpacing) + return keyLines * assets.EncryptedKeysTextFontSize +} + +func (r *singleKeyBox) Render() { + startY := r.pdf.GetY() + contentWidth := r.getBoxContentWidth() + boxHeight := r.Height() + + assets.SetWhiteBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(keyBoxHorizontalMargin, startY, r.getBoxTotalWidth(), boxHeight, "F") + + innerY := startY + keyBoxInnerTopVerticalMargin + innerX := keyBoxHorizontalMargin + assets.StandardHorizontalMargin + + assets.SetKeyBoxTitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.RenderTextWithLetterSpacing(innerX, innerY, r.TitleText, letterSpacing, "L", "T") + + assets.SetEncryptedKeyFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + keyTextY := r.pdf.GetY() + keyBoxTitleToKeySpacing + resources.Mm(assets.KeyBoxTitleFontSize) + r.pdf.SetXY(innerX, keyTextY) + + // EncryptedKeysTextFontSize gives a 1.86 ratio for the KeyText 13px font. This high ratio + // is intentional to improve readability of dense monospace text. + r.pdf.MultiCellWithLetterSpacing(contentWidth, assets.EncryptedKeysTextFontSize, r.KeyText, assets.BodyLetterSpacing) + + r.pdf.SetY(startY + boxHeight) +} + +func (r *singleKeyBox) getBoxContentWidth() float64 { + return r.getBoxTotalWidth() - 2*assets.StandardHorizontalMargin +} + +func (r *singleKeyBox) getBoxTotalWidth() float64 { + pageWidth, _ := r.pdf.GetPageSize() + return pageWidth - 2*keyBoxHorizontalMargin +} + +func (r *singleKeyBox) calculateTitlePlusTextHeight() float64 { + return resources.Mm(assets.KeyBoxTitleFontSize) + r.keyTextHeight() + keyBoxTitleToKeySpacing +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go new file mode 100644 index 00000000..6b7168d1 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_component.go @@ -0,0 +1,105 @@ +package keys + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" + "strings" + "time" +) + +var ( + keyBoxSpacing = resources.Mm(4) + dateBoxSize = resources.Mm(56) +) + +type KeysComponent struct { + pdf *emergency_kit.PdfExtensions + FirstKeyText string + SecondKeyText string + Translations *assets.Translations +} + +func NewKeysComponent( + pdf *emergency_kit.PdfExtensions, + firstKey string, + secondKey string, + translations *assets.Translations, +) *KeysComponent { + return &KeysComponent{ + pdf: pdf, + FirstKeyText: firstKey, + SecondKeyText: secondKey, + Translations: translations, + } +} + +func (r *KeysComponent) Height() float64 { + header := NewKeysHeaderComponent(r.pdf, r.Translations) + firstBox := newSingleKeyBox(r.pdf, r.Translations.Keys.FirstKeyLabel, r.FirstKeyText) + secondBox := newSingleKeyBox(r.pdf, r.Translations.Keys.SecondKeyLabel, r.SecondKeyText) + + return header.Height() + firstBox.Height() + keyBoxSpacing + secondBox.Height() + dateBoxSize +} + +func (r *KeysComponent) Render() { + startY := r.pdf.GetY() + + assets.SetKeysBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(assets.StandardHorizontalMargin, startY, r.pdf.GetDrawablePageWidth(), r.Height(), "F") + r.pdf.SetY(startY) + + header := NewKeysHeaderComponent(r.pdf, r.Translations) + header.Render() + + firstBox := newSingleKeyBox(r.pdf, r.Translations.Keys.FirstKeyLabel, r.FirstKeyText) + firstBox.Render() + + r.pdf.SetY(r.pdf.GetY() + keyBoxSpacing) + + secondBox := newSingleKeyBox(r.pdf, r.Translations.Keys.SecondKeyLabel, r.SecondKeyText) + secondBox.Render() + + r.renderDate() +} + +func (r *KeysComponent) renderDate() { + startY := r.pdf.GetY() + + assets.SetKeysBackgroundColor(r.pdf.Fpdf) + r.pdf.Rect(assets.StandardHorizontalMargin, startY, r.pdf.GetDrawablePageWidth(), dateBoxSize, "F") + + prefix := r.Translations.Keys.CreatedOnPrefix + " " + date := strings.ToUpper(r.Translations.LocalizedDate(time.Now())) + + // Calculate widths for each styled part + assets.SetDateLabelFont(r.pdf.Fpdf) + prefixWidth := r.pdf.GetStringWidthWithLetterSpacing(prefix, letterSpacing) + + // GetStringWidthWithLetterSpacing removes trailing spacing, but we need it between prefix and date + fontSizePt, _ := r.pdf.GetFontSize() + fontSizeMm := resources.PtToMm(fontSizePt) + letterSpacingAmount := fontSizeMm * letterSpacing + + assets.SetDateValueFont(r.pdf.Fpdf) + dateWidth := r.pdf.GetStringWidthWithLetterSpacing(date, letterSpacing) + + // With center alignment, compensate for first char centering + firstDateCharWidth := r.pdf.GetStringWidth(string(date[0])) + + // Total width includes the letter spacing between prefix and date, minus centering compensation + totalWidth := prefixWidth + letterSpacingAmount + dateWidth - firstDateCharWidth/2 + pageWidth, _ := r.pdf.GetPageSize() + centeredX := (pageWidth - totalWidth) / 2 + + assets.SetDateLabelFont(r.pdf.Fpdf) + assets.SetSecondaryTextColor(r.pdf.Fpdf) + prefixEndX := r.pdf.RenderTextWithLetterSpacing(centeredX, startY, prefix, letterSpacing, "C", "M", dateBoxSize) + + assets.SetDateValueFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + dateStartX := prefixEndX + letterSpacingAmount - firstDateCharWidth/2 + r.pdf.RenderTextWithLetterSpacing(dateStartX, startY, date, letterSpacing, "C", "M", dateBoxSize) + + r.pdf.SetY(startY + dateBoxSize) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go new file mode 100644 index 00000000..82c49dd4 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/components/keys/keys_header_component.go @@ -0,0 +1,71 @@ +package keys + +import ( + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" +) + +var ( + iconMarginTop = resources.Mm(10) + titleY = resources.Mm(30) + iconMarginX = resources.Mm(4) + subtitleToBottomSpace = resources.Mm(31) + titleToSubtitleSpace = resources.Mm(4) +) + +type KeysHeaderComponent struct { + pdf *emergency_kit.PdfExtensions + Translations *assets.Translations +} + +func NewKeysHeaderComponent(pdf *emergency_kit.PdfExtensions, translations *assets.Translations) *KeysHeaderComponent { + return &KeysHeaderComponent{ + pdf: pdf, + Translations: translations, + } +} + +func (r *KeysHeaderComponent) Height() float64 { + iconTrailing := iconMarginX + assets.PadlockIconSize + textWidth := r.pdf.GetDrawablePageWidth() - iconTrailing + + assets.SetKeysHeaderSubtitlesFont(r.pdf.Fpdf) + fullText := r.Translations.Keys.EncryptedBackupDesc1 + " " + r.Translations.Keys.EncryptedBackupDesc2 + subtitleLines := r.pdf.LineCountWithLetterSpacing(textWidth, fullText, assets.BodyLetterSpacing) + if subtitleLines < 2 { + subtitleLines = 2 + } + + totalTextHeightWithSpaces := assets.KeysSectionTitleLineHeight + titleToSubtitleSpace + subtitleLines*assets.BodyParagraphLineHeight + + return assets.StandardHorizontalMargin + totalTextHeightWithSpaces + subtitleToBottomSpace +} + +func (r *KeysHeaderComponent) Render() { + startY := r.pdf.GetY() + + iconX := assets.StandardHorizontalMargin + iconMarginX + iconY := startY + assets.StandardHorizontalMargin + iconMarginTop + r.pdf.Image(assets.PadlockImageName, iconX, iconY, assets.PadlockIconSize, assets.PadlockIconSize, false, "", 0, "") + + titleX := iconX + assets.PadlockIconSize + relativeToComponentYTitleY := startY + titleY + + assets.SetKeysSectionTitleFont(r.pdf.Fpdf) + assets.SetTitleColor(r.pdf.Fpdf) + r.pdf.SetXY(titleX, relativeToComponentYTitleY) + r.pdf.CellFormat(0, assets.KeysSectionTitleLineHeight, r.Translations.Keys.EncryptedBackupTitle, "", 2, "L", false, 0, "") + + subtitleY := r.pdf.GetY() + titleToSubtitleSpace + textWidth := r.pdf.GetDrawablePageWidth() - (titleX - assets.StandardHorizontalMargin) + + parts := []emergency_kit.TextPart{ + {Text: r.Translations.Keys.EncryptedBackupDesc1, SetFont: assets.SetKeysHeaderSubtitlesFont, SetColor: assets.SetSecondaryTextColor}, + {Text: r.Translations.Keys.EncryptedBackupDesc2, SetFont: assets.SetKeysHeaderSubtitleBoldFont, SetColor: assets.SetTitleColor}, + } + + _ = r.pdf.RenderMultiStyledText(titleX, subtitleY, textWidth, assets.BodyParagraphLineHeight, parts, assets.BodyLetterSpacing, 2) + + r.pdf.SetY(startY + r.Height()) +} diff --git a/libwallet/domain/model/emergency_kit/go_render/render.go b/libwallet/domain/model/emergency_kit/go_render/render.go new file mode 100644 index 00000000..2de196d6 --- /dev/null +++ b/libwallet/domain/model/emergency_kit/go_render/render.go @@ -0,0 +1,106 @@ +package go_render + +import ( + "fmt" + "github.com/muun/libwallet/data/emergency_kit" + "github.com/muun/libwallet/data/emergency_kit/resources" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/assets" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/components" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/components/advanced" + "github.com/muun/libwallet/domain/model/emergency_kit/go_render/components/keys" + "github.com/muun/libwallet/emergencykit" +) + +// GeneratedEKPDF is a model including the path in which Libwallet left the generated pdf, the verificationCode and +// the version +type GeneratedEKPDF struct { + Path string + VerificationCode string + Version int +} + +func Render( + ekInput *emergencykit.Input, + expectedFilePath string, + lang string, +) (*GeneratedEKPDF, error) { + verificationCode := emergencykit.GenerateDeterministicCode(ekInput) + + translations, err := loadTranslations(lang) + if err != nil { + return nil, fmt.Errorf("failed to load translations: %w", err) + } + + fmt.Println("Creating PDF with custom page size...") + + ctx := emergency_kit.RenderingContext{ + NonDrawableHorizontalMargins: assets.StandardHorizontalMargin, + Images: []emergency_kit.ImageAsset{ + {Name: assets.PadlockImageName, Format: "png", Data: assets.PadlockPNG}, + {Name: assets.HelpImageName, Format: "png", Data: assets.HelpPNG}, + }, + TextStyling: emergency_kit.TextStyling{ + SetBodyFont: assets.SetBodyParagraphFont, + SetBodyColor: assets.SetSecondaryTextColor, + SetLinkFont: assets.SetBodyParagraphFontUnderlined, + SetLinkColor: assets.SetLinkColor, + }, + } + pdfExt := emergency_kit.CreateAndSetupPdf(ctx) + + pdfExt.AddPage() + pdfExt.SetXY(0, 0) + + // Render sections: + components.NewHeaderComponent(pdfExt, verificationCode, translations).Render() + + pdfExt.SetY(pdfExt.GetY() + resources.Mm(25.5)) + + keys.NewKeysComponent( + pdfExt, + ekInput.FirstEncryptedKey, + ekInput.SecondEncryptedKey, + translations, + ).Render() + + pdfExt.AddComponentSeparator() + + components.NewInstructionsComponent(pdfExt, translations).Render() + + pdfExt.AddComponentSeparator() + + components.NewHelpComponent(pdfExt, translations).Render() + + pdfExt.AddPage() + pdfExt.SetXY(0, 0) + + descriptorsData := &emergencykit.DescriptorsData{ + FirstFingerprint: ekInput.FirstFingerprint, + SecondFingerprint: ekInput.SecondFingerprint, + } + descriptors := emergencykit.GetDescriptors(descriptorsData) + + advanced.NewAdvancedComponent(pdfExt, descriptors, translations).Render() + + // Save PDF to file + err = pdfExt.OutputFileAndClose(expectedFilePath) + if err != nil { + return nil, fmt.Errorf("failed to save PDF: %w", err) + } + + generatedEKit := &GeneratedEKPDF{ + Path: expectedFilePath, + VerificationCode: verificationCode, + Version: ekInput.Version, + } + return generatedEKit, nil +} + +func loadTranslations(lang string) (*assets.Translations, error) { + language := assets.English + if lang == "es" { + language = assets.Spanish + } + + return assets.LoadTranslations(language) +} diff --git a/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go b/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go new file mode 100644 index 00000000..6d53cf5d --- /dev/null +++ b/libwallet/domain/model/security_cards_marketplace/SecurityCardsMarketplace.go @@ -0,0 +1,20 @@ +package security_cards_marketplace + +type Marketplace struct { + Providers []SecurityCardsProvider +} + +type SecurityCardsProvider struct { + Name string + SecurityCards []SecurityCard + CurrencyCode string + ColorHex string + Material string + Price float64 + ShippingCost float64 +} + +type SecurityCard struct { + Image string + Stock int32 +} diff --git a/libwallet/domain/nfc/protocol_common.go b/libwallet/domain/nfc/protocol_common.go index 8107935d..b442a6bb 100644 --- a/libwallet/domain/nfc/protocol_common.go +++ b/libwallet/domain/nfc/protocol_common.go @@ -88,7 +88,7 @@ func parseMetadata(data []byte) (*CardMetadata, error) { // - Pairing Slot (2 bytes): Index identifying the pairing slot on the card // - Metadata (75 bytes): Card metadata information (parsed separately) // - MAC (32 bytes): Message Authentication Code for integrity verification -// - Global Signature (variable, 70-72 bytes): Digital signature for authentication +// - Global Signature (variable, max 72 bytes): Digital signature for authentication // // The total expected format is: P (65) || index (2) || metadata (75) || mac (32) || signature (variable) // @@ -100,9 +100,9 @@ func parseMetadata(data []byte) (*CardMetadata, error) { // - error: Parsing error if data is malformed, too short, or contains invalid values // // The function validates: -// - Minimum data length (must be at least PairResponseSize) +// - Minimum data length (must be at least PairResponseSize). Ignores signature's length // - Card public key format (must be a valid Secp256r1 point) -// - Global signature length (must be between 70-72 bytes) +// - Global signature length (must be maximum 72 bytes) func parsePairingResponse(data []byte) (*PairingResponse, error) { // TODO: this doesn't take into account signature size @@ -155,7 +155,7 @@ func parsePairingResponse(data []byte) (*PairingResponse, error) { pairingResp.GlobalSignature = remainingBytes globalSignatureLength := len(pairingResp.GlobalSignature) - if globalSignatureLength < 70 || globalSignatureLength > 72 { + if globalSignatureLength > 72 { return nil, fmt.Errorf("invalid global signature length: %v", globalSignatureLength) } diff --git a/libwallet/domain/nfc/protocol_common_test.go b/libwallet/domain/nfc/protocol_common_test.go index d3dd857f..31d75ae8 100644 --- a/libwallet/domain/nfc/protocol_common_test.go +++ b/libwallet/domain/nfc/protocol_common_test.go @@ -173,15 +173,6 @@ func TestParsePairingResponse_ErrorScenarios(t *testing.T) { }, expectError: "failed to parse metadata:", }, - { - name: "global signature too short - 69 bytes", - data: func() []byte { - validData := createValidPairingResponseData(t) - // Remove last byte to make signature 69 bytes (invalid) - return validData[:len(validData)-1] - }, - expectError: "invalid global signature length", - }, { name: "global signature too long - 73 bytes", data: func() []byte { diff --git a/libwallet/emergency_kit.go b/libwallet/emergency_kit.go index 0222c92c..ecfd0478 100755 --- a/libwallet/emergency_kit.go +++ b/libwallet/emergency_kit.go @@ -14,8 +14,10 @@ const ( // EKVersionDescriptors is the first PDF including the descriptors EKVersionDescriptors = 2 // EKVersionMusig add the musig descriptors - EKVersionMusig = 3 - ekVersionCurrent = EKVersionMusig + EKVersionMusig = 3 + // This is public because this is being consumed by the new architecture inside emergency_kit packages. + // TODO: execute the non-trivial refactor to migrate this inside emergency kit package + EkVersionCurrent = EKVersionMusig ) // EKInput input struct to fill the PDF @@ -46,7 +48,9 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er FirstFingerprint: ekParams.FirstFingerprint, SecondEncryptedKey: ekParams.SecondEncryptedKey, SecondFingerprint: ekParams.SecondFingerprint, - Version: ekVersionCurrent, + // This is public because this is being consumed by the new architecture inside emergency_kit packages. + // TODO: execute the non-trivial refactor to migrate this inside emergency kit package + Version: EkVersionCurrent, } // Create the HTML and the verification code: @@ -56,7 +60,7 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er } // Create and serialize the metadata: - metadata, err := createEmergencyKitMetadata(ekParams) + metadata, err := CreateEmergencyKitMetadata(ekParams) if err != nil { return nil, fmt.Errorf("GenerateEkHtml failed to create metadata: %w", err) } @@ -79,6 +83,8 @@ func GenerateEmergencyKitHTML(ekParams *EKInput, language string) (*EKOutput, er // AddEmergencyKitMetadata produces a copy of the PDF file at `srcFile` with embedded metadata, // writing it into `dstFile`. The provided metadata must be the same opaque string produced by // `GenerateEmergencyKitHTML`. +// This is public because this is being consumed by the new architecture inside emergency_kit packages. +// TODO: execute the non-trivial refactor to migrate this inside emergency kit package func AddEmergencyKitMetadata(metadataText string, srcFile string, dstFile string) error { // Initialize the MetadataWriter: metadataWriter := &emergencykit.MetadataWriter{ @@ -102,7 +108,9 @@ func AddEmergencyKitMetadata(metadataText string, srcFile string, dstFile string return nil } -func createEmergencyKitMetadata(ekParams *EKInput) (*emergencykit.Metadata, error) { +// This is public because this is being consumed by the new architecture inside emergency_kit packages. +// TODO: execute the non-trivial refactor to migrate this inside emergency kit package +func CreateEmergencyKitMetadata(ekParams *EKInput) (*emergencykit.Metadata, error) { // NOTE: // This method would be more naturally placed in the `emergencykit` module, but given the current // project structure (heavily determined by `gomobile` and the need for top-level bindings) and @@ -133,7 +141,7 @@ func createEmergencyKitMetadata(ekParams *EKInput) (*emergencykit.Metadata, erro } metadata := &emergencykit.Metadata{ - Version: ekVersionCurrent, + Version: EkVersionCurrent, BirthdayBlock: secondKey.Birthday, EncryptedKeys: keys, OutputDescriptors: descriptors, diff --git a/libwallet/emergencykit/content.go b/libwallet/emergencykit/content.go index 282c81d2..c61bb2c4 100644 --- a/libwallet/emergencykit/content.go +++ b/libwallet/emergencykit/content.go @@ -181,7 +181,7 @@ const contentES = `

Instrucciones

-

Éste procedimiento de emergencia te ayudará a recuperar tus fondos si no puedes usar Muun en tu teléfono.

+

Este procedimiento de emergencia te ayudará a recuperar tus fondos si no puedes usar Muun en tu teléfono.

@@ -199,7 +199,7 @@ const contentES = `

Descarga la Herramienta de Recuperación

-

Ingresa en github.com/muun/recovery y descarga la herramienta en tu computadora..

+

Ingresa en github.com/muun/recovery y descarga la herramienta en tu computadora.

@@ -252,7 +252,7 @@ incluye algunos elementos que aún no están estandarizados.

Cuando los descriptors lleguen a una etapa más madura, podrás llevar tus fondos de una billetera a la otra con completa -independencia. Muun cree que ésta libertad es central a la promesa de Bitcoin, y está trabajando para que eso suceda. +independencia. Muun cree que esta libertad es central a la promesa de Bitcoin, y está trabajando para que eso suceda.

` diff --git a/libwallet/emergencykit/emergencykit.go b/libwallet/emergencykit/emergencykit.go index 2ff0b279..1d962bba 100644 --- a/libwallet/emergencykit/emergencykit.go +++ b/libwallet/emergencykit/emergencykit.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "fmt" + "github.com/muun/libwallet/data/emergency_kit/resources" "strconv" "text/template" "time" @@ -24,24 +25,9 @@ type Output struct { VerificationCode string } -var spanishMonthNames = []string{ - "Enero", - "Febrero", - "Marzo", - "Abril", - "Mayo", - "Junio", - "Julio", - "Agosto", - "Septiembre", - "Octubre", - "Noviembre", - "Diciembre", -} - // GenerateHTML returns the translated emergency kit html as a string along with the verification code. func GenerateHTML(params *Input, lang string) (*Output, error) { - verificationCode := generateDeterministicCode(params) + verificationCode := GenerateDeterministicCode(params) // Render output descriptors: var descriptors string @@ -61,7 +47,7 @@ func GenerateHTML(params *Input, lang string) (*Output, error) { // Computed by us: VerificationCode: verificationCode, - CurrentDate: formatDate(time.Now(), lang), + CurrentDate: resources.FormatDate(time.Now(), lang), Descriptors: descriptors, // Template pieces separated for reuse: @@ -87,20 +73,8 @@ func GenerateHTML(params *Input, lang string) (*Output, error) { }, nil } -func formatDate(t time.Time, lang string) string { - if lang == "en" { - return t.Format("January 2, 2006") - - } else { - // Golang has no i18n facilities, so we do our own formatting. - year, month, day := t.Date() - monthName := spanishMonthNames[month-1] - - return fmt.Sprintf("%d de %s, %d", day, monthName, year) - } -} -func generateDeterministicCode(params *Input) string { +func GenerateDeterministicCode(params *Input) string { // NOTE: // This function creates a stable verification code given the inputs to render the Emergency Kit. For now, the // implementation relies exclusively on the SecondEncryptedKey, which is the Muun key. This is obviously not ideal, diff --git a/libwallet/emergencykit/emergencykit_test.go b/libwallet/emergencykit/emergencykit_test.go index 23670fa2..8e9ee748 100644 --- a/libwallet/emergencykit/emergencykit_test.go +++ b/libwallet/emergencykit/emergencykit_test.go @@ -96,7 +96,7 @@ func TestGenerateDeterministicCode(t *testing.T) { // Do the thing: for _, testCase := range versionExpectedCodes { input.Version = testCase.version - code := generateDeterministicCode(input) + code := GenerateDeterministicCode(input) if code != testCase.expectedCode { t.Fatalf("expected code from %+v to be %s, not %s", input, testCase.expectedCode, code) diff --git a/libwallet/features.go b/libwallet/features.go index 89c9b269..e75ea016 100644 --- a/libwallet/features.go +++ b/libwallet/features.go @@ -17,6 +17,7 @@ const ( BackendFeatureNfcSensors = "NFC_SENSORS" BackendFeatureDiagnosticMode = "DIAGNOSTIC_MODE" BackendFeatureSecurityCardsMarketplace = "SECURITY_CARDS_MARKETPLACE" + BackendFeatureEkGoRendering = "EK_GO_RENDERING" BackendFeatureUnsupported = "UNSUPPORTED_FEATURE" diff --git a/libwallet/go.mod b/libwallet/go.mod index 5abf7617..53ab501d 100644 --- a/libwallet/go.mod +++ b/libwallet/go.mod @@ -11,17 +11,18 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/fiatjaf/go-lnurl v1.13.1 github.com/google/uuid v1.6.0 + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/jinzhu/gorm v1.9.16 github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f github.com/lightningnetwork/lnd v0.18.0-beta github.com/lightningnetwork/lnd/tlv v1.2.3 github.com/pdfcpu/pdfcpu v0.3.11 + github.com/phpdave11/gofpdf v1.4.3 github.com/pkg/errors v0.9.1 github.com/shopspring/decimal v1.2.0 github.com/stretchr/testify v1.9.0 github.com/test-go/testify v1.1.4 golang.org/x/crypto v0.25.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.36.3 gopkg.in/gormigrate.v1 v1.6.0 @@ -67,7 +68,6 @@ require ( github.com/google/btree v1.0.1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/websocket v1.5.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect @@ -171,6 +171,7 @@ require ( golang.org/x/tools v0.23.0 // indirect google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/libwallet/go.sum b/libwallet/go.sum index e0107f6a..361b5ddf 100644 --- a/libwallet/go.sum +++ b/libwallet/go.sum @@ -64,6 +64,7 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -410,6 +411,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= @@ -524,6 +526,9 @@ github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4a github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= github.com/pdfcpu/pdfcpu v0.3.11 h1:T5XLD5blrB61tBjkSrQnwikrQO4gmwQm61fsyGZa04w= github.com/pdfcpu/pdfcpu v0.3.11/go.mod h1:SZ51teSs9l709Xim2VEuOYGf+uf7RdH2eY0LrXvz7n8= +github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= +github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= +github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -559,6 +564,7 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= @@ -706,6 +712,7 @@ golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63/go.mod h1:UH99kUObWAZk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= diff --git a/libwallet/internal/kvmigrationlock/kv_migration_lock.go b/libwallet/internal/kvmigrationlock/kv_migration_lock.go new file mode 100644 index 00000000..f06bcb7d --- /dev/null +++ b/libwallet/internal/kvmigrationlock/kv_migration_lock.go @@ -0,0 +1,183 @@ +package kvmigrationlock + +import ( + "bytes" + "crypto/sha256" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "sort" + + "github.com/muun/libwallet/storage" +) + +// Lockfile represents the structure of the lockfile on disk. +type Lockfile struct { + Version int `json:"version"` + Migrations []MigrationLock `json:"migrations"` +} + +// MigrationLock holds the hash of a single migration and the individual hashes of its changes. +type MigrationLock struct { + Description string `json:"description"` + Hash string `json:"hash"` + ChangeHashes []string `json:"change_hashes"` +} + +// Generate produces a Lockfile from a migration plan by hashing each change. +// migrationsFilePath must point to the Go source file that defines the plan, +// so that CustomChange function literals can be located and hashed via AST. +func Generate(plan []storage.Migration, migrationsFilePath string) (*Lockfile, error) { + fset := token.NewFileSet() + // The 0 mode flag intentionally excludes comments from the AST, so that changing a comment + // inside an AddCustomChange literal does not affect its hash and invalidate the lockfile. + fileNode, err := parser.ParseFile(fset, migrationsFilePath, nil, 0) + if err != nil { + return nil, fmt.Errorf("could not parse %s: %w", migrationsFilePath, err) + } + + lockfile := &Lockfile{ + Version: 1, + Migrations: make([]MigrationLock, 0, len(plan)), + } + + for _, migration := range plan { + lock := MigrationLock{Description: migration.Description} + + for _, change := range migration.Changes { + h, err := hashChange(change, fset, fileNode) + if err != nil { + return nil, fmt.Errorf("migration '%s': %w", migration.Description, err) + } + lock.ChangeHashes = append(lock.ChangeHashes, h) + } + + // Hash the migration as description + change hashes in order. + // Order is intentionally preserved: swapping two changes within a migration must be detected. + hw := sha256.New() + hw.Write([]byte(migration.Description)) + for _, ch := range lock.ChangeHashes { + hw.Write([]byte(ch)) + } + lock.Hash = fmt.Sprintf("sha256:%x", hw.Sum(nil)) + + lockfile.Migrations = append(lockfile.Migrations, lock) + } + + return lockfile, nil +} + +func hashChange(change storage.Change, fset *token.FileSet, fileNode *ast.File) (string, error) { + content, err := stableChangeString(change, fset, fileNode) + if err != nil { + return "", err + } + return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), nil +} + +// stableChangeString produces a deterministic string representation of a Change for hashing. +// json.Marshal is not used because ValueType is an interface and would serialize to {} for all types. +func stableChangeString(change storage.Change, fset *token.FileSet, fileNode *ast.File) (string, error) { + switch c := change.(type) { + case storage.KeyDefinition: + return fmt.Sprintf("KeyDefinition{Key:%s, BackupType:%d, BackupSecurity:%d, SecurityCritical:%v, ValueType:%T}", + c.Key, c.BackupType, c.BackupSecurity, c.SecurityCritical, c.ValueType), nil + + case storage.TypeMigration: + return fmt.Sprintf("TypeMigration{Key:%s, NewType:%T}", + c.Key, c.NewType), nil + + case storage.MappedTypeMigration: + return fmt.Sprintf("MappedTypeMigration{Key:%s, NewType:%T, Map:%s}", + c.Key, c.NewType, stableMapString(c.OldToNewMap)), nil + + case storage.MapUpdate: + return fmt.Sprintf("MapUpdate{Key:%s, Map:%s}", + c.Key, stableMapString(c.OldToNewMap)), nil + + case storage.CustomChange: + src, err := findCustomChangeSource(c.ID, fset, fileNode) + if err != nil { + return "", err + } + return fmt.Sprintf("CustomChange{ID:%s, Step:%s}", c.ID, src), nil + + default: + return "", fmt.Errorf("unknown change type %T", change) + } +} + +// findCustomChangeSource finds and formats the function literal passed to AddCustomChange("id", ...) +// by matching the ID string argument in the AST. +func findCustomChangeSource(id string, fset *token.FileSet, fileNode *ast.File) (string, error) { + var source string + var found bool + var findErr error + + ast.Inspect(fileNode, func(n ast.Node) bool { + if found || findErr != nil { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + ident, ok := call.Fun.(*ast.Ident) + if !ok || ident.Name != "AddCustomChange" { + return true + } + if len(call.Args) != 2 { + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok || lit.Value != fmt.Sprintf("%q", id) { + return true + } + funcLit, ok := call.Args[1].(*ast.FuncLit) + if !ok { + findErr = fmt.Errorf("second argument of AddCustomChange(%q, ...) is not a function literal", id) + return false + } + var buf bytes.Buffer + + // format.Node produces the canonical representation of the code, + // normalizing whitespace and empty lines so they don't affect the hash. + if err := format.Node(&buf, fset, funcLit); err != nil { + findErr = fmt.Errorf("failed to format function literal for id %q: %w", id, err) + return false + } + source = buf.String() + found = true + return false + }) + + if findErr != nil { + return "", findErr + } + if !found { + return "", fmt.Errorf("AddCustomChange(%q, ...) not found in migrations file", id) + } + return source, nil +} + +// stableMapString produces a deterministic string for a map by sorting keys first. +func stableMapString(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteString("{") + for i, k := range keys { + if i > 0 { + buf.WriteString(", ") + } + fmt.Fprintf(&buf, "%s:%s", k, m[k]) + } + buf.WriteString("}") + return buf.String() +} diff --git a/libwallet/librs/Cargo.lock b/libwallet/librs/Cargo.lock index 21fa1992..5c0102da 100644 --- a/libwallet/librs/Cargo.lock +++ b/libwallet/librs/Cargo.lock @@ -10,6 +10,7 @@ checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "const-random", + "getrandom", "once_cell", "version_check", "zerocopy 0.7.35", @@ -43,6 +44,7 @@ checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" name = "bindings" version = "0.0.0" dependencies = [ + "ahash", "anyhow", "libc", "plonky2-cosigning-key-validation", @@ -246,6 +248,7 @@ dependencies = [ name = "generate" version = "0.0.0" dependencies = [ + "ahash", "hex", "plonky2-cosigning-key-validation", ] diff --git a/libwallet/librs/Cargo.toml b/libwallet/librs/Cargo.toml index 5c426872..3a9ba423 100644 --- a/libwallet/librs/Cargo.toml +++ b/libwallet/librs/Cargo.toml @@ -7,6 +7,9 @@ lto = true [workspace.dependencies] plonky2-cosigning-key-validation = { path = "../../prover/libs/plonky2-cosigning-key-validation" } +# Force ahash to use runtime RNG, instead of compile time RNG to avoid breaking reproducible builds +# More info: https://github.com/muun/muun/pull/15939 +ahash = { version = "0.8.11", features = ["runtime-rng"] } [patch.crates-io] # Include https://github.com/eira-fransham/crunchy/pull/17 so cross compilation to windows works diff --git a/libwallet/librs/bindings/Cargo.toml b/libwallet/librs/bindings/Cargo.toml index cd8155e4..a034c7af 100644 --- a/libwallet/librs/bindings/Cargo.toml +++ b/libwallet/librs/bindings/Cargo.toml @@ -7,6 +7,7 @@ crate-type = ["staticlib"] [dependencies] plonky2-cosigning-key-validation = { workspace = true } +ahash = { workspace = true } libc = "0.2" anyhow = "1" diff --git a/libwallet/librs/generate/Cargo.toml b/libwallet/librs/generate/Cargo.toml index bd7e2136..cbbbb5cd 100644 --- a/libwallet/librs/generate/Cargo.toml +++ b/libwallet/librs/generate/Cargo.toml @@ -4,4 +4,5 @@ edition = "2024" [dependencies] plonky2-cosigning-key-validation = { workspace = true } +ahash = { workspace = true } hex = "0.4.3" diff --git a/libwallet/libwallet_init/init.go b/libwallet/libwallet_init/init.go index 4a19376f..467a4eac 100644 --- a/libwallet/libwallet_init/init.go +++ b/libwallet/libwallet_init/init.go @@ -2,19 +2,23 @@ package libwallet_init import ( "errors" + "fmt" + "log/slog" + "net" + "path" + "runtime/debug" + "github.com/grpc-ecosystem/go-grpc-middleware" "github.com/muun/libwallet/data/keys" "github.com/muun/libwallet/domain/action/challenge_keys" "github.com/muun/libwallet/domain/action/diagnostic_mode_reports" + "github.com/muun/libwallet/domain/action/emergency_kit" nfcActions "github.com/muun/libwallet/domain/action/nfc" "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/domain/action/security_cards_marketplace" "github.com/muun/libwallet/domain/nfc" "github.com/muun/libwallet/electrum" "github.com/muun/libwallet/storage" - "log/slog" - "net" - "path" - "runtime/debug" "github.com/muun/libwallet" "github.com/muun/libwallet/app_provided_data" @@ -45,6 +49,8 @@ var resetSecurityCardAction *nfcActions.ResetSecurityCardAction var signMessageSecurityCardAction *nfcActions.SignMessageSecurityCardAction var pairSecurityCardActionV2 *nfcActions.PairSecurityCardActionV2 var signMessageSecurityCardActionV2 *nfcActions.SignMessageSecurityCardActionV2 +var securityCardsMarketplaceAction *security_cards_marketplace.GetSecurityCardsMarketplaceAction +var generateEmergencyKitPDFAction *emergency_kit.GenerateEmergencyKitPDFAction // Init configures libwallet func Init(c *app_provided_data.Config) { @@ -63,8 +69,13 @@ func Init(c *app_provided_data.Config) { houstonService = service.NewHoustonService(cfg.HttpClientSessionProvider) } - var storageSchema = storage.BuildStorageSchema() - keyValueStorage = storage.NewKeyValueStorage(path.Join(cfg.DataDir, "wallet.db"), storageSchema) + dbPath := path.Join(cfg.DataDir, "wallet.db") + storageSchema, err := storage.RunKeyValueMigrations(dbPath, storage.BuildKVMigrationPlan()) + if err != nil { + slog.Error("failed to run key-value migrations", "error", err) + panic(fmt.Sprintf("failed to run key-value migrations: %v", err)) + } + keyValueStorage = storage.NewKeyValueStorage(dbPath, storageSchema) mockHoustonService = service.NewMockHoustonService(keyValueStorage) @@ -122,6 +133,8 @@ func Init(c *app_provided_data.Config) { keyValueStorage, pairSecurityCardActionV2, ) + securityCardsMarketplaceAction = security_cards_marketplace.NewGetSecurityCardsMarketplaceAction() + generateEmergencyKitPDFAction = emergency_kit.NewGenerateEmergencyKitPDFAction() } func StartServer() error { @@ -162,6 +175,8 @@ func StartServer() error { signMessageSecurityCardAction, pairSecurityCardActionV2, signMessageSecurityCardActionV2, + securityCardsMarketplaceAction, + generateEmergencyKitPDFAction, )) listener, err := net.Listen("unix", cfg.SocketPath) diff --git a/libwallet/partiallysignedtransaction.go b/libwallet/partiallysignedtransaction.go index d2bee8ae..0f26bf61 100644 --- a/libwallet/partiallysignedtransaction.go +++ b/libwallet/partiallysignedtransaction.go @@ -377,7 +377,10 @@ func (p *PartiallySignedTransaction) Verify(expectations *SigningExpectations, u } else { actualFee := actualTotal - expectedAmount if actualFee >= expectedFee+dustThreshold { - return errors.New("change output is too big to be burned as fee") + return fmt.Errorf( + "change output is too big to be burned as fee. actual fee: %v, expected: %v", + actualFee, expectedFee, + ) } } diff --git a/libwallet/presentation/api/wallet_service.pb.go b/libwallet/presentation/api/wallet_service.pb.go index 587733ff..9b4e222f 100644 --- a/libwallet/presentation/api/wallet_service.pb.go +++ b/libwallet/presentation/api/wallet_service.pb.go @@ -2488,6 +2488,559 @@ func (b0 GetByPrefixRequest_builder) Build() *GetByPrefixRequest { return m0 } +type SecurityCardsProvider struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` + xxx_hidden_SecurityCards *[]*SecurityCard `protobuf:"bytes,2,rep,name=security_cards,json=securityCards,proto3"` + xxx_hidden_Currency string `protobuf:"bytes,3,opt,name=currency,proto3"` + xxx_hidden_ColorHex string `protobuf:"bytes,4,opt,name=color_hex,json=colorHex,proto3"` + xxx_hidden_Material string `protobuf:"bytes,5,opt,name=material,proto3"` + xxx_hidden_Price float64 `protobuf:"fixed64,6,opt,name=price,proto3"` + xxx_hidden_ShippingCost float64 `protobuf:"fixed64,7,opt,name=shipping_cost,json=shippingCost,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecurityCardsProvider) Reset() { + *x = SecurityCardsProvider{} + mi := &file_wallet_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecurityCardsProvider) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityCardsProvider) ProtoMessage() {} + +func (x *SecurityCardsProvider) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SecurityCardsProvider) GetName() string { + if x != nil { + return x.xxx_hidden_Name + } + return "" +} + +func (x *SecurityCardsProvider) GetSecurityCards() []*SecurityCard { + if x != nil { + if x.xxx_hidden_SecurityCards != nil { + return *x.xxx_hidden_SecurityCards + } + } + return nil +} + +func (x *SecurityCardsProvider) GetCurrency() string { + if x != nil { + return x.xxx_hidden_Currency + } + return "" +} + +func (x *SecurityCardsProvider) GetColorHex() string { + if x != nil { + return x.xxx_hidden_ColorHex + } + return "" +} + +func (x *SecurityCardsProvider) GetMaterial() string { + if x != nil { + return x.xxx_hidden_Material + } + return "" +} + +func (x *SecurityCardsProvider) GetPrice() float64 { + if x != nil { + return x.xxx_hidden_Price + } + return 0 +} + +func (x *SecurityCardsProvider) GetShippingCost() float64 { + if x != nil { + return x.xxx_hidden_ShippingCost + } + return 0 +} + +func (x *SecurityCardsProvider) SetName(v string) { + x.xxx_hidden_Name = v +} + +func (x *SecurityCardsProvider) SetSecurityCards(v []*SecurityCard) { + x.xxx_hidden_SecurityCards = &v +} + +func (x *SecurityCardsProvider) SetCurrency(v string) { + x.xxx_hidden_Currency = v +} + +func (x *SecurityCardsProvider) SetColorHex(v string) { + x.xxx_hidden_ColorHex = v +} + +func (x *SecurityCardsProvider) SetMaterial(v string) { + x.xxx_hidden_Material = v +} + +func (x *SecurityCardsProvider) SetPrice(v float64) { + x.xxx_hidden_Price = v +} + +func (x *SecurityCardsProvider) SetShippingCost(v float64) { + x.xxx_hidden_ShippingCost = v +} + +type SecurityCardsProvider_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Name string + SecurityCards []*SecurityCard + Currency string + ColorHex string + Material string + Price float64 + ShippingCost float64 +} + +func (b0 SecurityCardsProvider_builder) Build() *SecurityCardsProvider { + m0 := &SecurityCardsProvider{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Name = b.Name + x.xxx_hidden_SecurityCards = &b.SecurityCards + x.xxx_hidden_Currency = b.Currency + x.xxx_hidden_ColorHex = b.ColorHex + x.xxx_hidden_Material = b.Material + x.xxx_hidden_Price = b.Price + x.xxx_hidden_ShippingCost = b.ShippingCost + return m0 +} + +type SecurityCard struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Image string `protobuf:"bytes,1,opt,name=image,proto3"` + xxx_hidden_Stock int32 `protobuf:"varint,2,opt,name=stock,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecurityCard) Reset() { + *x = SecurityCard{} + mi := &file_wallet_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecurityCard) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityCard) ProtoMessage() {} + +func (x *SecurityCard) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *SecurityCard) GetImage() string { + if x != nil { + return x.xxx_hidden_Image + } + return "" +} + +func (x *SecurityCard) GetStock() int32 { + if x != nil { + return x.xxx_hidden_Stock + } + return 0 +} + +func (x *SecurityCard) SetImage(v string) { + x.xxx_hidden_Image = v +} + +func (x *SecurityCard) SetStock(v int32) { + x.xxx_hidden_Stock = v +} + +type SecurityCard_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Image string + Stock int32 +} + +func (b0 SecurityCard_builder) Build() *SecurityCard { + m0 := &SecurityCard{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Image = b.Image + x.xxx_hidden_Stock = b.Stock + return m0 +} + +type GetSecurityCardsMarketplaceResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Providers *[]*SecurityCardsProvider `protobuf:"bytes,1,rep,name=providers,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSecurityCardsMarketplaceResponse) Reset() { + *x = GetSecurityCardsMarketplaceResponse{} + mi := &file_wallet_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSecurityCardsMarketplaceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSecurityCardsMarketplaceResponse) ProtoMessage() {} + +func (x *GetSecurityCardsMarketplaceResponse) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GetSecurityCardsMarketplaceResponse) GetProviders() []*SecurityCardsProvider { + if x != nil { + if x.xxx_hidden_Providers != nil { + return *x.xxx_hidden_Providers + } + } + return nil +} + +func (x *GetSecurityCardsMarketplaceResponse) SetProviders(v []*SecurityCardsProvider) { + x.xxx_hidden_Providers = &v +} + +type GetSecurityCardsMarketplaceResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Providers []*SecurityCardsProvider +} + +func (b0 GetSecurityCardsMarketplaceResponse_builder) Build() *GetSecurityCardsMarketplaceResponse { + m0 := &GetSecurityCardsMarketplaceResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Providers = &b.Providers + return m0 +} + +type EKInputRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_FirstEncryptedKey string `protobuf:"bytes,1,opt,name=first_encrypted_key,json=firstEncryptedKey,proto3"` + xxx_hidden_FirstFingerprint string `protobuf:"bytes,2,opt,name=first_fingerprint,json=firstFingerprint,proto3"` + xxx_hidden_SecondEncryptedKey string `protobuf:"bytes,3,opt,name=second_encrypted_key,json=secondEncryptedKey,proto3"` + xxx_hidden_SecondFingerprint string `protobuf:"bytes,4,opt,name=second_fingerprint,json=secondFingerprint,proto3"` + xxx_hidden_RcChecksum string `protobuf:"bytes,5,opt,name=rc_checksum,json=rcChecksum,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EKInputRequest) Reset() { + *x = EKInputRequest{} + mi := &file_wallet_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EKInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EKInputRequest) ProtoMessage() {} + +func (x *EKInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *EKInputRequest) GetFirstEncryptedKey() string { + if x != nil { + return x.xxx_hidden_FirstEncryptedKey + } + return "" +} + +func (x *EKInputRequest) GetFirstFingerprint() string { + if x != nil { + return x.xxx_hidden_FirstFingerprint + } + return "" +} + +func (x *EKInputRequest) GetSecondEncryptedKey() string { + if x != nil { + return x.xxx_hidden_SecondEncryptedKey + } + return "" +} + +func (x *EKInputRequest) GetSecondFingerprint() string { + if x != nil { + return x.xxx_hidden_SecondFingerprint + } + return "" +} + +func (x *EKInputRequest) GetRcChecksum() string { + if x != nil { + return x.xxx_hidden_RcChecksum + } + return "" +} + +func (x *EKInputRequest) SetFirstEncryptedKey(v string) { + x.xxx_hidden_FirstEncryptedKey = v +} + +func (x *EKInputRequest) SetFirstFingerprint(v string) { + x.xxx_hidden_FirstFingerprint = v +} + +func (x *EKInputRequest) SetSecondEncryptedKey(v string) { + x.xxx_hidden_SecondEncryptedKey = v +} + +func (x *EKInputRequest) SetSecondFingerprint(v string) { + x.xxx_hidden_SecondFingerprint = v +} + +func (x *EKInputRequest) SetRcChecksum(v string) { + x.xxx_hidden_RcChecksum = v +} + +type EKInputRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + FirstEncryptedKey string + FirstFingerprint string + SecondEncryptedKey string + SecondFingerprint string + RcChecksum string +} + +func (b0 EKInputRequest_builder) Build() *EKInputRequest { + m0 := &EKInputRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_FirstEncryptedKey = b.FirstEncryptedKey + x.xxx_hidden_FirstFingerprint = b.FirstFingerprint + x.xxx_hidden_SecondEncryptedKey = b.SecondEncryptedKey + x.xxx_hidden_SecondFingerprint = b.SecondFingerprint + x.xxx_hidden_RcChecksum = b.RcChecksum + return m0 +} + +type GenerateEmergencyKitPDFRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_EkInput *EKInputRequest `protobuf:"bytes,1,opt,name=ek_input,json=ekInput,proto3"` + xxx_hidden_OutputPath string `protobuf:"bytes,2,opt,name=output_path,json=outputPath,proto3"` + xxx_hidden_Language string `protobuf:"bytes,3,opt,name=language,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateEmergencyKitPDFRequest) Reset() { + *x = GenerateEmergencyKitPDFRequest{} + mi := &file_wallet_service_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateEmergencyKitPDFRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateEmergencyKitPDFRequest) ProtoMessage() {} + +func (x *GenerateEmergencyKitPDFRequest) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GenerateEmergencyKitPDFRequest) GetEkInput() *EKInputRequest { + if x != nil { + return x.xxx_hidden_EkInput + } + return nil +} + +func (x *GenerateEmergencyKitPDFRequest) GetOutputPath() string { + if x != nil { + return x.xxx_hidden_OutputPath + } + return "" +} + +func (x *GenerateEmergencyKitPDFRequest) GetLanguage() string { + if x != nil { + return x.xxx_hidden_Language + } + return "" +} + +func (x *GenerateEmergencyKitPDFRequest) SetEkInput(v *EKInputRequest) { + x.xxx_hidden_EkInput = v +} + +func (x *GenerateEmergencyKitPDFRequest) SetOutputPath(v string) { + x.xxx_hidden_OutputPath = v +} + +func (x *GenerateEmergencyKitPDFRequest) SetLanguage(v string) { + x.xxx_hidden_Language = v +} + +func (x *GenerateEmergencyKitPDFRequest) HasEkInput() bool { + if x == nil { + return false + } + return x.xxx_hidden_EkInput != nil +} + +func (x *GenerateEmergencyKitPDFRequest) ClearEkInput() { + x.xxx_hidden_EkInput = nil +} + +type GenerateEmergencyKitPDFRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + EkInput *EKInputRequest + OutputPath string + Language string +} + +func (b0 GenerateEmergencyKitPDFRequest_builder) Build() *GenerateEmergencyKitPDFRequest { + m0 := &GenerateEmergencyKitPDFRequest{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_EkInput = b.EkInput + x.xxx_hidden_OutputPath = b.OutputPath + x.xxx_hidden_Language = b.Language + return m0 +} + +type GenerateEmergencyKitPDFResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_VerificationCode string `protobuf:"bytes,1,opt,name=verification_code,json=verificationCode,proto3"` + xxx_hidden_Version int32 `protobuf:"varint,2,opt,name=version,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateEmergencyKitPDFResponse) Reset() { + *x = GenerateEmergencyKitPDFResponse{} + mi := &file_wallet_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateEmergencyKitPDFResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateEmergencyKitPDFResponse) ProtoMessage() {} + +func (x *GenerateEmergencyKitPDFResponse) ProtoReflect() protoreflect.Message { + mi := &file_wallet_service_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GenerateEmergencyKitPDFResponse) GetVerificationCode() string { + if x != nil { + return x.xxx_hidden_VerificationCode + } + return "" +} + +func (x *GenerateEmergencyKitPDFResponse) GetVersion() int32 { + if x != nil { + return x.xxx_hidden_Version + } + return 0 +} + +func (x *GenerateEmergencyKitPDFResponse) SetVerificationCode(v string) { + x.xxx_hidden_VerificationCode = v +} + +func (x *GenerateEmergencyKitPDFResponse) SetVersion(v int32) { + x.xxx_hidden_Version = v +} + +type GenerateEmergencyKitPDFResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + VerificationCode string + Version int32 +} + +func (b0 GenerateEmergencyKitPDFResponse_builder) Build() *GenerateEmergencyKitPDFResponse { + m0 := &GenerateEmergencyKitPDFResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_VerificationCode = b.VerificationCode + x.xxx_hidden_Version = b.Version + return m0 +} + var File_wallet_service_proto protoreflect.FileDescriptor var file_wallet_service_proto_rawDesc = string([]byte{ @@ -2676,142 +3229,216 @@ var file_wallet_service_proto_rawDesc = string([]byte{ 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x2c, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x2a, 0x33, 0x0a, 0x09, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x4c, 0x49, - 0x45, 0x4e, 0x54, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x49, 0x42, 0x57, 0x41, 0x4c, 0x4c, - 0x45, 0x54, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x4f, 0x55, 0x53, 0x54, 0x4f, 0x4e, 0x10, - 0x02, 0x2a, 0x1b, 0x0a, 0x09, 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x0e, - 0x0a, 0x0a, 0x4e, 0x55, 0x4c, 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x00, 0x32, 0x97, - 0x0b, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x12, 0x3e, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, - 0x72, 0x70, 0x63, 0x2e, 0x58, 0x70, 0x75, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x43, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x64, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, - 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, - 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x13, 0x53, - 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, - 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1e, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, - 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x19, 0x53, 0x69, - 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x43, 0x61, 0x72, 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, - 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x52, 0x0a, 0x16, 0x53, 0x74, 0x61, 0x72, 0x74, - 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, - 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x1d, 0x50, - 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, - 0x53, 0x63, 0x61, 0x6e, 0x46, 0x6f, 0x72, 0x55, 0x74, 0x78, 0x6f, 0x73, 0x12, 0x20, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x1a, 0x17, - 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, - 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, 0x62, - 0x6d, 0x69, 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x4c, 0x6f, 0x67, - 0x12, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, - 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, - 0x6f, 0x72, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, - 0x74, 0x69, 0x63, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, - 0x49, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, - 0x78, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, - 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, - 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, - 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x53, 0x69, - 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, - 0x65, 0x65, 0x70, 0x54, 0x78, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, - 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, - 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, - 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x4e, 0x0a, 0x13, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, - 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, - 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x43, - 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x56, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, - 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x23, 0x2e, 0x72, 0x70, - 0x63, 0x2e, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, - 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x22, 0xf5, 0x01, 0x0a, + 0x15, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x38, 0x0a, 0x0e, 0x73, 0x65, + 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, + 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, 0x0d, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, + 0x61, 0x72, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, + 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x5f, 0x68, 0x65, 0x78, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x48, 0x65, 0x78, 0x12, 0x1a, 0x0a, + 0x08, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, + 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, + 0x23, 0x0a, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x6f, 0x73, 0x74, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0c, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, + 0x43, 0x6f, 0x73, 0x74, 0x22, 0x3a, 0x0a, 0x0c, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, + 0x43, 0x61, 0x72, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, + 0x6f, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, + 0x22, 0x5f, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, + 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x73, 0x22, 0xef, 0x01, 0x0a, 0x0e, 0x45, 0x4b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x65, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x11, 0x66, 0x69, 0x72, 0x73, 0x74, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x11, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x66, 0x69, + 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x66, 0x69, 0x72, 0x73, 0x74, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, + 0x74, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x65, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x12, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x12, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x5f, 0x66, 0x69, + 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x11, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, + 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x63, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x75, + 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x63, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x73, 0x75, 0x6d, 0x22, 0x8d, 0x01, 0x0a, 0x1e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, + 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x65, 0x6b, 0x5f, 0x69, 0x6e, 0x70, + 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x45, + 0x4b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x65, + 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, + 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, + 0x61, 0x67, 0x65, 0x22, 0x68, 0x0a, 0x1f, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, + 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x10, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, + 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x2a, 0x33, 0x0a, + 0x09, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x4c, + 0x49, 0x45, 0x4e, 0x54, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x49, 0x42, 0x57, 0x41, 0x4c, + 0x4c, 0x45, 0x54, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x4f, 0x55, 0x53, 0x54, 0x4f, 0x4e, + 0x10, 0x02, 0x2a, 0x1b, 0x0a, 0x09, 0x4e, 0x75, 0x6c, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, + 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x00, 0x32, + 0xde, 0x0c, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x3e, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x58, 0x70, 0x75, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x43, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x64, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, + 0x64, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, + 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, + 0x43, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x13, + 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, + 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1e, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, + 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x19, 0x53, + 0x69, 0x67, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x56, 0x32, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x58, 0x0a, 0x18, 0x50, 0x6f, 0x70, 0x75, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x52, 0x0a, 0x16, 0x53, 0x74, 0x61, 0x72, + 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x20, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x1d, + 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, + 0x63, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x6f, 0x72, 0x55, 0x74, 0x78, 0x6f, 0x73, 0x12, 0x20, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x1a, + 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, + 0x73, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x4c, 0x6f, + 0x67, 0x12, 0x20, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, + 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x6f, 0x72, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, + 0x73, 0x74, 0x69, 0x63, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x49, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, 0x70, + 0x54, 0x78, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, + 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x72, 0x65, 0x70, 0x61, 0x72, 0x65, 0x53, 0x77, 0x65, 0x65, + 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x53, + 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, + 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x69, 0x67, + 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x77, 0x65, + 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x41, 0x6e, 0x64, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, + 0x73, 0x74, 0x53, 0x77, 0x65, 0x65, 0x70, 0x54, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x4e, 0x0a, 0x13, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, + 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x1a, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x43, + 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x75, 0x70, + 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x56, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x23, 0x2e, 0x72, + 0x70, 0x63, 0x2e, 0x46, 0x69, 0x6e, 0x69, 0x73, 0x68, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, + 0x79, 0x43, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x58, 0x0a, 0x18, 0x50, 0x6f, 0x70, + 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, + 0x75, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x6f, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, - 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x24, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x6f, 0x70, 0x75, 0x6c, - 0x61, 0x74, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x75, 0x75, 0x6e, - 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x53, 0x61, 0x76, 0x65, 0x12, 0x10, 0x2e, 0x72, 0x70, 0x63, - 0x2e, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, - 0x6d, 0x70, 0x74, 0x79, 0x12, 0x28, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x0f, 0x2e, 0x72, 0x70, - 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, - 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x12, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, - 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x09, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x12, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x12, 0x37, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x2e, 0x72, - 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x6e, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x53, 0x61, 0x76, 0x65, 0x12, 0x10, 0x2e, 0x72, 0x70, + 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x28, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x0f, 0x2e, 0x72, + 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x34, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x12, 0x2e, 0x72, 0x70, 0x63, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x09, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x12, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x61, 0x76, 0x65, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x12, 0x37, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x47, 0x65, + 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, + 0x47, 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x47, 0x65, 0x74, - 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x17, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, - 0x65, 0x74, 0x42, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x15, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x1f, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6d, 0x75, 0x75, 0x6e, 0x2f, 0x6c, 0x69, 0x62, 0x77, - 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x1b, 0x47, 0x65, 0x74, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, + 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x28, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x43, 0x61, 0x72, 0x64, 0x73, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x70, 0x6c, 0x61, + 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x17, 0x47, 0x65, + 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, + 0x69, 0x74, 0x50, 0x44, 0x46, 0x12, 0x23, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x6e, 0x65, + 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, 0x63, 0x79, 0x4b, 0x69, 0x74, + 0x50, 0x44, 0x46, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x70, 0x63, + 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x6e, + 0x63, 0x79, 0x4b, 0x69, 0x74, 0x50, 0x44, 0x46, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x42, 0x1f, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6d, + 0x75, 0x75, 0x6e, 0x2f, 0x6c, 0x69, 0x62, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x61, 0x70, + 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var file_wallet_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_wallet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_wallet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_wallet_service_proto_goTypes = []any{ - (ErrorType)(0), // 0: rpc.ErrorType - (NullValue)(0), // 1: rpc.NullValue - (*ErrorDetail)(nil), // 2: rpc.ErrorDetail - (*XpubResponse)(nil), // 3: rpc.XpubResponse - (*SetupSecurityCardResponse)(nil), // 4: rpc.SetupSecurityCardResponse - (*SignMessageSecurityCardRequest)(nil), // 5: rpc.SignMessageSecurityCardRequest - (*SignMessageSecurityCardResponse)(nil), // 6: rpc.SignMessageSecurityCardResponse - (*DiagnosticSessionDescriptor)(nil), // 7: rpc.DiagnosticSessionDescriptor - (*ScanProgressUpdate)(nil), // 8: rpc.ScanProgressUpdate - (*FoundUtxoReport)(nil), // 9: rpc.FoundUtxoReport - (*ScanComplete)(nil), // 10: rpc.ScanComplete - (*DiagnosticSubmitStatus)(nil), // 11: rpc.DiagnosticSubmitStatus - (*PrepareSweepTxRequest)(nil), // 12: rpc.PrepareSweepTxRequest - (*PrepareSweepTxResponse)(nil), // 13: rpc.PrepareSweepTxResponse - (*SignAndBroadcastSweepTxRequest)(nil), // 14: rpc.SignAndBroadcastSweepTxRequest - (*SignAndBroadcastSweepTxResponse)(nil), // 15: rpc.SignAndBroadcastSweepTxResponse - (*ChallengeSetupRequest)(nil), // 16: rpc.ChallengeSetupRequest - (*SetupChallengeResponse)(nil), // 17: rpc.SetupChallengeResponse - (*FinishRecoveryCodeSetupRequest)(nil), // 18: rpc.FinishRecoveryCodeSetupRequest - (*PopulateEncryptedMuunKeyRequest)(nil), // 19: rpc.PopulateEncryptedMuunKeyRequest - (*Struct)(nil), // 20: rpc.Struct - (*Value)(nil), // 21: rpc.Value - (*SaveRequest)(nil), // 22: rpc.SaveRequest - (*GetRequest)(nil), // 23: rpc.GetRequest - (*GetResponse)(nil), // 24: rpc.GetResponse - (*DeleteRequest)(nil), // 25: rpc.DeleteRequest - (*SaveBatchRequest)(nil), // 26: rpc.SaveBatchRequest - (*GetBatchRequest)(nil), // 27: rpc.GetBatchRequest - (*GetBatchResponse)(nil), // 28: rpc.GetBatchResponse - (*GetByPrefixRequest)(nil), // 29: rpc.GetByPrefixRequest - nil, // 30: rpc.Struct.FieldsEntry - (*emptypb.Empty)(nil), // 31: google.protobuf.Empty + (ErrorType)(0), // 0: rpc.ErrorType + (NullValue)(0), // 1: rpc.NullValue + (*ErrorDetail)(nil), // 2: rpc.ErrorDetail + (*XpubResponse)(nil), // 3: rpc.XpubResponse + (*SetupSecurityCardResponse)(nil), // 4: rpc.SetupSecurityCardResponse + (*SignMessageSecurityCardRequest)(nil), // 5: rpc.SignMessageSecurityCardRequest + (*SignMessageSecurityCardResponse)(nil), // 6: rpc.SignMessageSecurityCardResponse + (*DiagnosticSessionDescriptor)(nil), // 7: rpc.DiagnosticSessionDescriptor + (*ScanProgressUpdate)(nil), // 8: rpc.ScanProgressUpdate + (*FoundUtxoReport)(nil), // 9: rpc.FoundUtxoReport + (*ScanComplete)(nil), // 10: rpc.ScanComplete + (*DiagnosticSubmitStatus)(nil), // 11: rpc.DiagnosticSubmitStatus + (*PrepareSweepTxRequest)(nil), // 12: rpc.PrepareSweepTxRequest + (*PrepareSweepTxResponse)(nil), // 13: rpc.PrepareSweepTxResponse + (*SignAndBroadcastSweepTxRequest)(nil), // 14: rpc.SignAndBroadcastSweepTxRequest + (*SignAndBroadcastSweepTxResponse)(nil), // 15: rpc.SignAndBroadcastSweepTxResponse + (*ChallengeSetupRequest)(nil), // 16: rpc.ChallengeSetupRequest + (*SetupChallengeResponse)(nil), // 17: rpc.SetupChallengeResponse + (*FinishRecoveryCodeSetupRequest)(nil), // 18: rpc.FinishRecoveryCodeSetupRequest + (*PopulateEncryptedMuunKeyRequest)(nil), // 19: rpc.PopulateEncryptedMuunKeyRequest + (*Struct)(nil), // 20: rpc.Struct + (*Value)(nil), // 21: rpc.Value + (*SaveRequest)(nil), // 22: rpc.SaveRequest + (*GetRequest)(nil), // 23: rpc.GetRequest + (*GetResponse)(nil), // 24: rpc.GetResponse + (*DeleteRequest)(nil), // 25: rpc.DeleteRequest + (*SaveBatchRequest)(nil), // 26: rpc.SaveBatchRequest + (*GetBatchRequest)(nil), // 27: rpc.GetBatchRequest + (*GetBatchResponse)(nil), // 28: rpc.GetBatchResponse + (*GetByPrefixRequest)(nil), // 29: rpc.GetByPrefixRequest + (*SecurityCardsProvider)(nil), // 30: rpc.SecurityCardsProvider + (*SecurityCard)(nil), // 31: rpc.SecurityCard + (*GetSecurityCardsMarketplaceResponse)(nil), // 32: rpc.GetSecurityCardsMarketplaceResponse + (*EKInputRequest)(nil), // 33: rpc.EKInputRequest + (*GenerateEmergencyKitPDFRequest)(nil), // 34: rpc.GenerateEmergencyKitPDFRequest + (*GenerateEmergencyKitPDFResponse)(nil), // 35: rpc.GenerateEmergencyKitPDFResponse + nil, // 36: rpc.Struct.FieldsEntry + (*emptypb.Empty)(nil), // 37: google.protobuf.Empty } var file_wallet_service_proto_depIdxs = []int32{ 0, // 0: rpc.ErrorDetail.type:type_name -> rpc.ErrorType @@ -2821,56 +3448,63 @@ var file_wallet_service_proto_depIdxs = []int32{ 7, // 4: rpc.PrepareSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor 7, // 5: rpc.SignAndBroadcastSweepTxRequest.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor 7, // 6: rpc.SignAndBroadcastSweepTxResponse.sessionDescriptor:type_name -> rpc.DiagnosticSessionDescriptor - 30, // 7: rpc.Struct.fields:type_name -> rpc.Struct.FieldsEntry + 36, // 7: rpc.Struct.fields:type_name -> rpc.Struct.FieldsEntry 1, // 8: rpc.Value.null_value:type_name -> rpc.NullValue 21, // 9: rpc.SaveRequest.value:type_name -> rpc.Value 21, // 10: rpc.GetResponse.value:type_name -> rpc.Value 20, // 11: rpc.SaveBatchRequest.items:type_name -> rpc.Struct 20, // 12: rpc.GetBatchResponse.items:type_name -> rpc.Struct - 21, // 13: rpc.Struct.FieldsEntry.value:type_name -> rpc.Value - 31, // 14: rpc.WalletService.SetupSecurityCard:input_type -> google.protobuf.Empty - 31, // 15: rpc.WalletService.ResetSecurityCard:input_type -> google.protobuf.Empty - 5, // 16: rpc.WalletService.SignMessageSecurityCard:input_type -> rpc.SignMessageSecurityCardRequest - 31, // 17: rpc.WalletService.SetupSecurityCardV2:input_type -> google.protobuf.Empty - 31, // 18: rpc.WalletService.SignMessageSecurityCardV2:input_type -> google.protobuf.Empty - 31, // 19: rpc.WalletService.StartDiagnosticSession:input_type -> google.protobuf.Empty - 7, // 20: rpc.WalletService.PerformDiagnosticScanForUtxos:input_type -> rpc.DiagnosticSessionDescriptor - 7, // 21: rpc.WalletService.SubmitDiagnosticLog:input_type -> rpc.DiagnosticSessionDescriptor - 12, // 22: rpc.WalletService.PrepareSweepTx:input_type -> rpc.PrepareSweepTxRequest - 14, // 23: rpc.WalletService.SignAndBroadcastSweepTx:input_type -> rpc.SignAndBroadcastSweepTxRequest - 16, // 24: rpc.WalletService.StartChallengeSetup:input_type -> rpc.ChallengeSetupRequest - 18, // 25: rpc.WalletService.FinishRecoveryCodeSetup:input_type -> rpc.FinishRecoveryCodeSetupRequest - 19, // 26: rpc.WalletService.PopulateEncryptedMuunKey:input_type -> rpc.PopulateEncryptedMuunKeyRequest - 22, // 27: rpc.WalletService.Save:input_type -> rpc.SaveRequest - 23, // 28: rpc.WalletService.Get:input_type -> rpc.GetRequest - 25, // 29: rpc.WalletService.Delete:input_type -> rpc.DeleteRequest - 26, // 30: rpc.WalletService.SaveBatch:input_type -> rpc.SaveBatchRequest - 27, // 31: rpc.WalletService.GetBatch:input_type -> rpc.GetBatchRequest - 29, // 32: rpc.WalletService.GetByPrefix:input_type -> rpc.GetByPrefixRequest - 3, // 33: rpc.WalletService.SetupSecurityCard:output_type -> rpc.XpubResponse - 31, // 34: rpc.WalletService.ResetSecurityCard:output_type -> google.protobuf.Empty - 6, // 35: rpc.WalletService.SignMessageSecurityCard:output_type -> rpc.SignMessageSecurityCardResponse - 4, // 36: rpc.WalletService.SetupSecurityCardV2:output_type -> rpc.SetupSecurityCardResponse - 31, // 37: rpc.WalletService.SignMessageSecurityCardV2:output_type -> google.protobuf.Empty - 7, // 38: rpc.WalletService.StartDiagnosticSession:output_type -> rpc.DiagnosticSessionDescriptor - 8, // 39: rpc.WalletService.PerformDiagnosticScanForUtxos:output_type -> rpc.ScanProgressUpdate - 11, // 40: rpc.WalletService.SubmitDiagnosticLog:output_type -> rpc.DiagnosticSubmitStatus - 13, // 41: rpc.WalletService.PrepareSweepTx:output_type -> rpc.PrepareSweepTxResponse - 15, // 42: rpc.WalletService.SignAndBroadcastSweepTx:output_type -> rpc.SignAndBroadcastSweepTxResponse - 17, // 43: rpc.WalletService.StartChallengeSetup:output_type -> rpc.SetupChallengeResponse - 31, // 44: rpc.WalletService.FinishRecoveryCodeSetup:output_type -> google.protobuf.Empty - 31, // 45: rpc.WalletService.PopulateEncryptedMuunKey:output_type -> google.protobuf.Empty - 31, // 46: rpc.WalletService.Save:output_type -> google.protobuf.Empty - 24, // 47: rpc.WalletService.Get:output_type -> rpc.GetResponse - 31, // 48: rpc.WalletService.Delete:output_type -> google.protobuf.Empty - 31, // 49: rpc.WalletService.SaveBatch:output_type -> google.protobuf.Empty - 28, // 50: rpc.WalletService.GetBatch:output_type -> rpc.GetBatchResponse - 28, // 51: rpc.WalletService.GetByPrefix:output_type -> rpc.GetBatchResponse - 33, // [33:52] is the sub-list for method output_type - 14, // [14:33] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 31, // 13: rpc.SecurityCardsProvider.security_cards:type_name -> rpc.SecurityCard + 30, // 14: rpc.GetSecurityCardsMarketplaceResponse.providers:type_name -> rpc.SecurityCardsProvider + 33, // 15: rpc.GenerateEmergencyKitPDFRequest.ek_input:type_name -> rpc.EKInputRequest + 21, // 16: rpc.Struct.FieldsEntry.value:type_name -> rpc.Value + 37, // 17: rpc.WalletService.SetupSecurityCard:input_type -> google.protobuf.Empty + 37, // 18: rpc.WalletService.ResetSecurityCard:input_type -> google.protobuf.Empty + 5, // 19: rpc.WalletService.SignMessageSecurityCard:input_type -> rpc.SignMessageSecurityCardRequest + 37, // 20: rpc.WalletService.SetupSecurityCardV2:input_type -> google.protobuf.Empty + 37, // 21: rpc.WalletService.SignMessageSecurityCardV2:input_type -> google.protobuf.Empty + 37, // 22: rpc.WalletService.StartDiagnosticSession:input_type -> google.protobuf.Empty + 7, // 23: rpc.WalletService.PerformDiagnosticScanForUtxos:input_type -> rpc.DiagnosticSessionDescriptor + 7, // 24: rpc.WalletService.SubmitDiagnosticLog:input_type -> rpc.DiagnosticSessionDescriptor + 12, // 25: rpc.WalletService.PrepareSweepTx:input_type -> rpc.PrepareSweepTxRequest + 14, // 26: rpc.WalletService.SignAndBroadcastSweepTx:input_type -> rpc.SignAndBroadcastSweepTxRequest + 16, // 27: rpc.WalletService.StartChallengeSetup:input_type -> rpc.ChallengeSetupRequest + 18, // 28: rpc.WalletService.FinishRecoveryCodeSetup:input_type -> rpc.FinishRecoveryCodeSetupRequest + 19, // 29: rpc.WalletService.PopulateEncryptedMuunKey:input_type -> rpc.PopulateEncryptedMuunKeyRequest + 22, // 30: rpc.WalletService.Save:input_type -> rpc.SaveRequest + 23, // 31: rpc.WalletService.Get:input_type -> rpc.GetRequest + 25, // 32: rpc.WalletService.Delete:input_type -> rpc.DeleteRequest + 26, // 33: rpc.WalletService.SaveBatch:input_type -> rpc.SaveBatchRequest + 27, // 34: rpc.WalletService.GetBatch:input_type -> rpc.GetBatchRequest + 29, // 35: rpc.WalletService.GetByPrefix:input_type -> rpc.GetByPrefixRequest + 37, // 36: rpc.WalletService.GetSecurityCardsMarketplace:input_type -> google.protobuf.Empty + 34, // 37: rpc.WalletService.GenerateEmergencyKitPDF:input_type -> rpc.GenerateEmergencyKitPDFRequest + 3, // 38: rpc.WalletService.SetupSecurityCard:output_type -> rpc.XpubResponse + 37, // 39: rpc.WalletService.ResetSecurityCard:output_type -> google.protobuf.Empty + 6, // 40: rpc.WalletService.SignMessageSecurityCard:output_type -> rpc.SignMessageSecurityCardResponse + 4, // 41: rpc.WalletService.SetupSecurityCardV2:output_type -> rpc.SetupSecurityCardResponse + 37, // 42: rpc.WalletService.SignMessageSecurityCardV2:output_type -> google.protobuf.Empty + 7, // 43: rpc.WalletService.StartDiagnosticSession:output_type -> rpc.DiagnosticSessionDescriptor + 8, // 44: rpc.WalletService.PerformDiagnosticScanForUtxos:output_type -> rpc.ScanProgressUpdate + 11, // 45: rpc.WalletService.SubmitDiagnosticLog:output_type -> rpc.DiagnosticSubmitStatus + 13, // 46: rpc.WalletService.PrepareSweepTx:output_type -> rpc.PrepareSweepTxResponse + 15, // 47: rpc.WalletService.SignAndBroadcastSweepTx:output_type -> rpc.SignAndBroadcastSweepTxResponse + 17, // 48: rpc.WalletService.StartChallengeSetup:output_type -> rpc.SetupChallengeResponse + 37, // 49: rpc.WalletService.FinishRecoveryCodeSetup:output_type -> google.protobuf.Empty + 37, // 50: rpc.WalletService.PopulateEncryptedMuunKey:output_type -> google.protobuf.Empty + 37, // 51: rpc.WalletService.Save:output_type -> google.protobuf.Empty + 24, // 52: rpc.WalletService.Get:output_type -> rpc.GetResponse + 37, // 53: rpc.WalletService.Delete:output_type -> google.protobuf.Empty + 37, // 54: rpc.WalletService.SaveBatch:output_type -> google.protobuf.Empty + 28, // 55: rpc.WalletService.GetBatch:output_type -> rpc.GetBatchResponse + 28, // 56: rpc.WalletService.GetByPrefix:output_type -> rpc.GetBatchResponse + 32, // 57: rpc.WalletService.GetSecurityCardsMarketplace:output_type -> rpc.GetSecurityCardsMarketplaceResponse + 35, // 58: rpc.WalletService.GenerateEmergencyKitPDF:output_type -> rpc.GenerateEmergencyKitPDFResponse + 38, // [38:59] is the sub-list for method output_type + 17, // [17:38] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_wallet_service_proto_init() } @@ -2897,7 +3531,7 @@ func file_wallet_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_wallet_service_proto_rawDesc), len(file_wallet_service_proto_rawDesc)), NumEnums: 2, - NumMessages: 29, + NumMessages: 35, NumExtensions: 0, NumServices: 1, }, diff --git a/libwallet/presentation/api/wallet_service.proto b/libwallet/presentation/api/wallet_service.proto index 8d35619c..8691c041 100644 --- a/libwallet/presentation/api/wallet_service.proto +++ b/libwallet/presentation/api/wallet_service.proto @@ -32,6 +32,12 @@ service WalletService { rpc SaveBatch(SaveBatchRequest) returns (google.protobuf.Empty); rpc GetBatch(GetBatchRequest) returns (GetBatchResponse); rpc GetByPrefix(GetByPrefixRequest) returns (GetBatchResponse); + + // Marketplace + rpc GetSecurityCardsMarketplace(google.protobuf.Empty) returns (GetSecurityCardsMarketplaceResponse); + + // Emergency Kit PDF Generation + rpc GenerateEmergencyKitPDF(GenerateEmergencyKitPDFRequest) returns (GenerateEmergencyKitPDFResponse); } enum ErrorType { @@ -186,3 +192,41 @@ message GetBatchResponse { message GetByPrefixRequest { string prefix = 1; } + +message SecurityCardsProvider { + string name = 1; + repeated SecurityCard security_cards = 2; + string currency = 3; + string color_hex = 4; + string material = 5; + double price = 6; + double shipping_cost = 7; +} + +message SecurityCard { + string image = 1; + int32 stock = 2; +} + +message GetSecurityCardsMarketplaceResponse { + repeated SecurityCardsProvider providers = 1; +} + +message EKInputRequest { + string first_encrypted_key = 1; + string first_fingerprint = 2; + string second_encrypted_key = 3; + string second_fingerprint = 4; + string rc_checksum = 5; +} + +message GenerateEmergencyKitPDFRequest { + EKInputRequest ek_input = 1; + string output_path = 2; + string language = 3; +} + +message GenerateEmergencyKitPDFResponse { + string verification_code = 1; + int32 version = 2; +} diff --git a/libwallet/presentation/api/wallet_service_grpc.pb.go b/libwallet/presentation/api/wallet_service_grpc.pb.go index dd611644..7fba4d5d 100644 --- a/libwallet/presentation/api/wallet_service_grpc.pb.go +++ b/libwallet/presentation/api/wallet_service_grpc.pb.go @@ -39,6 +39,8 @@ const ( WalletService_SaveBatch_FullMethodName = "/rpc.WalletService/SaveBatch" WalletService_GetBatch_FullMethodName = "/rpc.WalletService/GetBatch" WalletService_GetByPrefix_FullMethodName = "/rpc.WalletService/GetByPrefix" + WalletService_GetSecurityCardsMarketplace_FullMethodName = "/rpc.WalletService/GetSecurityCardsMarketplace" + WalletService_GenerateEmergencyKitPDF_FullMethodName = "/rpc.WalletService/GenerateEmergencyKitPDF" ) // WalletServiceClient is the client API for WalletService service. @@ -67,6 +69,10 @@ type WalletServiceClient interface { SaveBatch(ctx context.Context, in *SaveBatchRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) GetBatch(ctx context.Context, in *GetBatchRequest, opts ...grpc.CallOption) (*GetBatchResponse, error) GetByPrefix(ctx context.Context, in *GetByPrefixRequest, opts ...grpc.CallOption) (*GetBatchResponse, error) + // Marketplace + GetSecurityCardsMarketplace(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetSecurityCardsMarketplaceResponse, error) + // Emergency Kit PDF Generation + GenerateEmergencyKitPDF(ctx context.Context, in *GenerateEmergencyKitPDFRequest, opts ...grpc.CallOption) (*GenerateEmergencyKitPDFResponse, error) } type walletServiceClient struct { @@ -276,6 +282,26 @@ func (c *walletServiceClient) GetByPrefix(ctx context.Context, in *GetByPrefixRe return out, nil } +func (c *walletServiceClient) GetSecurityCardsMarketplace(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetSecurityCardsMarketplaceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSecurityCardsMarketplaceResponse) + err := c.cc.Invoke(ctx, WalletService_GetSecurityCardsMarketplace_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletServiceClient) GenerateEmergencyKitPDF(ctx context.Context, in *GenerateEmergencyKitPDFRequest, opts ...grpc.CallOption) (*GenerateEmergencyKitPDFResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GenerateEmergencyKitPDFResponse) + err := c.cc.Invoke(ctx, WalletService_GenerateEmergencyKitPDF_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // WalletServiceServer is the server API for WalletService service. // All implementations must embed UnimplementedWalletServiceServer // for forward compatibility. @@ -302,6 +328,10 @@ type WalletServiceServer interface { SaveBatch(context.Context, *SaveBatchRequest) (*emptypb.Empty, error) GetBatch(context.Context, *GetBatchRequest) (*GetBatchResponse, error) GetByPrefix(context.Context, *GetByPrefixRequest) (*GetBatchResponse, error) + // Marketplace + GetSecurityCardsMarketplace(context.Context, *emptypb.Empty) (*GetSecurityCardsMarketplaceResponse, error) + // Emergency Kit PDF Generation + GenerateEmergencyKitPDF(context.Context, *GenerateEmergencyKitPDFRequest) (*GenerateEmergencyKitPDFResponse, error) mustEmbedUnimplementedWalletServiceServer() } @@ -369,6 +399,12 @@ func (UnimplementedWalletServiceServer) GetBatch(context.Context, *GetBatchReque func (UnimplementedWalletServiceServer) GetByPrefix(context.Context, *GetByPrefixRequest) (*GetBatchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetByPrefix not implemented") } +func (UnimplementedWalletServiceServer) GetSecurityCardsMarketplace(context.Context, *emptypb.Empty) (*GetSecurityCardsMarketplaceResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSecurityCardsMarketplace not implemented") +} +func (UnimplementedWalletServiceServer) GenerateEmergencyKitPDF(context.Context, *GenerateEmergencyKitPDFRequest) (*GenerateEmergencyKitPDFResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GenerateEmergencyKitPDF not implemented") +} func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {} func (UnimplementedWalletServiceServer) testEmbeddedByValue() {} @@ -725,6 +761,42 @@ func _WalletService_GetByPrefix_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _WalletService_GetSecurityCardsMarketplace_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).GetSecurityCardsMarketplace(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_GetSecurityCardsMarketplace_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).GetSecurityCardsMarketplace(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletService_GenerateEmergencyKitPDF_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GenerateEmergencyKitPDFRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletServiceServer).GenerateEmergencyKitPDF(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WalletService_GenerateEmergencyKitPDF_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletServiceServer).GenerateEmergencyKitPDF(ctx, req.(*GenerateEmergencyKitPDFRequest)) + } + return interceptor(ctx, in, info, handler) +} + // WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -804,6 +876,14 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetByPrefix", Handler: _WalletService_GetByPrefix_Handler, }, + { + MethodName: "GetSecurityCardsMarketplace", + Handler: _WalletService_GetSecurityCardsMarketplace_Handler, + }, + { + MethodName: "GenerateEmergencyKitPDF", + Handler: _WalletService_GenerateEmergencyKitPDF_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/libwallet/presentation/emergency_kit_generator_test.go b/libwallet/presentation/emergency_kit_generator_test.go new file mode 100644 index 00000000..3156072f --- /dev/null +++ b/libwallet/presentation/emergency_kit_generator_test.go @@ -0,0 +1,77 @@ +package presentation + +import ( + "fmt" + "os" + "testing" + + "github.com/muun/libwallet/presentation/api" +) + +func TestGenerateEmergencyKitPDFGrpc(t *testing.T) { + ekInput := api.EKInputRequest_builder{ + FirstEncryptedKey: "5zZPjShrCywaeaqK3bPxL9bG18eLcXwQ5DyAkVy8asPujTWK58PJFyjwixASB967rfQcG2PhnZJ6ksKVWasup29WmPtAyjN6heNYC7pQARUxMsVrUVD5pGc4aJH5W3QdXDFhyiRrszFsedz2T4s", + SecondEncryptedKey: "4UrzWNdJzNg5XYkypVCAqxLreHnK6uYyaUNTmuEkdet6T1dDhHKkCicTT7MKa2BCKA4TA39o4gAzjBCageg9bvRVZs2deazEykpTgPaY6yF25AK1ckdT1dVKE9NbmVfuf5N6qFVLRBe1myYS6eD", + FirstFingerprint: "af932357", + SecondFingerprint: "61f4d2a0", + RcChecksum: "checksum123", + }.Build() + + t.Run("Basic (Eng)", func(t *testing.T) { + conn, ctx := newGrpcClient(t) + defer conn.Close() + client := api.NewWalletServiceClient(conn) + + outputPath := fmt.Sprintf("%s/emergency_kit_en.pdf", os.TempDir()) + + request := api.GenerateEmergencyKitPDFRequest_builder{ + EkInput: ekInput, + OutputPath: outputPath, + Language: "en", + }.Build() + + result, err := client.GenerateEmergencyKitPDF(ctx, request) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + if result.GetVerificationCode() != "429645" { + t.Fatalf("Verification Code should be 429645, its to supposed to be deterministic") + } + if result.GetVersion() != 3 { + t.Fatalf("Version should be 3") + } + + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Fatalf("PDF file not created at expected path: %s", outputPath) + } + + _ = os.Remove(outputPath) + }) + + t.Run("iOS FileURL handling (esp)", func(t *testing.T) { + conn, ctx := newGrpcClient(t) + defer conn.Close() + client := api.NewWalletServiceClient(conn) + + fileURLPath := fmt.Sprintf("file://%s/emergency_kit_es.pdf", os.TempDir()) + + request := api.GenerateEmergencyKitPDFRequest_builder{ + EkInput: ekInput, + OutputPath: fileURLPath, + Language: "es", + }.Build() + + _, err := client.GenerateEmergencyKitPDF(ctx, request) + if err != nil { + failWithGrpcErrorDetails(t, err) + } + + expectedPath := fmt.Sprintf("%s/emergency_kit_es.pdf", os.TempDir()) + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Fatalf("PDF file not created at expected path: %s", expectedPath) + } + + _ = os.Remove(expectedPath) + }) +} diff --git a/libwallet/presentation/muun_key_verification_test.go b/libwallet/presentation/muun_key_verification_test.go index 88f81e3d..690abea1 100644 --- a/libwallet/presentation/muun_key_verification_test.go +++ b/libwallet/presentation/muun_key_verification_test.go @@ -6,6 +6,11 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "io" + "net/http" + "testing" + "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/muun/libwallet" "github.com/muun/libwallet/domain/action/challenge_keys" @@ -16,15 +21,11 @@ import ( "github.com/muun/libwallet/service/model" "github.com/muun/libwallet/storage" "github.com/test-go/testify/assert" - "io" - "net/http" - "testing" - "time" ) func TestEncryptedMuunKeyAfterFinishSetupRecoveryCode_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") @@ -111,7 +112,7 @@ func TestEncryptedMuunKeyAfterFinishSetupRecoveryCode_Integration(t *testing.T) func TestPollForVerifiedEncryptedMuunKey_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") @@ -246,7 +247,7 @@ func TestPollForVerifiedEncryptedMuunKey_Integration(t *testing.T) { func TestPollForVerifiedEncryptedMuunKeyWithDelay_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") @@ -364,7 +365,7 @@ func TestPollForVerifiedEncryptedMuunKeyWithDelay_Integration(t *testing.T) { func TestVerifiedMuunKeyForExistingUsers_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") @@ -486,7 +487,7 @@ func TestVerifiedMuunKeyForExistingUsers_Integration(t *testing.T) { func TestUnverifiedEncryptedMuunKeyForExistingUsers_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") diff --git a/libwallet/presentation/wallet_server.go b/libwallet/presentation/wallet_server.go index dcc275ac..4583f1ec 100644 --- a/libwallet/presentation/wallet_server.go +++ b/libwallet/presentation/wallet_server.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "github.com/muun/libwallet/domain/action/emergency_kit" "log/slog" "github.com/btcsuite/btcd/btcec/v2" @@ -19,6 +20,7 @@ import ( "github.com/muun/libwallet/domain/action/diagnostic_mode_reports" "github.com/muun/libwallet/domain/action/nfc" "github.com/muun/libwallet/domain/action/recovery" + "github.com/muun/libwallet/domain/action/security_cards_marketplace" "github.com/muun/libwallet/domain/diagnostic_mode" apierrors "github.com/muun/libwallet/errors" "github.com/muun/libwallet/presentation/api" @@ -31,23 +33,25 @@ import ( type WalletServer struct { api.UnsafeWalletServiceServer - nfcBridge app_provided_data.NfcBridge - keyProvider keys.KeyProvider - network *libwallet.Network - houstonService service.HoustonService - keyValueStorage *storage.KeyValueStorage - startChallengeSetup *challenge_keys.StartChallengeSetupAction - finishChallengeSetup *challenge_keys.FinishChallengeSetupAction - populateEncryptedMuunKey *recovery.PopulateEncryptedMuunKeyAction - scanForFunds *recovery.ScanForFundsAction - submitDiagnostic *diagnostic_mode_reports.SubmitDiagnosticAction - buildSweepTx *recovery.BuildSweepTxAction - signSweepTx *recovery.SignSweepTxAction - pairSecurityCard *nfc.PairSecurityCardAction - resetSecurityCard *nfc.ResetSecurityCardAction - signMessageSecurityCard *nfc.SignMessageSecurityCardAction - pairSecurityCardV2 *nfc.PairSecurityCardActionV2 - signMessageSecurityCardV2 *nfc.SignMessageSecurityCardActionV2 + nfcBridge app_provided_data.NfcBridge + keyProvider keys.KeyProvider + network *libwallet.Network + houstonService service.HoustonService + keyValueStorage *storage.KeyValueStorage + startChallengeSetup *challenge_keys.StartChallengeSetupAction + finishChallengeSetup *challenge_keys.FinishChallengeSetupAction + populateEncryptedMuunKey *recovery.PopulateEncryptedMuunKeyAction + scanForFunds *recovery.ScanForFundsAction + submitDiagnostic *diagnostic_mode_reports.SubmitDiagnosticAction + buildSweepTx *recovery.BuildSweepTxAction + signSweepTx *recovery.SignSweepTxAction + pairSecurityCard *nfc.PairSecurityCardAction + resetSecurityCard *nfc.ResetSecurityCardAction + signMessageSecurityCard *nfc.SignMessageSecurityCardAction + pairSecurityCardV2 *nfc.PairSecurityCardActionV2 + signMessageSecurityCardV2 *nfc.SignMessageSecurityCardActionV2 + getSecurityCardsMarketplace *security_cards_marketplace.GetSecurityCardsMarketplaceAction + generateEmergencyKitPDF *emergency_kit.GenerateEmergencyKitPDFAction } func NewWalletServer( @@ -68,26 +72,30 @@ func NewWalletServer( signMessageSecurityCard *nfc.SignMessageSecurityCardAction, pairSecurityCardV2 *nfc.PairSecurityCardActionV2, signMessageSecurityCardV2 *nfc.SignMessageSecurityCardActionV2, + getSecurityCardsMarketplace *security_cards_marketplace.GetSecurityCardsMarketplaceAction, + generateEmergencyKitPDF *emergency_kit.GenerateEmergencyKitPDFAction, ) *WalletServer { return &WalletServer{ - nfcBridge: nfcBridge, - keyProvider: keyProvider, - network: network, - houstonService: houstonService, - keyValueStorage: keyValueStorage, - startChallengeSetup: startChallengeSetup, - finishChallengeSetup: finishChallengeSetup, - populateEncryptedMuunKey: obtainVerifiedEncryptedMuunKeyIfAbsent, - scanForFunds: scanForFunds, - submitDiagnostic: submitDiagnostic, - buildSweepTx: buildSweepTx, - signSweepTx: signSweepTxAction, - pairSecurityCard: pairSecurityCard, - resetSecurityCard: resetSecurityCard, - signMessageSecurityCard: signMessageSecurityCard, - pairSecurityCardV2: pairSecurityCardV2, - signMessageSecurityCardV2: signMessageSecurityCardV2, + nfcBridge: nfcBridge, + keyProvider: keyProvider, + network: network, + houstonService: houstonService, + keyValueStorage: keyValueStorage, + startChallengeSetup: startChallengeSetup, + finishChallengeSetup: finishChallengeSetup, + populateEncryptedMuunKey: obtainVerifiedEncryptedMuunKeyIfAbsent, + scanForFunds: scanForFunds, + submitDiagnostic: submitDiagnostic, + buildSweepTx: buildSweepTx, + signSweepTx: signSweepTxAction, + pairSecurityCard: pairSecurityCard, + resetSecurityCard: resetSecurityCard, + signMessageSecurityCard: signMessageSecurityCard, + pairSecurityCardV2: pairSecurityCardV2, + signMessageSecurityCardV2: signMessageSecurityCardV2, + getSecurityCardsMarketplace: getSecurityCardsMarketplace, + generateEmergencyKitPDF: generateEmergencyKitPDF, } } @@ -524,6 +532,43 @@ func (ws WalletServer) GetByPrefix(_ context.Context, req *api.GetByPrefixReques }.Build(), nil } +func (ws WalletServer) GetSecurityCardsMarketplace( + ctx context.Context, req *emptypb.Empty, +) (*api.GetSecurityCardsMarketplaceResponse, error) { + + marketplace, err := ws.getSecurityCardsMarketplace.Run() + if err != nil { + return nil, NewGrpcError(fmt.Errorf("failed to get security cards marketplace data: %w", err)) + } + + providers := make([]*api.SecurityCardsProvider, 0, len(marketplace.Providers)) + for _, provider := range marketplace.Providers { + + securityCards := make([]*api.SecurityCard, 0, len(provider.SecurityCards)) + for _, securityCard := range provider.SecurityCards { + + securityCards = append(securityCards, api.SecurityCard_builder{ + Image: securityCard.Image, + Stock: securityCard.Stock, + }.Build()) + } + + providers = append(providers, api.SecurityCardsProvider_builder{ + Name: provider.Name, + SecurityCards: securityCards, + Currency: provider.CurrencyCode, + ColorHex: provider.ColorHex, + Material: provider.Material, + Price: provider.Price, + ShippingCost: provider.ShippingCost, + }.Build()) + } + + return api.GetSecurityCardsMarketplaceResponse_builder{ + Providers: providers, + }.Build(), nil +} + func toAny(protoValue *api.Value) (any, error) { switch protoValue.WhichKind() { case api.Value_NullValue_case: @@ -599,3 +644,34 @@ func toProtoValueMap(items map[string]any) (*api.Struct, error) { } return api.Struct_builder{Fields: protoItems}.Build(), nil } + +// GenerateEmergencyKitPDF outputPath must be the full path where the PDF should be saved (including filename). +// Example: "/path/to/documents/expected_kit_name.pdf" +// The directory will be created if it doesn't exist. +func (ws WalletServer) GenerateEmergencyKitPDF( + ctx context.Context, + request *api.GenerateEmergencyKitPDFRequest, +) (*api.GenerateEmergencyKitPDFResponse, error) { + ekInput := request.GetEkInput() + ekParams := &libwallet.EKInput{ + FirstEncryptedKey: ekInput.GetFirstEncryptedKey(), + FirstFingerprint: ekInput.GetFirstFingerprint(), + SecondEncryptedKey: ekInput.GetSecondEncryptedKey(), + SecondFingerprint: ekInput.GetSecondFingerprint(), + RcChecksum: ekInput.GetRcChecksum(), + } + + result, err := ws.generateEmergencyKitPDF.Run( + ekParams, + request.GetOutputPath(), + request.GetLanguage(), + ) + if err != nil { + return nil, NewGrpcError(fmt.Errorf("failed to generate emergency kit PDF: %w", err)) + } + + return api.GenerateEmergencyKitPDFResponse_builder{ + VerificationCode: result.VerificationCode, + Version: int32(result.Version), + }.Build(), nil +} diff --git a/libwallet/presentation/wallet_server_test.go b/libwallet/presentation/wallet_server_test.go index 5a139d25..8284dca6 100644 --- a/libwallet/presentation/wallet_server_test.go +++ b/libwallet/presentation/wallet_server_test.go @@ -129,7 +129,7 @@ func waitForHealthcheck() error { func TestSaveAndGetAndDelete(t *testing.T) { t.Run("success when saving, reading and deleting a key-value pair", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -192,7 +192,7 @@ func TestSaveAndGetAndDelete(t *testing.T) { t.Run("return error when SaveRequest does not have a key defined", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -241,7 +241,7 @@ func TestSaveAndGetAndDelete(t *testing.T) { t.Run("return error when SaveRequest has an invalid key", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -294,7 +294,7 @@ func TestSaveAndGetAndDelete(t *testing.T) { t.Run("success when saving a key with NullValue", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -337,7 +337,7 @@ func TestSaveAndGetAndDelete(t *testing.T) { func TestSaveBatchAndGetBatch(t *testing.T) { t.Run("success when saving and reading key-value pairs in batches", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -416,7 +416,7 @@ func TestSaveBatchAndGetBatch(t *testing.T) { func TestGetByPrefix(t *testing.T) { t.Run("success when getting key-value pairs by prefix", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -500,7 +500,7 @@ func TestGetByPrefix(t *testing.T) { }) t.Run("success when no keys match prefix", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -529,7 +529,7 @@ func TestGetByPrefix(t *testing.T) { func TestErrorInterceptors(t *testing.T) { t.Run("return internal error when rpc execution raises a panic", func(t *testing.T) { - setupKeyValueStorage(t, buildStorageSchemaForTests()) + setupKeyValueStorage(t, buildTestMigrationPlan()) // Initialize grpc client of WalletService with bufconn conn, ctx := newGrpcClient(t) @@ -655,7 +655,7 @@ func TestErrorInterceptors(t *testing.T) { } func TestFinishRecoveryCodeSetupEndpoint_Integration(t *testing.T) { - setupKeyValueStorage(t, storage.BuildStorageSchema()) + setupKeyValueStorage(t, storage.BuildKVMigrationPlan()) recoveryCode := recoverycode.Generate() recoveryCodePrivateKey, err := recoverycode.ConvertToKey(recoveryCode, "") @@ -753,13 +753,16 @@ func createFirstSession(t *testing.T, key *libwallet.HDPublicKey) model.CreateFi return sessionOkJson } -func setupKeyValueStorage(t *testing.T, schema map[string]storage.Classification) { +func setupKeyValueStorage(t *testing.T, migrationPlan []storage.Migration) { // Create a new empty DB providing a new dataFilePath dataFilePath := path.Join(t.TempDir(), "test.db") - keyValueStorage := storage.NewKeyValueStorage(dataFilePath, schema) - - // For testing purpose, change reference to this new keyValueStorage in order to have a new empty DB - walletServer.keyValueStorage = keyValueStorage + schema, err := storage.RunKeyValueMigrations(dataFilePath, migrationPlan) + if err != nil { + t.Fatalf("failed to run KV migrations: %v", err) + } + // For testing purpose, change reference to this new keyValueStorage + // in order to have a new empty DB + walletServer.keyValueStorage = storage.NewKeyValueStorage(dataFilePath, schema) } func newGrpcClient(t *testing.T) (*grpc.ClientConn, context.Context) { @@ -811,62 +814,19 @@ func failWithGrpcErrorDetails(t testing.TB, err error) { } } -func buildStorageSchemaForTests() map[string]storage.Classification { - return map[string]storage.Classification{ - "email": { - BackupType: storage.NoAutoBackup, - BackupSecurity: storage.NotApplicable, - SecurityCritical: false, - ValueType: &storage.StringType{}, - }, - "emergencyKitVersion": { - BackupType: storage.NoAutoBackup, - BackupSecurity: storage.NotApplicable, - SecurityCritical: false, - ValueType: &storage.IntType{}, - }, - "gcmToken": { - BackupType: storage.NoAutoBackup, - BackupSecurity: storage.NotApplicable, - SecurityCritical: false, - ValueType: &storage.StringType{}, - }, - "isEmailVerified": { - BackupType: storage.NoAutoBackup, - BackupSecurity: storage.NotApplicable, - SecurityCritical: false, - ValueType: &storage.BoolType{}, - }, - "primaryCurrency": { - BackupType: storage.NoAutoBackup, - BackupSecurity: storage.NotApplicable, - SecurityCritical: false, - ValueType: &storage.StringType{}, - }, - "featureFlag:useDiagnosticMode": { - BackupType: storage.AsyncAutoBackup, - BackupSecurity: storage.Plain, - SecurityCritical: false, - ValueType: &storage.BoolType{}, - }, - "featureFlag:isDogfood": { - BackupType: storage.AsyncAutoBackup, - BackupSecurity: storage.Plain, - SecurityCritical: false, - ValueType: &storage.BoolType{}, - }, - "featureFlag:supportsNfc": { - BackupType: storage.AsyncAutoBackup, - BackupSecurity: storage.Plain, - SecurityCritical: false, - ValueType: &storage.BoolType{}, - }, - "featureFlag:utxoSelectionStrategy": { - BackupType: storage.AsyncAutoBackup, - BackupSecurity: storage.Plain, - SecurityCritical: false, - ValueType: &storage.StringType{}, - }, +func buildTestMigrationPlan() []storage.Migration { + return []storage.Migration{ + {Description: "Schema for testing purpose", Changes: []storage.Change{ + storage.Define("email", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define("emergencyKitVersion", storage.NoAutoBackup, storage.NotApplicable, false, &storage.IntType{}), + storage.Define("gcmToken", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define("isEmailVerified", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), + storage.Define("primaryCurrency", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + storage.Define("featureFlag:useDiagnosticMode", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), + storage.Define("featureFlag:isDogfood", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), + storage.Define("featureFlag:supportsNfc", storage.NoAutoBackup, storage.NotApplicable, false, &storage.BoolType{}), + storage.Define("featureFlag:utxoSelectionStrategy", storage.NoAutoBackup, storage.NotApplicable, false, &storage.StringType{}), + }}, } } diff --git a/libwallet/service/mock_houston.go b/libwallet/service/mock_houston.go index bcde50d9..91040a70 100644 --- a/libwallet/service/mock_houston.go +++ b/libwallet/service/mock_houston.go @@ -274,6 +274,12 @@ func (m *MockHoustonService) ChallengeSecurityCardSign( return model.ChallengeSecurityCardSignResponseJson{}, houstonError } + err = m.persistCardData() + if err != nil { + houstonError := mapToInternalServerHoustonError("error persisting houston data", err) + return model.ChallengeSecurityCardSignResponseJson{}, houstonError + } + challengeMac := nfc.MakeChallengeSignMac( m.secretCardBytes[:16], randomPublicKey, diff --git a/libwallet/service/model/security_card.go b/libwallet/service/model/security_card.go new file mode 100644 index 00000000..8113b8d1 --- /dev/null +++ b/libwallet/service/model/security_card.go @@ -0,0 +1,6 @@ +package model + +type SecurityCardJson struct { + Image string `json:"image"` + Stock int32 `json:"stock"` +} diff --git a/libwallet/service/model/security_cards_marketplace_json.go b/libwallet/service/model/security_cards_marketplace_json.go new file mode 100644 index 00000000..6a7326a6 --- /dev/null +++ b/libwallet/service/model/security_cards_marketplace_json.go @@ -0,0 +1,5 @@ +package model + +type SecurityCardsMarketplaceJson struct { + Providers []SecurityCardsProviderJson `json:"providers"` +} diff --git a/libwallet/service/model/security_cards_provider.go b/libwallet/service/model/security_cards_provider.go new file mode 100644 index 00000000..c351da06 --- /dev/null +++ b/libwallet/service/model/security_cards_provider.go @@ -0,0 +1,11 @@ +package model + +type SecurityCardsProviderJson struct { + Name string `json:"name"` + SecurityCards []SecurityCardJson `json:"securityCards"` + CurrencyCode string `json:"currencyCode"` + ColorHex string `json:"colorHex"` + Material string `json:"material"` + Price float64 `json:"price"` + ShippingCost float64 `json:"shippingCost"` +} diff --git a/libwallet/service/model_objects_mapper.go b/libwallet/service/model_objects_mapper.go index 841a82ec..9bf50bfd 100644 --- a/libwallet/service/model_objects_mapper.go +++ b/libwallet/service/model_objects_mapper.go @@ -3,7 +3,9 @@ package service import ( "encoding/hex" "fmt" + "github.com/muun/libwallet/domain/model/security_card" + "github.com/muun/libwallet/domain/model/security_cards_marketplace" "github.com/muun/libwallet/service/model" ) @@ -47,3 +49,35 @@ func MapSecurityCardSignChallengeResponse( CardUsageCount: in.CardUsageCount, }, nil } + +func MapSecurityCardsMarketplace( + in model.SecurityCardsMarketplaceJson, +) (*security_cards_marketplace.Marketplace, error) { + + providers := make([]security_cards_marketplace.SecurityCardsProvider, 0, len(in.Providers)) + for _, provider := range in.Providers { + + securityCards := make([]security_cards_marketplace.SecurityCard, 0, len(provider.SecurityCards)) + for _, securityCard := range provider.SecurityCards { + + securityCards = append(securityCards, security_cards_marketplace.SecurityCard{ + Image: securityCard.Image, + Stock: securityCard.Stock, + }) + } + + providers = append(providers, security_cards_marketplace.SecurityCardsProvider{ + Name: provider.Name, + CurrencyCode: provider.CurrencyCode, + ColorHex: provider.ColorHex, + Material: provider.Material, + Price: provider.Price, + ShippingCost: provider.ShippingCost, + SecurityCards: securityCards, + }) + } + + return &security_cards_marketplace.Marketplace{ + Providers: providers, + }, nil +} diff --git a/libwallet/service/nfc_integration_test.go b/libwallet/service/nfc_integration_test.go index 2355d902..c3f8bfc2 100644 --- a/libwallet/service/nfc_integration_test.go +++ b/libwallet/service/nfc_integration_test.go @@ -4,12 +4,13 @@ import ( "crypto/ecdh" "crypto/rand" "encoding/hex" - "github.com/muun/libwallet/domain/nfc" - "github.com/muun/libwallet/service/model" - "github.com/muun/libwallet/storage" "path" "strings" "testing" + + "github.com/muun/libwallet/domain/nfc" + "github.com/muun/libwallet/service/model" + "github.com/muun/libwallet/storage" ) func TestMockCardPairCardSuccess(t *testing.T) { @@ -118,6 +119,7 @@ func TestMockCardSignChallenge(t *testing.T) { testSignChallengeInvalidSlot(t, mockHouston, card, reason) testSignChallengeInvalidMac(t, mockHouston, card, reason) testSignChallengeSecretUpdates(t, mockHouston, card, reason) + testSignChallengeCounterAdvancesEvenIfSolveChallengeFails(t, mockHouston, card, reason) } // testSignChallengeSuccess performs a complete sign challenge flow @@ -206,6 +208,70 @@ func testSignChallengeInvalidCounter( } } +func testSignChallengeCounterAdvancesEvenIfSolveChallengeFails( + t *testing.T, + mockHouston *MockHoustonService, + card *nfc.MuunCardV2, + reason []byte, +) { + challengeResponse, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }) + if err != nil { + t.Fatalf("error requesting a challenge from houston: %v", err) + } + // Usage card counter should be updated in houston local storage. securityCardUsageCount += 1 + + challenge, err := MapSecurityCardSignChallengeResponse(challengeResponse) + if err != nil { + t.Fatalf("fail to parse sign challenge response from houston: %v", err) + } + + _, err = card.SignChallenge(challenge, reason) + + if err != nil { + t.Fatalf("should succeed with correct card counter, got: %v", err) + } + + // The card has now consumed `CardUsageCount` for this slot. + // We intentionally skip SolveChallenge here to simulate a failure between "sign" and "solve". + // The next challenge must use a strictly higher counter (server must not re-issue the same counter), + // otherwise the card would reject it with InvalidCounter. + + // Simulate failure: SolveSecurityCardChallenge` is not called for the first signed challenge. + challengeResponse2, err := mockHouston.ChallengeSecurityCardSign(model.ChallengeSecurityCardSignJson{ + ReasonInHex: hex.EncodeToString(reason), + }) + if err != nil { + t.Fatalf("error requesting a challenge from houston: %v", err) + } + + challenge2, err := MapSecurityCardSignChallengeResponse(challengeResponse2) + if err != nil { + t.Fatalf("fail to parse sign challenge response from houston: %v", err) + } + + signChallengeResponse, err := card.SignChallenge(challenge2, reason) + + // Requesting a second challenge after a missed solve must still allow signing + // (no counter desync between server and card). + if err != nil { + t.Fatalf("should succeed with correct card counter, got: %v", err) + } + + cardPublicKeyInHex := hex.EncodeToString(signChallengeResponse.CardPublicKey) + macInHex := hex.EncodeToString(signChallengeResponse.MAC) + securityCardChallengeJson := model.SolveSecurityCardChallengeJson{ + PublicKeyInHex: cardPublicKeyInHex, + MacInHex: macInHex, + } + + err = mockHouston.SolveSecurityCardChallenge(securityCardChallengeJson) + if err != nil { + t.Fatalf("error solving challenge: %v", err) + } +} + func testSignChallengeInvalidSlot( t *testing.T, mockHouston *MockHoustonService, @@ -321,31 +387,31 @@ func testSignChallengeSecretUpdates( func buildStorageSchemaForTests() map[string]storage.Classification { return map[string]storage.Classification{ storage.KeyLastRandomPrivKeyInHex: { - BackupType: storage.AsyncAutoBackup, + BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, SecurityCritical: false, ValueType: &storage.StringType{}, }, storage.KeySecurityCardUsageCount: { - BackupType: storage.AsyncAutoBackup, + BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, SecurityCritical: false, ValueType: &storage.IntType{}, }, storage.KeySecretCardBytesInHex: { - BackupType: storage.AsyncAutoBackup, + BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, SecurityCritical: false, ValueType: &storage.StringType{}, }, storage.KeySecurityCardPairingSlot: { - BackupType: storage.AsyncAutoBackup, + BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, SecurityCritical: false, ValueType: &storage.IntType{}, }, storage.KeyTimeSinceLastChallengeUnixMillis: { - BackupType: storage.AsyncAutoBackup, + BackupType: storage.NoAutoBackup, BackupSecurity: storage.NotApplicable, SecurityCritical: false, ValueType: &storage.LongType{}, diff --git a/libwallet/storage/kv_migration_functions.go b/libwallet/storage/kv_migration_functions.go new file mode 100644 index 00000000..7d756b1c --- /dev/null +++ b/libwallet/storage/kv_migration_functions.go @@ -0,0 +1,266 @@ +package storage + +import ( + "fmt" + "reflect" + + "github.com/muun/libwallet/walletdb" +) + +// Change is the interface that represents a single, atomic modification to the +// database schema or its data. +type Change interface { + apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) +} + +// KeyDefinition is a Change that introduces a new key to the schema. +type KeyDefinition struct { + Key string + BackupType BackupType + BackupSecurity BackupSecurity + SecurityCritical bool + ValueType ValueType +} + +func (c KeyDefinition) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { + _, exists := schema[c.Key] + if exists { + panic(fmt.Sprintf("kv migration: key '%s' is already defined", c.Key)) + } + schema[c.Key] = Classification{ + BackupType: c.BackupType, + BackupSecurity: c.BackupSecurity, + SecurityCritical: c.SecurityCritical, + ValueType: c.ValueType, + } + *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { + return repo.Create(c.Key) + }) +} + +// Define returns a new KeyDefinition. +func Define(key string, backupType BackupType, backupSecurity BackupSecurity, securityCritical bool, valueType ValueType) Change { + if reflect.TypeOf(valueType).Kind() != reflect.Ptr { + panic(fmt.Sprintf( + "kv migration: ValueType for key '%s' expects a pointer &%s{} but got value %s{}", + key, + reflect.TypeOf(valueType).Name(), + reflect.TypeOf(valueType).Name(), + )) + } + if backupType != NoAutoBackup && backupSecurity == NotApplicable { + panic(fmt.Sprintf( + "kv migration: key '%s' has auto-backup but BackupSecurity is NotApplicable", + key, + )) + } + if backupType == NoAutoBackup && backupSecurity != NotApplicable { + panic(fmt.Sprintf( + "kv migration: key '%s' has NoAutoBackup but BackupSecurity is not NotApplicable", + key, + )) + } + + // TODO: remove these validations once AutoBackup and SecurityCritical are implemented + if backupType != NoAutoBackup { + panic(fmt.Sprintf( + "kv migration: key '%s' uses BackupType %v which is not yet implemented", + key, backupType, + )) + } + if securityCritical { + panic(fmt.Sprintf( + "kv migration: key '%s' has SecurityCritical=true which is not yet implemented", + key, + )) + } + return KeyDefinition{ + Key: key, + BackupType: backupType, + BackupSecurity: backupSecurity, + SecurityCritical: securityCritical, + ValueType: valueType, + } +} + +// TypeMigration is a Change that updates a key's type for trivial conversions. +type TypeMigration struct { + Key string + NewType ValueType +} + +func (c TypeMigration) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { + classification, ok := schema[c.Key] + if !ok { + panic(fmt.Sprintf("kv migration: attempted to migrate type for key '%s' which has not been defined yet", c.Key)) + } + oldType := classification.ValueType + if !isTrivialConversion(oldType, c.NewType) { + panic(fmt.Sprintf( + "kv migration: MigrateValueType cannot be used for a non-trivial conversion "+ + "from %T to %T for key '%s'. Use MigrateValueTypeWithMap instead.", + oldType, c.NewType, c.Key, + )) + } + classification.ValueType = c.NewType + schema[c.Key] = classification +} + +// MigrateValueType returns a new TypeMigration. +func MigrateValueType(key string, newType ValueType) Change { + return TypeMigration{Key: key, NewType: newType} +} + +// MappedTypeMigration is a Change that updates a key's type using a map. +type MappedTypeMigration struct { + Key string + NewType ValueType + OldToNewMap map[string]string +} + +func (c MappedTypeMigration) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { + classification, ok := schema[c.Key] + if !ok { + panic(fmt.Sprintf("kv migration: attempted to migrate type for key '%s' which has not been defined yet", c.Key)) + } + classification.ValueType = c.NewType + schema[c.Key] = classification + + // Extract old values to check for unmapped ones. + allowedOldValues := make([]string, 0, len(c.OldToNewMap)) + for k := range c.OldToNewMap { + allowedOldValues = append(allowedOldValues, k) + } + + // Verify that no unmapped values exist. + // NULL is allowed, since it means the key was never set (so there is no value to migrate). + *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { + val, err := repo.Get(c.Key) + if err != nil { + return err + } + if val == nil { + return nil + } + ok, err := repo.IsValueIn(c.Key, allowedOldValues) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("kv migration: unmapped value found for key '%s'", c.Key) + } + return nil + }) + + *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { + _, err := repo.UpdateAccordingToMap(c.Key, c.OldToNewMap) + return err + }) +} + +// MigrateValueTypeWithMap returns a new MappedTypeMigration. +// WARNING: Do not use this function in kv_migrations.go yet. Rollback support is not yet designed, +// and these map-based migrations are only reversible if the map is bijective. +// If you need this, please discuss with the Wallet team first. +func MigrateValueTypeWithMap(key string, newType ValueType, oldToNewMap map[string]string) Change { + return MappedTypeMigration{Key: key, NewType: newType, OldToNewMap: oldToNewMap} +} + +// MapUpdate is a Change that updates a key-value pair based on a map. +type MapUpdate struct { + Key string + OldToNewMap map[string]string +} + +func (c MapUpdate) apply(schema map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { + _, exists := schema[c.Key] + if !exists { + panic(fmt.Sprintf("kv migration: attempted to update key '%s' which has not been defined yet", c.Key)) + } + + *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { + _, err := repo.UpdateAccordingToMap(c.Key, c.OldToNewMap) + return err + }) +} + +// UpdateAccordingToMap returns a new MapUpdate. +// WARNING: Do not use this function in kv_migrations.go yet. Rollback support is not yet designed, +// and these map-based migrations are only reversible if the map is bijective. +// If you need this, please discuss with the Wallet team first. +func UpdateAccordingToMap(key string, oldToNewMap map[string]string) Change { + return MapUpdate{Key: key, OldToNewMap: oldToNewMap} +} + +// CustomChange is a Change that wraps a custom database operation. +type CustomChange struct { + ID string + Step func(tx LimitedKeyValueRepository) error +} + +func (c CustomChange) apply(_ map[string]Classification, dbOps *[]func(repo walletdb.KeyValueRepository) error) { + *dbOps = append(*dbOps, func(repo walletdb.KeyValueRepository) error { + // Wrap the full repo in the limited repo before passing it to the custom step. + limitedRepo := &limitedKeyValueRepository{keyValueRepository: repo} + return c.Step(limitedRepo) + }) +} + +// AddCustomChange returns a new CustomChange. The id must be unique across all migrations. +// WARNING: Do not use this function in kv_migrations.go yet. Rollback support is not yet designed, +// and custom changes are only reversible if the operation itself is. If you need this, please +// discuss with the Wallet team first. +func AddCustomChange(id string, customStep func(tx LimitedKeyValueRepository) error) Change { + return CustomChange{ID: id, Step: customStep} +} + +// isTrivialConversion checks if a type migration is safe to perform without a data mapping. +func isTrivialConversion(from, to ValueType) bool { + fromType := reflect.TypeOf(from) + toType := reflect.TypeOf(to) + + // Trivial conversion to String from safe types. + if toType == reflect.TypeOf(&StringType{}) { + switch fromType { + case reflect.TypeOf(&IntType{}), + reflect.TypeOf(&LongType{}), + reflect.TypeOf(&DoubleType{}), + reflect.TypeOf(&BoolType{}): + return true + } + } + + // Trivial conversion from Int to Long. + if fromType == reflect.TypeOf(&IntType{}) && toType == reflect.TypeOf(&LongType{}) { + return true + } + + return false +} + +// LimitedKeyValueRepository defines the limited set of database operations available +// within a custom migration change. +type LimitedKeyValueRepository interface { + Save(key string, value *string) error + Get(key string) (*string, error) + Update(key string, newValue string) error + Delete(key string) error +} + +// limitedKeyValueRepository is the internal implementation of LimitedKeyValueRepository. +type limitedKeyValueRepository struct { + keyValueRepository walletdb.KeyValueRepository +} + +func (r *limitedKeyValueRepository) Save(key string, value *string) error { + return r.keyValueRepository.Save(key, value) +} +func (r *limitedKeyValueRepository) Get(key string) (*string, error) { + return r.keyValueRepository.Get(key) +} +func (r *limitedKeyValueRepository) Update(key string, newValue string) error { + return r.keyValueRepository.Update(key, newValue) +} +func (r *limitedKeyValueRepository) Delete(key string) error { + return r.keyValueRepository.Delete(key) +} diff --git a/libwallet/storage/kv_migrations.go b/libwallet/storage/kv_migrations.go new file mode 100644 index 00000000..003ef55d --- /dev/null +++ b/libwallet/storage/kv_migrations.go @@ -0,0 +1,34 @@ +package storage + +//go:generate go run ../cmd/kv_migration_tool lock -migrations-file kv_migrations.go -lockfile testdata/kv_migrations.lock + +// BuildKVMigrationPlan provides the ordered history of the key-value schema and data migrations. +func BuildKVMigrationPlan() []Migration { + return []Migration{ + Migration{"Initial schema", []Change{ + Define("isBalanceHidden", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), + // TODO: migrate to AsyncAutoBackup, Authenticated + Define("securityCardXpubSerialized", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("biometricsOptIn", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("pinLength", NoAutoBackup, NotApplicable, false, &IntType{}), + // TODO: migrate to AsyncAutoBackup, Plain + Define("unverifiedEncryptedMuungKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), + // TODO: migrate to AsyncAutoBackup, Authenticated, SecurityCritical + Define("verifiedEncryptedMuunKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), + // TODO: migrate to AsyncAutoBackup, Authenticated, SecurityCritical + Define("encryptedUserKeyPrototype", NoAutoBackup, NotApplicable, false, &StringType{}), + // TODO: migrate to AsyncAutoBackup, Plain + Define("featureFlagOverrides:nfcCardV2", NoAutoBackup, NotApplicable, false, &BoolType{}), + // TODO: migrate to AsyncAutoBackup, Plain + Define("featureFlagOverrides:ekGoRendering", NoAutoBackup, NotApplicable, false, &BoolType{}), + }}, + Migration{"Mock Houston server state", []Change{ + Define("lastRandomPrivKeyInHex", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), + Define("secretCardBytesInHex", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("securityCardPairingSlot", NoAutoBackup, NotApplicable, false, &IntType{}), + Define("timeSinceLastChallengeUnixMillis", NoAutoBackup, NotApplicable, false, &LongType{}), + }}, + } +} diff --git a/libwallet/storage/kv_migrations_lock_test.go b/libwallet/storage/kv_migrations_lock_test.go new file mode 100644 index 00000000..66cc9247 --- /dev/null +++ b/libwallet/storage/kv_migrations_lock_test.go @@ -0,0 +1,48 @@ +package storage_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/muun/libwallet/internal/kvmigrationlock" + "github.com/muun/libwallet/storage" +) + +// TestMigrationsLockfileIsUpToDate verifies that no past migration has been modified +// and that all current migrations are reflected in the lockfile. +// If this test fails, run: go generate ./storage/... (from the libwallet directory). +func TestMigrationsLockfileIsUpToDate(t *testing.T) { + current, err := kvmigrationlock.Generate(storage.BuildKVMigrationPlan(), "kv_migrations.go") + if err != nil { + t.Fatalf("failed to generate lockfile from current migrations: %v", err) + } + + data, err := os.ReadFile("testdata/kv_migrations.lock") + if err != nil { + t.Fatalf("failed to read testdata/kv_migrations.lock: %v \nrun: go generate ./storage/... (from libwallet/)", err) + } + + var committed kvmigrationlock.Lockfile + if err := json.Unmarshal(data, &committed); err != nil { + t.Fatalf("failed to parse testdata/kv_migrations.lock: %v", err) + } + + if len(committed.Migrations) != len(current.Migrations) { + t.Fatalf( + "lockfile has %d migrations but plan has %d \nrun: go generate ./storage/... (from libwallet/)", + len(committed.Migrations), + len(current.Migrations), + ) + } + + for i, existing := range committed.Migrations { + if existing.Hash != current.Migrations[i].Hash { + t.Fatalf( + "migration %d ('%s') was modified \nrun: go generate ./storage/... (from libwallet/)", + i+1, + existing.Description, + ) + } + } +} diff --git a/libwallet/storage/kv_migrator.go b/libwallet/storage/kv_migrator.go new file mode 100644 index 00000000..7db0076e --- /dev/null +++ b/libwallet/storage/kv_migrator.go @@ -0,0 +1,121 @@ +package storage + +import ( + "fmt" + + "github.com/muun/libwallet/walletdb" +) + +// Migration is a collection of Changes that are executed together +// within a single database transaction. +type Migration struct { + Description string + Changes []Change +} + +// run executes all Changes in the migration within a single transaction. +func (m *Migration) run(tx walletdb.KeyValueRepository, schema map[string]Classification) error { + + var dbOperations []func(repository walletdb.KeyValueRepository) error + + // Generate all DB operations by applying the changes to the schema. + for _, change := range m.Changes { + change.apply(schema, &dbOperations) + } + + // Execute all generated operations within the transaction. + for _, dbOperation := range dbOperations { + err := dbOperation(tx) + if err != nil { + return err + } + } + + return nil +} + +// RunKeyValueMigrations executes the entire migration plan, returning the final schema. +func RunKeyValueMigrations(dataFilePath string, migrations []Migration) (map[string]Classification, error) { + + // Validate that all CustomChange IDs are unique across all migrations. + seenIDs := make(map[string]bool) + for _, migration := range migrations { + for _, change := range migration.Changes { + if cc, ok := change.(CustomChange); ok { + if seenIDs[cc.ID] { + return nil, fmt.Errorf("duplicate custom change id '%s' in migration '%s'", cc.ID, migration.Description) + } + seenIDs[cc.ID] = true + } + } + } + + // Build the final schema in memory by running all changes without DB operations. + // This is done upfront to ensure we can return a valid schema even if DB operations fail. + finalSchema := make(map[string]Classification) + var discardedDbOperations []func(walletdb.KeyValueRepository) error + for i := range migrations { + for _, change := range migrations[i].Changes { + change.apply(finalSchema, &discardedDbOperations) + } + } + + // Open DB and get current state. + db, err := walletdb.Open(dataFilePath) + if err != nil { + return finalSchema, fmt.Errorf("failed to open database: %w", err) + } + defer db.Close() + + schemaStateRepository := db.NewKVSchemaStateRepository() + currentVersion, err := schemaStateRepository.GetCurrentSchemaVersion() + if err != nil { + return finalSchema, fmt.Errorf("failed to get current schema version: %w", err) + } + + // Early exit if no new migrations are needed. + if currentVersion >= len(migrations) { + return finalSchema, nil + } + + // Re-build the schema state up to the current version before starting migrations. + currentSchema := make(map[string]Classification) + for i := 0; i < currentVersion; i++ { + for _, change := range migrations[i].Changes { + change.apply(currentSchema, &discardedDbOperations) + } + } + + // Execute only the new migrations, each in its own transaction. + for i := currentVersion; i < len(migrations); i++ { + migration := &migrations[i] + targetVersion := i + 1 + + dbTx := db.Gorm().Begin() + if dbTx.Error != nil { + return finalSchema, fmt.Errorf("failed to begin transaction for migration %d: %w", targetVersion, dbTx.Error) + } + + txRepository := walletdb.NewKeyValueRepository(dbTx) + txSchemaStateRepository := walletdb.NewKVSchemaStateRepository(dbTx) + + err = migration.run(txRepository, currentSchema) + if err != nil { + dbTx.Rollback() + return finalSchema, fmt.Errorf("migration %d (%s) failed: %w", targetVersion, migration.Description, err) + } + + err = txSchemaStateRepository.BumpSchemaVersion(targetVersion) + if err != nil { + dbTx.Rollback() + return finalSchema, fmt.Errorf("failed to bump schema version to %d: %w", targetVersion, err) + } + + err = dbTx.Commit().Error + if err != nil { + return finalSchema, fmt.Errorf("failed to commit transaction for migration %d: %w", targetVersion, err) + } + } + + return finalSchema, nil +} diff --git a/libwallet/storage/kv_migrator_test.go b/libwallet/storage/kv_migrator_test.go new file mode 100644 index 00000000..6d66b29e --- /dev/null +++ b/libwallet/storage/kv_migrator_test.go @@ -0,0 +1,592 @@ +package storage + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/muun/libwallet/walletdb" +) + +func TestMigrateValueTypeWithMap_FromIntToString(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1 of the app: nightMode is an Int + v1Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + } + schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + if err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + storageV1 := NewKeyValueStorage(dbPath, schemaV1) + if err := storageV1.Save("nightMode", int32(0)); err != nil { + t.Fatalf("V1 save failed: %v", err) + } + + // V2 of the app: nightMode becomes a String + v2Plan := []Migration{ + Migration{"Initial Schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + Migration{"Migrate nightMode to string", []Change{ + MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ + "0": "light", + "1": "dark", + }), + }}, + } + schemaV2, err := RunKeyValueMigrations(dbPath, v2Plan) + if err != nil { + t.Fatalf("V2 migration failed: %v", err) + } + storageV2 := NewKeyValueStorage(dbPath, schemaV2) + + // Verify migrated value + value, err := storageV2.Get("nightMode") + if err != nil { + t.Fatalf("V2 get failed: %v", err) + } + + strValue, ok := value.(string) + if !ok { + t.Fatalf("Expected type string, got %T", value) + } + if strValue != "light" { + t.Fatalf("Expected 'light', got %v", value) + } +} + +func TestMigrateValueTypeWithMap_FailsWhenValueIsNotMapped(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1 of the app: nightMode is a String, and an unexpected value "system" was saved + v1Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + } + schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + if err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + storageV1 := NewKeyValueStorage(dbPath, schemaV1) + if err := storageV1.Save("nightMode", "system"); err != nil { + t.Fatalf("V1 save failed: %v", err) + } + + // V2: nightMode becomes an Int, but the map only covers "light" and "dark", not "system" + v2Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + Migration{"Migrate nightMode to int", []Change{ + MigrateValueTypeWithMap("nightMode", &IntType{}, map[string]string{ + "light": "0", + "dark": "1", + }), + }}, + } + _, err = RunKeyValueMigrations(dbPath, v2Plan) + if err == nil { + t.Fatal("Expected migration to fail due to unmapped value 'system', but it succeeded") + } +} + +func TestMigrateValueTypeWithMap_SucceedsWhenValueIsNull(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1: Define a key but never set its value, leaving it as NULL. + v1Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + } + if _, err := RunKeyValueMigrations(dbPath, v1Plan); err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + + // V2: NULL means absence of value, so the migration should succeed and leave the value as NULL + v2Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + Migration{"Migrate nightMode to string", []Change{ + MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ + "0": "light", + "1": "dark", + }), + }}, + } + schema, err := RunKeyValueMigrations(dbPath, v2Plan) + if err != nil { + t.Fatalf("Expected migration to succeed for NULL value, but got: %v", err) + } + + value, err := NewKeyValueStorage(dbPath, schema).Get("nightMode") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if value != nil { + t.Fatalf("Expected nil value, got %v", value) + } +} + +func TestUpdateAccordingToMap_SucceedsWhenKeyHasNoValue(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1: Define a key but never set its value, leaving it as NULL. + v1Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + } + if _, err := RunKeyValueMigrations(dbPath, v1Plan); err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + + // V2: Update according to a map. Since no value is set, no rows should be affected + v2Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + Migration{"Update nightMode according to map", []Change{ + UpdateAccordingToMap("nightMode", map[string]string{ + "light": "0", + "dark": "1", + }), + }}, + } + schema, err := RunKeyValueMigrations(dbPath, v2Plan) + if err != nil { + t.Fatalf("Expected migration to succeed when key has no value, but got: %v", err) + } + + // Value should remain NULL. + value, err := NewKeyValueStorage(dbPath, schema).Get("nightMode") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if value != nil { + t.Fatalf("Expected nil value, got %v", value) + } +} + +func TestRunAllMigrations(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1 state: Define a schema that existed before all migrations ran + v1Plan := []Migration{ + Migration{"Initial test schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + Define("protocolForReceiving", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("useTurboChannels", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("useTaproot", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("useDefaultConfig", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), + }}, + } + schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + if err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + + // Save data for initial schema + storageV1 := NewKeyValueStorage(dbPath, schemaV1) + storageV1.Save("nightMode", int32(0)) + storageV1.Save("protocolForReceiving", "UNIFIED") + storageV1.Save("useTurboChannels", true) + storageV1.Save("useTaproot", false) + + // V2 state: Run the full migration plan + finalPlan := buildTestMigrationPlan() + finalSchema, err := RunKeyValueMigrations(dbPath, finalPlan) + if err != nil { + t.Fatalf("Final migration failed: %v", err) + } + storageFinal := NewKeyValueStorage(dbPath, finalSchema) + + // Assertions + nightMode, _ := storageFinal.Get("nightMode") + if nightMode.(string) != "light" { + t.Fatalf("nightMode=%v, want light", nightMode) + } + protocolForReceiving, _ := storageFinal.Get("protocolForReceiving") + if protocolForReceiving.(string) != "LIGHTNING" { + t.Fatalf("protocolForReceiving=%v, want LIGHTNING", protocolForReceiving) + } + securityCardUsageCount, _ := storageFinal.Get("securityCardUsageCount") + if securityCardUsageCount.(int32) != 0 { + t.Fatalf("securityCardUsageCount=%v, want 0", securityCardUsageCount) + } + useDefaultConfig, _ := storageFinal.Get("useDefaultConfig") + if useDefaultConfig.(bool) != true { + t.Fatalf("useDefaultConfig=%v, want true", useDefaultConfig) + } +} + +func TestEarlyExitMigration(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + db, _ := walletdb.Open(dbPath) + defer db.Close() + schemaStateRepository := db.NewKVSchemaStateRepository() + keyValueRepository := db.NewKeyValueRepository() + + plan := buildTestMigrationPlan() + // Set version to max to force an early-exit in the migration + err := schemaStateRepository.BumpSchemaVersion(len(plan)) + if err != nil { + t.Fatalf("BumpSchemaVersion failed: %v", err) + } + + // Set a raw value that would be changed if migrations ran + err = keyValueRepository.Save("nightMode", strPtr("0")) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + // When trying to run migrations, it should early-exit without running any migration + _, err = RunKeyValueMigrations(dbPath, plan) + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify the raw value was not changed + val, _ := keyValueRepository.Get("nightMode") + if *val != "0" { + t.Fatalf("Value was changed despite early exit: got %s", *val) + } +} + +func TestMigrationRollback(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + // V1: Define a key and save an initial value + v1Plan := []Migration{ + Migration{"Define nightMode", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{})}, + }, + } + schemaV1, _ := RunKeyValueMigrations(dbPath, v1Plan) + storageV1 := NewKeyValueStorage(dbPath, schemaV1) + err := storageV1.Save("nightMode", "dark") + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + // V2: A failing migration + v2Plan := []Migration{ + Migration{"Define nightMode", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &StringType{})}, + }, + Migration{"This one will fail", []Change{ + AddCustomChange("failing-change", func(tx LimitedKeyValueRepository) error { + err := tx.Save("nightMode", strPtr("this_value_should_be_rolled_back")) + if err != nil { + return err + } + return errors.New("an error running custom change") + }), + }}, + } + finalSchema, err := RunKeyValueMigrations(dbPath, v2Plan) + if err == nil { + t.Fatal("Expected an error, but got none") + } + + // Verify state + finalStorage := NewKeyValueStorage(dbPath, finalSchema) + value, _ := finalStorage.Get("nightMode") + if value.(string) != "dark" { + t.Fatalf("Expected 'dark', got %v", value) + } + + db, _ := walletdb.Open(dbPath) + defer db.Close() + version, _ := db.NewKVSchemaStateRepository().GetCurrentSchemaVersion() + if version != 1 { + t.Fatalf("Expected schema version 1, got %d", version) + } +} + +func TestDefine_PanicsOnDuplicatedKeyDefinitionWithinSameMigration(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Errorf("The code did not panic") + } + }() + plan := []Migration{ + Migration{"Duplicated keys", []Change{ + Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + } + dbPath := filepath.Join(t.TempDir(), "wallet.db") + _, _ = RunKeyValueMigrations(dbPath, plan) +} + +func TestDefine_PanicsOnDuplicatedKeyDefinitionWhenUsingDifferentMigrations(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Errorf("The code did not panic") + } + }() + plan := []Migration{ + Migration{"Define a key", []Change{ + Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + Migration{"Duplicated key", []Change{ + Define("someKey", NoAutoBackup, NotApplicable, false, &StringType{}), + }}, + } + dbPath := filepath.Join(t.TempDir(), "wallet.db") + _, _ = RunKeyValueMigrations(dbPath, plan) +} + +func TestRunKeyValueMigrations_FailsOnDuplicatedCustomChangeID_WithinSameMigration(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + plan := []Migration{ + Migration{"A migration", []Change{ + AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + return nil + }), + AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + return nil + }), + }}, + } + _, err := RunKeyValueMigrations(dbPath, plan) + if err == nil { + t.Fatal("Expected an error due to duplicate CustomChange ID, but got none") + } +} + +func TestRunKeyValueMigrations_FailsOnDuplicatedCustomChangeID_WhenUsingDifferentMigration(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + plan := []Migration{ + Migration{"First migration", []Change{ + AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + return nil + }), + }}, + Migration{"Second migration", []Change{ + AddCustomChange("duplicated-id", func(tx LimitedKeyValueRepository) error { + return nil + }), + }}, + } + _, err := RunKeyValueMigrations(dbPath, plan) + if err == nil { + t.Fatal("Expected an error due to duplicate CustomChange ID, but got none") + } +} + +func TestDefine_PanicsWhenValueTypeIsNotAPointer(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + plan := []Migration{ + Migration{"Value type is not a pointer", []Change{ + Define("someKey", NoAutoBackup, NotApplicable, false, StringType{}), + }}, + } + dbPath := filepath.Join(t.TempDir(), "wallet.db") + _, _ = RunKeyValueMigrations(dbPath, plan) +} + +func buildTestMigrationPlan() []Migration { + return []Migration{ + Migration{"Initial test schema", []Change{ + Define("nightMode", NoAutoBackup, NotApplicable, false, &IntType{}), + Define("protocolForReceiving", NoAutoBackup, NotApplicable, false, &StringType{}), + Define("securityCardUsageCount", NoAutoBackup, NotApplicable, false, &IntType{}), + Define("useTurboChannels", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("useTaproot", NoAutoBackup, NotApplicable, false, &BoolType{}), + Define("useDefaultConfig", NoAutoBackup, NotApplicable, false, &BoolType{}), + }}, + Migration{"Migrate nightMode and protocol", []Change{ + MigrateValueTypeWithMap("nightMode", &StringType{}, map[string]string{ + // Switch-case via map: "0"->"light", "1"->"dark" + "0": "light", + "1": "dark", + }), + UpdateAccordingToMap("protocolForReceiving", map[string]string{ + "UNIFIED": "LIGHTNING", + }), + }}, + Migration{"Initialize a key and update a second key based on other keys", []Change{ + AddCustomChange("initialize-security-card-usage-count", func(tx LimitedKeyValueRepository) error { + val, _ := tx.Get("securityCardUsageCount") + if val == nil { + return tx.Save("securityCardUsageCount", strPtr("0")) + } + return nil + }), + AddCustomChange("set-use-default-config", func(tx LimitedKeyValueRepository) error { + tc, _ := tx.Get("useTurboChannels") + tr, _ := tx.Get("useTaproot") + if tc != nil && tr != nil && *tc == "true" && *tr == "false" { + return tx.Save("useDefaultConfig", strPtr("true")) + } + return nil + }), + }}, + } +} + +func TestMigrateValueType_TrivialConversions(t *testing.T) { + cases := []struct { + name string + fromType ValueType + toType ValueType + initialValue any + wantValue any + }{ + {"Int to String", &IntType{}, &StringType{}, int32(91), "91"}, + {"Long to String", &LongType{}, &StringType{}, int64(91), "91"}, + {"Bool to String", &BoolType{}, &StringType{}, true, "true"}, + {"Double to String", &DoubleType{}, &StringType{}, float64(3.14), "3.14"}, + {"Int to Long", &IntType{}, &LongType{}, int32(91), int64(91)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + v1Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), + }}, + } + schemaV1, err := RunKeyValueMigrations(dbPath, v1Plan) + if err != nil { + t.Fatalf("V1 migration failed: %v", err) + } + if err := NewKeyValueStorage(dbPath, schemaV1).Save("testKey", tc.initialValue); err != nil { + t.Fatalf("V1 save failed: %v", err) + } + + v2Plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), + }}, + Migration{"Migrate testKey", []Change{ + MigrateValueType("testKey", tc.toType), + }}, + } + schemaV2, err := RunKeyValueMigrations(dbPath, v2Plan) + if err != nil { + t.Fatalf("V2 migration failed: %v", err) + } + + value, err := NewKeyValueStorage(dbPath, schemaV2).Get("testKey") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if value != tc.wantValue { + t.Fatalf("got %v (%T), want %v (%T)", value, value, tc.wantValue, tc.wantValue) + } + }) + } +} + +func TestMigrateValueType_PanicsOnNonTrivialConversions(t *testing.T) { + cases := []struct { + name string + fromType ValueType + toType ValueType + }{ + {"String to Int", &StringType{}, &IntType{}}, + {"String to Long", &StringType{}, &LongType{}}, + {"String to Bool", &StringType{}, &BoolType{}}, + {"String to Double", &StringType{}, &DoubleType{}}, + {"Long to Int", &LongType{}, &IntType{}}, + {"Bool to Int", &BoolType{}, &IntType{}}, + {"Double to Int", &DoubleType{}, &IntType{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for conversion from %T to %T, but did not panic", tc.fromType, tc.toType) + } + }() + dbPath := filepath.Join(t.TempDir(), "wallet.db") + plan := []Migration{ + Migration{"Initial schema", []Change{ + Define("testKey", NoAutoBackup, NotApplicable, false, tc.fromType), + }}, + Migration{"Migrate testKey", []Change{ + MigrateValueType("testKey", tc.toType), + }}, + } + _, _ = RunKeyValueMigrations(dbPath, plan) + }) + } +} + +func TestDefine_PanicsOnInvalidBackupSecurityCombination(t *testing.T) { + cases := []struct { + name string + backupType BackupType + backupSecurity BackupSecurity + }{ + {"AutoBackup with NotApplicable (Sync)", SyncAutoBackup, NotApplicable}, + {"AutoBackup with NotApplicable (Async)", AsyncAutoBackup, NotApplicable}, + {"NoAutoBackup with Plain", NoAutoBackup, Plain}, + {"NoAutoBackup with Authenticated", NoAutoBackup, Authenticated}, + {"NoAutoBackup with Encrypted", NoAutoBackup, Encrypted}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for combination %v + %v, but did not panic", tc.backupType, tc.backupSecurity) + } + }() + Define("someKey", tc.backupType, tc.backupSecurity, false, &StringType{}) + }) + } +} + +// TODO: remove these tests once AutoBackup and SecurityCritical are implemented. +func TestDefine_PanicsOnUnimplementedClassifications(t *testing.T) { + t.Run("SyncAutoBackup is not yet implemented", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for SyncAutoBackup, but did not panic") + } + }() + Define("someKey", SyncAutoBackup, Plain, false, &StringType{}) + }) + + t.Run("AsyncAutoBackup is not yet implemented", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for AsyncAutoBackup, but did not panic") + } + }() + Define("someKey", AsyncAutoBackup, Plain, false, &StringType{}) + }) + + t.Run("SecurityCritical is not yet implemented", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for SecurityCritical=true, but did not panic") + } + }() + Define("someKey", NoAutoBackup, NotApplicable, true, &StringType{}) + }) +} + +func strPtr(s string) *string { + return &s +} diff --git a/libwallet/storage/schema.go b/libwallet/storage/schema.go index 377c9f73..26bde0de 100644 --- a/libwallet/storage/schema.go +++ b/libwallet/storage/schema.go @@ -35,7 +35,9 @@ const ( EncryptedUserKey string = "encryptedUserKeyPrototype" // ==== Feature flag overrides ==== - FeatureFlagOverridesNfcCardV2Key = "featureFlagOverrides:nfcCardV2" + FeatureFlagOverridesNfcCardV2Key = "featureFlagOverrides:nfcCardV2" + FeatureFlagOverridesekGoRendering = "featureFlagOverrides:ekGoRendering" + // ==== End of feature flag overrides ==== // ==== Temporary keys for mock houston. Will remove soon ==== KeyLastRandomPrivKeyInHex string = "lastRandomPrivKeyInHex" @@ -141,89 +143,3 @@ type Classification struct { ValueType ValueType } -func BuildStorageSchema() map[string]Classification { - return map[string]Classification{ - KeyIsBalanceHidden: { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, - }, - KeyNightMode: { - BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &StringType{}, - }, - KeySecurityCardXpubSerialized: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: &StringType{}, - }, - KeyBiometricsOptIn: { - BackupType: NoAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: &BoolType{}, - }, - KeyPinLength: { - BackupType: NoAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: &IntType{}, - }, - UnverifiedEncryptedMuunKey: { - BackupType: AsyncAutoBackup, - BackupSecurity: Plain, - SecurityCritical: false, - ValueType: &StringType{}, - }, - VerifiedEncryptedMuunKey: { - BackupType: AsyncAutoBackup, - BackupSecurity: Authenticated, - SecurityCritical: true, - ValueType: &StringType{}, - }, - EncryptedUserKey: { - BackupType: AsyncAutoBackup, - BackupSecurity: Authenticated, - SecurityCritical: true, - ValueType: &StringType{}, - }, - // ==== Feature flag overrides ==== - FeatureFlagOverridesNfcCardV2Key: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: &BoolType{}, - }, - // ==== End of feature flag overrides ==== - // ==== Temporary keys for mock houston. Will remove soon ==== - KeyLastRandomPrivKeyInHex: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: &StringType{}, - }, - KeySecurityCardUsageCount: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: IntType{}, - }, - KeySecretCardBytesInHex: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: StringType{}, - }, - KeySecurityCardPairingSlot: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: IntType{}, - }, - KeyTimeSinceLastChallengeUnixMillis: { - BackupType: AsyncAutoBackup, - BackupSecurity: NotApplicable, - SecurityCritical: false, - ValueType: LongType{}, - }, - // ==== End of temporary keys for mock houston ==== - } -} diff --git a/libwallet/storage/storage_test.go b/libwallet/storage/storage_test.go index af468561..0ef26671 100644 --- a/libwallet/storage/storage_test.go +++ b/libwallet/storage/storage_test.go @@ -598,13 +598,13 @@ func buildStorageSchemaForTests() map[string]Classification { BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &StringType{}, }, "featureFlag:useDiagnosticMode": { - BackupType: AsyncAutoBackup, BackupSecurity: Plain, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, }, "featureFlag:isDogfood": { - BackupType: AsyncAutoBackup, BackupSecurity: Plain, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, }, "featureFlag:supportsNfc": { - BackupType: AsyncAutoBackup, BackupSecurity: Plain, SecurityCritical: false, ValueType: &BoolType{}, + BackupType: NoAutoBackup, BackupSecurity: NotApplicable, SecurityCritical: false, ValueType: &BoolType{}, }, } } diff --git a/libwallet/storage/testdata/kv_migrations.lock b/libwallet/storage/testdata/kv_migrations.lock new file mode 100644 index 00000000..0741232b --- /dev/null +++ b/libwallet/storage/testdata/kv_migrations.lock @@ -0,0 +1,32 @@ +{ + "version": 1, + "migrations": [ + { + "description": "Initial schema", + "hash": "sha256:982988c032425b8b441863777ad963d5e0bd72d8788b48f844ba29bb39f044b7", + "change_hashes": [ + "sha256:792e3f6b6027fcaebfcbda91930ec56207f46c1d1ae2a91262c40fa3e48b24ec", + "sha256:759216331673c4b6d26950b4ec91874f30f04dd8fa1758ed12cb57e22fc04d0a", + "sha256:1b77e83dec913f8555ac24ded8f5476a2b3e9785a4378df94b8f308d0e787ac1", + "sha256:d526b42ae621da77a3779dbbb0b9e95bd997498f76c67addebfc8f2a4211999f", + "sha256:0428e08b72212717b19dfe8f8b7301c97b6731352d4b019bd926d85ca4e2d5ea", + "sha256:cb04ecdd7ff32fcc5898d2a578dd2b3e90fd09d78735966b464827d9071359e4", + "sha256:1f790f58002c3e65074a505970da066cf81f67fc1ff68acd5be6109efe7c2879", + "sha256:a0e875c71907caa28a025bec23b60af73252762b22642e83eddb20246fd9d17b", + "sha256:1f5bb41c9e6a7ca1174108f07a0d25fcb5bfb07c7b7bf864226d4b3e295ce0bd", + "sha256:d5e40b9a92b8de56335e77432c30841c5a1dfe804483722feba21798af63493f" + ] + }, + { + "description": "Mock Houston server state", + "hash": "sha256:cc96dbb056871f10ccafc254c3c5a794c13fc1d6e6dbeace3704a65911d1d368", + "change_hashes": [ + "sha256:399b54977ef2f4284bef10a96db978f082cd2bd7a76156c278260f4eddcd4ee8", + "sha256:293e64f4fab474361dd5a9faac554f94ab4a7358f60764c7418ecdf1346d4f35", + "sha256:d774f647919cdafd0cc17f413ba04239eed7bece3668b08cc78bc00d9c3cec30", + "sha256:689fea473d3b8fa6a35c9793abea4b2b70249def662ed11d7e2f88897523c753", + "sha256:6749b6f1376eb169e7e87429f7cf8e2e63882b33ed93b0836c3c9b97ad0b5a57" + ] + } + ] +} \ No newline at end of file diff --git a/libwallet/walletdb/key_value_repository.go b/libwallet/walletdb/key_value_repository.go index 4d506411..4fe2025e 100644 --- a/libwallet/walletdb/key_value_repository.go +++ b/libwallet/walletdb/key_value_repository.go @@ -4,17 +4,22 @@ import ( "database/sql" "errors" "fmt" - "github.com/jinzhu/gorm" "strings" "time" + + "github.com/jinzhu/gorm" ) type KeyValueRepository interface { + Create(key string) error Save(key string, value *string) error + Update(key string, newValue string) error Get(key string) (*string, error) Delete(key string) error SaveBatch(items map[string]*string) error GetBatch(keys []string) (map[string]*string, error) + UpdateAccordingToMap(key string, oldToNewMap map[string]string) (int, error) + IsValueIn(key string, allowedValues []string) (bool, error) } type KeyValue struct { @@ -27,10 +32,23 @@ type GORMKeyValueRepository struct { db *gorm.DB } +// Create inserts a key with a null value if the key doesn't exist. +func (r *GORMKeyValueRepository) Create(key string) error { + + now := time.Now().UTC() + query := ` + INSERT INTO key_values (key, created_at, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO NOTHING + ` + _, err := r.db.CommonDB().Exec(query, key, now, now) + return err +} + // Save inserts or updates a key-value into database func (r *GORMKeyValueRepository) Save(key string, value *string) error { - now := time.Now() + now := time.Now().UTC() query := ` INSERT INTO key_values (key, value, created_at, updated_at) VALUES (?, ?, ?, ?) @@ -45,10 +63,17 @@ func (r *GORMKeyValueRepository) Save(key string, value *string) error { return nil } +// Update updates the value of a key if it exists. +func (r *GORMKeyValueRepository) Update(key string, newValue string) error { + now := time.Now().UTC() + query := `UPDATE key_values SET value=?, updated_at=? WHERE key=?` + _, err := r.db.CommonDB().Exec(query, newValue, now, key) + return err +} + // Get value by key from database func (r *GORMKeyValueRepository) Get(key string) (*string, error) { var ns sql.NullString - err := r.db.Raw("SELECT value FROM key_values WHERE key = ?", key).Row().Scan(&ns) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -57,7 +82,6 @@ func (r *GORMKeyValueRepository) Get(key string) (*string, error) { } return nil, fmt.Errorf("failed to fetch from db: %v", err) } - if ns.Valid { return &ns.String, nil } @@ -80,7 +104,7 @@ func (r *GORMKeyValueRepository) SaveBatch(items map[string]*string) error { return fmt.Errorf("no items provided for database insertion") } - now := time.Now() + now := time.Now().UTC() placeholders := make([]string, 0, len(items)) args := make([]any, 0, len(items)*2) for key, value := range items { @@ -153,3 +177,54 @@ func (r *GORMKeyValueRepository) GetBatch(keys []string) (map[string]*string, er } return keyValues, nil } + +// UpdateAccordingToMap updates key-values based on a map of old-to-new values. +func (r *GORMKeyValueRepository) UpdateAccordingToMap(key string, oldToNewMap map[string]string) (int, error) { + if len(oldToNewMap) == 0 { + return 0, nil + } + now := time.Now().UTC() + var queryBuilder strings.Builder + queryBuilder.WriteString("UPDATE key_values SET value = CASE value ") + var args []any + for oldValue, newValue := range oldToNewMap { + queryBuilder.WriteString("WHEN ? THEN ? ") + args = append(args, oldValue, newValue) + } + queryBuilder.WriteString("ELSE value END, updated_at = ? WHERE key = ?") + args = append(args, now, key) + + result, err := r.db.CommonDB().Exec(queryBuilder.String(), args...) + if err != nil { + return 0, err + } + rowsAffected, _ := result.RowsAffected() + return int(rowsAffected), nil +} + +// IsValueIn returns true if the current value of key is in allowedValues. +func (r *GORMKeyValueRepository) IsValueIn(key string, allowedValues []string) (bool, error) { + if len(allowedValues) == 0 { + return false, nil + } + + placeholders := make([]string, len(allowedValues)) + args := make([]any, 0, len(allowedValues)+1) + args = append(args, key) + for i, v := range allowedValues { + placeholders[i] = "?" + args = append(args, v) + } + + query := fmt.Sprintf( + `SELECT COUNT(*) FROM key_values WHERE key = ? AND value IN (%s)`, + strings.Join(placeholders, ","), + ) + + var count int + row := r.db.CommonDB().QueryRow(query, args...) + if err := row.Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} diff --git a/libwallet/walletdb/key_value_repository_test.go b/libwallet/walletdb/key_value_repository_test.go new file mode 100644 index 00000000..71843fa0 --- /dev/null +++ b/libwallet/walletdb/key_value_repository_test.go @@ -0,0 +1,119 @@ +package walletdb + +import ( + "strings" + "testing" + "time" +) + +// TestKeyValueRepositoryTimestampsAreUTC verifies that every write method in +// GORMKeyValueRepository stores timestamps with a UTC timezone indicator ("Z"). +// This guards against regressions where time.Now() without .UTC() would produce +// a local-timezone timestamp. +func TestKeyValueRepositoryTimestampsAreUTC(t *testing.T) { + loc, err := time.LoadLocation("America/Argentina/Buenos_Aires") + if err != nil { + t.Skip("Cannot load timezone for test") + } + original := time.Local + time.Local = loc + defer func() { time.Local = original }() + + db, err := setupTestDb(t) + if err != nil { + t.Fatalf("failed to set up test db: %v", err) + } + defer db.Close() + + repo := db.NewKeyValueRepository() + + t.Run("Create", func(t *testing.T) { + if err := repo.Create("create-key"); err != nil { + t.Fatalf("Create failed: %v", err) + } + assertTimestampsUTC(t, db, "create-key") + }) + + t.Run("Save", func(t *testing.T) { + value := "save-value" + if err := repo.Save("save-key", &value); err != nil { + t.Fatalf("Save failed: %v", err) + } + assertTimestampsUTC(t, db, "save-key") + }) + + t.Run("Update", func(t *testing.T) { + // Seed the row first, then update it. + initial := "initial" + if err := repo.Save("update-key", &initial); err != nil { + t.Fatalf("Save (seed) failed: %v", err) + } + if err := repo.Update("update-key", "new-value"); err != nil { + t.Fatalf("Update failed: %v", err) + } + assertTimestampsUTC(t, db, "update-key") + }) + + t.Run("SaveBatch", func(t *testing.T) { + v1, v2 := "v1", "v2" + batch := map[string]*string{ + "batch-key-1": &v1, + "batch-key-2": &v2, + } + if err := repo.SaveBatch(batch); err != nil { + t.Fatalf("SaveBatch failed: %v", err) + } + assertTimestampsUTC(t, db, "batch-key-1") + assertTimestampsUTC(t, db, "batch-key-2") + }) + + t.Run("UpdateAccordingToMap", func(t *testing.T) { + old := "old-val" + if err := repo.Save("map-key", &old); err != nil { + t.Fatalf("Save (seed) failed: %v", err) + } + _, err := repo.UpdateAccordingToMap("map-key", map[string]string{"old-val": "new-val"}) + if err != nil { + t.Fatalf("UpdateAccordingToMap failed: %v", err) + } + assertTimestampsUTC(t, db, "map-key") + }) +} + +// assertTimestampsUTC queries the raw created_at and updated_at strings for the +// given key and fails the test if either lacks a UTC timezone marker. +func assertTimestampsUTC(t *testing.T, db *DB, key string) { + t.Helper() + + rows, err := db.db.CommonDB().Query( + `SELECT created_at, updated_at FROM key_values WHERE key = ?`, key, + ) + if err != nil { + t.Fatalf("query failed for key %q: %v", key, err) + } + defer rows.Close() + + if !rows.Next() { + t.Fatalf("no row found for key %q", key) + } + + var createdAt, updatedAt string + if err := rows.Scan(&createdAt, &updatedAt); err != nil { + t.Fatalf("scan failed for key %q: %v", key, err) + } + + for _, ts := range []struct{ col, val string }{ + {"created_at", createdAt}, + {"updated_at", updatedAt}, + } { + if !isUTC(ts.val) { + t.Errorf("key %q: %s = %q does not contain UTC timezone indicator 'Z'", + key, ts.col, ts.val) + } + } +} + +// isUTC returns true if the raw timestamp string carries a UTC timezone marker. +func isUTC(ts string) bool { + return strings.HasSuffix(ts, "Z") +} diff --git a/libwallet/walletdb/kv_schema_state_repository.go b/libwallet/walletdb/kv_schema_state_repository.go new file mode 100644 index 00000000..34ce79d7 --- /dev/null +++ b/libwallet/walletdb/kv_schema_state_repository.go @@ -0,0 +1,46 @@ +package walletdb + +import ( + "database/sql" + "errors" + "time" + + "github.com/jinzhu/gorm" +) + +type KVSchemaStateRepository interface { + GetCurrentSchemaVersion() (int, error) + BumpSchemaVersion(v int) error +} + +type GORMKVSchemaStateRepository struct { + db *gorm.DB +} + +// GetCurrentSchemaVersion returns the latest version from the schema state table. +func (r *GORMKVSchemaStateRepository) GetCurrentSchemaVersion() (int, error) { + var v sql.NullInt64 + row := r.db.CommonDB().QueryRow(`SELECT MAX(schema_version) FROM kv_schema_state`) + err := row.Scan(&v) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // No version recorded yet + return 0, nil + } + return 0, err + } + if !v.Valid { + return 0, nil + } + return int(v.Int64), nil +} + +// BumpSchemaVersion inserts a new version into the schema state table. +func (r *GORMKVSchemaStateRepository) BumpSchemaVersion(version int) error { + now := time.Now().UTC() + query := ` + INSERT INTO kv_schema_state (schema_version, applied_at) + VALUES (?, ?) + ` + return r.db.Exec(query, version, now).Error +} diff --git a/libwallet/walletdb/kv_schema_state_repository_test.go b/libwallet/walletdb/kv_schema_state_repository_test.go new file mode 100644 index 00000000..1b6f644f --- /dev/null +++ b/libwallet/walletdb/kv_schema_state_repository_test.go @@ -0,0 +1,53 @@ +package walletdb + +import ( + "testing" + "time" +) + +// TestKVSchemaStateRepositoryTimestampsAreUTC verifies that BumpSchemaVersion +// stores applied_at with a UTC timezone indicator ("Z"). This guards against +// regressions where time.Now() without .UTC() would produce a local-timezone +// timestamp. +func TestKVSchemaStateRepositoryTimestampsAreUTC(t *testing.T) { + loc, err := time.LoadLocation("America/Argentina/Buenos_Aires") + if err != nil { + t.Skip("Cannot load timezone for test") + } + original := time.Local + time.Local = loc + defer func() { time.Local = original }() + + db, err := setupTestDb(t) + if err != nil { + t.Fatalf("failed to set up test db: %v", err) + } + defer db.Close() + + repo := db.NewKVSchemaStateRepository() + + if err := repo.BumpSchemaVersion(1); err != nil { + t.Fatalf("BumpSchemaVersion failed: %v", err) + } + + rows, err := db.db.CommonDB().Query( + `SELECT applied_at FROM kv_schema_state WHERE schema_version = 1`, + ) + if err != nil { + t.Fatalf("query failed: %v", err) + } + defer rows.Close() + + if !rows.Next() { + t.Fatal("no row found for schema_version 1") + } + + var appliedAt string + if err := rows.Scan(&appliedAt); err != nil { + t.Fatalf("scan failed: %v", err) + } + + if !isUTC(appliedAt) { + t.Errorf("applied_at = %q does not contain UTC timezone indicator 'Z'", appliedAt) + } +} diff --git a/libwallet/walletdb/walletdb.go b/libwallet/walletdb/walletdb.go index 58787583..6923ea23 100644 --- a/libwallet/walletdb/walletdb.go +++ b/libwallet/walletdb/walletdb.go @@ -36,7 +36,11 @@ type DB struct { } func Open(path string) (*DB, error) { - db, err := gorm.Open("sqlite3", path) + // _busy_timeout: retry for up to 1s before returning "database is locked" on concurrent writes. + // Without it, SQLite fails immediately when two concurrent writes overlap. + // _journal_mode=WAL: improves read/write concurrency. + // Readers can proceed concurrently with a writer, though SQLite still allows only one writer at a time. + db, err := gorm.Open("sqlite3", path+"?_busy_timeout=1000&_journal_mode=WAL") if err != nil { return nil, err } @@ -47,6 +51,10 @@ func Open(path string) (*DB, error) { return &DB{db}, nil } +func (d *DB) Gorm() *gorm.DB { + return d.db +} + func (d *DB) NewFeeBumpRepository() FeeBumpRepository { return &GORMFeeBumpRepository{db: d.db} } @@ -55,6 +63,22 @@ func (d *DB) NewKeyValueRepository() KeyValueRepository { return &GORMKeyValueRepository{db: d.db} } +func NewKeyValueRepository(db *gorm.DB) KeyValueRepository { + // This constructor is useful to build a new repository for key-value operations bound + // to a specific GORM instance (which can be a DB connection or a transaction). + return &GORMKeyValueRepository{db: db} +} + +func (d *DB) NewKVSchemaStateRepository() KVSchemaStateRepository { + return &GORMKVSchemaStateRepository{db: d.db} +} + +func NewKVSchemaStateRepository(db *gorm.DB) KVSchemaStateRepository { + // This constructor is useful to build a new repository for schema state operations bound + // to a specific GORM instance (which can be a DB connection or a transaction). + return &GORMKVSchemaStateRepository{db: db} +} + func migrate(db *gorm.DB) error { opts := gormigrate.Options{ UseTransaction: true, @@ -201,6 +225,32 @@ func migrate(db *gorm.DB) error { return tx.DropTable("key_values").Error }, }, + { + ID: "create table kv_schema_state for key-value migrations", + Migrate: func(tx *gorm.DB) error { + // Note: this table was created with plain SQL instead of a struct like other + // migrations. If a future migration needs to add columns via AutoMigrate, + // consider that its equivalent struct representation is: + // + // type KVSchemaState struct { + // SchemaVersion int `gorm:"primaryKey"` + // AppliedAt time.Time `gorm:"not null"` + // } + // + // GORM will diff the struct against the real table regardless of how it was created. + return tx.Exec(` + CREATE TABLE IF NOT EXISTS kv_schema_state ( + schema_version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL + ); + `).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(` + DROP TABLE IF EXISTS kv_schema_state; + `).Error + }, + }, }) return m.Migrate() } diff --git a/libwallet/walletdb/walletdb_test.go b/libwallet/walletdb/walletdb_test.go index d332a0a1..ed3949d5 100644 --- a/libwallet/walletdb/walletdb_test.go +++ b/libwallet/walletdb/walletdb_test.go @@ -1,14 +1,69 @@ package walletdb import ( + "bufio" "bytes" + "context" "crypto/rand" + "database/sql" + "fmt" + "io" "math" "os" + "os/exec" "path" "testing" + "time" + + _ "github.com/jinzhu/gorm/dialects/sqlite" ) +// TestMain intercepts subprocess invocations from TestBusyTimeout. +func TestMain(m *testing.M) { + // When LOCK_HOLDER_DB_PATH is set, this process acts as the lock holder: + // it acquires an exclusive SQLite lock, signals READY to stdout, waits for + // a byte on stdin, then releases the lock and exits. + // When the env var is absent, it runs the test suite normally. + dbPath := os.Getenv("LOCK_HOLDER_DB_PATH") + if dbPath != "" { + runLockHolder(dbPath) + return + } + os.Exit(m.Run()) +} + +// runLockHolder acquires an exclusive SQLite lock on dbPath, signals "READY" to stdout, +// and holds the lock until it receives any byte on stdin. +func runLockHolder(dbPath string) { + rawDB, err := sql.Open("sqlite3", dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, "open error:", err) + os.Exit(1) + } + defer rawDB.Close() + + ctx := context.Background() + conn, err := rawDB.Conn(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "conn error:", err) + os.Exit(1) + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, "BEGIN EXCLUSIVE"); err != nil { + fmt.Fprintln(os.Stderr, "BEGIN EXCLUSIVE error:", err) + os.Exit(1) + } + + fmt.Println("READY") + + // Block until the parent writes anything to stdin (signals us to release). + buf := make([]byte, 1) + os.Stdin.Read(buf) + + conn.ExecContext(ctx, "ROLLBACK") +} + func TestOpen(t *testing.T) { dir, err := os.MkdirTemp("", "libwallet") if err != nil { @@ -85,6 +140,108 @@ func TestInvoices(t *testing.T) { } } +// TestBusyTimeout verifies that _busy_timeout prevents "database is locked" errors +// when a write is attempted while another connection holds an exclusive lock. +func TestBusyTimeout(t *testing.T) { + dir, err := os.MkdirTemp("", "libwallet") + if err != nil { + t.Fatal(err) + } + dbPath := path.Join(dir, "test.db") + + // Open and close connection to ensure migrations are executed before acquiring any lock. + db, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + db.Close() + + db, err = Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + lh := startLockHolder(t, dbPath) + defer lh.cleanup() + + // Release the lock after 500ms while the writer is retrying. + // With _busy_timeout=1000 (our current setting), SQLite retries for up to 1s before giving up, + // so the write should succeed once the lock is released. + go func() { + time.Sleep(500 * time.Millisecond) + lh.release() + }() + + value := "test" + err = db.NewKeyValueRepository().Save("someKey", &value) + if err != nil { + t.Errorf("expected success, got: %v", err) + } +} + +type lockHolder struct { + cmd *exec.Cmd + stdin io.WriteCloser +} + +// startLockHolder re-executes the test binary as a subprocess that acquires an exclusive SQLite lock on dbPath +// and holds it until released. +// It returns once the subprocess has signalled that the lock is held. +// +// A separate process is required because SQLite uses POSIX fcntl() advisory locks, +// which are per open file description. +// On Linux, closing any file descriptor to a file releases all fcntl locks held by that process, +// regardless of how many other descriptors remain open. +// This means that a lock acquired from within the same process +// would not reliably reproduce the SQLITE_BUSY contention +func startLockHolder(t *testing.T, dbPath string) *lockHolder { + t.Helper() + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + cmd := exec.Command(exe) + cmd.Env = append(os.Environ(), "LOCK_HOLDER_DB_PATH="+dbPath) + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + // Wait for the subprocess to signal it holds the lock. + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if scanner.Text() == "READY" { + return &lockHolder{cmd: cmd, stdin: stdin} + } + } + + cmd.Process.Kill() + t.Fatal("lock holder subprocess did not signal READY") + return nil +} + +func (lh *lockHolder) release() { + lh.stdin.Write([]byte("x")) +} + +func (lh *lockHolder) cleanup() { + lh.stdin.Close() + lh.cmd.Wait() +} + func randomBytes(count int) []byte { buf := make([]byte, count) _, err := rand.Read(buf) diff --git a/prover/libs/plonky2-bitcoin-hpke/.dockerignore b/prover/libs/plonky2-bitcoin-hpke/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-bitcoin-hpke/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-bitcoin-hpke/src/hpke.rs b/prover/libs/plonky2-bitcoin-hpke/src/hpke.rs index ee49e08c..821ea4e8 100644 --- a/prover/libs/plonky2-bitcoin-hpke/src/hpke.rs +++ b/prover/libs/plonky2-bitcoin-hpke/src/hpke.rs @@ -424,7 +424,7 @@ mod test { let receiver_public_key_precomputation = builder .add_const_precomputed_windowed_mul_target(from_uncompressed_public_key( receiver_public_key.try_into().unwrap(), - )); + )?); let (ciphertext, enc) = single_shot( &mut builder, diff --git a/prover/libs/plonky2-bytes/.dockerignore b/prover/libs/plonky2-bytes/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-bytes/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-chacha20-poly1305/.dockerignore b/prover/libs/plonky2-chacha20-poly1305/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-chacha20-poly1305/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-cosigning-key-validation/.dockerignore b/prover/libs/plonky2-cosigning-key-validation/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-cosigning-key-validation/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-ecdsa/.dockerignore b/prover/libs/plonky2-ecdsa/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-ecdsa/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-hkdf-sha256/.dockerignore b/prover/libs/plonky2-hkdf-sha256/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-hkdf-sha256/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-precomputed-windowed-mul/.dockerignore b/prover/libs/plonky2-precomputed-windowed-mul/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-precomputed-windowed-mul/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-sha256/.dockerignore b/prover/libs/plonky2-sha256/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-sha256/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/prover/libs/plonky2-u32/.dockerignore b/prover/libs/plonky2-u32/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/prover/libs/plonky2-u32/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/settings.gradle b/settings.gradle index 32bf7c1f..af3597a3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { plugins { // Must match global_kotlin_version in top level build.gradle! - id 'org.jetbrains.kotlin.android' version "1.8.20" + id 'org.jetbrains.kotlin.android' version "2.1.0" } } diff --git a/tools/libwallet-android.sh b/tools/libwallet-android.sh index be22bd71..91a4e42d 100755 --- a/tools/libwallet-android.sh +++ b/tools/libwallet-android.sh @@ -50,12 +50,12 @@ rm -rf "$GOCACHE"/src-android-* 2>/dev/null \ export CGO_LDFLAGS="-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384" # Finally run gomobile bind using the version pinned by the go.mod file. -# We need -androidapi 19 to set the min api targeted by the NDK. +# We need -androidapi 21 to set the min api targeted by the NDK. # The -trimpath and -ldflags are passed on to go build and are part of keeping the build reproducible. # Note that we bind & build two packages top-level libwallet and newop. go run golang.org/x/mobile/cmd/gomobile bind \ -target="android" -o "$libwallet" \ - -androidapi 19 \ + -androidapi 21 \ -trimpath -ldflags="-buildid=. -v" \ . ./newop ./app_provided_data ./libwallet_init diff --git a/tools/verify-apollo.sh b/tools/verify-apollo.sh index 8424a42c..0af8f255 100755 --- a/tools/verify-apollo.sh +++ b/tools/verify-apollo.sh @@ -25,7 +25,7 @@ trap 'rm -rf "$tmp"' EXIT # Prepare paths to extract APKs mkdir -p "$tmp/to_verify" "$tmp/baseline" -echo "Building the APKs from source. This might take a while (10-20 minutes)..." +echo "Building the APKs from source. This might take a while (from 20min up to 45-50min)..." mkdir -p apk DOCKER_BUILDKIT=1 docker build -f android/Dockerfile -o apk .