diff --git a/.claude/agents/DEVELOPMENT-PATTERNS.md b/.claude/agents/DEVELOPMENT-PATTERNS.md index 18af92da0a..7f454825a0 100644 --- a/.claude/agents/DEVELOPMENT-PATTERNS.md +++ b/.claude/agents/DEVELOPMENT-PATTERNS.md @@ -78,6 +78,88 @@ component to our component: - ListEmptyState - ListEmptyState - tablelist-masternodekeys - TableListMasternodeKeyRow - EnterAmount (input bar) - EnterAmount +- TextField-Base / text.field - TextField +- addressField - AddressField + +## Dark Mode Compatibility + +Every new screen, dialog, and component must work in both light and dark mode. The app uses `Theme.AppCompat.DayNight` which activates `values-night/` resource qualifiers automatically. + +### Compose: The Golden Rule + +**One call to `LocalDashColors.current` per composable, at the top. Never use `MyTheme.Colors.*` directly.** + +```kotlin +@Composable +fun MyComponent(...) { + val colors = LocalDashColors.current // ← always this, nothing else + Column(modifier = Modifier.background(colors.backgroundPrimary)) { + Text("Hello", color = colors.textPrimary) + } +} +``` + +**Root entry point wrapping** — The topmost composable in a Fragment's `setContent { }` block must be wrapped in `DashWalletTheme`: + +```kotlin +composeView.setContent { + DashWalletTheme { // ← selects light or dark colors once + MyScreen(...) + } +} +``` + +**Rules:** +- `DashWalletTheme` wraps the root once. Child composables never call `isSystemInDarkTheme()`. +- `MyTheme.Colors` (light) and `MyTheme.DarkColors` (dark) are the source-of-truth instances; `LocalDashColors.current` resolves to the correct one automatically. +- Always wrap previews in `DashWalletTheme`. For dark previews add `uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES`: + +```kotlin +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun MyPreviewDark() { + DashWalletTheme { MyComponent(...) } +} +``` + +### ColorScheme Fields Reference + +Key fields from `MyTheme.ColorScheme` (use via `LocalDashColors.current`): + +| Field | Light | Dark | Use for | +|-------|-------|------|---------| +| `backgroundPrimary` | `#F5F6F7` | `#10151F` | Page background | +| `backgroundSecondary` | `#FFFFFF` | `#1D2532` | Cards, sheets, panels | +| `textPrimary` | `#191C1F` | `#FFFFFF` | Primary text | +| `textSecondary` | `#6E757C` | `#92929C` | Secondary / helper text | +| `textTertiary` | `#75808A` | `#75808A` | Tertiary / label text | +| `dashBlue` | `#008DE4` | `#008DE4` | Brand accent, links | +| `dividerColor` | `#EDF0F2` | `#2C3748` | Dividers, borders | +| `disabledButtonBg` | `#EEEEEE` | `#3C3C3C` | Disabled button background | +| `contentDisabled` | `#92929C` | `#92929C` | Disabled text / icon | + +### XML Layouts: Semantic Color Tokens + +Use semantic tokens from `values/colors.xml` — they have night overrides in `values-night/colors.xml`. Never use `@android:color/white` or literal hex values for surfaces or text. + +| Purpose | Token | +|---------|-------| +| Page background | `@color/background_primary` | +| Card / sheet surface | `@color/background_secondary` | +| Primary text | `@color/content_primary` | +| Dividers / borders | `@color/divider_color` | + +### XML Drawables: Adaptive Colors + +- **Shape drawables** (cards, panels): fill with `@color/background_secondary`, not `@android:color/white` +- **Vector icons**: set `android:tint="@color/content_primary"` on the `` element instead of a hardcoded fill +- **Color state lists** (``): the default (last) item must use `@color/content_primary`, not a hardcoded hex + +### DashButton Disabled State + +`DashButton` handles the disabled state automatically via `colors.disabledButtonBg` and `colors.contentDisabled`. Do not set `alpha` or override colors manually for disabled buttons — just pass `isEnabled = false`. + +--- ## NavBar / TopNavBase (Figma: NavBar) @@ -373,7 +455,7 @@ ListItem( Icon( painter = painterResource(R.drawable.ic_dash_blue_filled), contentDescription = null, - tint = MyTheme.Colors.dashBlue, + tint = LocalDashColors.current.dashBlue, modifier = Modifier.size(32.dp) ) } @@ -414,7 +496,7 @@ ListItem( Icon( painter = painterResource(R.drawable.ic_menu_row_arrow), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = LocalDashColors.current.textTertiary, modifier = Modifier.size(16.dp) ) } @@ -452,7 +534,7 @@ ListEmptyState( Icon( painter = painterResource(R.drawable.ic_dash_blue_filled), contentDescription = null, - tint = MyTheme.Colors.dashBlue, + tint = LocalDashColors.current.dashBlue, modifier = Modifier.size(48.dp) ) }, @@ -605,7 +687,7 @@ FeatureItemNumber(number = "1") **Visual Specs**: - Size: 20dp circle -- Background: MyTheme.Colors.dashBlue +- Background: `colors.dashBlue` (via `LocalDashColors.current`) - Border radius: 8dp - Text: 12sp, white, centered @@ -813,7 +895,7 @@ A horizontal **amount-input bar** from the design system. Renders, left to right **Figma file:** Design system - Android — node `4414:23352` The component lives in: -``` +```text common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt ``` @@ -913,6 +995,58 @@ EnterAmount( - Typography substitutions vs. the Figma spec: `LabelSmallSemibold` (11 sp) is used in place of the spec's 10 sp SemiBold for the Max label — closest available preset, ~1 sp visual diff. - The chevron icon `ic_chevron_down_small.xml` is a small (10 × 6 dp) stroke vector created specifically for this component. +## TextField (Figma: TextField-Base / text.field) + +The general-purpose design-system text field lives in: +``` +common/src/main/java/org/dash/wallet/common/ui/components/TextField.kt +``` + +**Figma Design System nodes:** base `4111:12913`, variant set `4112:13707` + +A rounded (16 dp) input with a **floating label**: when the field is empty, the label renders +full-size (`Typography.TitleSmall`, textSecondary) on the text line as the placeholder; once +there's content it shrinks to a small line above the text (`Typography.LabelMedium`). + +### States (driven by focus / content / parameters — no state enum) + +| Figma variant | Trigger | Visual | +|---|---|---| +| Default | unfocused, empty | `gray400` @ 10% background, label as placeholder | +| Focused | focused | white background, 1 dp `dashBlue` border, 3 dp `dashBlue` @ 10% focus ring | +| Typing | focused + text | focused look + automatic clear (✕) trailing button (`ic_clear_input`) | +| Filled | unfocused + text | resting gray background, small label above text | +| Error | `isError = true` | `red5` background + 1 dp `red` border; `message` below renders red (wins over focused) | +| Disabled | `enabled = false` | content at 40% alpha, input ignored | + +### Slots + +- `label` — floating label inside the field (Figma `label`): full-size on the text line when empty, shrinks above the text once filled +- `innerLabel` — permanent small label above the text line, always visible even when empty (same as `AddressField.innerLabel`; don't combine with `label`). **Address-input screens use this for "BTC Address"-style text — never a disappearing hint.** +- `placeholder` — text-line placeholder (hint) when empty; only used when `label` is null +- `helperTextInside` — right-aligned `Typography.BodySmall` inside the field, below the text line (Figma `helpTextInside`); when null and `maxLength` is set, an automatic `n/max` counter renders here +- `message` + `isError` — help text below the field (Figma `helpTextOutside`); `isErrorMessage` (defaults to `isError`) controls the message color independently — pass `isErrorMessage = false` for an error-styled field with neutral gray help text (Figma shows both) +- `trailingIcon` + `onTrailingIconClick` — custom trailing button in a 30 dp / 8 dp-radius touch area (Figma `buttonIcon`); the clear button takes precedence while typing +- `maxLength`, `singleLine`, `showClearButton`, `keyboardOptions`, `visualTransformation`, `focusRequester`, `onImeAction` + +### Example + +```kotlin +TextField( + value = uiState.name, + onValueChange = viewModel::onNameChanged, + label = stringResource(R.string.name_label), + message = uiState.nameError?.let { stringResource(it) }, + isError = uiState.nameError != null, + maxLength = 25 +) +``` + +### Notes + +- For crypto addresses use `AddressField` (QR-scan affordance, long-press-to-paste); for search bars use `SearchField`. `TextField` is the generic single/multi-line input. +- Layout specs from Figma: min height 58 dp, padding start 16 / end 12 / vertical 10, radius 16, row gap 10, label-to-text gap 2. + ## Typography Mapping (Figma Design System → MyTheme.Typography) When implementing designs from Figma, use the following typography mappings. All styles are available in `MyTheme.Typography.*`: @@ -997,18 +1131,21 @@ When implementing designs from Figma, use the following typography mappings. All ### Usage Examples ```kotlin +// Always read colors at the top of the composable: +val colors = LocalDashColors.current + // Dialog title - Use Headline S Bold Text( text = stringResource(R.string.upgrade_pin_title), style = MyTheme.Typography.HeadlineSmallBold, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) // Dialog description - Use Body M (Regular) Text( text = stringResource(R.string.upgrade_pin_description), style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) // List item title - Use Title M Semibold @@ -1021,7 +1158,7 @@ Text( Text( text = "2 hours ago", style = MyTheme.Typography.LabelMedium, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) // Button text - Use Label L Semibold (handled by DashButton) @@ -1137,10 +1274,11 @@ private fun SettingsScreenContent( else -> stringResource(statusId) } + val colors = LocalDashColors.current Column( modifier = Modifier .fillMaxSize() - .background(MyTheme.Colors.backgroundPrimary) + .background(colors.backgroundPrimary) ) { // Top Navigation NavBarBack(onBackClick = onBackClick) diff --git a/.claude/agents/figma-to-compose.md b/.claude/agents/figma-to-compose.md index d2fb0e4ef2..e7b20ec9a7 100644 --- a/.claude/agents/figma-to-compose.md +++ b/.claude/agents/figma-to-compose.md @@ -1,7 +1,7 @@ --- name: "figma-to-compose" description: "Implements Android Jetpack Compose screens from Figma designs. Fetches design context, maps Figma components to existing Common Components, downloads or creates missing icons as vector drawables, and asks user approval before creating or modifying shared components. Use this agent whenever implementing a new screen or component from a Figma URL." -tools: ["mcp__figma-dev-mode-mcp-server__get_design_context", "mcp__figma-dev-mode-mcp-server__get_screenshot", "mcp__figma-dev-mode-mcp-server__get_metadata", "mcp__figma-dev-mode-mcp-server__get_variable_defs", "Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "AskUserQuestion", "mcp__ide__getDiagnostics"] +tools: ["mcp__figma-dev-mode-mcp-server__get_design_context", "mcp__figma-dev-mode-mcp-server__get_screenshot", "mcp__figma-dev-mode-mcp-server__get_metadata", "mcp__figma-dev-mode-mcp-server__get_variable_defs", "mcp__figma__get_design_context", "mcp__figma__get_screenshot", "mcp__figma__get_metadata", "mcp__figma__get_variable_defs", "ToolSearch", "Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "AskUserQuestion", "mcp__ide__getDiagnostics"] --- # Figma to Jetpack Compose Agent @@ -12,7 +12,8 @@ This agent implements Android Jetpack Compose screens and components from Figma ### 1. Fetch the Design -Call `mcp__figma-dev-mode-mcp-server__get_design_context` with the node ID extracted from the Figma URL: +Call `mcp__figma-dev-mode-mcp-server__get_design_context` with the node ID extracted from the Figma URL. +If the `mcp__figma-dev-mode-mcp-server__*` tools are not available in your session, the same Figma tools may be exposed under the `mcp__figma__*` names (`mcp__figma__get_design_context`, `mcp__figma__get_metadata`, `mcp__figma__get_screenshot`, `mcp__figma__get_variable_defs`) — if they appear as deferred tools, load them with ToolSearch (`select:mcp__figma__get_design_context,...`) before calling. **Never implement a Figma design from guesswork: if no Figma tool is reachable, stop and report that instead of inferring the design from sibling components.** - URL format: `https://www.figma.com/design/{fileKey}/{name}?node-id={nodeId}` - Extract nodeId, replacing `-` with `:` (e.g. `24007-4540` → `24007:4540`) - Always set `clientLanguages: "kotlin"` and `clientFrameworks: "jetpack compose, android"` @@ -62,6 +63,8 @@ You MUST consult the component mapping table for every Figma component before wr | `ListEmptyState` | `ListEmptyState` | `org.dash.wallet.common.ui.components.ListEmptyState` | | `Toast` | `Toast` composable | `org.dash.wallet.common.ui.components.Toast` | | `EnterAmount` (input bar) | `EnterAmount` | `org.dash.wallet.common.ui.components.EnterAmount` | +| `TextField-Base` / `text.field` | `TextField` | `org.dash.wallet.common.ui.components.TextField` | +| `addressField` | `AddressField` | `org.dash.wallet.common.ui.components.AddressField` | See `development-patterns` for full `NavBarBack`/`NavBarBackTitle`/`TopIntro` usage examples and all named NavBar variants. @@ -93,18 +96,20 @@ For list rows, prefer the numbered design-system variants `ListItem1`…`ListIte #### Color Mapping -| Figma Token | MyTheme Reference | Hex | -|-------------|-------------------|-----| -| `text/primary` | `MyTheme.Colors.textPrimary` | `#191C1F` | -| `text/secondary` | `MyTheme.Colors.textSecondary` | `#6E757C` | -| `text/tertiary` | `MyTheme.Colors.textTertiary` | `#75808A` | -| `background/primary` | `MyTheme.Colors.backgroundPrimary` | `#F5F6F7` | -| `background/secondary` | `MyTheme.Colors.backgroundSecondary` | `#FFFFFF` | -| `colors/dash-blue` | `MyTheme.Colors.dashBlue` | `#008DE4` | -| `colors/orange` | `MyTheme.Colors.orange` | `#FA9269` | -| `colors/red` | `MyTheme.Colors.red` | `#EA3943` | -| `colors/green` | `MyTheme.Colors.green` | `#3CB878` | -| `colors/gray` | `MyTheme.Colors.gray` | `#B0B6BC` | +**Important:** Never reference `MyTheme.Colors.*` directly in composables. Always read the current theme colors via `val colors = LocalDashColors.current` at the top of each composable, then use `colors.*`. This ensures correct values in both light and dark mode. + +| Figma Token | `colors.*` field | Light hex | Dark hex | +|-------------|-----------------|-----------|----------| +| `text/primary` | `colors.textPrimary` | `#191C1F` | `#FFFFFF` | +| `text/secondary` | `colors.textSecondary` | `#6E757C` | `#92929C` | +| `text/tertiary` | `colors.textTertiary` | `#75808A` | `#75808A` | +| `background/primary` | `colors.backgroundPrimary` | `#F5F6F7` | `#10151F` | +| `background/secondary` | `colors.backgroundSecondary` | `#FFFFFF` | `#1D2532` | +| `colors/dash-blue` | `colors.dashBlue` | `#008DE4` | `#008DE4` | +| `colors/orange` | `colors.orange` | `#FA9269` | `#FA9269` | +| `colors/red` | `colors.red` | `#EA3943` | `#EA3943` | +| `colors/green` | `colors.green` | `#3CB878` | `#3CB878` | +| `colors/gray` | `colors.gray` | `#B0B6BC` | `#B0B6BC` | ### 4. Handle Icons and Image Assets @@ -300,6 +305,79 @@ After implementation: 3. Verify all `R.string.*` references have entries in strings.xml 4. Confirm the nav graph `tools:layout` attribute is removed if the fragment uses ComposeView +## Dark Mode Compatibility + +Every new screen, dialog, and component must work correctly in both light and dark mode. Follow these rules without exception. + +### Compose: Theme Color Access + +**Root entry point** — The composable entry point called from a Fragment's `ComposeView.setContent { }` must be wrapped in `DashWalletTheme`: + +```kotlin +// In Fragment.onCreateView or onViewCreated: +composeView.setContent { + DashWalletTheme { + MyScreen(...) + } +} +``` + +**Inside every composable** — read current colors once at the top: + +```kotlin +@Composable +fun MyComponent(...) { + val colors = LocalDashColors.current + // Use colors.textPrimary, colors.backgroundSecondary, etc. + // NEVER use MyTheme.Colors.* directly here +} +``` + +**Rules:** +- Never call `isSystemInDarkTheme()` inside individual components or screens — `DashWalletTheme` handles this once at the root +- Never use `MyTheme.Colors.*` directly in a composable body — it always returns light-mode values +- `MyTheme.Colors` and `MyTheme.DarkColors` are the source-of-truth data class instances; `LocalDashColors.current` selects the right one automatically + +### Compose: Preview Dark Mode + +Always include both a light and dark preview for new components: + +```kotlin +@Composable +@Preview(name = "Light") +fun MyComponentPreviewLight() { + DashWalletTheme { MyComponent(...) } +} + +@Composable +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +fun MyComponentPreviewDark() { + DashWalletTheme { MyComponent(...) } +} +``` + +### XML Layouts: Semantic Color Tokens + +Use semantic color names that have night-mode overrides in `values-night/colors.xml`. Never use raw `@android:color/white` or literal hex values for surfaces or text. + +| Purpose | Use this color token | +|---------|---------------------| +| Page background | `@color/background_primary` | +| Card / sheet surface | `@color/background_secondary` | +| Primary text | `@color/content_primary` | +| Secondary text | `@color/content_secondary` | +| Dividers / borders | `@color/divider_color` | + +### XML Drawables: Adaptive Colors + +- **Shape drawables** (cards, panels): fill with `@color/background_secondary`, not `@android:color/white` +- **Vector icons**: add `android:tint="@color/content_primary"` to the `` element instead of hardcoding a fill color +- **Color state lists** (selectors): default state should use `@color/content_primary`, not a hardcoded dark hex + +### DashButton Disabled State + +`DashButton` handles disabled appearance automatically through `colors.disabledButtonBg` and `colors.contentDisabled` from `LocalDashColors`. Do not override button colors manually for the disabled case. + ## Common Pitfalls ### Kotlin Overload Resolution with Trailing Lambdas diff --git a/.claude/agents/update-swapkit-currencies.md b/.claude/agents/update-swapkit-currencies.md new file mode 100644 index 0000000000..e4a12a0a6a --- /dev/null +++ b/.claude/agents/update-swapkit-currencies.md @@ -0,0 +1,209 @@ +--- +name: "update-swapkit-currencies" +description: "Fetches tokens from the SwapKit API and updates MayaCurrencyList with any new coins or tokens reachable from DASH via the providers the wallet uses. Use this agent whenever you need to sync the app's supported currency list with what SwapKit can route." +tools: ["*"] +--- + +# Update SwapKit Currency List + +## Purpose +Sync `MayaCurrencyList` in `MayaCryptoCurrency.kt` with the live tokens exposed by the SwapKit API. The SwapKit and Maya backends share `MayaCurrencyList` (same `CHAIN.ASSET[-CONTRACT]` notation), so a single curated list backs both `MayaApiAggregator` and `SwapKitApiAggregator`. This agent uses SwapKit as the source of truth for what assets the wallet should support when SwapKit is the active swap backend. + +## Key Files +- **Currency list**: `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/MayaCryptoCurrency.kt` +- **String resources**: `integrations/maya/src/main/res/values/strings-maya.xml` +- **Parsers directory**: `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/payments/parsers/` +- **SwapKit constants** (provider list, API key): `integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt` + +## Source of Truth + +**`GET /swapTo?sellAsset=DASH.DASH` is the source of truth.** This is the exact endpoint `SwapKitApiAggregator.refreshPools()` calls to populate the wallet's currency picker — every identifier it returns must have a matching entry in `MayaCurrencyList` so the picker can render it. Do NOT filter by provider during discovery: `DASH_SUPPORTED_PROVIDERS` applies at quote time only, and the picker shows everything `/swapTo` returns. + +`/tokens?provider=NAME` is supplementary — use it only to look up display metadata (`name`, `decimals`, `coingeckoId`) for an identifier already in the target set. Do not intersect. + +## Steps + +### 1. Fetch the target set from `/swapTo` + +The SwapKit API requires the `x-api-key` header. Read the key from `SwapKitConstants.API_KEY` (or override via the `SWAPKIT_API_KEY` env var if set): + +```bash +KEY="${SWAPKIT_API_KEY:-$(grep -E 'API_KEY' integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitConstants.kt | head -1 | sed -E 's/.*"([^"]+)".*/\1/')}" +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/swapTo?sellAsset=DASH.DASH" | jq -r '.[]' | sort -u +``` + +The full response is the **target set**. It includes everything reachable from DASH across every aggregated provider (MAYACHAIN, NEAR Intents, CHAINFLIP, GARDEN, FLASHNET, …). Do NOT filter by provider — `SwapKitApiAggregator` calls this endpoint without a provider filter and surfaces every result in the picker. + +Each entry is a `CHAIN.SYMBOL[-CONTRACT]` identifier. Uppercase the hex contract suffix when comparing against `MayaCurrencyList` (Maya stores `0X` uppercase). + +### 1b. Fetch token metadata for naming + +For each chain prefix that appears in the target set, fetch the token list of any provider that supports that chain — this is just to get human-readable `name`, `decimals`, and `coingeckoId` for the new identifiers. Reasonable starting points: + +```bash +# Provider lookup: which provider serves a given chain? +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/providers" \ + | jq -r '.[] | "\(.name)\t\(.supportedChainIds|join(","))"' + +# Token metadata for a given provider +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=MAYACHAIN_STREAMING" | jq '.tokens[]' +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=NEAR" | jq '.tokens[]' +curl -s -H "x-api-key: $KEY" "https://api.swapkit.dev/tokens?provider=CHAINFLIP_STREAMING" | jq '.tokens[]' +``` + +> Note: SwapKit currently returns `MAYACHAIN_STREAMING` (not bare `MAYACHAIN`) when you `/tokens?provider=MAYACHAIN_STREAMING`. The two share token lists per the protocol doc. + +The token list gives you the human display name (`name`) for the network string and confirms `decimals`. If multiple providers list the same identifier with different names, prefer the one from a chain-native provider (e.g. NEAR token from `NEAR`, not from a bridge). + +### 2. Extract existing assets from MayaCurrencyList + +Read `MayaCryptoCurrency.kt`. Find all `asset` string values inside `MayaCurrencyList.init {}`. These are lines like: + +```kotlin +"ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7", +``` + +Build a set of existing asset strings (uppercase the contract suffix when comparing). + +### 3. Identify new assets + +Compare the SwapKit `/swapTo` target set against the existing set. Identifiers present in SwapKit but absent from the code are new — **all of them must be added**, regardless of which provider services them. The wallet's picker shows everything `/swapTo` returns, so missing entries become picker bugs. + +**Skip ONLY**: +- `DASH.DASH` — handled separately by the swap source side. +- `THOR.RUNE` — special-cased; already mapped via `MayaRuneCryptoCurrency`. + +Do NOT skip NEAR, SOL, BASE, OP, POL, ZEC, BCH, LTC, DOGE, AVAX, BSC, XRP, TRON, ATOM, etc. — if `/swapTo` returns it, it goes in. + +### 4. Categorize each new asset + +#### EVM Tokens (ETH.\* and ARB.\*) + +These share the Ethereum address format (`0x...`). Use `MayaEthereumTokenCryptoCurrency`. + +For an asset like `ETH.MOCA-0X53312F85BBA24C8CB99CFFC13BF82420157230D3`: +- `chain` = `ETH`, `symbol` = `MOCA`, `contractAddr` = `0X53312F85BBA24C8CB99CFFC13BF82420157230D3` +- `shortAlias` = last 5 hex chars of contractAddr = `230D3` +- `memoAsset` = `ETH.MOCA-230D3` +- `uriPrefix` = `symbol.lowercase()` = `"moca"` +- Display `name` = the SwapKit `name` field (e.g. `"Mocaverse"`); fall back to the symbol if SwapKit returned no name. + +Generate: +```kotlin +MayaEthereumTokenCryptoCurrency( + "MOCA", + "Mocaverse", // SwapKit token.name + "ETH.MOCA-0X53312F85BBA24C8CB99CFFC13BF82420157230D3", + EthereumPaymentIntentParser("moca", "ETH.MOCA-230D3"), + R.string.cryptocurrency_moca_code, + R.string.cryptocurrency_moca_ethereum_network +), +``` + +String resources to add: +```xml +MOCA +Mocaverse (Ethereum) +``` + +Network display name format: `"Name (Chain)"` where Chain is `Ethereum` for ETH and `Arbitrum` for ARB. + +**Naming convention for string resource IDs:** +- code: `cryptocurrency_{symbol.lowercase()}_code` +- network: `cryptocurrency_{symbol.lowercase()}_{chain.lowercase()}_network` + +If the same symbol already has a `_code` resource (e.g., `cryptocurrency_usdt_code`), reuse it but still add a new chain-specific network string. + +#### L1 Native Coins (new chains: ZEC, XRD, MAYA, KUJI, etc.) + +These need: +1. A new `Maya{Name}CryptoCurrency` class in `MayaCryptoCurrency.kt` +2. Possibly a new `{Chain}PaymentIntentParser` class in the parsers directory +3. String resources + +**Address format guide by chain:** + +| Chain | Address format | Parser class to use | +|-------|---------------|---------------------| +| ETH / ARB / BSC / AVAX / BASE / OP / POL | `0x[a-fA-F0-9]{40}` | `EthereumPaymentIntentParser` | +| BTC / DASH / LTC / BCH / DOGE | Base58Check / Bech32 | `BitcoinPaymentIntentParser` | +| ZEC | Base58Check (t-prefix) | `ZcashPaymentIntentParser` | +| THOR / MAYA chain | Bech32, prefix `thor` / `maya`, length 38 | `Bech32PaymentIntentParser` | +| KUJI | Bech32, prefix `kujira`, length 38 | `Bech32PaymentIntentParser` | +| ATOM (cosmoshub) | Bech32, HRP `cosmos`, length 39 | `Bech32PaymentIntentParser` | +| XRD (Radix) | Bech32, HRP `account_rdx`, length ~61 | `XrdPaymentIntentParser` | +| SOL (Solana) | Base58, 32–44 chars (no checksum) | new `SolanaPaymentIntentParser` | +| NEAR | implicit hex 64-char OR `*.near` | new `NearPaymentIntentParser` | +| TRON | Base58 starting with `T`, 34 chars | new `TronPaymentIntentParser` | +| XRP | Base58 starting with `r`, 25–35 chars | new `XrpPaymentIntentParser` | + +For a new L1, create a class extending `MayaBitcoinCryptoCurrency` (which uses 1e8 units — Maya's internal representation for all assets): + +```kotlin +open class Maya{Name}CryptoCurrency : MayaBitcoinCryptoCurrency() { + override val code: String = "SYMBOL" + override val name: String = "Full Name" // from SwapKit token.name + override val asset: String = "CHAIN.SYMBOL" + override val exampleAddress: String = "example_address_here" + override val paymentIntentParser: PaymentIntentParser = ... + override val addressParser: AddressParser = ... + override val codeId: Int = R.string.cryptocurrency_{symbol_lower}_code + override val nameId: Int = R.string.cryptocurrency_{symbol_lower}_network +} +``` + +If the chain uses bech32 addresses, use: +- `Bech32AddressParser("prefix", length, null)` for the address parser +- `Bech32PaymentIntentParser("SYMBOL", "prefix", "prefix", length, "CHAIN.SYMBOL")` for the payment intent parser + +If a new `PaymentIntentParser` class file is needed (for non-Bech32 non-ETH chains), create it in the parsers directory following the `ZcashPaymentIntentParser.kt` pattern. + +> **Decimal note**: SwapKit reports per-chain `decimals` (18 for EVM, 8 for BTC/DASH, etc.). The wallet's internal representation always uses 1e8 (`MayaBitcoinCryptoCurrency`). Don't introduce per-asset decimal overrides — Maya's quote/swap pipeline already normalises everything to 1e8. + +### 5. Insert new entries into MayaCurrencyList + +- Add EVM token entries grouped by chain (ETH tokens first, then ARB tokens) before the KUJI block. +- Add new L1 coins at the end of the list, after `MayaRuneCryptoCurrency()`. + +Insertion point for EVM tokens — add after the last existing ARB entry: +```kotlin +// ... existing ARB.WSTETH entry ... +), +// NEW EVM TOKENS GO HERE + +MayaKujiraCryptoCurrency(), +``` + +Insertion point for new L1 coins — after `MayaRuneCryptoCurrency()`: +```kotlin +MayaRuneCryptoCurrency(), +// NEW L1 COINS GO HERE +``` + +### 6. Add string resources to strings-maya.xml + +Add new entries before ``: +```xml + +XXX +Full Name (Chain) +``` + +### 7. Verify + +After making changes: +- Check that all `R.string.*` references have corresponding entries in `strings-maya.xml`. +- Check that all new `PaymentIntentParser` classes are imported in `MayaCryptoCurrency.kt`. +- Check that the `currencyMap` key (asset string) matches exactly between the `MayaCryptoCurrency` subclass and the list entry. +- Run a price spot-check via SwapKit to confirm the new identifier resolves: `curl -s -H "x-api-key: $KEY" -X POST -H "Content-Type: application/json" -d '{"tokens":[{"identifier":""}]}' https://api.swapkit.dev/price` — `price_usd: 0` means SwapKit doesn't recognise the identifier (likely a transcription error in the contract address). +- Quick build check: `./gradlew :integrations:maya:compile_testNet3DebugKotlin`. + +## Important Conventions + +- **Memo alias (shortened asset)**: Use the last 5 hex characters of the contract address for EVM tokens. Example: contract `...3606EB48` → memo alias `ETH.USDC-6EB48`. Do NOT include the `0X` prefix in the memo alias. +- **Identifier casing**: SwapKit returns contract addresses in mixed case in `address`/`identifier`. Uppercase the hex suffix when storing in `MayaCurrencyList` so it matches the existing entries (Maya stores `0X` uppercase, e.g. `ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7`). +- **Unit scaling**: All Maya L1 classes extend `MayaBitcoinCryptoCurrency` (1e8 units per coin). ETH/ARB native ETH tokens use `MayaEthereumCryptoCurrency` (1e9 / GWEI). SwapKit's `decimals` field is informational only — do not propagate it into the class. +- **String IDs**: If a symbol already exists with a code resource (e.g., USDT already has `cryptocurrency_tether_code`), reuse the code string but add a new chain-specific network string. +- **`translatable="false"`** must be set on all coin code strings. +- **API key handling**: The key in `SwapKitConstants.API_KEY` is committed for development convenience. Do not echo it into commit messages or PR descriptions. Treat it as a secret in any external output. +- **Provider drift**: If `/swapTo?sellAsset=DASH.DASH` ever returns identifiers that aren't in `/tokens?provider=MAYACHAIN`, SwapKit has expanded DASH routing to a new provider — flag this rather than silently adding the asset, since the wallet's `DASH_SUPPORTED_PROVIDERS` whitelist would still exclude it at quote time. \ No newline at end of file diff --git a/.github/workflows/dashwallet.yml b/.github/workflows/dashwallet.yml index 5ed01d6c8a..7625bc0a79 100644 --- a/.github/workflows/dashwallet.yml +++ b/.github/workflows/dashwallet.yml @@ -4,13 +4,30 @@ on: push: branches: [ master ] pull_request: - branches: [ master, feature-*, bugfix-* ] + branches: [ master, feature-*, bugfix-*, feat/*, fix/* ] + +permissions: + contents: read + packages: read jobs: build: runs-on: ubuntu-latest + # Resolve org.dashj:dash-sdk-android from GitHub Packages on dashpay/platform + # (see the repository declaration in build.gradle). Prefer the GH_PACKAGES_TOKEN + # secret — a PAT with read:packages — because the automatic GITHUB_TOKEN is + # scoped to this repository and cannot read another repository's packages + # unless that package explicitly grants this repo read access. + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + # The SDK AAR dependency makes the data-binding merge task unserializable + # by the configuration cache (fails _testNet3Release); runners are fresh + # each run so the cache buys nothing in CI — disable it here only. + GRADLE_OPTS: -Dorg.gradle.configuration-cache=false + steps: - name: Get build number from run id run: | diff --git a/.github/workflows/ktlint.yml b/.github/workflows/ktlint.yml index 0bf8157979..e518da7cde 100644 --- a/.github/workflows/ktlint.yml +++ b/.github/workflows/ktlint.yml @@ -2,12 +2,22 @@ name: Code format check on: pull_request: - branches: [ master, feature-*, bugfix-*, dashpay-* ] + branches: [ master, feature-*, bugfix-*, feat/*, fix/* ] + +permissions: + contents: read + packages: read jobs: check: runs-on: ubuntu-latest + # See dashwallet.yml — needed to resolve org.dashj:dash-sdk-android from + # GitHub Packages on dashpay/platform. + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/manual_distribution.yml b/.github/workflows/manual_distribution.yml index c2de4bc37b..73b7fe33dd 100644 --- a/.github/workflows/manual_distribution.yml +++ b/.github/workflows/manual_distribution.yml @@ -6,21 +6,31 @@ on: taskID: description: 'Task ID' required: true - default: 'NMA-' + default: 'MO-' flavor: description: 'Flavor' required: true - default: 'staging' + default: '_testNet3' type: description: 'Type' required: true default: 'release' +permissions: + contents: read + packages: read + jobs: build: runs-on: ubuntu-latest + # See dashwallet.yml — needed to resolve org.dashj:dash-sdk-android from + # GitHub Packages on dashpay/platform. + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + steps: - name: Map flavor to firebase app id uses: kanga333/variable-mapper@master diff --git a/CLAUDE.md b/CLAUDE.md index 5ec039d771..4a05d94e50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,32 @@ adb install wallet/build/outputs/apk/dash-wallet-_testNet3-debug.apk ./gradlew build ``` +### BIP70 payment-request testing (device/emulator) + +`scripts/bip70-test-server.py` (stdlib Python, no dependencies) serves a +local BIP70 invoice and acks the returned Payment — the fastest way to +exercise the scanned-invoice flow (`PaymentProtocolFragment`: fetch → +preview → confirm → Payment POST → ACK) end to end. By default the invoice +pays 0.01 tDASH back to the Dash testnet faucet +(`yjSvwyLB5X4dqQqVMPMu6UdrFpYZ3u9v5U`); pass your own receive address for a +fee-only self-pay. + +```bash +# Terminal 1: serve the invoice (defaults: faucet address, 0.01 tDASH, :8330) +python3 scripts/bip70-test-server.py + +# Terminal 2: bridge the port and open the invoice in the wallet +adb reverse tcp:8330 tcp:8330 +adb shell am start -a android.intent.action.VIEW \ + -d "dash:?r=http://127.0.0.1:8330/invoice" hashengineering.darkcoin.wallet_test +``` + +Post-cutover (`CUT_OVER`) watch logcat for `l1DeferredBuild` when the +preview opens (SDK builds + reserves the tx, exact fee shown) and +`l1DeferredBroadcast` of the same txid after confirm — see +`SendCoinsTaskRunner.directPayViaSdk`. The server can reply `nack` (edit +`do_POST`) to exercise the reservation-release path. + ### Code Quality ```bash # Format Kotlin code with ktlint diff --git a/DASHJ-KILL-LIST.md b/DASHJ-KILL-LIST.md new file mode 100644 index 0000000000..7a38c29cd3 --- /dev/null +++ b/DASHJ-KILL-LIST.md @@ -0,0 +1,218 @@ +# DASHJ KILL LIST — every remaining dashj dependency, and the fewest big steps to zero + +Goal per team-lead direction (Brian): **no dashj mirroring/scaffolding — the fastest credible +path to deleting dashj from `build.gradle` entirely.** This inventory is grounded in a full +repo sweep (2026-07-18, branch `claude/blissful-cannon-7d4ac4`). + +## Headline numbers + +| Module | Files importing `org.bitcoinj` | +|---|---| +| `wallet/src` | 217 | +| `common/src` | 68 | +| `integrations/crowdnode` (the only integration on dashj) | 25 | +| `features/`, `integration-android/`, other integrations | 0 | +| **Total** | **~310** | + +Most-used bitcoinj types repo-wide: `Coin` (105 files), `Transaction` (86), `Sha256Hash` (72), +`Address` (65), `Wallet` (60), `NetworkParameters` (48), `MonetaryFormat` (36), `Fiat` (20), +`ExchangeRate` (18), `AuthenticationGroupExtension` (17). + +### Gradle declarations to delete at the end + +- `build.gradle` (root): `dashjVersion = '22.0.3'`, `dppVersion = "2.0.6-SNAPSHOT"` +- `wallet/build.gradle` L83: `org.dashj:dashj-core:$dashjVersion` +- `wallet/build.gradle` L91–93: `dashj-bls-android`, `dashj-x11-android`, `dashj-scrypt-android` +- `wallet/build.gradle` L556–563: per-flavor `org.dashj.platform:dash-sdk-{java,kotlin,android}` + (the OLD Java Platform SDK — distinct from the NEW `org.dashj:dash-sdk-android:0.1.0-SNAPSHOT` + Kotlin SDK we are migrating TO, which stays) +- `wallet/build.gradle` L504–505: packaging of `org/bitcoinj/crypto/mnemonic/wordlist/english.txt` + and `org/bitcoinj/crypto/cacerts` +- `wallet/build.gradle` L566+: the hand-written java.nio covariant-buffer patch that exists ONLY + because `dashj-core 22.x` `SPVBlockStore` crashes on Android ≤ 15 — a standing tax of keeping + the dashj L1 engine +- `common/build.gradle` L56: `org.dashj:dashj-core` (L58–59 bouncycastle stays for the copied BIP70 code) +- `integrations/crowdnode/build.gradle` L51: `org.dashj:dashj-core` + +--- + +## Subsystem inventory + +### 1. L1 engine (Wallet, PeerGroup, BlockChain, SPVBlockStore, masternode sync) +- **What it does**: the full dashj SPV node — header chain, bloom-filtered block download, + peer discovery, masternode list sync, chainlock/islock handlers. Post-cutover (Phase 5d, + live now behind `CUTOVER_STATE`) this engine is HELD and the Kotlin SDK's Rust SPV + (`dash-spv` via platform-mobile JNI) owns L1. +- **SDK replacement**: exists and is cutover-gated already — `L1ShadowSyncService` runs the SDK + SPV, `SdkL1SendService` routes sends, `SdkSourcedQuorums` feeds quorum lookups, and (new) + `CutoverUiDataService` feeds the home-screen balance/tx-list/notifications. +- **Blast radius**: ~12 files own the engine surface — `wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt` + (~38 bitcoinj imports), `BlockchainService.java`, `DashSystemService.kt` (wraps + `org.bitcoinj.manager.DashSystem`), `BlockchainStateDataProvider.kt`, `TestingSPVBlockStore.kt`, + `ui/{BlockListFragment,BlockListAdapter,PeerListFragment}.java`, `util/AllowLockTimeRiskAnalysis.kt`. +- **Named gaps**: SDK exposes no peer-list/block-list debug surface (those two debug screens die + with the engine); dashj's `BlockchainState` (sync %, replaying, impediments) must be fully + derived from `SpvSyncProgressData` (partially wired for the home header already). + +### 2. Key derivation / signing / seed handling +- **What it does**: `DeterministicSeed` (10 files), `KeyChainGroup`, `DeterministicKeyChain`, + `ECKey` (15), `KeyCrypterScrypt`/`KeyCrypterException` (17), `MnemonicCode`, `BIP38PrivateKey`, + `LinuxSecureRandom`. `SecurityGuard` (38 files) brokers the wallet password/PIN and feeds + dashj's KeyCrypter for wallet encryption; `PlatformMnemonicProvider`/`SecurityGuardMnemonicProvider` + already bridge the seed into the Kotlin SDK (the SDK signs with its own derivation — parity-proven). +- **SDK replacement**: the SDK derives/signs everything from the mnemonic (`mnemonicResolverHandle`; + no private key crosses the JNI boundary). GAPS: BIP38 private-key sweep import, paper-key + decryption UI, and BIP39 wordlist utilities (`MnemonicCodeExt` checks words locally) have no + SDK equivalent yet — either the SDK exposes mnemonic/WIF utilities or we keep a tiny + self-contained BIP39/Base58 util (NOT dashj). +- **Blast radius**: `wallet/src/de/schildbach/wallet/security/{SecurityGuard.java,SecurityFunctions.kt,MnemonicBasedKeyProvider.kt}`, + `payments/{DeriveKeyTask,DecryptSeedTask,DecodePrivateKeyTask}.java`, + `livedata/EncryptWalletLiveData.kt`, `ui/{EncryptKeysDialogFragment,BackupWalletToSeedDialogFragment,…}.java`, + `util/MnemonicCodeExt.kt` — ~40 files. + +### 3. Neutral value/address types leaked through `common/` APIs +- **What it does**: `org.dash.wallet.common.WalletDataProvider` exposes ~11 bitcoinj types + (`Address`, `Coin`, `NetworkParameters`, `Sha256Hash`, `Transaction`, `TransactionBag`, + `CoinSelector`, `Wallet`, `AuthenticationGroupExtension`, …) — every integration inherits + bitcoinj through this one interface. `Coin`/`Fiat`/`MonetaryFormat` are the money types of the + entire UI (61 files); `ScriptPattern` (16 files) parses outputs. +- **SDK replacement**: none needed from the SDK — this is an APP-SIDE abstraction job. The + neutral `Dash` money type (`org.dash.wallet.common.money.Dash`) and the neutral send overload + (`sendCoins(address: String, amount: Dash)`) already exist and are used by Coinbase/Maya; the + new `L1TxUiRecord` covers tx display. Finish the facade: duffs-Long/`Dash` for amounts, + base58 `String` for addresses, hex `String` for txids. +- **Blast radius**: `common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt` (+Ext), + `common/.../transactions/*`, `common/.../money/*`, `common/.../data/PaymentIntent.java` — + 68 files in common, ~25 in crowdnode, and every wallet-module implementer. + +### 4. Payment protocol / BIP70 / NFC & QR URIs +- **What it does**: `uri.BitcoinURI` (14 files) parses QR/NFC `dash:` URIs; + BIP70 was already COPIED out of dashj into `common/.../payments/bip70/` (5 files) but still + imports dashj's `crypto.TrustStoreLoader` types; address parsing uses `Base58`, + `AddressFormatException`, `PrefixedChecksummedBytes`. +- **SDK replacement**: none required — URI parsing and BIP70 are pure-JVM; finish the copy-out + (self-contained Base58/bech32 already exists: `common/.../payments/parsers/{Bech32.java,SegwitAddress.java}`, + and the sdk package's `Bech32m.kt`). The FFI validates addresses Rust-side on send. +- **Blast radius**: `common/.../payments/{parsers,bip70}/*`, `wallet/.../ui/util/InputParser.java`, + `WalletUri.java`, `SendCoinsQrActivity.java`, `offline/*` (NFC/Bluetooth payments) — ~25 files. + +### 5. Identity / DashPay signing (the OLD Java Platform SDK, `org.dashj.platform.*`) +- **What it does**: 41–44 files use `dpp.identifier.Identifier`, `sdk.platform.Names`, + `dashpay.BlockchainIdentity`, DAPI clients, voting types; bitcoinj-side identity funding uses + `AssetLockTransaction` (10 files) + `AuthenticationGroupExtension` (17 files) + BLS types. +- **SDK replacement**: the Kotlin SDK already carries the write paths (`SdkDashPayWrites`, + `SdkShieldedUsernameCreation`, `SdkShieldedInviteCreation`, `SdkVotingQueries`, + `SdkIdentityVerifyWrites`, `SdkProfileQueries`, …) behind `USE_KOTLIN_SDK_*` flags. The kill + is flipping those flags to unconditional and deleting the dashj-platform twins in + `PlatformRepo`/`PlatformBroadcastService`/`CreateIdentityService`. +- **Named gaps**: identity RESTORE/topup edge flows and the `AuthenticationGroupExtension` + key-usage report (masternode keys screen) still have no SDK query equivalents. +- **Blast radius**: `wallet/.../ui/dashpay/**`, `wallet/.../service/platform/**` — ~50 files, + plus the per-flavor `dash-sdk-{java,kotlin,android}` Gradle lines. + +### 6. Checkpoints / birth height / chain bootstrap +- **What it does**: `CheckpointManager` (3 files) + `wallet/assets/checkpoints{,-testnet}.txt` + fast-forward dashj's chain; `Constants.java` (11 bitcoinj imports) holds `NetworkParameters`. +- **SDK replacement**: the SDK SPV has its own bootstrap; `BirthHeightResolver.kt` already maps + the dashj checkpoint file to an SDK birth height. Post-dashj, keep the checkpoint TEXT files + (they are just data) with a tiny local parser, or move birth-height mapping into the SDK. +- **Blast radius**: `Constants.java`, `BlockchainServiceImpl.kt`, `BlockchainStateDataProvider.kt`, + `service/platform/sdk/BirthHeightResolver.kt` — 5 files. + +### 7. InstantSend / ChainLock verification (pre-cutover display + spend gating) +- **What it does**: `TransactionConfidence` (14 files; `IXType`, `isChainLocked`) drives + Sending/Sent/Processing display and `ChainLockedCoinSelector` spend gating; + `org.bitcoinj.quorums.*` (3 files) verifies islocks pre-cutover. +- **SDK replacement**: the SDK's Rust core verifies islocks/chainlocks itself and persists the + verdict as the `transactions.context` column (0=mempool, 1=instantSend, 2=inBlock, + 3=inChainLockedBlock) — the new `CutoverUiDataService`/`L1TxUiRecord` already consumes it + post-cutover. Dies fully with the L1 engine (step B below). +- **Blast radius**: `payments/ChainLockedCoinSelector.kt`, `common/.../filters/LockedTransaction.kt`, + tx display cluster (~28 files touch confidence-based logic). + +### 8. Wallet file format / backup / encryption +- **What it does**: `WalletProtobufSerializer` (5 files) + `Protos` read/write the `.wallet` + file; `WalletEx` (12 files) is the concrete wallet type; encrypted protobuf backups + (`BackupWalletDialogFragment`, `Crypto.java`). +- **SDK replacement**: the SDK persists its own wallet (Room + Rust state). The `.wallet` file + is retained READ-ONLY through the cutover horizon for rollback; at SETTLED it is dead weight. + GAP: user-facing backup/restore interop — a backup made post-dashj must still restore into + old app versions or be explicitly versioned; seed-phrase (BIP39) restore is the durable path. +- **Blast radius**: `service/WalletFactory.kt`, `WalletApplication.java`, `ui/backup/*`, + `util/{WalletUtils,Crypto}.java`, `ui/util/InputParser.java` — ~10 files. + +### 9. CoinJoin remnants +- **What it does**: the mixing engine is GONE (verified); only the classification enum + `org.bitcoinj.coinjoin.utils.CoinJoinTransactionType` remains in 6 files, labeling HISTORIC + mixing txs in the list/CSV export. +- **Replacement**: none needed — historic labels live in the Room display cache + (`tx_display_cache` persists resolved title strings). One rebuild-less release later the enum + usage can be deleted outright; SDK direction code 3 (coinJoin) covers any residual need. +- **Blast radius**: `ui/transactions/TxResourceMapper.kt`, `transactions/coinjoin/*` (3), + `service/WalletTransactionMetadataProvider.kt`, `transactions/CSVExporter.kt`, + `service/platform/PlatformSyncService.kt` — 6 files. + +### 10. Exchange rates / fiat / money formatting +- **What it does**: `Fiat` (20), `ExchangeRate` (18), `MonetaryFormat` (36) — 61 files of pure + JVM arithmetic/formatting, zero networking, zero consensus. +- **Replacement**: no SDK capability needed. Either (a) lift the three classes' logic into + `common/.../money/` (small, Apache-licensed, self-contained — `FiatValue.kt`/`Dash` already + half-do this), or (b) keep them as the last deleted piece of step D. +- **Blast radius**: `common/.../money/*`, `common/.../util/*`, `CurrencyAmountView.java`, + enter-amount UI, send flow — 61 files but a mechanical type swap. + +### 11. Transaction display / wrapping +- **What it does**: `TransactionWrapper`/`TxResourceMapper`/`TransactionRowView` (~21 files) + render dashj `Transaction`s into rows; `TxDisplayCacheService` persists them into the neutral + Room cache (`TxDisplayCacheEntry` — primitives only). +- **SDK replacement**: `tx_display_cache` IS the neutral model; post-cutover + `CutoverUiDataService` already writes rows straight from SDK records. The kill: make the SDK + the ONLY row producer, render history once from the frozen dashj wallet (final cache build), + then delete the wrapper/mapper pipeline. GAP (named): tx DETAIL surfaces + (`TransactionResultViewBinder`) still require a live dashj `Transaction` — needs an SDK + tx-detail query (inputs/outputs/addresses/fee) or an extended cache row. +- **Blast radius**: `ui/transactions/*`, `ui/main/TransactionAdapter.kt`, + `service/TxDisplayCacheService.kt`, `common/.../transactions/*` — ~21 files. + +### 12. Everything else +- `core.Context` propagation (33 files) + `utils.Threading` (12) — dies with the engine. +- `core.VersionMessage` (peer UA, 2 files), `core.Utils`/`Base58`/`VarInt` scattered utils. +- `integrations/crowdnode` (25 files): tx-matchers typed on `Transaction`/`ScriptPattern` — needs + the neutral facade of §3 plus an outputs-by-tx SDK query. +- The `service/platform/sdk/` bridge layer itself (30 files) intentionally imports bitcoinj to + translate between worlds; it shrinks to nothing as each twin dies. + +--- + +## Kill order — FOUR big steps + +**Step A — Neutralize the facade (no dashj types across module boundaries).** +Rewrite `WalletDataProvider` + `common/.../transactions/*` + `PaymentIntent` on neutral types +(duffs `Long`/`Dash`, base58/hex `String`s, `L1TxUiRecord`), port crowdnode's matchers, and +absorb `Fiat`/`ExchangeRate`/`MonetaryFormat` into `common/.../money/`. Finish the BIP70/URI +copy-out. Deletes `dashj-core` from **common** and **crowdnode** Gradle files. (§3, §4, §10, half §12) + +**Step B — Settle the L1 cutover and delete the engine.** +Drive CUT_OVER → SETTLED, then delete `BlockchainServiceImpl`'s engine half, `DashSystemService`, +block/peer debug UIs, checkpoints wiring (keep the data files + `BirthHeightResolver`), +`ChainLockedCoinSelector`, confidence-based status logic, and `Context`/`Threading` propagation. +Requires closing the named SDK gaps first: tx-detail query, `BlockchainState` derivation, +send-all (GAP: `sendToAddresses` exposes no drain strategy). (§1, §6, §7, most §11, §12) + +**Step C — Retire the old Java Platform SDK.** +Flip every `USE_KOTLIN_SDK_*` DashPay flag unconditional, delete the dashj-platform twins in +`PlatformRepo`/`PlatformBroadcastService`/`CreateIdentityService`, port identity-funding +bookkeeping off `AssetLockTransaction`/`AuthenticationGroupExtension`. Deletes the per-flavor +`org.dashj.platform:*` lines + `dashj-bls-android`/`dashj-x11-android`. (§5) + +**Step D — Kill the wallet-of-record and the last utils.** +At SETTLED: stop writing the `.wallet` file, move backup to seed-phrase + SDK export, replace +`SecurityGuard`'s KeyCrypter usage with the SDK's encryption, swap `MnemonicCode` for a local +BIP39 util, drop the CoinJoin enum labels, delete `WalletFactory`/`WalletEx`/serializer code — +then remove `org.dashj:dashj-core` and `dashj-scrypt-android` from `wallet/build.gradle`, the +wordlist/cacerts packaging lines, and the java.nio SPVBlockStore patch. **dashj is gone.** (§2, §8, §9) + +Order rationale: A is pure refactor (shippable anytime, unblocks every module), B rides the +already-running cutover machinery and removes the biggest runtime cost (double SPV, the +Android-15 buffer patch), C and D are then local to the wallet module with no cross-module +consumers left. diff --git a/build.gradle b/build.gradle index d6a50099dc..3d139c0b9e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,11 @@ buildscript { coroutinesVersion = '1.6.4' ok_http_version = '4.12.0' dashjVersion = '22.0.4' + // Dash Platform Kotlin SDK (org.dashj:dash-sdk-android). Single source of + // truth for both the dependency coordinate (wallet/build.gradle) and the + // DASH_SDK_VERSION BuildConfig field shown on the About screen once the + // cutover hands L1 ownership to the SDK. + dashSdkVersion = '0.1.0-v42int4c' dppVersion = "4.0.0" hiltVersion = '2.53' hiltCompilerVersion = '1.2.0' @@ -69,10 +74,48 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") version "$kotlin_version" apply false } +// Credentials for the GitHub Packages Maven registry that hosts the pre-release +// Dash Platform Kotlin SDK (see the repository declaration below). GitHub Packages +// requires authentication for reads as well as writes. +// CI : GITHUB_ACTOR / GITHUB_TOKEN environment variables. +// Local : optional gpr.user / gpr.token in ~/.gradle/gradle.properties. +// Never commit a token; both sources are read at configuration time only. +def githubPackagesUser = System.getenv('GITHUB_ACTOR') ?: findProperty('gpr.user') +def githubPackagesToken = System.getenv('GITHUB_TOKEN') ?: findProperty('gpr.token') + allprojects { repositories { - google() mavenLocal() + // org.dashj:dash-sdk-android pre-release snapshots are published to GitHub + // Packages on dashpay/platform; they are deliberately not on Maven Central + // until the SDK cutover ships. mavenLocal() above is declared first, so a + // locally published SDK build still wins for local development, and this + // repository is only consulted when the coordinate is not already in ~/.m2. + // The content filter keeps every other dependency off this registry — + // GitHub Packages answers unknown coordinates with 401/403, which would + // otherwise turn unrelated lookups into hard resolution failures. + // Declared only when credentials exist so a credential-less checkout keeps + // building against ~/.m2. + // The pre-release SDK AAR is published to THIS repo's own package + // registry (publishing needs write on the destination repo, which + // fork-based contributors do not have on dashpay/platform). The + // package is public, so no credentials are required to read it — + // CI resolves it with nothing configured. Credentials are still + // honoured when present, for private-package scenarios. + maven { + name 'GitHubPackagesDashSdk' + url 'https://maven.pkg.github.com/dashpay/dash-wallet' + if (githubPackagesUser && githubPackagesToken) { + credentials { + username = githubPackagesUser + password = githubPackagesToken + } + } + content { + includeModule('org.dashj', 'dash-sdk-android') + } + } + google() mavenCentral() maven { url 'https://jitpack.io' } maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } diff --git a/common/build.gradle b/common/build.gradle index 2850048a5b..eb5318906e 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { compileSdk 35 - minSdkVersion 24 + minSdkVersion 29 targetSdkVersion 35 vectorDrawables.useSupportLibrary = true @@ -18,8 +18,14 @@ android { buildTypes { release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + // Library-level minification is redundant (the app's release + // build minifies the final APK with these consumer rules) and + // actively harmful here: the shrunk classes feed :wallet's + // release compile/KSP classpath, stripping the Room annotations + // off this module's entities — ksp_*ReleaseKotlin then fails + // ("must have @PrimaryKey" / "no usable public constructor" / + // "no such table"). Matches exploredash/coinbase/crowdnode/maya. + minifyEnabled false consumerProguardFiles 'proguard-rules.pro' } debug { @@ -53,8 +59,10 @@ dependencies { // Core implementation 'androidx.appcompat:appcompat:1.2.0' - implementation "org.dashj:dashj-core:$dashjVersion" implementation 'com.google.protobuf:protobuf-javalite:3.17.3' + // Needed at compile time by the BIP70 code copied from dashj (payments.bip70.X509Utils); + // dashj only exposes bouncycastle as a runtime dependency. Version matches dashj 22.0.3. + implementation 'org.bouncycastle:bcprov-jdk15to18:1.74' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" diff --git a/common/proguard-rules.pro b/common/proguard-rules.pro index 5429e2d3cb..e0fb599403 100644 --- a/common/proguard-rules.pro +++ b/common/proguard-rules.pro @@ -1,4 +1,12 @@ --keepattributes Exceptions, InnerClasses +# *Annotation* + Kotlin metadata are load-bearing for consumers of the +# RELEASE (minified) variant: :wallet's Room KSP reads @Entity/@PrimaryKey +# and Kotlin constructor metadata off these classes (ExchangeRate, +# BlockchainState, TransactionMetadata …). With only Exceptions/InnerClasses +# kept, ksp_*ReleaseKotlin fails with "Entities and POJOs must have a usable +# public constructor" / "must have at least 1 field annotated with +# @PrimaryKey" while debug (unminified) builds pass. +-keepattributes Exceptions, InnerClasses, *Annotation*, Signature +-keep class kotlin.Metadata -keep public class org.dash.wallet.common.** { public protected *; } diff --git a/common/src/main/java/org/dash/wallet/common/Configuration.java b/common/src/main/java/org/dash/wallet/common/Configuration.java index 175f153a83..990445be2d 100644 --- a/common/src/main/java/org/dash/wallet/common/Configuration.java +++ b/common/src/main/java/org/dash/wallet/common/Configuration.java @@ -31,8 +31,8 @@ import com.google.common.base.Strings; -import org.bitcoinj.core.Coin; -import org.bitcoinj.utils.MonetaryFormat; +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.money.MonetaryFormat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt b/common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt index 86c7739312..1d5a61f237 100644 --- a/common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt +++ b/common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt @@ -18,59 +18,59 @@ package org.dash.wallet.common import kotlinx.coroutines.flow.Flow -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.NetworkParameters -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionBag -import org.bitcoinj.core.TransactionOutPoint -import org.bitcoinj.wallet.CoinSelector -import org.bitcoinj.wallet.Wallet -import org.bitcoinj.wallet.authentication.AuthenticationGroupExtension -import org.bitcoinj.wallet.authentication.AuthenticationKeyUsage +import org.dash.wallet.common.money.Dash import org.dash.wallet.common.services.LeftoverBalanceException import org.dash.wallet.common.transactions.TransactionWrapper import org.dash.wallet.common.transactions.TransactionWrapperFactory +import org.dash.wallet.common.transactions.TxInfo import org.dash.wallet.common.transactions.filters.TransactionFilter +/** + * Neutral (dashj-free) facade over the wallet for feature/integration modules. + * + * Amounts are neutral [Dash] values, addresses are base58 strings, transaction ids are hex + * strings and transactions are [TxInfo] snapshots. The wallet module implements this interface + * (converting to/from its dashj types internally) and additionally exposes the dashj-typed + * surface through its own `WalletData` interface for wallet-internal consumers. + */ interface WalletDataProvider { - @Deprecated("The wallet is in here temporary and will be moved to a separate holder, limited to the the wallet module. In feature modules, use transactionBag instead.") - val wallet: Wallet? - fun observeWallet(): Flow + /** Network id string, e.g. [org.dash.wallet.common.payments.parsers.AddressNetwork.ID_MAINNET]. */ + val networkId: String - val transactionBag: TransactionBag + /** True while a wallet is loaded. Balance accessors return zero (not null) when it is false. */ + val walletLoaded: Boolean - val networkParameters: NetworkParameters - val authenticationGroupExtension: AuthenticationGroupExtension? - fun freshReceiveAddress(): Address - fun currentReceiveAddress(): Address + fun freshReceiveAddressString(): String + fun currentReceiveAddressString(): String - fun getWalletBalance(): Coin - fun getMixedBalance(): Coin + /** Estimated wallet balance. */ + fun getWalletBalance(): Dash + + /** + * Number of spendable unspent outputs coin selection can draw on — + * `calculateAllSpendCandidates(false, false)`, the exact output set + * `getBalance(ESTIMATED)` sums (all keychains) — or 0 while no wallet + * is loaded. + */ + fun spendableUtxoCount(): Int fun observeWalletChanged(): Flow fun observeWalletReset(): Flow - fun observeBalance( - balanceType: Wallet.BalanceType = Wallet.BalanceType.ESTIMATED, - coinSelector: CoinSelector? = null - ): Flow - - fun observeSpendableBalance(): Flow + /** Estimated balance stream (mirrors observing `Wallet.getBalance(ESTIMATED)`). */ + fun observeEstimatedBalance(): Flow fun canAffordIdentityCreation(): Boolean // Treat @withConfidence with care - it may produce a lot of events and affect performance. - fun observeTransactions(withConfidence: Boolean = false, vararg filters: TransactionFilter): Flow + fun observeTransactions(withConfidence: Boolean = false, vararg filters: TransactionFilter): Flow - fun observeAuthenticationKeyUsage(): Flow> + /** The wallet transaction with hex id [txId], or null if the wallet doesn't know it. */ + fun getTransaction(txId: String): TxInfo? - fun getTransaction(hash: Sha256Hash): Transaction? - - fun getTransactions(vararg filters: TransactionFilter): Collection + fun getTransactions(vararg filters: TransactionFilter): Collection fun wrapAllTransactions(vararg wrappers: TransactionWrapperFactory): Collection @@ -78,13 +78,21 @@ interface WalletDataProvider { fun detachOnWalletWipedListener(listener: suspend () -> Unit) - fun processDirectTransaction(tx: Transaction) - @Throws(LeftoverBalanceException::class) - fun checkSendingConditions(address: Address?, amount: Coin) - - fun observeMostRecentTransaction(): Flow - fun observeMixedBalance(): Flow - fun observeTotalBalance(): Flow - fun lockOutput(outPoint: TransactionOutPoint): Boolean + fun checkSendingConditions(address: String?, amount: Dash) + + fun observeTotalBalance(): Flow + + /** + * Locks the outputs of the wallet transaction [txId] that pay the base58 [address] + * (P2PKH outputs only, mirroring the original CrowdNode account-output locking). + */ + fun lockOutputsPayingTo(txId: String, address: String) + + /** + * Suspends until the wallet transaction [txId] is locked (IS-lock, mined, or seen by + * more than one broadcast peer) — the same condition as the `LockedTransaction` filter. + * Returns immediately when the transaction already satisfies it. + */ + suspend fun waitUntilLocked(txId: String) } diff --git a/common/src/main/java/org/dash/wallet/common/WalletDataProviderExt.kt b/common/src/main/java/org/dash/wallet/common/WalletDataProviderExt.kt new file mode 100644 index 0000000000..b90104f10b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/WalletDataProviderExt.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.services.LeftoverBalanceException +import org.dash.wallet.common.transactions.filters.LockedTransaction + +// --------------------------------------------------------------------------------------------- +// Convenience adapters over the neutral WalletDataProvider facade. +// --------------------------------------------------------------------------------------------- + +/** + * Issue a fresh receive address OFF the calling thread. + * + * The wallet-module implementation of [WalletDataProvider.freshReceiveAddressString] + * (dashj `freshReceiveAddress()`) issues a key and then forces a SYNCHRONOUS + * full-wallet save — measured 1.2s of main-thread block on a wallet with 215 + * DashPay friend key chains — so it must never run on the main thread. This + * helper imposes [Dispatchers.IO] at the seam; feature/integration callers in + * coroutines (which typically run on the Main dispatcher) should use it + * instead of calling [WalletDataProvider.freshReceiveAddressString] directly. + * + * An extension (not an interface method) so test fakes/mocks that stub + * `freshReceiveAddressString()` keep working unchanged. + */ +suspend fun WalletDataProvider.freshReceiveAddressStringOffMain(): String = + withContext(Dispatchers.IO) { freshReceiveAddressString() } + +/** [WalletDataProvider.observeTotalBalance] as neutral [Dash] amounts. */ +fun WalletDataProvider.observeTotalDashBalance(): Flow = observeTotalBalance() + +/** [WalletDataProvider.observeEstimatedBalance] as neutral [Dash] amounts. */ +fun WalletDataProvider.observeDashBalance(): Flow = observeEstimatedBalance() + +/** [WalletDataProvider.getWalletBalance] as a neutral [Dash] amount. */ +fun WalletDataProvider.getDashBalance(): Dash = getWalletBalance() + +/** + * Estimated wallet balance, or null when no wallet is loaded (the old + * `wallet?.getBalance(ESTIMATED)` contract that callers like Maya's ConvertViewViewModel rely on). + */ +fun WalletDataProvider.getEstimatedDashBalance(): Dash? = + if (walletLoaded) getWalletBalance() else null + +/** + * Emits the hex tx id once the wallet transaction with hex id [txId] is IS-locked or confirmed + * (mirrors [WalletDataProvider.observeTransactions] with a [LockedTransaction] filter). + */ +fun WalletDataProvider.observeTransactionLocked(txId: String): Flow = + observeTransactions(true, LockedTransaction(txId)).map { it.txId } + +/** + * Whether the wallet transaction with hex id [txId] is pending (mirrors `Transaction.isPending`); + * false if the wallet doesn't know the transaction. + */ +fun WalletDataProvider.isTransactionPending(txId: String): Boolean = + getTransaction(txId)?.isPending ?: false + +/** + * Net wallet value of the transaction with hex id [txId] (mirrors + * `Transaction.getValue(transactionBag)`), or null if the wallet doesn't know the transaction. + */ +fun WalletDataProvider.getTransactionValue(txId: String): Dash? = + getTransaction(txId)?.let { Dash(it.netValueDuffs) } + +/** Serialized hex of the wallet transaction with hex id [txId], or null if unknown. Useful for logging. */ +fun WalletDataProvider.getTransactionHex(txId: String): String? = + getTransaction(txId)?.rawHex + +/** + * True when sending [amount] would trip the leftover-balance check + * (i.e. [WalletDataProvider.checkSendingConditions] would throw [LeftoverBalanceException]). + */ +fun WalletDataProvider.needsLeftoverBalanceWarning(amount: Dash): Boolean { + return try { + checkSendingConditions(null, amount) + false + } catch (_: LeftoverBalanceException) { + true + } +} diff --git a/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39.kt b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39.kt new file mode 100644 index 0000000000..2b70575f69 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.crypto.bip39 + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Self-contained BIP39 implementation, a byte-faithful port of `org.bitcoinj.crypto.MnemonicCode` + * (dashj 22.0.3) with no dashj dependency: + * + * - [check] / [toEntropy]: word membership (collator-based, see [Bip39Wordlist.indexOf]) plus + * checksum validation, throwing [Bip39Exception] subtypes that mirror `MnemonicException` 1:1; + * - [toMnemonic]: entropy → words; + * - [toSeed]: PBKDF2-HMAC-SHA512, 2048 iterations, 64-byte seed, password = words joined with a + * single space, salt = `"mnemonic" + passphrase`, both UTF-8 — exactly dashj's + * `MnemonicCode.toSeed` / `PBKDF2SHA512.derive`. + */ +object Bip39 { + + /** dashj `MnemonicCode.PBKDF2_ROUNDS`. */ + const val PBKDF2_ROUNDS = 2048 + + /** Seed length in bytes produced by [toSeed]. */ + const val SEED_LENGTH = 64 + + /** + * Validates the mnemonic (membership + checksum) against [wordlist]. + * Mirror of `MnemonicCode.check`. + * + * @throws Bip39Exception.LengthException word count is zero or not a multiple of 3 + * @throws Bip39Exception.WordException a word is not in the wordlist + * @throws Bip39Exception.ChecksumException the checksum bits do not match + */ + @JvmStatic + @Throws(Bip39Exception::class) + fun check(words: List, wordlist: Bip39Wordlist = Bip39Wordlist.ENGLISH) { + toEntropy(words, wordlist) + } + + /** True when [check] passes. */ + @JvmStatic + fun isValid(words: List, wordlist: Bip39Wordlist = Bip39Wordlist.ENGLISH): Boolean { + return try { + check(words, wordlist) + true + } catch (e: Bip39Exception) { + false + } + } + + /** + * Converts a mnemonic back to its entropy, validating the checksum. + * Line-for-line port of `MnemonicCode.toEntropy` (dashj 22.0.3). + */ + @JvmStatic + @Throws(Bip39Exception::class) + fun toEntropy(words: List, wordlist: Bip39Wordlist = Bip39Wordlist.ENGLISH): ByteArray { + if (words.size % 3 > 0) { + throw Bip39Exception.LengthException("Word list size must be multiple of three words.") + } + if (words.isEmpty()) { + throw Bip39Exception.LengthException("Word list is empty.") + } + + // Look up all the words in the list and construct the concatenation of the original entropy and the checksum. + val concatLenBits = words.size * 11 + val concatBits = BooleanArray(concatLenBits) + var wordindex = 0 + for (word in words) { + // Find the word's index in the wordlist (collator-based, like dashj's search()). + val ndx = wordlist.indexOf(word) + if (ndx < 0) { + throw Bip39Exception.WordException(word) + } + // Set the next 11 bits to the value of the index. + for (ii in 0 until 11) { + concatBits[wordindex * 11 + ii] = (ndx and (1 shl (10 - ii))) != 0 + } + ++wordindex + } + + val checksumLengthBits = concatLenBits / 33 + val entropyLengthBits = concatLenBits - checksumLengthBits + + // Extract original entropy as bytes. + val entropy = ByteArray(entropyLengthBits / 8) + for (ii in entropy.indices) { + for (jj in 0 until 8) { + if (concatBits[ii * 8 + jj]) { + entropy[ii] = (entropy[ii].toInt() or (1 shl (7 - jj))).toByte() + } + } + } + + // Take the digest of the entropy. + val hash = sha256(entropy) + val hashBits = bytesToBits(hash) + + // Check all the checksum bits. + for (ii in 0 until checksumLengthBits) { + if (concatBits[entropyLengthBits + ii] != hashBits[ii]) { + throw Bip39Exception.ChecksumException() + } + } + + return entropy + } + + /** + * Converts entropy to a mnemonic. Port of `MnemonicCode.toMnemonic` (dashj 22.0.3). + * + * @throws Bip39Exception.LengthException entropy is empty or not a multiple of 32 bits + */ + @JvmStatic + @Throws(Bip39Exception::class) + fun toMnemonic(entropy: ByteArray, wordlist: Bip39Wordlist = Bip39Wordlist.ENGLISH): List { + if (entropy.size % 4 > 0) { + throw Bip39Exception.LengthException("Entropy length not multiple of 32 bits.") + } + if (entropy.isEmpty()) { + throw Bip39Exception.LengthException("Entropy is empty.") + } + + // We take initial entropy of ENT bits and compute its checksum by taking first ENT / 32 bits of its SHA256 hash. + val hash = sha256(entropy) + val hashBits = bytesToBits(hash) + val entropyBits = bytesToBits(entropy) + val checksumLengthBits = entropyBits.size / 32 + + // We append these bits to the end of the initial entropy. + val concatBits = BooleanArray(entropyBits.size + checksumLengthBits) + System.arraycopy(entropyBits, 0, concatBits, 0, entropyBits.size) + System.arraycopy(hashBits, 0, concatBits, entropyBits.size, checksumLengthBits) + + // Next we take these concatenated bits and split them into groups of 11 bits. Each group encodes a number + // from 0-2047 which is a position in a wordlist. + val words = ArrayList() + val nwords = concatBits.size / 11 + for (i in 0 until nwords) { + var index = 0 + for (j in 0 until 11) { + index = index shl 1 + if (concatBits[i * 11 + j]) { + index = index or 0x1 + } + } + words.add(wordlist.wordAt(index)) + } + + return words + } + + /** + * Converts a mnemonic to the 64-byte BIP39 seed. Mirror of `MnemonicCode.toSeed`: + * no wordlist involvement and no normalization — password is the words joined by a single + * space, salt is `"mnemonic" + passphrase`, PBKDF2-HMAC-SHA512 with 2048 iterations. + */ + @JvmStatic + @JvmOverloads + fun toSeed(words: List, passphrase: String = ""): ByteArray { + val pass = words.joinToString(" ") + val salt = "mnemonic$passphrase" + return pbkdf2HmacSha512( + pass.toByteArray(StandardCharsets.UTF_8), + salt.toByteArray(StandardCharsets.UTF_8), + PBKDF2_ROUNDS, + SEED_LENGTH + ) + } + + private fun sha256(input: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(input) + + private fun bytesToBits(data: ByteArray): BooleanArray { + val bits = BooleanArray(data.size * 8) + for (i in data.indices) { + for (j in 0 until 8) { + bits[i * 8 + j] = (data[i].toInt() and (1 shl (7 - j))) != 0 + } + } + return bits + } + + /** Standard PBKDF2 (RFC 2898) with HMAC-SHA512, equivalent to dashj's `PBKDF2SHA512.derive`. */ + private fun pbkdf2HmacSha512(password: ByteArray, salt: ByteArray, iterations: Int, dkLen: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(password, "HmacSHA512")) + val hLen = mac.macLength // 64 + val blocks = (dkLen + hLen - 1) / hLen + val derived = ByteArray(blocks * hLen) + for (block in 1..blocks) { + // U1 = PRF(P, S || INT(block)) + mac.update(salt) + mac.update( + byteArrayOf( + (block ushr 24).toByte(), + (block ushr 16).toByte(), + (block ushr 8).toByte(), + block.toByte() + ) + ) + var u = mac.doFinal() + val t = u.copyOf() + for (i in 1 until iterations) { + u = mac.doFinal(u) + for (k in t.indices) { + t[k] = (t[k].toInt() xor u[k].toInt()).toByte() + } + } + System.arraycopy(t, 0, derived, (block - 1) * hLen, hLen) + } + return derived.copyOf(dkLen) + } +} diff --git a/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Exception.kt b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Exception.kt new file mode 100644 index 0000000000..d8ad40dfb9 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Exception.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.crypto.bip39 + +/** + * Dashj-free mirror of `org.bitcoinj.crypto.MnemonicException` (dashj 22.0.3). + * Subtype-for-subtype identical so call sites can translate 1:1. + */ +sealed class Bip39Exception(message: String?) : Exception(message) { + + /** Mirror of `MnemonicException.MnemonicLengthException`: word count is not a multiple of 3 (or empty). */ + class LengthException(message: String) : Bip39Exception(message) + + /** Mirror of `MnemonicException.MnemonicWordException`: [badWord] was not found in the wordlist. */ + class WordException(val badWord: String) : Bip39Exception(badWord) + + /** Mirror of `MnemonicException.MnemonicChecksumException`: the checksum bits do not match. */ + class ChecksumException : Bip39Exception(null) +} diff --git a/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Wordlist.kt b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Wordlist.kt new file mode 100644 index 0000000000..4b8a182a3e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/crypto/bip39/Bip39Wordlist.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.crypto.bip39 + +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.text.Collator +import java.util.Locale + +/** + * A BIP39 wordlist with dashj-faithful word lookup. + * + * Self-contained port of the wordlist handling inside `org.bitcoinj.crypto.MnemonicCode` + * (dashj 22.0.3), byte-for-byte replicating its semantics: + * + * - the stream is read line-by-line as UTF-8 and must contain exactly 2048 words; + * - the optional digest is SHA-256 over the concatenated word bytes (no newlines), lowercase hex — + * the same check `MnemonicCode(InputStream, String)` performs; + * - [indexOf] uses a linear scan with an English-locale [Collator] at PRIMARY strength, exactly like + * dashj's `MnemonicCode.search`, so lookups are case- and diacritics-insensitive + * (e.g. "Abandon" matches "abandon", "medaille" matches "médaille"). + */ +class Bip39Wordlist private constructor(private val wordList: List) { + + /** Same comparator dashj builds: English collator, PRIMARY strength (case/diacritics-insensitive). */ + private val diacriticsInsensitiveComparer: Collator = Collator.getInstance(Locale.ENGLISH).apply { + strength = Collator.PRIMARY + } + + val words: List + get() = wordList + + val size: Int + get() = wordList.size + + fun wordAt(index: Int): String = wordList[index] + + /** + * Index of [word] in this wordlist, or -1. Mirrors dashj's `MnemonicCode.search`: + * linear scan using the English PRIMARY-strength collator. + * (Collator is not thread-safe, hence the synchronization; result is unaffected.) + */ + fun indexOf(word: String): Int { + synchronized(diacriticsInsensitiveComparer) { + for (i in wordList.indices) { + if (diacriticsInsensitiveComparer.compare(wordList[i], word) == 0) { + return i + } + } + } + return -1 + } + + /** True when [word] is in this wordlist under the same matching rules as dashj's lookup. */ + fun contains(word: String): Boolean = indexOf(word) >= 0 + + companion object { + /** + * SHA-256 (lowercase hex) of the concatenated English words — the exact digest constant + * dashj's `MnemonicCode` uses (`BIP39_ENGLISH_SHA256`). + */ + const val ENGLISH_WORDS_DIGEST = "ad90bf3beb7b0eb7e5acd74727dc0da96e0a280a258354e7293fb7e211ac03db" + + private const val ENGLISH_RESOURCE = "wordlist/english.txt" + + /** + * The canonical BIP39 English wordlist, vendored from the dashj 22.0.3 jar resource + * `org/bitcoinj/crypto/mnemonic/wordlist/english.txt` (file sha256 + * `2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda`, the canonical + * BIP39 English list). Digest-checked on load. + */ + @JvmStatic + val ENGLISH: Bip39Wordlist by lazy { + val stream = Bip39Wordlist::class.java.getResourceAsStream(ENGLISH_RESOURCE) + ?: throw IOException("Missing resource: $ENGLISH_RESOURCE") + load(stream, ENGLISH_WORDS_DIGEST) + } + + /** + * Loads a wordlist from [stream] (closing it), mirroring `MnemonicCode(InputStream, String)`. + * + * @param wordListDigest optional lowercase-hex SHA-256 over the concatenated word bytes + * @throws IllegalArgumentException if the stream does not contain exactly 2048 words or the digest mismatches + */ + @JvmStatic + @JvmOverloads + @Throws(IOException::class) + fun load(stream: InputStream, wordListDigest: String? = null): Bip39Wordlist { + val words = ArrayList(2048) + val md = MessageDigest.getInstance("SHA-256") + BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8)).use { reader -> + var word = reader.readLine() + while (word != null) { + md.update(word.toByteArray(StandardCharsets.UTF_8)) + words.add(word) + word = reader.readLine() + } + } + require(words.size == 2048) { "input stream did not contain 2048 words" } + if (wordListDigest != null) { + val digest = md.digest().joinToString("") { "%02x".format(it) } + require(digest == wordListDigest) { "wordlist digest mismatch" } + } + return Bip39Wordlist(words) + } + + /** Wraps an already-loaded 2048-word list (e.g. dashj's `MnemonicCode.getWordList()`). */ + @JvmStatic + fun of(words: List): Bip39Wordlist { + require(words.size == 2048) { "word list did not contain 2048 words" } + return Bip39Wordlist(ArrayList(words)) + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/data/BaseConfig.kt b/common/src/main/java/org/dash/wallet/common/data/BaseConfig.kt index 2be25c8a22..d7783f2c3f 100644 --- a/common/src/main/java/org/dash/wallet/common/data/BaseConfig.kt +++ b/common/src/main/java/org/dash/wallet/common/data/BaseConfig.kt @@ -33,12 +33,15 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.dash.wallet.common.WalletDataProvider import org.dash.wallet.common.util.security.EncryptionProvider +import org.slf4j.LoggerFactory import java.io.IOException +import java.util.WeakHashMap abstract class BaseConfig( private val context: Context, @@ -47,9 +50,54 @@ abstract class BaseConfig( private val encryptionProvider: EncryptionProvider? = null, migrations: List> = listOf() ) { + companion object { + private val log = LoggerFactory.getLogger(BaseConfig::class.java) + + // Registry of live BaseConfig instances, weakly referenced so GC'able + // configs don't leak. Every instance registers itself on construction. + // A wallet wipe must clear each LIVE config through its DataStore API + // (memory + disk reset atomically) instead of deleting the backing file + // out-of-band: an out-of-band delete leaves the live DataStore's + // in-memory cache populated while disk is empty, so later reads return + // stale values and later writes recreate the file with a random subset + // of keys (observed live: debug SDK flags never reseeding after a + // Reset Wallet because the stale cache made them look already-set). + private val registryLock = Any() + private val liveInstances = WeakHashMap() + + /** + * Clears every live [BaseConfig] instance through its DataStore API and + * returns the DataStore file names (e.g. "dashpay.preferences_pb") that + * were cleared. An instance whose clear fails is logged and excluded + * from the returned set so callers can fall back to raw file deletion + * for it. + */ + suspend fun clearAllLiveInstances(): Set { + val snapshot = synchronized(registryLock) { liveInstances.keys.toList() } + val cleared = mutableSetOf() + + for (instance in snapshot) { + try { + instance.clearAll() + cleared.add(instance.preferencesFileName) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("failed to clear live config '{}' via API", instance.preferencesFileName, e) + } + } + + return cleared + } + } + private val securityKeyAlias = "${name}_security_key" private val json = Json { encodeDefaults = true } + /** File name used by the underlying preferences DataStore for this config. */ + val preferencesFileName: String + get() = "$name.preferences_pb" + protected val Context.dataStore by preferencesDataStore( name = name, produceMigrations = { migrations } @@ -65,6 +113,7 @@ abstract class BaseConfig( } init { + synchronized(registryLock) { liveInstances[this] = Unit } walletDataProvider.attachOnWalletWipedListener { clearAll() } @@ -84,6 +133,13 @@ abstract class BaseConfig( } } + /** Remove [key] entirely, so a later [get] returns null (not a stale value). */ + open suspend fun remove(key: Preferences.Key) { + context.dataStore.edit { preferences -> + preferences.remove(key) + } + } + fun observeSecureData(key: Preferences.Key): Flow { return data.secureMap { preferences -> preferences[key].orEmpty() } } @@ -95,7 +151,7 @@ abstract class BaseConfig( context.dataStore.secureEdit(value) { preferences, encryptedValue -> preferences[key] = encryptedValue } } - suspend fun clearAll() { + open suspend fun clearAll() { context.dataStore.edit { it.clear() } } diff --git a/common/src/main/java/org/dash/wallet/common/data/PaymentIntent.java b/common/src/main/java/org/dash/wallet/common/data/PaymentIntent.java index 9bd4d7b97f..0a5b046aee 100644 --- a/common/src/main/java/org/dash/wallet/common/data/PaymentIntent.java +++ b/common/src/main/java/org/dash/wallet/common/data/PaymentIntent.java @@ -22,42 +22,34 @@ import android.text.TextUtils; import com.google.common.io.BaseEncoding; + import java.util.Arrays; import java.util.Date; import javax.annotation.Nullable; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.params.MainNetParams; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.protocols.payments.PaymentProtocol; -import org.bitcoinj.protocols.payments.PaymentProtocolException; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; -import org.bitcoinj.script.ScriptException; -import org.bitcoinj.script.ScriptPattern; -import org.bitcoinj.uri.BitcoinURI; -import org.bitcoinj.wallet.SendRequest; +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.payments.bip70.PaymentProtocol; +import org.dash.wallet.common.payments.bip70.PaymentProtocolException; +import org.dash.wallet.common.payments.parsers.AddressFormatException; +import org.dash.wallet.common.payments.parsers.AddressNetwork; +import org.dash.wallet.common.payments.parsers.PaymentURI; +import org.dash.wallet.common.payments.parsers.Scripts; import org.dash.wallet.common.util.Bluetooth; import org.dash.wallet.common.util.GenericUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.io.BaseEncoding; import org.dash.wallet.common.util.Constants; -import java.util.Arrays; - -import javax.annotation.Nullable; - import static com.google.common.base.Preconditions.checkArgument; import androidx.annotation.NonNull; /** + * Dashj-free payment intent: amounts are the self-contained {@link Coin} port, addresses are + * base58 strings, output scripts are raw script bytes. + * * @author Andreas Schildbach */ public final class PaymentIntent implements Parcelable { @@ -67,22 +59,23 @@ public enum Standard { public final static class Output implements Parcelable { public final Coin amount; - public final Script script; + public final byte[] scriptData; - public Output(final Coin amount, final Script script) { + public Output(final Coin amount, final byte[] scriptData) { this.amount = amount; - this.script = script; + this.scriptData = scriptData; } public static Output valueOf(final PaymentProtocol.Output output) throws PaymentProtocolException.InvalidOutputs { - try { - final Script script = new Script(output.scriptData); - return new PaymentIntent.Output(output.amount, script); - } catch (final ScriptException x) { + // Reject structurally invalid scripts here, at BIP70 parse time, like the old + // `new Script(scriptData)` did — not later, post-confirmation, in the send path. + if (output.scriptData == null || output.scriptData.length == 0 + || !Scripts.isParseable(output.scriptData)) { throw new PaymentProtocolException.InvalidOutputs( - "unparseable script in output: " + Constants.HEX.encode(output.scriptData)); + "unparseable script in output: " + Constants.HEX.encode(output.scriptData == null ? new byte[0] : output.scriptData)); } + return new PaymentIntent.Output(output.amount, output.scriptData); } public boolean hasAmount() { @@ -92,24 +85,24 @@ public boolean hasAmount() { @NonNull @Override public String toString() { - return toString(MainNetParams.get()); + return toString(AddressNetwork.DASH_MAINNET); } - public String toString(NetworkParameters params) { + public String toString(AddressNetwork network) { final StringBuilder builder = new StringBuilder(); builder.append(getClass().getSimpleName()); builder.append('['); builder.append(hasAmount() ? amount.toPlainString() : "null"); builder.append(','); - if (ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) - builder.append(script.getToAddress(params)); - else if (ScriptPattern.isP2PK(script)) - builder.append(Constants.HEX.encode(ScriptPattern.extractKeyFromP2PK(script))); - else if (ScriptPattern.isSentToMultisig(script)) + if (Scripts.isP2PKH(scriptData) || Scripts.isP2SH(scriptData)) + builder.append(Scripts.addressOf(scriptData, network)); + else if (Scripts.isP2PK(scriptData)) + builder.append(Constants.HEX.encode(Scripts.extractKeyFromP2PK(scriptData))); + else if (Scripts.isMultisig(scriptData)) builder.append("multisig"); - else if (ScriptPattern.isOpReturn(script)) - builder.append(script); + else if (Scripts.isOpReturn(scriptData)) + builder.append(Constants.HEX.encode(scriptData)); else builder.append("unknown"); builder.append(']'); @@ -126,9 +119,8 @@ public int describeContents() { public void writeToParcel(final Parcel dest, final int flags) { dest.writeSerializable(amount); - final byte[] program = script.getProgram(); - dest.writeInt(program.length); - dest.writeByteArray(program); + dest.writeInt(scriptData.length); + dest.writeByteArray(scriptData); } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @@ -147,9 +139,8 @@ private Output(final Parcel in) { amount = (Coin) in.readSerializable(); final int programLength = in.readInt(); - final byte[] program = new byte[programLength]; - in.readByteArray(program); - script = new Script(program); + scriptData = new byte[programLength]; + in.readByteArray(scriptData); } } @@ -222,7 +213,7 @@ public PaymentIntent(@Nullable final Standard standard, @Nullable final String p } - private PaymentIntent(final Address address, @Nullable final String addressLabel) { + private PaymentIntent(final String address, @Nullable final String addressLabel) { this(null, null, null, buildSimplePayTo(Coin.ZERO, address), addressLabel, null, null, null, null, null, null); } @@ -232,23 +223,27 @@ public static PaymentIntent blank() { return new PaymentIntent(null, null, null, null, null, null, null, null, null, null, null); } - public static PaymentIntent fromAddress(final Address address, @Nullable final String addressLabel) { + /** Creates a payment intent for the given base58 address (network inferred from the version byte). */ + public static PaymentIntent fromAddress(final String address, @Nullable final String addressLabel) + throws AddressFormatException { return new PaymentIntent(address, addressLabel); } - public static PaymentIntent fromAddressWithIdentity(final Address address, @Nullable final String payeeUserId) { + public static PaymentIntent fromAddressWithIdentity(final String address, @Nullable final String payeeUserId) { return new PaymentIntent(null, null, null, buildSimplePayTo(Coin.ZERO, address), null, null, null, null, null, payeeUserId, null); } - public static PaymentIntent fromAddressWithIdentity(final Address address, @Nullable final String payeeUserId, Coin amount) { + public static PaymentIntent fromAddressWithIdentity(final String address, @Nullable final String payeeUserId, Coin amount) { return new PaymentIntent(null, null, null, buildSimplePayTo(amount, address), null, null, null, null, null, payeeUserId, null); } - public static PaymentIntent fromAddress(final String address, @Nullable final String addressLabel, NetworkParameters params) + /** Creates a payment intent for the given base58 address, validated against the given network. */ + public static PaymentIntent fromAddress(final String address, @Nullable final String addressLabel, AddressNetwork network) throws AddressFormatException { - return new PaymentIntent(Address.fromString(params, address), addressLabel); + Scripts.outputScriptForAddress(address, network); // validation + return new PaymentIntent(address, addressLabel); } public static PaymentIntent fromUserId(final String payeeUserId) { @@ -257,22 +252,23 @@ public static PaymentIntent fromUserId(final String payeeUserId) { } public static PaymentIntent from(final String address, @Nullable final String addressLabel, - @Nullable final Coin amount, NetworkParameters params) throws AddressFormatException { + @Nullable final Coin amount, AddressNetwork network) throws AddressFormatException { + Scripts.outputScriptForAddress(address, network); // validation return new PaymentIntent(null, null, null, - buildSimplePayTo(amount, Address.fromString(params, address)), addressLabel, null, + buildSimplePayTo(amount, address), addressLabel, null, null, null, null, null, null); } - public static PaymentIntent fromBitcoinUri(final BitcoinURI bitcoinUri) { - final Address address = bitcoinUri.getAddress(); - final Output[] outputs = address != null ? buildSimplePayTo(bitcoinUri.getAmount(), address) : null; - final String bluetoothMac = (String) bitcoinUri.getParameterByName(Bluetooth.MAC_URI_PARAM); - final String paymentRequestHashStr = (String) bitcoinUri.getParameterByName("h"); + public static PaymentIntent fromPaymentUri(final PaymentURI paymentUri) { + final String address = paymentUri.getAddress(); + final Output[] outputs = address != null ? buildSimplePayTo(paymentUri.getAmount(), address) : null; + final String bluetoothMac = (String) paymentUri.getParameterByName(Bluetooth.MAC_URI_PARAM); + final String paymentRequestHashStr = (String) paymentUri.getParameterByName("h"); final byte[] paymentRequestHash = paymentRequestHashStr != null ? base64UrlDecode(paymentRequestHashStr) : null; - final String dashPayUsername = bitcoinUri.getUser(); + final String dashPayUsername = paymentUri.getUser(); - return new PaymentIntent(PaymentIntent.Standard.BIP21, null, null, outputs, bitcoinUri.getLabel(), - bluetoothMac != null ? "bt:" + bluetoothMac : null, null, bitcoinUri.getPaymentRequestUrl(), + return new PaymentIntent(PaymentIntent.Standard.BIP21, null, null, outputs, paymentUri.getLabel(), + bluetoothMac != null ? "bt:" + bluetoothMac : null, null, paymentUri.getPaymentRequestUrl(), paymentRequestHash, null, dashPayUsername); } @@ -288,7 +284,7 @@ private static byte[] base64UrlDecode(final String encoded) { } public PaymentIntent mergeWithEditedValues(@Nullable final Coin editedAmount, - @Nullable final Address editedAddress) { + @Nullable final String editedAddress) { final Output[] outputs; if (hasOutputs()) { @@ -296,7 +292,7 @@ public PaymentIntent mergeWithEditedValues(@Nullable final Coin editedAmount, checkArgument(editedAmount != null); // put all coins on first output, skip the others - outputs = new Output[]{new Output(editedAmount, this.outputs[0].script)}; + outputs = new Output[]{new Output(editedAmount, this.outputs[0].scriptData)}; } else { // exact copy of outputs outputs = this.outputs; @@ -312,15 +308,8 @@ public PaymentIntent mergeWithEditedValues(@Nullable final Coin editedAmount, return new PaymentIntent(standard, payeeName, payeeVerifiedBy, outputs, memo, null, payeeData, null, null, null, null); } - public SendRequest toSendRequest(NetworkParameters params) { - final Transaction transaction = new Transaction(params); - for (final PaymentIntent.Output output : outputs) - transaction.addOutput(output.amount, output.script); - return SendRequest.forTx(transaction); - } - - private static Output[] buildSimplePayTo(final Coin amount, final Address address) { - return new Output[]{new Output(amount, ScriptBuilder.createOutputScript(address))}; + private static Output[] buildSimplePayTo(final Coin amount, final String address) { + return new Output[]{new Output(amount, Scripts.outputScriptForAddress(address))}; } public boolean hasPayee() { @@ -339,16 +328,17 @@ public boolean hasAddress() { if (outputs == null || outputs.length != 1) return false; - final Script script = outputs[0].script; - return script.isSentToAddress() || script.isPayToScriptHash() || script.isSentToRawPubKey(); + final byte[] script = outputs[0].scriptData; + return Scripts.isP2PKH(script) || Scripts.isP2SH(script) || Scripts.isP2PK(script); } - public Address getAddress(NetworkParameters params) { + /** The destination address (base58) on the given network. */ + public String getAddress(AddressNetwork network) { if (!hasAddress()) throw new IllegalStateException(); - final Script script = outputs[0].script; - return script.getToAddress(params, true); + final byte[] script = outputs[0].scriptData; + return Scripts.addressOf(script, network, true); } public boolean mayEditAddress() { @@ -429,13 +419,13 @@ public boolean isIdentityPaymentRequest() { * @param other payment intent that is checked if it extends this one * @return true if it extends */ - public boolean isExtendedBy(final PaymentIntent other, boolean ignoreDetails, NetworkParameters params) { + public boolean isExtendedBy(final PaymentIntent other, boolean ignoreDetails, AddressNetwork network) { // shortcut via hash if (standard == Standard.BIP21 && other.standard == Standard.BIP70) if (paymentRequestHash != null && Arrays.equals(paymentRequestHash, other.paymentRequestHash)) return true; - return ignoreDetails || (equalsAmount(other) && equalsAddress(other, params)); + return ignoreDetails || (equalsAmount(other) && equalsAddress(other, network)); } public boolean equalsAmount(final PaymentIntent other) { @@ -447,11 +437,11 @@ public boolean equalsAmount(final PaymentIntent other) { return true; } - public boolean equalsAddress(final PaymentIntent other, NetworkParameters params) { + public boolean equalsAddress(final PaymentIntent other, AddressNetwork network) { final boolean hasAddress = hasAddress(); if (hasAddress != other.hasAddress()) return false; - if (hasAddress && !getAddress(params).equals(other.getAddress(params))) + if (hasAddress && !getAddress(network).equals(other.getAddress(network))) return false; return true; } diff --git a/common/src/main/java/org/dash/wallet/common/data/PresentableTxMetadata.kt b/common/src/main/java/org/dash/wallet/common/data/PresentableTxMetadata.kt index 06d199a7fd..291f545b35 100644 --- a/common/src/main/java/org/dash/wallet/common/data/PresentableTxMetadata.kt +++ b/common/src/main/java/org/dash/wallet/common/data/PresentableTxMetadata.kt @@ -19,15 +19,42 @@ package org.dash.wallet.common.data import android.graphics.Bitmap import androidx.room.Ignore -import org.bitcoinj.core.Sha256Hash +import org.dash.wallet.common.data.TxId +import org.dash.wallet.common.data.entity.SwapOrder data class PresentableTxMetadata( - var txId: Sha256Hash, + var txId: TxId, var memo: String = "", var service: String? = null, - var customIconId: Sha256Hash? = null + var customIconId: TxId? = null ) { @Ignore var icon: Bitmap? = null @Ignore var title: String? = null -} + /** Present when this tx funded a DEX swap; drives the conversion row on the home screen. */ + @Ignore var swapOrder: SwapOrder? = null + + // The tx display cache diffs these objects to decide which rows to rebuild, so the + // @Ignore fields that affect rendering (title, swapOrder) must count in equality. + // icon stays excluded: bitmaps are re-decoded per emission and compare by identity, + // which would mark every icon'd row as changed on each emission. + override fun equals(other: Any?): Boolean { + return other is PresentableTxMetadata && + txId == other.txId && + memo == other.memo && + service == other.service && + customIconId == other.customIconId && + title == other.title && + swapOrder == other.swapOrder + } + + override fun hashCode(): Int { + var result = txId.hashCode() + result = 31 * result + memo.hashCode() + result = 31 * result + (service?.hashCode() ?: 0) + result = 31 * result + (customIconId?.hashCode() ?: 0) + result = 31 * result + (title?.hashCode() ?: 0) + result = 31 * result + (swapOrder?.hashCode() ?: 0) + return result + } +} diff --git a/common/src/main/java/org/dash/wallet/common/data/ServiceName.kt b/common/src/main/java/org/dash/wallet/common/data/ServiceName.kt index 37ca756bd5..dd1f5d7c1d 100644 --- a/common/src/main/java/org/dash/wallet/common/data/ServiceName.kt +++ b/common/src/main/java/org/dash/wallet/common/data/ServiceName.kt @@ -7,6 +7,7 @@ object ServiceName { const val CTXSpend = "ctxspend" const val PiggyCards = "piggycards" const val Maya = "maya" + const val Swapkit = "swapkit" const val Unknown = "unknown" fun isDashSpend(serviceName: String?) = serviceName == CTXSpend || serviceName == PiggyCards diff --git a/common/src/main/java/org/dash/wallet/common/transactions/ExactOutputsSelector.kt b/common/src/main/java/org/dash/wallet/common/data/SyncStage.kt similarity index 53% rename from common/src/main/java/org/dash/wallet/common/transactions/ExactOutputsSelector.kt rename to common/src/main/java/org/dash/wallet/common/data/SyncStage.kt index fcb21acbd2..cc1661418d 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/ExactOutputsSelector.kt +++ b/common/src/main/java/org/dash/wallet/common/data/SyncStage.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Dash Core Group. + * Copyright 2026 Dash Core Group. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,19 +15,17 @@ * along with this program. If not, see . */ -package org.dash.wallet.common.transactions +package org.dash.wallet.common.data -import org.bitcoinj.core.Coin -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.wallet.CoinSelection -import org.bitcoinj.wallet.CoinSelector - -class ExactOutputsSelector(private val outputs: List) : CoinSelector { - override fun select( - target: Coin, - candidates: MutableList - ): CoinSelection { - val value = Coin.valueOf(outputs.sumOf { it.value.value }) - return CoinSelection(value, outputs) - } -} \ No newline at end of file +/** + * Blockchain sync stage, independent of the underlying wallet library. + * The wallet module maps its sync engine's stages onto these values. + */ +enum class SyncStage { + OFFLINE, + HEADERS, + MNLIST, + PREBLOCKS, + BLOCKS, + COMPLETE +} diff --git a/common/src/main/java/org/dash/wallet/common/data/TxId.kt b/common/src/main/java/org/dash/wallet/common/data/TxId.kt new file mode 100644 index 0000000000..ff0092f92d --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/data/TxId.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.data + +/** + * A 32-byte hash id (transaction id, icon id, …), dashj-free. + * + * Mirrors the semantics of `org.bitcoinj.core.Sha256Hash` exactly — same wrapped byte order, + * same hex `toString`, same `hashCode` (last four bytes) — so Room BLOB persistence and any + * hash-keyed containers behave identically to the previous Sha256Hash-typed fields. + */ +class TxId(bytes: ByteArray) : Comparable { + + val bytes: ByteArray = bytes.copyOf() + + init { + require(bytes.size == LENGTH) { "wrong hash length: " + bytes.size } + } + + companion object { + const val LENGTH = 32 + + @JvmField + val ZERO_HASH = TxId(ByteArray(LENGTH)) + + /** Mirrors `Sha256Hash.wrap(bytes)`. */ + @JvmStatic + fun wrap(bytes: ByteArray) = TxId(bytes) + + /** Mirrors `Sha256Hash.wrap(hexString)`. */ + @JvmStatic + fun wrap(hex: String): TxId { + require(hex.length == LENGTH * 2) { "not a 32-byte hex string: $hex" } + return TxId(ByteArray(LENGTH) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }) + } + + /** Mirrors `Sha256Hash.of(contents)`: single SHA-256 of the input. */ + @JvmStatic + fun of(contents: ByteArray): TxId = + TxId(java.security.MessageDigest.getInstance("SHA-256").digest(contents)) + + /** Mirrors `Sha256Hash.of(file)`: single SHA-256 of the file's contents. */ + @JvmStatic + fun of(file: java.io.File): TxId = of(file.readBytes()) + } + + /** Hex representation, exactly `Sha256Hash.toString()`. */ + override fun toString(): String = bytes.joinToString("") { "%02x".format(it) } + + /** Base58 representation, exactly `Sha256Hash.toStringBase58()`. */ + fun toStringBase58(): String = org.dash.wallet.common.payments.parsers.Base58.encode(bytes) + + override fun equals(other: Any?): Boolean = other is TxId && bytes.contentEquals(other.bytes) + + /** Mirrors `Sha256Hash.hashCode()`: an int from the last four bytes. */ + override fun hashCode(): Int = + (bytes[LENGTH - 4].toInt() and 0xFF shl 24) or + (bytes[LENGTH - 3].toInt() and 0xFF shl 16) or + (bytes[LENGTH - 2].toInt() and 0xFF shl 8) or + (bytes[LENGTH - 1].toInt() and 0xFF) + + /** Mirrors `Sha256Hash.compareTo`: unsigned comparison from the last byte backwards. */ + override fun compareTo(other: TxId): Int { + for (i in LENGTH - 1 downTo 0) { + val thisByte = bytes[i].toInt() and 0xFF + val otherByte = other.bytes[i].toInt() and 0xFF + if (thisByte > otherByte) return 1 + if (thisByte < otherByte) return -1 + } + return 0 + } +} diff --git a/common/src/main/java/org/dash/wallet/common/data/WalletUIConfig.kt b/common/src/main/java/org/dash/wallet/common/data/WalletUIConfig.kt index 48394f4f56..b71cb4b728 100644 --- a/common/src/main/java/org/dash/wallet/common/data/WalletUIConfig.kt +++ b/common/src/main/java/org/dash/wallet/common/data/WalletUIConfig.kt @@ -65,7 +65,6 @@ open class WalletUIConfig @Inject constructor( val SELECTED_CURRENCY = stringPreferencesKey("exchange_currency") val EXCHANGE_CURRENCY_DETECTED = booleanPreferencesKey("exchange_currency_detected") val LAST_TOTAL_BALANCE = longPreferencesKey("last_total_balance") - val LAST_MIXED_BALANCE = longPreferencesKey("last_mixed_balance") val CUSTOMIZED_SHORTCUTS = stringPreferencesKey("customized_shortcuts") val IS_SHORTCUT_INFO_HIDDEN = booleanPreferencesKey("is_shortcut_info_hidden") } diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/BlockchainState.kt b/common/src/main/java/org/dash/wallet/common/data/entity/BlockchainState.kt index 782d42eebd..ef5e795240 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/BlockchainState.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/BlockchainState.kt @@ -20,7 +20,14 @@ package org.dash.wallet.common.data.entity import androidx.room.Entity import androidx.room.PrimaryKey import java.util.* -// TODO: chainlockHeight is not updated when chainlocks are received +// NOTE on [chainlockHeight]: it is a MONOTONIC LOWER BOUND on the network's +// best chainlocked height, not a live mirror of it. Pre-cutover the dashj +// writer refreshes it from `chainLockHandler.bestChainLockBlockHeight` only +// on sync-progress callbacks; post-cutover the Kotlin SDK writer advances it +// from the engine's `ChainLockProcessed` wallet events, which it only sees +// while the process is running. Read it as "everything at or below this +// height is PROVEN chainlocked" and treat anything above as unknown — never +// as "not chainlocked". // TODO: not updated on new blocks after sync has finished @Entity(tableName = "blockchain_state") data class BlockchainState(var bestChainDate: Date?, diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt b/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt index 1b928336e0..2927fbbcb2 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt @@ -23,7 +23,7 @@ import android.os.Parcelable import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey -import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.money.Fiat import org.dash.wallet.common.data.CurrencyInfo import java.lang.IllegalArgumentException import java.math.BigDecimal diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/GiftCard.kt b/common/src/main/java/org/dash/wallet/common/data/entity/GiftCard.kt index 149e441e49..9c2609b4da 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/GiftCard.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/GiftCard.kt @@ -20,11 +20,11 @@ package org.dash.wallet.common.data.entity import androidx.room.Entity import androidx.room.PrimaryKey import com.google.zxing.BarcodeFormat -import org.bitcoinj.core.Sha256Hash +import org.dash.wallet.common.data.TxId @Entity(tableName = "gift_cards", primaryKeys = ["txId", "index"]) data class GiftCard( - var txId: Sha256Hash, + var txId: TxId, var merchantName: String = "", var price: Double = 0.0, var number: String? = null, @@ -35,4 +35,58 @@ data class GiftCard( var note: String? = null, // holds order number var index: Int = 0, var redeemUrlChallenge: String? = null -) +) { + companion object { + /** + * Builds a GiftCard from a hex transaction id. + */ + fun fromHex( + txId: String, + merchantName: String = "", + price: Double = 0.0, + number: String? = null, + pin: String? = null, + barcodeValue: String? = null, + barcodeFormat: BarcodeFormat? = null, + merchantUrl: String? = null, + note: String? = null, + index: Int = 0, + redeemUrlChallenge: String? = null + ) = GiftCard( + TxId.wrap(txId), merchantName, price, number, pin, barcodeValue, + barcodeFormat, merchantUrl, note, index, redeemUrlChallenge + ) + } + + /** The transaction id as a hex string; dashj-free accessor. */ + val txIdHex: String get() = txId.toString() + + /** + * Neutral (dashj-free) variant of [copy]: duplicates the card (txId always preserved) + * with the given field overrides, for modules that must not depend on dashj. + */ + fun copyCard( + merchantName: String = this.merchantName, + price: Double = this.price, + number: String? = this.number, + pin: String? = this.pin, + barcodeValue: String? = this.barcodeValue, + barcodeFormat: BarcodeFormat? = this.barcodeFormat, + merchantUrl: String? = this.merchantUrl, + note: String? = this.note, + index: Int = this.index, + redeemUrlChallenge: String? = this.redeemUrlChallenge + ) = copy( + txId = txId, + merchantName = merchantName, + price = price, + number = number, + pin = pin, + barcodeValue = barcodeValue, + barcodeFormat = barcodeFormat, + merchantUrl = merchantUrl, + note = note, + index = index, + redeemUrlChallenge = redeemUrlChallenge + ) +} diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/IconBitmap.kt b/common/src/main/java/org/dash/wallet/common/data/entity/IconBitmap.kt index 7a957f365a..6f0f32c903 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/IconBitmap.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/IconBitmap.kt @@ -20,12 +20,12 @@ package org.dash.wallet.common.data.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import org.bitcoinj.core.Sha256Hash +import org.dash.wallet.common.data.TxId @Entity(tableName = "icon_bitmaps") class IconBitmap( @PrimaryKey - var id: Sha256Hash, + var id: TxId, @ColumnInfo(typeAffinity = ColumnInfo.BLOB) val imageData: ByteArray, val originalUrl: String, diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/SwapOrder.kt b/common/src/main/java/org/dash/wallet/common/data/entity/SwapOrder.kt new file mode 100644 index 0000000000..4449f3174b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/data/entity/SwapOrder.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.dash.wallet.common.data.TxId + +/** Mirrors the SwapKit /track `status` values. */ +enum class SwapOrderStatus { + NOT_STARTED, + PENDING, + SWAPPING, + COMPLETED, + REFUNDED, + FAILED, + UNKNOWN; + + val isTerminal: Boolean + get() = this == COMPLETED || this == REFUNDED || this == FAILED + + companion object { + val active = listOf(NOT_STARTED, PENDING, SWAPPING, UNKNOWN) + + fun fromTrackStatus(value: String?): SwapOrderStatus = + entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: UNKNOWN + } +} + +/** + * A DEX sell swap (DASH -> other asset) keyed by the DASH transaction that funded it. + * Amounts are decimal strings denominated in [toAsset]; [timestamp], [finalisedAt] and + * [lastChecked] are milliseconds since epoch. + */ +@Entity(tableName = "swap_orders") +data class SwapOrder( + @PrimaryKey val txId: TxId, + val service: String, + val provider: String? = null, + val fromAsset: String, + val toAsset: String, + val toAddress: String, + /** Inbound address the DASH was sent to (Maya vault / NEAR deposit channel). NEAR + * Intents swaps are tracked by this address when the hash lookup fails. */ + val depositAddress: String? = null, + val expectedToAmount: String? = null, + val actualToAmount: String? = null, + val status: SwapOrderStatus = SwapOrderStatus.PENDING, + val outboundTxHash: String? = null, + val timestamp: Long, + val finalisedAt: Long? = null, + val lastChecked: Long = 0 +) diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/TransactionMetadata.kt b/common/src/main/java/org/dash/wallet/common/data/entity/TransactionMetadata.kt index 53f3084777..18e1964063 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/TransactionMetadata.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/TransactionMetadata.kt @@ -19,14 +19,14 @@ package org.dash.wallet.common.data.entity import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Sha256Hash +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.data.TxId import org.dash.wallet.common.data.TaxCategory import org.dash.wallet.common.transactions.TransactionCategory @Entity(tableName = "transaction_metadata") data class TransactionMetadata( - @PrimaryKey var txId: Sha256Hash, + @PrimaryKey var txId: TxId, var timestamp: Long, var value: Coin, var type: TransactionCategory, @@ -35,7 +35,7 @@ data class TransactionMetadata( var rate: String? = null, var memo: String = "", var service: String? = null, - var customIconId: Sha256Hash? = null + var customIconId: TxId? = null ) { @Ignore val canToggle = type.canToggle @@ -46,6 +46,9 @@ data class TransactionMetadata( @Ignore val defaultTaxCategory = TaxCategory.getDefault(value.isPositive, isTransfer) + /** [customIconId] as a hex string; dashj-free accessor. */ + val customIconIdHex: String? get() = customIconId?.toString() + fun isNotEmpty(): Boolean { return timestamp != 0L || taxCategory != null || memo.isNotEmpty() || currencyCode != null || rate != null || service != null || customIconId != null diff --git a/common/src/main/java/org/dash/wallet/common/money/AddressValidation.kt b/common/src/main/java/org/dash/wallet/common/money/AddressValidation.kt new file mode 100644 index 0000000000..68eda5a6d8 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/AddressValidation.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +import org.dash.wallet.common.payments.parsers.AddressFormatException +import org.dash.wallet.common.payments.parsers.AddressNetwork + +/** + * Network identifiers, decoupled from dashj's NetworkParameters constants + * (the values are the same strings dashj uses). + */ +object DashNetworks { + const val MAINNET = AddressNetwork.ID_MAINNET + const val TESTNET = AddressNetwork.ID_TESTNET +} + +/** + * Base58 Dash address validation for modules that must not depend on dashj. + * Accepted addresses are exactly those dashj's `Address.getParametersFromAddress` accepts. + */ +object DashAddressValidator { + + /** True if [address] parses as a Dash address on any network. */ + fun isValid(address: String): Boolean = networkIdOrNull(address) != null + + /** True if [address] parses as a Dash address on the network identified by [networkId] (see [DashNetworks]). */ + fun isValid(address: String, networkId: String): Boolean = networkIdOrNull(address) == networkId + + /** The network id of [address] (see [DashNetworks]), or null if it is not a valid address. */ + fun networkIdOrNull(address: String): String? { + return try { + AddressNetwork.fromDashAddress(address).id + } catch (e: AddressFormatException) { + null + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/money/Coin.java b/common/src/main/java/org/dash/wallet/common/money/Coin.java new file mode 100644 index 0000000000..3b2efd5e72 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/Coin.java @@ -0,0 +1,351 @@ +/* + * Copyright 2011 Google Inc. + * Copyright 2015 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.money; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.math.LongMath; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; + +/** + * Represents a monetary Dash value. This class is immutable. + * + *

Self-contained port of {@code org.bitcoinj.core.Coin} (dashj 22.0.3) so that modules + * depending on {@code common} need no dashj on their classpath. Behavior is identical.

+ */ +public final class Coin implements Monetary, Comparable, Serializable { + + /** + * Number of decimals for one Dash. This constant is useful for quick adapting to other coins because a lot of + * constants derive from it. + */ + public static final int SMALLEST_UNIT_EXPONENT = 8; + + /** + * The number of duffs equal to one Dash. + */ + private static final long COIN_VALUE = LongMath.pow(10, SMALLEST_UNIT_EXPONENT); + + /** + * Zero Dash. + */ + public static final Coin ZERO = Coin.valueOf(0); + + /** + * One Dash. + */ + public static final Coin COIN = Coin.valueOf(COIN_VALUE); + + /** + * 0.01 Dash. This unit is not really used much. + */ + public static final Coin CENT = COIN.divide(100); + + /** + * 0.001 Dash, also known as 1 mDASH. + */ + public static final Coin MILLICOIN = COIN.divide(1000); + + /** + * 0.000001 Dash, also known as 1 µDASH or 1 uDASH. + */ + public static final Coin MICROCOIN = MILLICOIN.divide(1000); + + /** + * A duff is the smallest unit that can be transferred. 100 million of them fit into a Dash. + */ + public static final Coin SATOSHI = Coin.valueOf(1); + + public static final Coin FIFTY_COINS = COIN.multiply(50); + + /** + * Represents a monetary value of minus one duff. + */ + public static final Coin NEGATIVE_SATOSHI = Coin.valueOf(-1); + + /** + * The number of duffs of this monetary value. + */ + public final long value; + + private Coin(final long duffs) { + this.value = duffs; + } + + public static Coin valueOf(final long duffs) { + return new Coin(duffs); + } + + @Override + public int smallestUnitExponent() { + return SMALLEST_UNIT_EXPONENT; + } + + /** + * Returns the number of duffs of this monetary value. + */ + @Override + public long getValue() { + return value; + } + + /** + * Convert an amount expressed in the way humans are used to into duffs. + */ + public static Coin valueOf(final int coins, final int cents) { + checkArgument(cents < 100); + checkArgument(cents >= 0); + checkArgument(coins >= 0); + final Coin coin = COIN.multiply(coins).add(CENT.multiply(cents)); + return coin; + } + + /** + * Parses an amount expressed in the way humans are used to. + * + *

This takes string in a format understood by {@link BigDecimal#BigDecimal(String)}, for example "0", "1", "0.10", + * "1.23E3", "1234.5E-5".

+ * + * @throws IllegalArgumentException if you try to specify fractional duffs, or a value out of range. + */ + public static Coin parseCoin(final String str) { + try { + long duffs = new BigDecimal(str).movePointRight(SMALLEST_UNIT_EXPONENT).longValueExact(); + return Coin.valueOf(duffs); + } catch (ArithmeticException e) { + // dashj retries with a value rounded down to 8 significant digits before giving up + try { + long duffs = new BigDecimal(str) + .round(new MathContext(SMALLEST_UNIT_EXPONENT, RoundingMode.DOWN)) + .movePointRight(SMALLEST_UNIT_EXPONENT) + .longValueExact(); + return Coin.valueOf(duffs); + } catch (ArithmeticException e2) { + throw new IllegalArgumentException(e2); + } + } + } + + /** + * Parses an amount expressed in the way humans are used to. The amount is cut to duff precision. + * + *

This takes string in a format understood by {@link BigDecimal#BigDecimal(String)}, for example "0", "1", "0.10", + * "1.23E3", "1234.5E-5".

+ * + * @throws IllegalArgumentException if you try to specify a value out of range. + */ + public static Coin parseCoinInexact(final String str) { + try { + long duffs = new BigDecimal(str).movePointRight(SMALLEST_UNIT_EXPONENT).longValue(); + return Coin.valueOf(duffs); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(e); // Repackage exception to honor method contract + } + } + + public Coin add(final Coin value) { + return new Coin(LongMath.checkedAdd(this.value, value.value)); + } + + /** Alias for add */ + public Coin plus(final Coin value) { + return add(value); + } + + public Coin subtract(final Coin value) { + return new Coin(LongMath.checkedSubtract(this.value, value.value)); + } + + /** Alias for subtract */ + public Coin minus(final Coin value) { + return subtract(value); + } + + public Coin multiply(final long factor) { + return new Coin(LongMath.checkedMultiply(this.value, factor)); + } + + /** Alias for multiply */ + public Coin times(final long factor) { + return multiply(factor); + } + + /** Alias for multiply */ + public Coin times(final int factor) { + return multiply(factor); + } + + public Coin divide(final long divisor) { + return new Coin(this.value / divisor); + } + + /** Alias for divide */ + public Coin div(final long divisor) { + return divide(divisor); + } + + /** Alias for divide */ + public Coin div(final int divisor) { + return divide(divisor); + } + + public Coin[] divideAndRemainder(final long divisor) { + return new Coin[] { new Coin(this.value / divisor), new Coin(this.value % divisor) }; + } + + public long divide(final Coin divisor) { + return this.value / divisor.value; + } + + /** + * Returns true if and only if this instance represents a monetary value greater than zero, + * otherwise false. + */ + public boolean isPositive() { + return signum() == 1; + } + + /** + * Returns true if and only if this instance represents a monetary value less than zero, + * otherwise false. + */ + public boolean isNegative() { + return signum() == -1; + } + + /** + * Returns true if and only if this instance represents zero monetary value, + * otherwise false. + */ + public boolean isZero() { + return signum() == 0; + } + + /** + * Returns true if the monetary value represented by this instance is greater than that + * of the given other Coin, otherwise false. + */ + public boolean isGreaterThan(Coin other) { + return compareTo(other) > 0; + } + + /** + * Returns true if the monetary value represented by this instance is less than that + * of the given other Coin, otherwise false. + */ + public boolean isLessThan(Coin other) { + return compareTo(other) < 0; + } + + /** + * Returns true if the monetary value represented by this instance is greater than or equal to that + * of the given other Coin, otherwise false. + */ + public boolean isGreaterThanOrEqualTo(Coin other) { + return compareTo(other) >= 0; + } + + /** + * Returns true if the monetary value represented by this instance is less than or equal to that + * of the given other Coin, otherwise false. + */ + public boolean isLessThanOrEqualTo(Coin other) { + return compareTo(other) <= 0; + } + + public Coin shiftLeft(final int n) { + return new Coin(this.value << n); + } + + public Coin shiftRight(final int n) { + return new Coin(this.value >> n); + } + + @Override + public int signum() { + if (this.value == 0) + return 0; + return this.value < 0 ? -1 : 1; + } + + public Coin negate() { + return new Coin(-this.value); + } + + /** + * Returns the number of duffs of this monetary value. It's deprecated in favour of accessing {@link #value} + * directly. + */ + public long longValue() { + return this.value; + } + + private static final MonetaryFormat FRIENDLY_FORMAT = MonetaryFormat.BTC.minDecimals(2) + .repeatOptionalDecimals(1, 6).postfixCode(); + + /** + * Returns the value as a 0.12 type string. More digits after the decimal place will be used + * if necessary, but two will always be present. + */ + public String toFriendlyString() { + return FRIENDLY_FORMAT.format(this).toString(); + } + + private static final MonetaryFormat PLAIN_FORMAT = MonetaryFormat.BTC.minDecimals(0) + .repeatOptionalDecimals(1, 8).noCode(); + + /** + *

+ * Returns the value as a plain string denominated in DASH. + * The result is unformatted with no trailing zeroes. + * For instance, a value of 150000 duffs gives an output string of "0.0015" DASH + *

+ */ + public String toPlainString() { + return PLAIN_FORMAT.format(this).toString(); + } + + @Override + public String toString() { + return Long.toString(value); + } + + @Override + public boolean equals(final Object o) { + if (o == this) + return true; + if (o == null || o.getClass() != getClass()) + return false; + final Coin other = (Coin) o; + return this.value == other.value; + } + + @Override + public int hashCode() { + return (int) this.value; + } + + @Override + public int compareTo(final Coin other) { + return Long.compare(this.value, other.value); + } +} diff --git a/common/src/main/java/org/dash/wallet/common/money/Dash.kt b/common/src/main/java/org/dash/wallet/common/money/Dash.kt new file mode 100644 index 0000000000..0a1c22c913 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/Dash.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +import java.math.BigDecimal + +/** + * A Dash amount in duffs (satoshis), independent of the underlying wallet library. + * + * The API mirrors dashj's `Coin` (and delegates to the self-contained [Coin] port internally) + * so behavior — parsing, formatting, arithmetic overflow — is identical, but consumers of this + * type never see dashj on their classpath. + */ +@JvmInline +value class Dash(val duffs: Long) : Comparable { + + companion object { + val ZERO = Dash(0) + val COIN = Dash(Coin.COIN.value) + + fun valueOf(duffs: Long) = Dash(duffs) + fun valueOf(coins: Int, cents: Int) = Dash(Coin.valueOf(coins, cents).value) + + /** Mirrors `Coin.parseCoin`: parses a decimal Dash amount, throws [IllegalArgumentException] on overflow/precision. */ + fun parse(str: String) = Dash(Coin.parseCoin(str).value) + } + + private val coin: Coin get() = Coin.valueOf(duffs) + + fun add(value: Dash) = Dash(coin.add(Coin.valueOf(value.duffs)).value) + operator fun plus(value: Dash) = add(value) + fun subtract(value: Dash) = Dash(coin.subtract(Coin.valueOf(value.duffs)).value) + operator fun minus(value: Dash) = subtract(value) + fun multiply(factor: Long) = Dash(coin.multiply(factor).value) + operator fun times(factor: Long) = multiply(factor) + fun div(divisor: Long) = Dash(coin.div(divisor).value) + fun divide(divisor: Dash): Long = coin.divide(Coin.valueOf(divisor.duffs)) + + val isZero: Boolean get() = duffs == 0L + val isPositive: Boolean get() = duffs > 0L + val isNegative: Boolean get() = duffs < 0L + fun isGreaterThan(other: Dash) = duffs > other.duffs + fun isLessThan(other: Dash) = duffs < other.duffs + override fun compareTo(other: Dash): Int = duffs.compareTo(other.duffs) + + /** Mirrors `Coin.toPlainString`: decimal representation without a currency code. */ + fun toPlainString(): String = coin.toPlainString() + + /** Mirrors `Coin.toFriendlyString`: denominated representation with a currency code. */ + fun toFriendlyString(): String = coin.toFriendlyString() + + fun toBigDecimal(): BigDecimal = BigDecimal(duffs).movePointLeft(8) + + override fun toString(): String = toPlainString() +} diff --git a/common/src/main/java/org/dash/wallet/common/money/ExchangeRate.java b/common/src/main/java/org/dash/wallet/common/money/ExchangeRate.java new file mode 100644 index 0000000000..45d3a4e01b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/ExchangeRate.java @@ -0,0 +1,96 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.money; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Objects; + +/** + * An exchange rate is expressed as a ratio of a {@link Coin} and a {@link Fiat} amount. + * + *

Self-contained port of {@code org.bitcoinj.utils.ExchangeRate} (dashj 22.0.3) so that modules + * depending on {@code common} need no dashj on their classpath. Behavior is identical.

+ */ +public class ExchangeRate implements Serializable { + + public final Coin coin; + public final Fiat fiat; + + /** Construct exchange rate. This amount of coin is worth that amount of fiat. */ + public ExchangeRate(Coin coin, Fiat fiat) { + checkArgument(coin.isPositive()); + checkArgument(fiat.isPositive()); + checkArgument(fiat.currencyCode != null, "currency code required"); + this.coin = coin; + this.fiat = fiat; + } + + /** Construct exchange rate. One coin is worth this amount of fiat. */ + public ExchangeRate(Fiat fiat) { + this(Coin.COIN, fiat); + } + + /** + * Convert a coin amount to a fiat amount using this exchange rate. + * @throws ArithmeticException if the converted fiat amount is too high or too low. + */ + public Fiat coinToFiat(Coin convertCoin) { + // Use BigInteger because it's much easier to maintain full precision without overflowing. + final BigInteger converted = BigInteger.valueOf(convertCoin.value).multiply(BigInteger.valueOf(fiat.value)) + .divide(BigInteger.valueOf(coin.value)); + if (converted.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 + || converted.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) + throw new ArithmeticException("Overflow"); + return Fiat.valueOf(fiat.currencyCode, converted.longValue()); + } + + /** + * Convert a fiat amount to a coin amount using this exchange rate. + * @throws ArithmeticException if the converted coin amount is too high or too low. + */ + public Coin fiatToCoin(Fiat convertFiat) { + checkArgument(convertFiat.currencyCode.equals(fiat.currencyCode), "Currency mismatch: %s vs %s", + convertFiat.currencyCode, fiat.currencyCode); + // Use BigInteger because it's much easier to maintain full precision without overflowing. + final BigInteger converted = BigInteger.valueOf(convertFiat.value).multiply(BigInteger.valueOf(coin.value)) + .divide(BigInteger.valueOf(fiat.value)); + if (converted.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 + || converted.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) + throw new ArithmeticException("Overflow"); + try { + return Coin.valueOf(converted.longValue()); + } catch (IllegalArgumentException x) { + throw new ArithmeticException("Overflow: " + x.getMessage()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final ExchangeRate other = (ExchangeRate) o; + return Objects.equals(this.coin, other.coin) && Objects.equals(this.fiat, other.fiat); + } + + @Override + public int hashCode() { + return Objects.hash(coin, fiat); + } +} diff --git a/common/src/main/java/org/dash/wallet/common/money/Fiat.java b/common/src/main/java/org/dash/wallet/common/money/Fiat.java new file mode 100644 index 0000000000..2569f17be3 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/Fiat.java @@ -0,0 +1,242 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.money; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.math.LongMath; + +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * Represents a monetary fiat value. It was decided to not fold this into {@link Coin} because of type + * safety. Volatile fiat values should not be blindly added to Dash values, be stored in wallets, etc. + * + *

Self-contained port of {@code org.bitcoinj.utils.Fiat} (dashj 22.0.3, which uses a smallest-unit + * exponent of 8 unlike upstream bitcoinj's 4) so that modules depending on {@code common} need no + * dashj on their classpath. Behavior is identical.

+ */ +public final class Fiat implements Monetary, Comparable, Serializable { + + /** + * The absolute value of exponent of the value of a "smallest unit" in scientific notation. We picked 8 rather than + * 4 (as in upstream bitcoinj), because Dash amounts can be converted from tiny fractions of a fiat unit. + */ + public static final int SMALLEST_UNIT_EXPONENT = 8; + + /** + * The number of smallest units of this monetary value. + */ + public final long value; + public final String currencyCode; + + private Fiat(final String currencyCode, final long value) { + this.value = value; + this.currencyCode = currencyCode; + } + + public static Fiat valueOf(final String currencyCode, final long value) { + return new Fiat(currencyCode, value); + } + + @Override + public int smallestUnitExponent() { + return SMALLEST_UNIT_EXPONENT; + } + + /** + * Returns the number of "smallest units" of this monetary value. + */ + @Override + public long getValue() { + return value; + } + + public String getCurrencyCode() { + return currencyCode; + } + + /** + * Parses an amount expressed in the way humans are used to. + * + *

This takes string in a format understood by {@link BigDecimal#BigDecimal(String)}, for example "0", "1", "0.10", + * "1.23E3", "1234.5E-5".

+ * + * @throws IllegalArgumentException + * if you try to specify more than the smallest-unit precision, or a value out of range. + */ + public static Fiat parseFiat(final String currencyCode, final String str) { + try { + long val = new BigDecimal(str).movePointRight(SMALLEST_UNIT_EXPONENT).longValueExact(); + return Fiat.valueOf(currencyCode, val); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Parses an amount expressed in the way humans are used to. The amount is cut to the smallest-unit precision. + * + *

This takes string in a format understood by {@link BigDecimal#BigDecimal(String)}, for example "0", "1", "0.10", + * "1.23E3", "1234.5E-5".

+ * + * @throws IllegalArgumentException if you try to specify a value out of range. + */ + public static Fiat parseFiatInexact(final String currencyCode, final String str) { + try { + long val = new BigDecimal(str).movePointRight(SMALLEST_UNIT_EXPONENT).longValue(); + return Fiat.valueOf(currencyCode, val); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(e); + } + } + + public Fiat add(final Fiat value) { + checkArgument(value.currencyCode.equals(currencyCode)); + return new Fiat(currencyCode, LongMath.checkedAdd(this.value, value.value)); + } + + public Fiat subtract(final Fiat value) { + checkArgument(value.currencyCode.equals(currencyCode)); + return new Fiat(currencyCode, LongMath.checkedSubtract(this.value, value.value)); + } + + public Fiat multiply(final long factor) { + return new Fiat(currencyCode, LongMath.checkedMultiply(this.value, factor)); + } + + public Fiat divide(final long divisor) { + return new Fiat(currencyCode, this.value / divisor); + } + + public Fiat[] divideAndRemainder(final long divisor) { + return new Fiat[] { new Fiat(currencyCode, this.value / divisor), new Fiat(currencyCode, this.value % divisor) }; + } + + public long divide(final Fiat divisor) { + checkArgument(divisor.currencyCode.equals(currencyCode)); + return this.value / divisor.value; + } + + /** + * Returns true if and only if this instance represents a monetary value greater than zero, otherwise false. + */ + public boolean isPositive() { + return signum() == 1; + } + + /** + * Returns true if and only if this instance represents a monetary value less than zero, otherwise false. + */ + public boolean isNegative() { + return signum() == -1; + } + + /** + * Returns true if and only if this instance represents zero monetary value, otherwise false. + */ + public boolean isZero() { + return signum() == 0; + } + + /** + * Returns true if the monetary value represented by this instance is greater than that of the given other Fiat, + * otherwise false. + */ + public boolean isGreaterThan(Fiat other) { + return compareTo(other) > 0; + } + + /** + * Returns true if the monetary value represented by this instance is less than that of the given other Fiat, + * otherwise false. + */ + public boolean isLessThan(Fiat other) { + return compareTo(other) < 0; + } + + @Override + public int signum() { + if (this.value == 0) + return 0; + return this.value < 0 ? -1 : 1; + } + + public Fiat negate() { + return new Fiat(currencyCode, -this.value); + } + + /** + * Returns the number of "smallest units" of this monetary value. It's deprecated in favour of accessing + * {@link #value} directly. + */ + public long longValue() { + return this.value; + } + + private static final MonetaryFormat FRIENDLY_FORMAT = MonetaryFormat.FIAT.postfixCode(); + + /** + * Returns the value as a 0.12 type string. More digits after the decimal place will be used if necessary, but two + * will always be present. + */ + public String toFriendlyString() { + return FRIENDLY_FORMAT.code(0, currencyCode).format(this).toString(); + } + + private static final MonetaryFormat PLAIN_FORMAT = MonetaryFormat.FIAT.minDecimals(0) + .repeatOptionalDecimals(1, 4).noCode(); + + /** + *

+ * Returns the value as a plain string. The result is unformatted with no trailing zeroes. For + * instance, an amount of 0.15 would be represented as "0.15". + *

+ */ + public String toPlainString() { + return PLAIN_FORMAT.format(this).toString(); + } + + @Override + public String toString() { + return Long.toString(value); + } + + @Override + public boolean equals(final Object o) { + if (o == this) + return true; + if (o == null || o.getClass() != getClass()) + return false; + final Fiat other = (Fiat) o; + return this.value == other.value && this.currencyCode.equals(other.currencyCode); + } + + @Override + public int hashCode() { + // Matches dashj 22.0.3: Objects.hash(value, currencyCode). + return java.util.Objects.hash(this.value, this.currencyCode); + } + + @Override + public int compareTo(final Fiat other) { + if (!this.currencyCode.equals(other.currencyCode)) + return this.currencyCode.compareTo(other.currencyCode); + return Long.compare(this.value, other.value); + } +} diff --git a/common/src/main/java/org/dash/wallet/common/money/FiatValue.kt b/common/src/main/java/org/dash/wallet/common/money/FiatValue.kt new file mode 100644 index 0000000000..a361cf06f3 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/FiatValue.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +import java.math.BigDecimal + +/** + * A fiat monetary amount ([value] is in the same smallest unit as dashj's `Fiat` — 1E-8 — + * and delegates to the self-contained [Fiat] port internally, so parsing and formatting + * behavior is identical). Feature/integration modules use this instead of Fiat. + */ +data class FiatValue(val currencyCode: String, val value: Long) : Comparable { + + companion object { + const val SMALLEST_UNIT_EXPONENT = Fiat.SMALLEST_UNIT_EXPONENT + + fun valueOf(currencyCode: String, value: Long) = FiatValue(currencyCode, value) + + /** Mirrors [Fiat.parseFiat]: throws [IllegalArgumentException] on overflow/precision. */ + fun parseFiat(currencyCode: String, str: String): FiatValue { + val fiat = Fiat.parseFiat(currencyCode, str) + return FiatValue(fiat.currencyCode, fiat.value) + } + + /** Mirrors [Fiat.parseFiatInexact]: rounds instead of throwing on excess precision. */ + fun parseFiatInexact(currencyCode: String, str: String): FiatValue { + val fiat = Fiat.parseFiatInexact(currencyCode, str) + return FiatValue(fiat.currencyCode, fiat.value) + } + + fun zero(currencyCode: String) = FiatValue(currencyCode, 0) + } + + private val fiat: Fiat get() = Fiat.valueOf(currencyCode, value) + + fun add(other: FiatValue) = FiatValue(currencyCode, fiat.add(Fiat.valueOf(other.currencyCode, other.value)).value) + operator fun plus(other: FiatValue) = add(other) + fun subtract(other: FiatValue) = + FiatValue(currencyCode, fiat.subtract(Fiat.valueOf(other.currencyCode, other.value)).value) + operator fun minus(other: FiatValue) = subtract(other) + + val isZero: Boolean get() = value == 0L + val isPositive: Boolean get() = value > 0L + val isNegative: Boolean get() = value < 0L + fun isGreaterThan(other: FiatValue) = value > other.value + fun isLessThan(other: FiatValue) = value < other.value + override fun compareTo(other: FiatValue): Int = value.compareTo(other.value) + + fun toPlainString(): String = fiat.toPlainString() + fun toFriendlyString(): String = fiat.toFriendlyString() + fun toBigDecimal(): BigDecimal = BigDecimal(value).movePointLeft(SMALLEST_UNIT_EXPONENT) + + override fun toString(): String = toPlainString() +} diff --git a/common/src/main/java/org/dash/wallet/common/money/Monetary.java b/common/src/main/java/org/dash/wallet/common/money/Monetary.java new file mode 100644 index 0000000000..82f4cc2c15 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/Monetary.java @@ -0,0 +1,41 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.money; + +import java.io.Serializable; + +/** + * Classes implementing this interface represent a monetary value, such as a Dash or fiat amount. + * + *

Self-contained port of {@code org.bitcoinj.core.Monetary} (dashj 22.0.3) so that modules + * depending on {@code common} need no dashj on their classpath. Behavior is identical.

+ */ +public interface Monetary extends Serializable { + + /** + * Returns the absolute value of exponent of the value of a "smallest unit" in scientific notation. For Dash, a + * duff is worth 1E-8 so this would be 8. + */ + int smallestUnitExponent(); + + /** + * Returns the number of "smallest units" of this monetary value. + */ + long getValue(); + + int signum(); +} diff --git a/common/src/main/java/org/dash/wallet/common/money/MonetaryFormat.java b/common/src/main/java/org/dash/wallet/common/money/MonetaryFormat.java new file mode 100644 index 0000000000..5c7e7c0039 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/MonetaryFormat.java @@ -0,0 +1,524 @@ +/* + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.money; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.math.LongMath; + +import java.math.RoundingMode; +import java.text.DecimalFormatSymbols; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * Utility for formatting and parsing coin values to and from human readable form. + * + *

Self-contained port of {@code org.bitcoinj.utils.MonetaryFormat} (dashj 22.0.3, including + * the dashj-specific grouping-separator support and DASH currency codes) so that modules + * depending on {@code common} need no dashj on their classpath. Formatting output and parsing + * behavior are byte-identical — see the parity unit tests in the wallet module.

+ * + *

MonetaryFormat instances are immutable. Invoking a configuration method has no effect on the receiving instance; + * you must store and use the new instance it returns, instead. Instances are thread safe.

+ */ +public final class MonetaryFormat { + + /** Standard format for the DASH denomination. */ + public static final MonetaryFormat BTC = new MonetaryFormat().shift(0).minDecimals(2).repeatOptionalDecimals(2, 3); + /** Standard format for the mDASH denomination. */ + public static final MonetaryFormat MBTC = new MonetaryFormat().shift(3).minDecimals(2).optionalDecimals(2); + /** Standard format for the µDASH denomination. */ + public static final MonetaryFormat UBTC = new MonetaryFormat().shift(6).minDecimals(0).optionalDecimals(2); + /** Standard format for fiat amounts. */ + public static final MonetaryFormat FIAT = new MonetaryFormat().shift(0).minDecimals(2).repeatOptionalDecimals(2, 1); + /** Currency code for base 1 Dash. */ + public static final String CODE_BTC = "DASH"; + /** Currency code for base 1/1000 Dash. */ + public static final String CODE_MBTC = "mDASH"; + /** Currency code for base 1/1000000 Dash. */ + public static final String CODE_UBTC = "µDASH"; + /** Currency symbol for base 1 Dash. */ + public static final String SYMBOL_BTC = "Ð"; + /** Currency symbol for base 1/1000 Dash. */ + public static final String SYMBOL_MBTC = "mÐ"; + /** Currency symbol for base 1/1000000 Dash. */ + public static final String SYMBOL_UBTC = "µÐ"; + + public static final int MAX_DECIMALS = 8; + + private final Locale locale; + private final char negativeSign; + private final char positiveSign; + private final char zeroDigit; + private final char decimalMark; + private final boolean showGroupingSeparator; + private final int minDecimals; + private final List decimalGroups; + private final int shift; + private final RoundingMode roundingMode; + private final String[] codes; + private final char codeSeparator; + private final boolean codePrefixed; + + private static final String DECIMALS_PADDING = "0000000000000000"; // a few more than necessary for Dash + + /** + * Set character to prefix negative values. + */ + public MonetaryFormat negativeSign(char negativeSign) { + checkArgument(!Character.isDigit(negativeSign)); + checkArgument(negativeSign > 0); + if (negativeSign == this.negativeSign) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set character to prefix positive values. A zero value means no sign is used in this case. For parsing, a missing + * sign will always be interpreted as if the positive sign was used. + */ + public MonetaryFormat positiveSign(char positiveSign) { + checkArgument(!Character.isDigit(positiveSign)); + if (positiveSign == this.positiveSign) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set character range to use for representing digits. It starts with the specified character representing zero. + */ + public MonetaryFormat digits(char zeroDigit) { + if (zeroDigit == this.zeroDigit) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set character to use as the decimal mark. If the formatted value does not have any decimals, no decimal mark is + * used either. + */ + public MonetaryFormat decimalMark(char decimalMark) { + checkArgument(!Character.isDigit(decimalMark)); + checkArgument(decimalMark > 0); + if (decimalMark == this.decimalMark) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set minimum number of decimals to use for formatting. If the value precision exceeds all decimals specified + * (including additional decimals specified by {@link #optionalDecimals(int...)} or + * {@link #repeatOptionalDecimals(int, int)}), the value will be rounded. This configuration is not relevant for + * parsing. + */ + public MonetaryFormat minDecimals(int minDecimals) { + if (minDecimals == this.minDecimals) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + *

+ * Set additional groups of decimals to use for formatting, e.g. 2 - 2 - 2 for 1.00 00 00. If the value precision + * exceeds all decimals specified (including minimum decimals), the value will be rounded. This configuration is not + * relevant for parsing. + *

+ * + *

+ * For example, if you pass {@code 4,2} it will add four decimals to your formatted string if needed, and then add + * another two decimals if needed. At this point, rather than adding further decimals the value will be rounded. + *

+ * + * @param groups + * any number numbers of decimals, one for each group + */ + public MonetaryFormat optionalDecimals(int... groups) { + List decimalGroups = new ArrayList<>(groups.length); + for (int group : groups) + decimalGroups.add(group); + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + *

+ * Set repeated additional groups of decimals to use for formatting, e.g. 1 - 1 - 1 for 1.0 0 0. If the value + * precision exceeds all decimals specified (including minimum decimals), the value will be rounded. This + * configuration is not relevant for parsing. + *

+ * + *

+ * For example, if you pass {@code 1,8} it will up to eight decimals to your formatted string if needed. After + * these have been used up, rather than adding further decimals the value will be rounded. + *

+ * + * @param decimals + * value of the group to be repeated + * @param repetitions + * number of repetitions + */ + public MonetaryFormat repeatOptionalDecimals(int decimals, int repetitions) { + checkArgument(repetitions >= 0); + List decimalGroups = new ArrayList<>(repetitions); + for (int i = 0; i < repetitions; i++) + decimalGroups.add(decimals); + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set number of digits to shift the decimal separator to the right, coming from the standard DASH notation that + * was common pre-2014. Note this will change the currency code if enabled. + */ + public MonetaryFormat shift(int shift) { + if (shift == this.shift) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Set rounding mode to use when it becomes necessary. + */ + public MonetaryFormat roundingMode(RoundingMode roundingMode) { + if (roundingMode == this.roundingMode) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Don't display currency code when formatting. This configuration is not relevant for parsing. + */ + public MonetaryFormat noCode() { + if (codes == null) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, null, codeSeparator, codePrefixed); + } + + /** + * Configure currency code for given decimal separator shift. This configuration is not relevant for parsing. + * + * @param codeShift + * decimal separator shift, see {@link #shift} + * @param code + * currency code + */ + public MonetaryFormat code(int codeShift, String code) { + checkArgument(codeShift >= 0); + final String[] codes = null == this.codes + ? new String[MAX_DECIMALS] + : Arrays.copyOf(this.codes, this.codes.length); + + codes[codeShift] = code; + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Separator between currency code and formatted value. This configuration is not relevant for parsing. + */ + public MonetaryFormat codeSeparator(char codeSeparator) { + checkArgument(!Character.isDigit(codeSeparator)); + checkArgument(codeSeparator > 0); + if (codeSeparator == this.codeSeparator) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Prefix formatted output by currency code. This configuration is not relevant for parsing. + */ + public MonetaryFormat prefixCode() { + if (codePrefixed) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, true); + } + + /** + * Postfix formatted output with currency code. This configuration is not relevant for parsing. + */ + public MonetaryFormat postfixCode() { + if (!codePrefixed) + return this; + else + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, false); + } + + /** + * Configure this instance with values from a {@link Locale}. + */ + public MonetaryFormat withLocale(Locale locale) { + DecimalFormatSymbols dfs = new DecimalFormatSymbols(locale); + char negativeSign = dfs.getMinusSign(); + char zeroDigit = dfs.getZeroDigit(); + char decimalMark = dfs.getMonetaryDecimalSeparator(); + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, showGroupingSeparator, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + /** + * Group integer part of the formatted value using the grouping separator of the configured locale. + */ + public MonetaryFormat withGroupingSeparator() { + return new MonetaryFormat(locale, negativeSign, positiveSign, zeroDigit, decimalMark, true, + minDecimals, decimalGroups, shift, roundingMode, codes, codeSeparator, codePrefixed); + } + + public MonetaryFormat() { + this(false); + } + + public MonetaryFormat(boolean useSymbol) { + // defaults + this.locale = Locale.US; + this.negativeSign = '-'; + this.positiveSign = 0; // none + this.zeroDigit = '0'; + this.decimalMark = '.'; + this.showGroupingSeparator = false; + this.minDecimals = 2; + this.decimalGroups = null; + this.shift = 0; + this.roundingMode = RoundingMode.HALF_UP; + this.codes = new String[MAX_DECIMALS]; + this.codes[0] = useSymbol ? SYMBOL_BTC : CODE_BTC; + this.codes[3] = useSymbol ? SYMBOL_MBTC : CODE_MBTC; + this.codes[6] = useSymbol ? SYMBOL_UBTC : CODE_UBTC; + this.codeSeparator = ' '; + this.codePrefixed = true; + } + + private MonetaryFormat(Locale locale, char negativeSign, char positiveSign, char zeroDigit, char decimalMark, + boolean showGroupingSeparator, int minDecimals, List decimalGroups, int shift, + RoundingMode roundingMode, String[] codes, char codeSeparator, boolean codePrefixed) { + this.locale = locale; + this.negativeSign = negativeSign; + this.positiveSign = positiveSign; + this.zeroDigit = zeroDigit; + this.decimalMark = decimalMark; + this.showGroupingSeparator = showGroupingSeparator; + this.minDecimals = minDecimals; + this.decimalGroups = decimalGroups; + this.shift = shift; + this.roundingMode = roundingMode; + this.codes = codes; + this.codeSeparator = codeSeparator; + this.codePrefixed = codePrefixed; + } + + /** + * Format the given monetary value to a human readable form. + */ + public CharSequence format(Monetary monetary) { + // determine maximum number of decimals that can be visible in the formatted string + // (if all decimal groups were to be used) + int max = minDecimals; + if (decimalGroups != null) + for (int group : decimalGroups) + max += group; + final int smallestUnitExponent = monetary.smallestUnitExponent(); + checkState(max <= smallestUnitExponent, + "The maximum possible number of decimals (%s) cannot exceed %s.", max, smallestUnitExponent); + + // rounding + long satoshis = Math.abs(monetary.getValue()); + long precisionDivisor = LongMath.checkedPow(10, smallestUnitExponent - shift - max); + satoshis = LongMath.checkedMultiply(LongMath.divide(satoshis, precisionDivisor, roundingMode), precisionDivisor); + + // shifting + long shiftDivisor = LongMath.checkedPow(10, smallestUnitExponent - shift); + long numbers = satoshis / shiftDivisor; + long decimals = satoshis % shiftDivisor; + + // formatting + String decimalsStr = String.format(Locale.US, "%0" + (smallestUnitExponent - shift) + "d", decimals); + StringBuilder str = new StringBuilder(decimalsStr); + while (str.length() > minDecimals && str.charAt(str.length() - 1) == '0') + str.setLength(str.length() - 1); // trim trailing zero + int i = minDecimals; + if (decimalGroups != null) { + for (int group : decimalGroups) { + if (str.length() > i && str.length() < i + group) { + while (str.length() < i + group) + str.append('0'); + break; + } + i += group; + } + } + if (str.length() > 0) + str.insert(0, decimalMark); + if (showGroupingSeparator) { + String grouped = String.format(locale, "%,d", numbers); + str.insert(0, grouped); + } else { + str.insert(0, numbers); + } + if (monetary.getValue() < 0) + str.insert(0, negativeSign); + else if (positiveSign != 0) + str.insert(0, positiveSign); + if (codes != null) { + if (codePrefixed) { + str.insert(0, codeSeparator); + str.insert(0, code()); + } else { + str.append(codeSeparator); + str.append(code()); + } + } + + // Convert to non-arabic digits. + if (zeroDigit != '0') { + int offset = zeroDigit - '0'; + for (int d = 0; d < str.length(); d++) { + char c = str.charAt(d); + if (Character.isDigit(c)) + str.setCharAt(d, (char) (c + offset)); + } + } + return str; + } + + /** + * Parse a human readable coin value to a {@link Coin} instance. + * + * @throws NumberFormatException + * if the string cannot be parsed for some reason + */ + public Coin parse(String str) throws NumberFormatException { + return Coin.valueOf(parseValue(str, Coin.SMALLEST_UNIT_EXPONENT)); + } + + /** + * Parse a human readable fiat value to a {@link Fiat} instance. + * + * @throws NumberFormatException + * if the string cannot be parsed for some reason + */ + public Fiat parseFiat(String currencyCode, String str) throws NumberFormatException { + return Fiat.valueOf(currencyCode, parseValue(str, Fiat.SMALLEST_UNIT_EXPONENT)); + } + + private long parseValue(String str, int smallestUnitExponent) { + if (showGroupingSeparator) { + DecimalFormatSymbols dfs = new DecimalFormatSymbols(locale); + char groupingSeparator = dfs.getGroupingSeparator(); + str = str.replace(String.valueOf(groupingSeparator), ""); + } + checkState(DECIMALS_PADDING.length() >= smallestUnitExponent); + if (str.isEmpty()) + throw new NumberFormatException("empty string"); + char first = str.charAt(0); + if (first == negativeSign || first == positiveSign) + str = str.substring(1); + String numbers; + String decimals; + int decimalMarkIndex = str.indexOf(decimalMark); + if (decimalMarkIndex != -1) { + numbers = str.substring(0, decimalMarkIndex); + decimals = (str + DECIMALS_PADDING).substring(decimalMarkIndex + 1); + if (decimals.indexOf(decimalMark) != -1) + throw new NumberFormatException("more than one decimal mark"); + } else { + numbers = str; + decimals = DECIMALS_PADDING; + } + String satoshis = numbers + decimals.substring(0, smallestUnitExponent - shift); + for (char c : satoshis.toCharArray()) + if (!Character.isDigit(c)) + throw new NumberFormatException("illegal character: " + c); + long value = Long.parseLong(satoshis); // Non-arabic digits allowed here. + if (first == negativeSign) + value = -value; + return value; + } + + /** + * Get currency code that will be used for current shift. + */ + public String code() { + if (codes == null) + return null; + if (codes[shift] == null) + throw new NumberFormatException("missing code for shift: " + shift); + return codes[shift]; + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + if (o == null || o.getClass() != getClass()) + return false; + final MonetaryFormat other = (MonetaryFormat) o; + if (!Objects.equals(this.negativeSign, other.negativeSign)) + return false; + if (!Objects.equals(this.positiveSign, other.positiveSign)) + return false; + if (!Objects.equals(this.zeroDigit, other.zeroDigit)) + return false; + if (!Objects.equals(this.decimalMark, other.decimalMark)) + return false; + if (!Objects.equals(this.minDecimals, other.minDecimals)) + return false; + if (!Objects.equals(this.decimalGroups, other.decimalGroups)) + return false; + if (!Objects.equals(this.shift, other.shift)) + return false; + if (!Objects.equals(this.roundingMode, other.roundingMode)) + return false; + if (!Arrays.equals(this.codes, other.codes)) + return false; + if (!Objects.equals(this.codeSeparator, other.codeSeparator)) + return false; + if (!Objects.equals(this.codePrefixed, other.codePrefixed)) + return false; + return true; + } + + @Override + public int hashCode() { + return Objects.hash(negativeSign, positiveSign, zeroDigit, decimalMark, minDecimals, decimalGroups, shift, + roundingMode, Arrays.hashCode(codes), codeSeparator, codePrefixed); + } +} diff --git a/common/src/main/java/org/dash/wallet/common/money/MoneyAdapters.kt b/common/src/main/java/org/dash/wallet/common/money/MoneyAdapters.kt new file mode 100644 index 0000000000..d232b8df42 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/MoneyAdapters.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +import org.dash.wallet.common.data.entity.ExchangeRate + +// --------------------------------------------------------------------------------------------- +// Adapters between the value-class money types ([Dash]/[FiatValue]) and the self-contained +// money core ([Coin]/[Fiat]). +// --------------------------------------------------------------------------------------------- + +fun Dash.toCoin(): Coin = Coin.valueOf(duffs) +fun Coin.toDash(): Dash = Dash(value) + +fun FiatValue.toFiat(): Fiat = Fiat.valueOf(currencyCode, value) +fun Fiat.toFiatValue(): FiatValue = FiatValue(currencyCode, value) + +// --------------------------------------------------------------------------------------------- +// Neutral conversion API on the app's ExchangeRate entity. Delegates to the ExchangeRate port +// so rounding matches dashj exactly. +// --------------------------------------------------------------------------------------------- + +val ExchangeRate.fiatValue: FiatValue? + get() = rate?.let { fiat.toFiatValue() } + +/** Converts a Dash amount to fiat at this rate. Mirrors `ExchangeRate.coinToFiat`. */ +fun ExchangeRate.dashToFiat(amount: Dash): FiatValue { + return ExchangeRateCalc(Coin.COIN, fiat).coinToFiat(amount.toCoin()).toFiatValue() +} + +/** Converts a fiat amount to Dash at this rate. Mirrors `ExchangeRate.fiatToCoin`. */ +fun ExchangeRate.fiatToDash(amount: FiatValue): Dash { + return ExchangeRateCalc(Coin.COIN, fiat).fiatToCoin(amount.toFiat()).toDash() +} + +/** + * Treats this fiat amount as the price of one Dash and converts [amount] to fiat. + * Mirrors `ExchangeRate(fiat).coinToFiat(coin)` for rates that aren't + * backed by the app's ExchangeRate entity (e.g. rates restored from transaction metadata). + */ +fun FiatValue.dashToFiat(amount: Dash): FiatValue { + return ExchangeRateCalc(Coin.COIN, toFiat()).coinToFiat(amount.toCoin()).toFiatValue() +} + +private typealias ExchangeRateCalc = org.dash.wallet.common.money.ExchangeRate diff --git a/common/src/main/java/org/dash/wallet/common/money/MoneyFormat.kt b/common/src/main/java/org/dash/wallet/common/money/MoneyFormat.kt new file mode 100644 index 0000000000..0725aabd48 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/MoneyFormat.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +import org.dash.wallet.common.Configuration +import java.math.RoundingMode +import java.util.Locale + +/** + * Immutable monetary formatter for [Dash] and [FiatValue] amounts. Mirrors the fluent API of + * dashj's `MonetaryFormat` (and delegates to the self-contained [MonetaryFormat] port internally, + * so output is identical), without exposing dashj types to feature/integration modules. + */ +class MoneyFormat internal constructor(internal val delegate: MonetaryFormat) { + + companion object { + /** Mirrors [MonetaryFormat.BTC]: standard Dash denomination format. */ + val BTC = MoneyFormat(MonetaryFormat.BTC) + } + + constructor() : this(MonetaryFormat()) + + fun noCode() = MoneyFormat(delegate.noCode()) + fun postfixCode() = MoneyFormat(delegate.postfixCode()) + fun minDecimals(minDecimals: Int) = MoneyFormat(delegate.minDecimals(minDecimals)) + fun optionalDecimals(vararg groups: Int) = MoneyFormat(delegate.optionalDecimals(*groups)) + fun repeatOptionalDecimals(decimals: Int, repetitions: Int) = + MoneyFormat(delegate.repeatOptionalDecimals(decimals, repetitions)) + fun withLocale(locale: Locale) = MoneyFormat(delegate.withLocale(locale)) + fun roundingMode(roundingMode: RoundingMode) = MoneyFormat(delegate.roundingMode(roundingMode)) + + fun format(amount: Dash): CharSequence = delegate.format(Coin.valueOf(amount.duffs)) + fun format(amount: FiatValue): CharSequence = delegate.format(Fiat.valueOf(amount.currencyCode, amount.value)) + + /** Mirrors [MonetaryFormat.parse]; throws on unparseable input. */ + fun parseDash(str: String): Dash = Dash(delegate.parse(str).value) + fun parseFiat(currencyCode: String, str: String): FiatValue { + val fiat = delegate.parseFiat(currencyCode, str) + return FiatValue(fiat.currencyCode, fiat.value) + } +} + +/** + * Neutral counterpart of [Configuration.getFormat] for feature/integration modules + * that must not depend on dashj. Same user-configured Dash format, wrapped in [MoneyFormat]. + */ +val Configuration.moneyFormat: MoneyFormat + get() = MoneyFormat(format) diff --git a/common/src/main/java/org/dash/wallet/common/money/TxIds.kt b/common/src/main/java/org/dash/wallet/common/money/TxIds.kt new file mode 100644 index 0000000000..8a61c9a64e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/money/TxIds.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.money + +/** + * Conversions for transaction ids represented as hex strings (`Sha256Hash.toString()`), + * for feature/integration modules that must not depend on dashj. Encodings are exactly + * the ones the wallet uses. + */ +object TxIds { + + /** Hex representation of the all-zero tx id (mirrors `Sha256Hash.ZERO_HASH.toString()`). */ + const val ZERO_HASH_HEX: String = "0000000000000000000000000000000000000000000000000000000000000000" + + /** Converts a hex tx id to its raw bytes (mirrors `Sha256Hash.wrap(hex).bytes`) — e.g. for Room BLOB queries. */ + fun toBytes(txIdHex: String): ByteArray { + require(txIdHex.length == 64) { "not a 32-byte hex string: " + txIdHex } + return ByteArray(32) { i -> txIdHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } + } + + /** Converts a hex tx id to its base58 representation (mirrors `Sha256Hash.toStringBase58()`). */ + fun toBase58(txIdHex: String): String = + org.dash.wallet.common.payments.parsers.Base58.encode(toBytes(txIdHex)) +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocol.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocol.java new file mode 100644 index 0000000000..08ed5d01d0 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocol.java @@ -0,0 +1,431 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.PaymentProtocol, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +/* + * Copyright 2013 Google Inc. + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.payments.bip70; + +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.payments.bip70.X509Utils; +import org.dash.wallet.common.payments.parsers.AddressNetwork; +import org.dash.wallet.common.payments.parsers.Scripts; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import org.dash.wallet.common.payments.bip70.Protos; + +import javax.annotation.Nullable; +import java.io.Serializable; +import java.security.*; +import java.security.PublicKey; +import java.security.cert.*; +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.List; + +/** + *

Utility methods and constants for working with + * BIP 70 aka the payment protocol. These are low level wrappers around the protocol buffers. If you're implementing + * a wallet app, look at {@link PaymentSession} for a higher level API that should simplify working with the protocol.

+ * + *

BIP 70 defines a binary, protobuf based protocol that runs directly between sender and receiver of funds. Payment + * protocol data does not flow over the Bitcoin P2P network or enter the block chain. It's instead for data that is only + * of interest to the parties involved but isn't otherwise needed for consensus.

+ */ +public class PaymentProtocol { + + // MIME types as defined in BIP71. + public static final String MIMETYPE_PAYMENTREQUEST = "application/dash-paymentrequest"; + public static final String MIMETYPE_PAYMENT = "application/dash-payment"; + public static final String MIMETYPE_PAYMENTACK = "application/dash-paymentack"; + + /** + * Create a payment request with one standard pay to address output. You may want to sign the request using + * {@link #signPaymentRequest}. Use {@link Protos.PaymentRequest.Builder#build} to get the actual payment + * request. + * + * @param params network parameters + * @param amount amount of coins to request, or null + * @param toAddress address to request coins to + * @param memo arbitrary, user readable memo, or null if none + * @param paymentUrl URL to send payment message to, or null if none + * @param merchantData arbitrary merchant data, or null if none + * @return created payment request, in its builder form + */ + public static Protos.PaymentRequest.Builder createPaymentRequest(AddressNetwork params, + @Nullable Coin amount, String toAddress, @Nullable String memo, @Nullable String paymentUrl, + @Nullable byte[] merchantData) { + return createPaymentRequest(params, ImmutableList.of(createPayToAddressOutput(amount, toAddress)), memo, + paymentUrl, merchantData); + } + + /** + * Create a payment request. You may want to sign the request using {@link #signPaymentRequest}. Use + * {@link Protos.PaymentRequest.Builder#build} to get the actual payment request. + * + * @param params network parameters + * @param outputs list of outputs to request coins to + * @param memo arbitrary, user readable memo, or null if none + * @param paymentUrl URL to send payment message to, or null if none + * @param merchantData arbitrary merchant data, or null if none + * @return created payment request, in its builder form + */ + public static Protos.PaymentRequest.Builder createPaymentRequest(AddressNetwork params, + List outputs, @Nullable String memo, @Nullable String paymentUrl, + @Nullable byte[] merchantData) { + final Protos.PaymentDetails.Builder paymentDetails = Protos.PaymentDetails.newBuilder(); + paymentDetails.setNetwork(params.getPaymentProtocolId()); + for (Protos.Output output : outputs) + paymentDetails.addOutputs(output); + if (memo != null) + paymentDetails.setMemo(memo); + if (paymentUrl != null) + paymentDetails.setPaymentUrl(paymentUrl); + if (merchantData != null) + paymentDetails.setMerchantData(ByteString.copyFrom(merchantData)); + paymentDetails.setTime(System.currentTimeMillis() / 1000); + + final Protos.PaymentRequest.Builder paymentRequest = Protos.PaymentRequest.newBuilder(); + paymentRequest.setSerializedPaymentDetails(paymentDetails.build().toByteString()); + return paymentRequest; + } + + /** + * Parse a payment request. + * + * @param paymentRequest payment request to parse + * @return instance of {@link PaymentSession}, used as a value object + * @throws PaymentProtocolException + */ + public static PaymentSession parsePaymentRequest(Protos.PaymentRequest paymentRequest) + throws PaymentProtocolException { + return new PaymentSession(paymentRequest, false, null); + } + + /** + * Sign the provided payment request. + * + * @param paymentRequest Payment request to sign, in its builder form. + * @param certificateChain Certificate chain to send with the payment request, ordered from client certificate to root + * certificate. The root certificate itself may be omitted. + * @param privateKey The key to sign with. Must match the public key from the first certificate of the certificate chain. + */ + public static void signPaymentRequest(Protos.PaymentRequest.Builder paymentRequest, + X509Certificate[] certificateChain, PrivateKey privateKey) { + try { + final Protos.X509Certificates.Builder certificates = Protos.X509Certificates.newBuilder(); + for (final Certificate certificate : certificateChain) + certificates.addCertificate(ByteString.copyFrom(certificate.getEncoded())); + + paymentRequest.setPkiType("x509+sha256"); + paymentRequest.setPkiData(certificates.build().toByteString()); + paymentRequest.setSignature(ByteString.EMPTY); + final Protos.PaymentRequest paymentRequestToSign = paymentRequest.build(); + + final String algorithm; + if ("RSA".equalsIgnoreCase(privateKey.getAlgorithm())) + algorithm = "SHA256withRSA"; + else + throw new IllegalStateException(privateKey.getAlgorithm()); + + final Signature signature = Signature.getInstance(algorithm); + signature.initSign(privateKey); + signature.update(paymentRequestToSign.toByteArray()); + + paymentRequest.setSignature(ByteString.copyFrom(signature.sign())); + } catch (final GeneralSecurityException x) { + // Should never happen so don't make users have to think about it. + throw new RuntimeException(x); + } + } + + /** + * Uses the provided PKI method to find the corresponding public key and verify the provided signature. + * + * @param paymentRequest Payment request to verify. + * @param trustStore KeyStore of trusted root certificate authorities. + * @return verification data, or null if no PKI method was specified in the {@link Protos.PaymentRequest}. + * @throws PaymentProtocolException if payment request could not be verified. + */ + @Nullable + public static PkiVerificationData verifyPaymentRequestPki(Protos.PaymentRequest paymentRequest, KeyStore trustStore) + throws PaymentProtocolException { + List certs = null; + try { + final String pkiType = paymentRequest.getPkiType(); + if ("none".equals(pkiType)) + // Nothing to verify. Everything is fine. Move along. + return null; + + String algorithm; + if ("x509+sha256".equals(pkiType)) + algorithm = "SHA256withRSA"; + else if ("x509+sha1".equals(pkiType)) + algorithm = "SHA1withRSA"; + else + throw new PaymentProtocolException.InvalidPkiType("Unsupported PKI type: " + pkiType); + + Protos.X509Certificates protoCerts = Protos.X509Certificates.parseFrom(paymentRequest.getPkiData()); + if (protoCerts.getCertificateCount() == 0) + throw new PaymentProtocolException.InvalidPkiData("No certificates provided in message: server config error"); + + // Parse the certs and turn into a certificate chain object. Cert factories can parse both DER and base64. + // The ordering of certificates is defined by the payment protocol spec to be the same as what the Java + // crypto API requires - convenient! + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + certs = Lists.newArrayList(); + for (ByteString bytes : protoCerts.getCertificateList()) + certs.add((X509Certificate) certificateFactory.generateCertificate(bytes.newInput())); + CertPath path = certificateFactory.generateCertPath(certs); + + // Retrieves the most-trusted CAs from keystore. + PKIXParameters params = new PKIXParameters(trustStore); + // Revocation not supported in the current version. + params.setRevocationEnabled(false); + + // Now verify the certificate chain is correct and trusted. This let's us get an identity linked pubkey. + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + PKIXCertPathValidatorResult result = (PKIXCertPathValidatorResult) validator.validate(path, params); + PublicKey publicKey = result.getPublicKey(); + // OK, we got an identity, now check it was used to sign this message. + Signature signature = Signature.getInstance(algorithm); + // Note that we don't use signature.initVerify(certs.get(0)) here despite it being the most obvious + // way to set it up, because we don't care about the constraints specified on the certificates: any + // cert that links a key to a domain name or other identity will do for us. + signature.initVerify(publicKey); + Protos.PaymentRequest.Builder reqToCheck = paymentRequest.toBuilder(); + reqToCheck.setSignature(ByteString.EMPTY); + signature.update(reqToCheck.build().toByteArray()); + if (!signature.verify(paymentRequest.getSignature().toByteArray())) + throw new PaymentProtocolException.PkiVerificationException("Invalid signature, this payment request is not valid."); + + // Signature verifies, get the names from the identity we just verified for presentation to the user. + final X509Certificate cert = certs.get(0); + String displayName = X509Utils.getDisplayNameFromCertificate(cert, true); + if (displayName == null) + throw new PaymentProtocolException.PkiVerificationException("Could not extract name from certificate"); + // Everything is peachy. Return some useful data to the caller. + return new PkiVerificationData(displayName, publicKey, result.getTrustAnchor()); + } catch (InvalidProtocolBufferException e) { + // Data structures are malformed. + throw new PaymentProtocolException.InvalidPkiData(e); + } catch (CertificateException e) { + // The X.509 certificate data didn't parse correctly. + throw new PaymentProtocolException.PkiVerificationException(e); + } catch (NoSuchAlgorithmException e) { + // Should never happen so don't make users have to think about it. PKIX is always present. + throw new RuntimeException(e); + } catch (InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } catch (CertPathValidatorException e) { + // The certificate chain isn't known or trusted, probably, the server is using an SSL root we don't + // know about and the user needs to upgrade to a new version of the software (or import a root cert). + throw new PaymentProtocolException.PkiVerificationException(e, certs); + } catch (InvalidKeyException e) { + // Shouldn't happen if the certs verified correctly. + throw new PaymentProtocolException.PkiVerificationException(e); + } catch (SignatureException e) { + // Something went wrong during hashing (yes, despite the name, this does not mean the sig was invalid). + throw new PaymentProtocolException.PkiVerificationException(e); + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + } + + /** + * Information about the X.509 signature's issuer and subject. + */ + public static class PkiVerificationData { + /** Display name of the payment requestor, could be a domain name, email address, legal name, etc */ + public final String displayName; + /** SSL public key that was used to sign. */ + public final PublicKey merchantSigningKey; + /** Object representing the CA that verified the merchant's ID */ + public final TrustAnchor rootAuthority; + /** String representing the display name of the CA that verified the merchant's ID */ + public final String rootAuthorityName; + + private PkiVerificationData(@Nullable String displayName, PublicKey merchantSigningKey, + TrustAnchor rootAuthority) throws PaymentProtocolException.PkiVerificationException { + try { + this.displayName = displayName; + this.merchantSigningKey = merchantSigningKey; + this.rootAuthority = rootAuthority; + this.rootAuthorityName = X509Utils.getDisplayNameFromCertificate(rootAuthority.getTrustedCert(), true); + } catch (CertificateParsingException x) { + throw new PaymentProtocolException.PkiVerificationException(x); + } + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("displayName", displayName) + .add("rootAuthorityName", rootAuthorityName) + .add("merchantSigningKey", merchantSigningKey) + .add("rootAuthority", rootAuthority) + .toString(); + } + } + + /** + * Create a payment message with one standard pay to address output. + * + * @param transactions one or more transactions that satisfy the requested outputs. + * @param refundAmount amount of coins to request as a refund, or null if no refund. + * @param refundAddress address to refund coins to + * @param memo arbitrary, user readable memo, or null if none + * @param merchantData arbitrary merchant data, or null if none + * @return created payment message + */ + public static Protos.Payment createPaymentMessage(List transactions, + @Nullable Coin refundAmount, @Nullable String refundAddress, @Nullable String memo, + @Nullable byte[] merchantData) { + if (refundAddress != null) { + if (refundAmount == null) + throw new IllegalArgumentException("Specify refund amount if refund address is specified."); + return createPaymentMessage(transactions, + ImmutableList.of(createPayToAddressOutput(refundAmount, refundAddress)), memo, merchantData); + } else { + return createPaymentMessage(transactions, null, memo, merchantData); + } + } + + /** + * Create a payment message. This wraps up transaction data along with anything else useful for making a payment. + * + * @param transactions transactions to include with the payment message + * @param refundOutputs list of outputs to refund coins to, or null + * @param memo arbitrary, user readable memo, or null if none + * @param merchantData arbitrary merchant data, or null if none + * @return created payment message + */ + public static Protos.Payment createPaymentMessage(List transactions, + @Nullable List refundOutputs, @Nullable String memo, @Nullable byte[] merchantData) { + Protos.Payment.Builder builder = Protos.Payment.newBuilder(); + for (byte[] transaction : transactions) { + builder.addTransactions(ByteString.copyFrom(transaction)); + } + if (refundOutputs != null) { + for (Protos.Output output : refundOutputs) + builder.addRefundTo(output); + } + if (memo != null) + builder.setMemo(memo); + if (merchantData != null) + builder.setMerchantData(ByteString.copyFrom(merchantData)); + return builder.build(); + } + + /** + * Parse serialized transactions from payment message. + * + * @param paymentMessage payment message to parse + * @return list of serialized transactions + */ + public static List parseTransactionsFromPaymentMessage(Protos.Payment paymentMessage) { + final List transactions = new ArrayList<>(paymentMessage.getTransactionsCount()); + for (final ByteString transaction : paymentMessage.getTransactionsList()) + transactions.add(transaction.toByteArray()); + return transactions; + } + + /** + * Message returned by the merchant in response to a Payment message. + */ + public static class Ack { + @Nullable private final String memo; + + Ack(@Nullable String memo) { + this.memo = memo; + } + + /** + * Returns the memo included by the merchant in the payment ack. This message is typically displayed to the user + * as a notification (e.g. "Your payment was received and is being processed"). If none was provided, returns + * null. + */ + @Nullable public String getMemo() { + return memo; + } + } + + /** + * Create a payment ack. + * + * @param paymentMessage payment message to send with the ack + * @param memo arbitrary, user readable memo, or null if none + * @return created payment ack + */ + public static Protos.PaymentACK createPaymentAck(Protos.Payment paymentMessage, @Nullable String memo) { + final Protos.PaymentACK.Builder builder = Protos.PaymentACK.newBuilder(); + builder.setPayment(paymentMessage); + if (memo != null) + builder.setMemo(memo); + return builder.build(); + } + + /** + * Parse payment ack into an object. + */ + public static Ack parsePaymentAck(Protos.PaymentACK paymentAck) { + final String memo = paymentAck.hasMemo() ? paymentAck.getMemo() : null; + return new Ack(memo); + } + + /** + * Create a standard pay to address output for usage in {@link #createPaymentRequest} and + * {@link #createPaymentMessage}. + * + * @param amount amount to pay, or null + * @param address address to pay to + * @return output + */ + public static Protos.Output createPayToAddressOutput(@Nullable Coin amount, String address) { + Protos.Output.Builder output = Protos.Output.newBuilder(); + if (amount != null) { + if (amount.compareTo(Coin.valueOf(AddressNetwork.MAX_MONEY_DUFFS)) > 0) + throw new IllegalArgumentException("Amount too big: " + amount); + output.setAmount(amount.value); + } else { + output.setAmount(0); + } + output.setScript(ByteString.copyFrom(Scripts.outputScriptForAddress(address))); + return output.build(); + } + + /** + * Value object to hold amount/script pairs. + */ + public static class Output implements Serializable { + @Nullable public final Coin amount; + public final byte[] scriptData; + public final boolean useInstantSend; + + public Output(@Nullable Coin amount, byte[] scriptData) { + this.amount = amount; + this.scriptData = scriptData; + this.useInstantSend = false; + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocolException.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocolException.java new file mode 100644 index 0000000000..ad8ea0489e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentProtocolException.java @@ -0,0 +1,112 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.PaymentProtocolException, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +/* + * Copyright 2013 Google Inc. + * + * 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. + */ + +package org.dash.wallet.common.payments.bip70; + +import java.security.cert.X509Certificate; +import java.util.List; + +public class PaymentProtocolException extends Exception { + public PaymentProtocolException(String msg) { + super(msg); + } + + public PaymentProtocolException(Exception e) { + super(e); + } + + public static class Expired extends PaymentProtocolException { + public Expired(String msg) { + super(msg); + } + } + + public static class InvalidPaymentRequestURL extends PaymentProtocolException { + public InvalidPaymentRequestURL(String msg) { + super(msg); + } + + public InvalidPaymentRequestURL(Exception e) { + super(e); + } + } + + public static class InvalidPaymentURL extends PaymentProtocolException { + public InvalidPaymentURL(Exception e) { + super(e); + } + + public InvalidPaymentURL(String msg) { + super(msg); + } + } + + public static class InvalidOutputs extends PaymentProtocolException { + public InvalidOutputs(String msg) { + super(msg); + } + } + + public static class InvalidVersion extends PaymentProtocolException { + public InvalidVersion(String msg) { + super(msg); + } + } + + public static class InvalidNetwork extends PaymentProtocolException { + public InvalidNetwork(String msg) { + super(msg); + } + } + + public static class InvalidPkiType extends PaymentProtocolException { + public InvalidPkiType(String msg) { + super(msg); + } + } + + public static class InvalidPkiData extends PaymentProtocolException { + public InvalidPkiData(String msg) { + super(msg); + } + + public InvalidPkiData(Exception e) { + super(e); + } + } + + public static class PkiVerificationException extends PaymentProtocolException { + public List certificates; + + public PkiVerificationException(String msg) { + super(msg); + } + + public PkiVerificationException(Exception e) { + super(e); + } + + public PkiVerificationException(Exception e, List certificates) { + super(e); + this.certificates = certificates; + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentSession.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentSession.java new file mode 100644 index 0000000000..0ce3fcaaa4 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/PaymentSession.java @@ -0,0 +1,426 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.PaymentSession, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +/* + * 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. + */ + +package org.dash.wallet.common.payments.bip70; + +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.payments.bip70.TrustStoreLoader; +import org.dash.wallet.common.payments.bip70.PaymentProtocol.PkiVerificationData; +import org.dash.wallet.common.payments.parsers.AddressNetwork; +import org.dash.wallet.common.payments.parsers.PaymentURI; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.dash.wallet.common.payments.bip70.Protos; + +import javax.annotation.Nullable; + +import java.io.*; +import java.net.*; +import java.security.KeyStoreException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; + +/** + *

Provides a standard implementation of the Payment Protocol (BIP 0070)

+ * + *

A PaymentSession can be initialized from one of the following:

+ * + *
    + *
  • A {@link PaymentURI} object that conforms to BIP 0072
  • + *
  • A url where the {@link Protos.PaymentRequest} can be fetched
  • + *
  • Directly with a {@link Protos.PaymentRequest} object
  • + *
+ * + *

If initialized with a BitcoinURI or a url, a network request is made for the payment request object and a + * ListenableFuture is returned that will be notified with the PaymentSession object after it is downloaded.

+ * + *

Once the PaymentSession is initialized, typically a wallet application will prompt the user to confirm that the + * amount and recipient are correct, perform any additional steps, and then construct a list of transactions to pass to + * the sendPayment method.

+ * + *

Call sendPayment with a list of transactions that will be broadcast. A {@link Protos.Payment} message will be sent + * to the merchant if a payment url is provided in the PaymentRequest. NOTE: sendPayment does NOT broadcast the + * transactions to the bitcoin network. Instead it returns a ListenableFuture that will be notified when a + * {@link Protos.PaymentACK} is received from the merchant. Typically a wallet will show the message to the user + * as a confirmation message that the payment is now "processing" or that an error occurred, and then broadcast the + * tx itself later if needed.

+ * + * @see BIP 0070 + */ +public class PaymentSession { + private static ListeningExecutorService executor = + MoreExecutors.listeningDecorator(Executors.newCachedThreadPool()); + private AddressNetwork params; + private Protos.PaymentRequest paymentRequest; + private Protos.PaymentDetails paymentDetails; + private Coin totalValue = Coin.ZERO; + + /** + * Stores the calculated PKI verification data, or null if none is available. + * Only valid after the session is created with the verifyPki parameter set to true. + */ + @Nullable public final PkiVerificationData pkiVerificationData; + + /** + *

Returns a future that will be notified with a PaymentSession object after it is fetched using the provided uri. + * uri is a BIP-72-style BitcoinURI object that specifies where the {@link Protos.PaymentRequest} object may + * be fetched in the r= parameter.

+ * + *

If the payment request object specifies a PKI method, then the system trust store will be used to verify + * the signature provided by the payment request. An exception is thrown by the future if the signature cannot + * be verified.

+ */ + public static ListenableFuture createFromBitcoinUri(final PaymentURI uri) throws PaymentProtocolException { + return createFromBitcoinUri(uri, true, null); + } + + /** + * Returns a future that will be notified with a PaymentSession object after it is fetched using the provided uri. + * uri is a BIP-72-style BitcoinURI object that specifies where the {@link Protos.PaymentRequest} object may + * be fetched in the r= parameter. + * If verifyPki is specified and the payment request object specifies a PKI method, then the system trust store will + * be used to verify the signature provided by the payment request. An exception is thrown by the future if the + * signature cannot be verified. + */ + public static ListenableFuture createFromBitcoinUri(final PaymentURI uri, final boolean verifyPki) + throws PaymentProtocolException { + return createFromBitcoinUri(uri, verifyPki, null); + } + + /** + * Returns a future that will be notified with a PaymentSession object after it is fetched using the provided uri. + * uri is a BIP-72-style BitcoinURI object that specifies where the {@link Protos.PaymentRequest} object may + * be fetched in the r= parameter. + * If verifyPki is specified and the payment request object specifies a PKI method, then the system trust store will + * be used to verify the signature provided by the payment request. An exception is thrown by the future if the + * signature cannot be verified. + * If trustStoreLoader is null, the system default trust store is used. + */ + public static ListenableFuture createFromBitcoinUri(final PaymentURI uri, final boolean verifyPki, @Nullable final TrustStoreLoader trustStoreLoader) + throws PaymentProtocolException { + String url = uri.getPaymentRequestUrl(); + if (url == null) + throw new PaymentProtocolException.InvalidPaymentRequestURL("No payment request URL (r= parameter) in BitcoinURI " + uri); + try { + return fetchPaymentRequest(new URI(url), verifyPki, trustStoreLoader); + } catch (URISyntaxException e) { + throw new PaymentProtocolException.InvalidPaymentRequestURL(e); + } + } + + /** + * Returns a future that will be notified with a PaymentSession object after it is fetched using the provided url. + * url is an address where the {@link Protos.PaymentRequest} object may be fetched. + * If verifyPki is specified and the payment request object specifies a PKI method, then the system trust store will + * be used to verify the signature provided by the payment request. An exception is thrown by the future if the + * signature cannot be verified. + */ + public static ListenableFuture createFromUrl(final String url) throws PaymentProtocolException { + return createFromUrl(url, true, null); + } + + /** + * Returns a future that will be notified with a PaymentSession object after it is fetched using the provided url. + * url is an address where the {@link Protos.PaymentRequest} object may be fetched. + * If the payment request object specifies a PKI method, then the system trust store will + * be used to verify the signature provided by the payment request. An exception is thrown by the future if the + * signature cannot be verified. + */ + public static ListenableFuture createFromUrl(final String url, final boolean verifyPki) + throws PaymentProtocolException { + return createFromUrl(url, verifyPki, null); + } + + /** + * Returns a future that will be notified with a PaymentSession object after it is fetched using the provided url. + * url is an address where the {@link Protos.PaymentRequest} object may be fetched. + * If the payment request object specifies a PKI method, then the system trust store will + * be used to verify the signature provided by the payment request. An exception is thrown by the future if the + * signature cannot be verified. + * If trustStoreLoader is null, the system default trust store is used. + */ + public static ListenableFuture createFromUrl(final String url, final boolean verifyPki, @Nullable final TrustStoreLoader trustStoreLoader) + throws PaymentProtocolException { + if (url == null) + throw new PaymentProtocolException.InvalidPaymentRequestURL("null paymentRequestUrl"); + try { + return fetchPaymentRequest(new URI(url), verifyPki, trustStoreLoader); + } catch(URISyntaxException e) { + throw new PaymentProtocolException.InvalidPaymentRequestURL(e); + } + } + + private static ListenableFuture fetchPaymentRequest(final URI uri, final boolean verifyPki, @Nullable final TrustStoreLoader trustStoreLoader) { + return executor.submit(new Callable() { + @Override + public PaymentSession call() throws Exception { + HttpURLConnection connection = (HttpURLConnection)uri.toURL().openConnection(); + connection.setRequestProperty("Accept", PaymentProtocol.MIMETYPE_PAYMENTREQUEST); + connection.setUseCaches(false); + Protos.PaymentRequest paymentRequest = Protos.PaymentRequest.parseFrom(connection.getInputStream()); + return new PaymentSession(paymentRequest, verifyPki, trustStoreLoader); + } + }); + } + + /** + * Creates a PaymentSession from the provided {@link Protos.PaymentRequest}. + * Verifies PKI by default. + */ + public PaymentSession(Protos.PaymentRequest request) throws PaymentProtocolException { + this(request, true, null); + } + + /** + * Creates a PaymentSession from the provided {@link Protos.PaymentRequest}. + * If verifyPki is true, also validates the signature and throws an exception if it fails. + */ + public PaymentSession(Protos.PaymentRequest request, boolean verifyPki) throws PaymentProtocolException { + this(request, verifyPki, null); + } + + /** + * Creates a PaymentSession from the provided {@link Protos.PaymentRequest}. + * If verifyPki is true, also validates the signature and throws an exception if it fails. + * If trustStoreLoader is null, the system default trust store is used. + */ + public PaymentSession(Protos.PaymentRequest request, boolean verifyPki, @Nullable final TrustStoreLoader trustStoreLoader) throws PaymentProtocolException { + TrustStoreLoader nonNullTrustStoreLoader = trustStoreLoader != null ? trustStoreLoader : new TrustStoreLoader.DefaultTrustStoreLoader(); + parsePaymentRequest(request); + if (verifyPki) { + try { + pkiVerificationData = PaymentProtocol.verifyPaymentRequestPki(request, nonNullTrustStoreLoader.getKeyStore()); + } catch (IOException x) { + throw new PaymentProtocolException(x); + } catch (KeyStoreException x) { + throw new PaymentProtocolException(x); + } + } else { + pkiVerificationData = null; + } + } + + /** + * Returns the outputs of the payment request. + */ + public List getOutputs() { + List outputs = new ArrayList<>(paymentDetails.getOutputsCount()); + for (Protos.Output output : paymentDetails.getOutputsList()) { + Coin amount = output.hasAmount() ? Coin.valueOf(output.getAmount()) : null; + outputs.add(new PaymentProtocol.Output(amount, output.getScript().toByteArray())); + } + return outputs; + } + + /** + * Returns the memo included by the merchant in the payment request, or null if not found. + */ + @Nullable public String getMemo() { + if (paymentDetails.hasMemo()) + return paymentDetails.getMemo(); + else + return null; + } + + /** + * Returns the total amount of bitcoins requested. + */ + public Coin getValue() { + return totalValue; + } + + /** + * Returns the date that the payment request was generated. + */ + public Date getDate() { + return new Date(paymentDetails.getTime() * 1000); + } + + /** + * Returns the expires time of the payment request, or null if none. + */ + @Nullable public Date getExpires() { + if (paymentDetails.hasExpires()) + return new Date(paymentDetails.getExpires() * 1000); + else + return null; + } + + /** + * This should always be called before attempting to call sendPayment. + */ + public boolean isExpired() { + return paymentDetails.hasExpires() && (System.currentTimeMillis() / 1000) > paymentDetails.getExpires(); + } + + /** + * Returns the payment url where the Payment message should be sent. + * Returns null if no payment url was provided in the PaymentRequest. + */ + @Nullable + public String getPaymentUrl() { + if (paymentDetails.hasPaymentUrl()) + return paymentDetails.getPaymentUrl(); + return null; + } + + /** + * Returns the merchant data included by the merchant in the payment request, or null if none. + */ + @Nullable public byte[] getMerchantData() { + if (paymentDetails.hasMerchantData()) + return paymentDetails.getMerchantData().toByteArray(); + else + return null; + } + + /** + * Generates a Payment message and sends the payment to the merchant who sent the PaymentRequest. + * Provide transactions built by the wallet. + * NOTE: This does not broadcast the transactions to the bitcoin network, it merely sends a Payment message to the + * merchant confirming the payment. + * Returns an object wrapping PaymentACK once received. + * If the PaymentRequest did not specify a payment_url, returns null and does nothing. + * @param txns list of transactions to be included with the Payment message. + * @param refundAddr will be used by the merchant to send money back if there was a problem. + * @param memo is a message to include in the payment message sent to the merchant. + */ + @Nullable + public ListenableFuture sendPayment(List txns, @Nullable String refundAddr, @Nullable String memo) + throws PaymentProtocolException, IOException { + Protos.Payment payment = getPayment(txns, refundAddr, memo); + if (payment == null) + return null; + if (isExpired()) + throw new PaymentProtocolException.Expired("PaymentRequest is expired"); + URL url; + try { + url = new URL(paymentDetails.getPaymentUrl()); + } catch (MalformedURLException e) { + throw new PaymentProtocolException.InvalidPaymentURL(e); + } + return sendPayment(url, payment); + } + + /** + * Generates a Payment message based on the information in the PaymentRequest. + * Provide transactions built by the wallet. + * If the PaymentRequest did not specify a payment_url, returns null. + * @param txns list of transactions to be included with the Payment message. + * @param refundAddr will be used by the merchant to send money back if there was a problem. + * @param memo is a message to include in the payment message sent to the merchant. + */ + @Nullable + public Protos.Payment getPayment(List txns, @Nullable String refundAddr, @Nullable String memo) + throws IOException, PaymentProtocolException.InvalidNetwork { + if (paymentDetails.hasPaymentUrl()) { + return PaymentProtocol.createPaymentMessage(txns, totalValue, refundAddr, memo, getMerchantData()); + } else { + return null; + } + } + + @VisibleForTesting + protected ListenableFuture sendPayment(final URL url, final Protos.Payment payment) { + return executor.submit(new Callable() { + @Override + public PaymentProtocol.Ack call() throws Exception { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", PaymentProtocol.MIMETYPE_PAYMENT); + connection.setRequestProperty("Accept", PaymentProtocol.MIMETYPE_PAYMENTACK); + connection.setRequestProperty("Content-Length", Integer.toString(payment.getSerializedSize())); + connection.setUseCaches(false); + connection.setDoInput(true); + connection.setDoOutput(true); + + // Send request. + DataOutputStream outStream = new DataOutputStream(connection.getOutputStream()); + payment.writeTo(outStream); + outStream.flush(); + outStream.close(); + + // Get response. + Protos.PaymentACK paymentAck = Protos.PaymentACK.parseFrom(connection.getInputStream()); + return PaymentProtocol.parsePaymentAck(paymentAck); + } + }); + } + + private void parsePaymentRequest(Protos.PaymentRequest request) throws PaymentProtocolException { + try { + if (request == null) + throw new PaymentProtocolException("request cannot be null"); + if (request.getPaymentDetailsVersion() != 1) + throw new PaymentProtocolException.InvalidVersion("Version 1 required. Received version " + request.getPaymentDetailsVersion()); + paymentRequest = request; + if (!request.hasSerializedPaymentDetails()) + throw new PaymentProtocolException("No PaymentDetails"); + paymentDetails = Protos.PaymentDetails.newBuilder().mergeFrom(request.getSerializedPaymentDetails()).build(); + if (paymentDetails == null) + throw new PaymentProtocolException("Invalid PaymentDetails"); + if (!paymentDetails.hasNetwork()) + params = AddressNetwork.DASH_MAINNET; + else + params = AddressNetwork.fromPaymentProtocolId(paymentDetails.getNetwork()); + if (params == null) + throw new PaymentProtocolException.InvalidNetwork("Invalid network " + paymentDetails.getNetwork()); + if (paymentDetails.getOutputsCount() < 1) + throw new PaymentProtocolException.InvalidOutputs("No outputs"); + for (Protos.Output output : paymentDetails.getOutputsList()) { + if (output.hasAmount()) + totalValue = totalValue.add(Coin.valueOf(output.getAmount())); + } + // This won't ever happen in practice. It would only happen if the user provided outputs + // that are obviously invalid. Still, we don't want to silently overflow. + if (totalValue.compareTo(Coin.valueOf(params.getMaxMoney())) > 0) + throw new PaymentProtocolException.InvalidOutputs("The outputs are way too big."); + } catch (InvalidProtocolBufferException e) { + throw new PaymentProtocolException(e); + } + } + + /** Returns the value of pkiVerificationData or null if it wasn't verified at construction time. */ + @Nullable public PkiVerificationData verifyPki() { + return pkiVerificationData; + } + + /** Gets the network as read from the PaymentRequest.network field: main is the default if missing. */ + public AddressNetwork getNetworkParameters() { + return params; + } + + /** Returns the protobuf that this object was instantiated with. */ + public Protos.PaymentRequest getPaymentRequest() { + return paymentRequest; + } + + /** Returns the protobuf that describes the payment to be made. */ + public Protos.PaymentDetails getPaymentDetails() { + return paymentDetails; + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/Protos.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/Protos.java new file mode 100644 index 0000000000..ce489808f5 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/Protos.java @@ -0,0 +1,4727 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.Protos, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: paymentrequest.proto + +package org.dash.wallet.common.payments.bip70; + +public final class Protos { + private Protos() {} + public static void registerAllExtensions( + com.google.protobuf.ExtensionRegistryLite registry) { + } + public interface OutputOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.Output) + com.google.protobuf.MessageLiteOrBuilder { + + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return Whether the amount field is set. + */ + boolean hasAmount(); + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return The amount. + */ + long getAmount(); + + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + * @return Whether the script field is set. + */ + boolean hasScript(); + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + * @return The script. + */ + com.google.protobuf.ByteString getScript(); + } + /** + *
+   * Generalized form of "send payment to this/these bitcoin addresses"
+   * 
+ * + * Protobuf type {@code payments.Output} + */ + public static final class Output extends + com.google.protobuf.GeneratedMessageLite< + Output, Output.Builder> implements + // @@protoc_insertion_point(message_implements:payments.Output) + OutputOrBuilder { + private Output() { + script_ = com.google.protobuf.ByteString.EMPTY; + } + private int bitField0_; + public static final int AMOUNT_FIELD_NUMBER = 1; + private long amount_; + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return Whether the amount field is set. + */ + @java.lang.Override + public boolean hasAmount() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return The amount. + */ + @java.lang.Override + public long getAmount() { + return amount_; + } + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @param value The amount to set. + */ + private void setAmount(long value) { + bitField0_ |= 0x00000001; + amount_ = value; + } + /** + *
+     * amount is integer-number-of-satoshis
+     * 
+ * + * optional uint64 amount = 1 [default = 0]; + */ + private void clearAmount() { + bitField0_ = (bitField0_ & ~0x00000001); + amount_ = 0L; + } + + public static final int SCRIPT_FIELD_NUMBER = 2; + private com.google.protobuf.ByteString script_; + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + * @return Whether the script field is set. + */ + @java.lang.Override + public boolean hasScript() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + * @return The script. + */ + @java.lang.Override + public com.google.protobuf.ByteString getScript() { + return script_; + } + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + * @param value The script to set. + */ + private void setScript(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000002; + script_ = value; + } + /** + *
+     * usually one of the standard Script forms
+     * 
+ * + * required bytes script = 2; + */ + private void clearScript() { + bitField0_ = (bitField0_ & ~0x00000002); + script_ = getDefaultInstance().getScript(); + } + + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Output parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.Output prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + *
+     * Generalized form of "send payment to this/these bitcoin addresses"
+     * 
+ * + * Protobuf type {@code payments.Output} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.Output, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.Output) + org.dash.wallet.common.payments.bip70.Protos.OutputOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.Output.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + *
+       * amount is integer-number-of-satoshis
+       * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return Whether the amount field is set. + */ + @java.lang.Override + public boolean hasAmount() { + return instance.hasAmount(); + } + /** + *
+       * amount is integer-number-of-satoshis
+       * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return The amount. + */ + @java.lang.Override + public long getAmount() { + return instance.getAmount(); + } + /** + *
+       * amount is integer-number-of-satoshis
+       * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @param value The amount to set. + * @return This builder for chaining. + */ + public Builder setAmount(long value) { + copyOnWrite(); + instance.setAmount(value); + return this; + } + /** + *
+       * amount is integer-number-of-satoshis
+       * 
+ * + * optional uint64 amount = 1 [default = 0]; + * @return This builder for chaining. + */ + public Builder clearAmount() { + copyOnWrite(); + instance.clearAmount(); + return this; + } + + /** + *
+       * usually one of the standard Script forms
+       * 
+ * + * required bytes script = 2; + * @return Whether the script field is set. + */ + @java.lang.Override + public boolean hasScript() { + return instance.hasScript(); + } + /** + *
+       * usually one of the standard Script forms
+       * 
+ * + * required bytes script = 2; + * @return The script. + */ + @java.lang.Override + public com.google.protobuf.ByteString getScript() { + return instance.getScript(); + } + /** + *
+       * usually one of the standard Script forms
+       * 
+ * + * required bytes script = 2; + * @param value The script to set. + * @return This builder for chaining. + */ + public Builder setScript(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setScript(value); + return this; + } + /** + *
+       * usually one of the standard Script forms
+       * 
+ * + * required bytes script = 2; + * @return This builder for chaining. + */ + public Builder clearScript() { + copyOnWrite(); + instance.clearScript(); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.Output) + } + private byte memoizedIsInitialized = 2; + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.Output(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "bitField0_", + "amount_", + "script_", + }; + java.lang.String info = + "\u0001\u0002\u0000\u0001\u0001\u0002\u0002\u0000\u0000\u0001\u0001\u1003\u0000\u0002" + + "\u150a\u0001"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.Output.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return memoizedIsInitialized; + } + case SET_MEMOIZED_IS_INITIALIZED: { + memoizedIsInitialized = (byte) (arg0 == null ? 0 : 1); + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.Output) + private static final org.dash.wallet.common.payments.bip70.Protos.Output DEFAULT_INSTANCE; + static { + Output defaultInstance = new Output(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + Output.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.Output getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + public interface PaymentDetailsOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.PaymentDetails) + com.google.protobuf.MessageLiteOrBuilder { + + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return Whether the network field is set. + */ + boolean hasNetwork(); + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return The network. + */ + java.lang.String getNetwork(); + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return The bytes for network. + */ + com.google.protobuf.ByteString + getNetworkBytes(); + + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + java.util.List + getOutputsList(); + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + org.dash.wallet.common.payments.bip70.Protos.Output getOutputs(int index); + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + int getOutputsCount(); + + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + * @return Whether the time field is set. + */ + boolean hasTime(); + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + * @return The time. + */ + long getTime(); + + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + * @return Whether the expires field is set. + */ + boolean hasExpires(); + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + * @return The expires. + */ + long getExpires(); + + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return Whether the memo field is set. + */ + boolean hasMemo(); + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return The memo. + */ + java.lang.String getMemo(); + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return The bytes for memo. + */ + com.google.protobuf.ByteString + getMemoBytes(); + + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return Whether the paymentUrl field is set. + */ + boolean hasPaymentUrl(); + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return The paymentUrl. + */ + java.lang.String getPaymentUrl(); + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return The bytes for paymentUrl. + */ + com.google.protobuf.ByteString + getPaymentUrlBytes(); + + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + * @return Whether the merchantData field is set. + */ + boolean hasMerchantData(); + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + * @return The merchantData. + */ + com.google.protobuf.ByteString getMerchantData(); + } + /** + * Protobuf type {@code payments.PaymentDetails} + */ + public static final class PaymentDetails extends + com.google.protobuf.GeneratedMessageLite< + PaymentDetails, PaymentDetails.Builder> implements + // @@protoc_insertion_point(message_implements:payments.PaymentDetails) + PaymentDetailsOrBuilder { + private PaymentDetails() { + network_ = "main"; + outputs_ = emptyProtobufList(); + memo_ = ""; + paymentUrl_ = ""; + merchantData_ = com.google.protobuf.ByteString.EMPTY; + } + private int bitField0_; + public static final int NETWORK_FIELD_NUMBER = 1; + private java.lang.String network_; + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return Whether the network field is set. + */ + @java.lang.Override + public boolean hasNetwork() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return The network. + */ + @java.lang.Override + public java.lang.String getNetwork() { + return network_; + } + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @return The bytes for network. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getNetworkBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(network_); + } + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @param value The network to set. + */ + private void setNetwork( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000001; + network_ = value; + } + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + */ + private void clearNetwork() { + bitField0_ = (bitField0_ & ~0x00000001); + network_ = getDefaultInstance().getNetwork(); + } + /** + *
+     * "main" or "test"
+     * 
+ * + * optional string network = 1 [default = "main"]; + * @param value The bytes for network to set. + */ + private void setNetworkBytes( + com.google.protobuf.ByteString value) { + network_ = value.toStringUtf8(); + bitField0_ |= 0x00000001; + } + + public static final int OUTPUTS_FIELD_NUMBER = 2; + private com.google.protobuf.Internal.ProtobufList outputs_; + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public java.util.List getOutputsList() { + return outputs_; + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + public java.util.List + getOutputsOrBuilderList() { + return outputs_; + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public int getOutputsCount() { + return outputs_.size(); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Output getOutputs(int index) { + return outputs_.get(index); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + public org.dash.wallet.common.payments.bip70.Protos.OutputOrBuilder getOutputsOrBuilder( + int index) { + return outputs_.get(index); + } + private void ensureOutputsIsMutable() { + com.google.protobuf.Internal.ProtobufList tmp = outputs_; + if (!tmp.isModifiable()) { + outputs_ = + com.google.protobuf.GeneratedMessageLite.mutableCopy(tmp); + } + } + + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void setOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureOutputsIsMutable(); + outputs_.set(index, value); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void addOutputs(org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureOutputsIsMutable(); + outputs_.add(value); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void addOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureOutputsIsMutable(); + outputs_.add(index, value); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void addAllOutputs( + java.lang.Iterable values) { + ensureOutputsIsMutable(); + com.google.protobuf.AbstractMessageLite.addAll( + values, outputs_); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void clearOutputs() { + outputs_ = emptyProtobufList(); + } + /** + *
+     * Where payment should be sent
+     * 
+ * + * repeated .payments.Output outputs = 2; + */ + private void removeOutputs(int index) { + ensureOutputsIsMutable(); + outputs_.remove(index); + } + + public static final int TIME_FIELD_NUMBER = 3; + private long time_; + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + * @return Whether the time field is set. + */ + @java.lang.Override + public boolean hasTime() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + * @return The time. + */ + @java.lang.Override + public long getTime() { + return time_; + } + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + * @param value The time to set. + */ + private void setTime(long value) { + bitField0_ |= 0x00000002; + time_ = value; + } + /** + *
+     * Timestamp; when payment request created
+     * 
+ * + * required uint64 time = 3; + */ + private void clearTime() { + bitField0_ = (bitField0_ & ~0x00000002); + time_ = 0L; + } + + public static final int EXPIRES_FIELD_NUMBER = 4; + private long expires_; + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + * @return Whether the expires field is set. + */ + @java.lang.Override + public boolean hasExpires() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + * @return The expires. + */ + @java.lang.Override + public long getExpires() { + return expires_; + } + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + * @param value The expires to set. + */ + private void setExpires(long value) { + bitField0_ |= 0x00000004; + expires_ = value; + } + /** + *
+     * Timestamp; when this request should be considered invalid
+     * 
+ * + * optional uint64 expires = 4; + */ + private void clearExpires() { + bitField0_ = (bitField0_ & ~0x00000004); + expires_ = 0L; + } + + public static final int MEMO_FIELD_NUMBER = 5; + private java.lang.String memo_; + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return memo_; + } + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(memo_); + } + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @param value The memo to set. + */ + private void setMemo( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000008; + memo_ = value; + } + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + */ + private void clearMemo() { + bitField0_ = (bitField0_ & ~0x00000008); + memo_ = getDefaultInstance().getMemo(); + } + /** + *
+     * Human-readable description of request for the customer
+     * 
+ * + * optional string memo = 5; + * @param value The bytes for memo to set. + */ + private void setMemoBytes( + com.google.protobuf.ByteString value) { + memo_ = value.toStringUtf8(); + bitField0_ |= 0x00000008; + } + + public static final int PAYMENT_URL_FIELD_NUMBER = 6; + private java.lang.String paymentUrl_; + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return Whether the paymentUrl field is set. + */ + @java.lang.Override + public boolean hasPaymentUrl() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return The paymentUrl. + */ + @java.lang.Override + public java.lang.String getPaymentUrl() { + return paymentUrl_; + } + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @return The bytes for paymentUrl. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPaymentUrlBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(paymentUrl_); + } + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @param value The paymentUrl to set. + */ + private void setPaymentUrl( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000010; + paymentUrl_ = value; + } + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + */ + private void clearPaymentUrl() { + bitField0_ = (bitField0_ & ~0x00000010); + paymentUrl_ = getDefaultInstance().getPaymentUrl(); + } + /** + *
+     * URL to send Payment and get PaymentACK
+     * 
+ * + * optional string payment_url = 6; + * @param value The bytes for paymentUrl to set. + */ + private void setPaymentUrlBytes( + com.google.protobuf.ByteString value) { + paymentUrl_ = value.toStringUtf8(); + bitField0_ |= 0x00000010; + } + + public static final int MERCHANT_DATA_FIELD_NUMBER = 7; + private com.google.protobuf.ByteString merchantData_; + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + * @return Whether the merchantData field is set. + */ + @java.lang.Override + public boolean hasMerchantData() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + * @return The merchantData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getMerchantData() { + return merchantData_; + } + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + * @param value The merchantData to set. + */ + private void setMerchantData(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000020; + merchantData_ = value; + } + /** + *
+     * Arbitrary data to include in the Payment message
+     * 
+ * + * optional bytes merchant_data = 7; + */ + private void clearMerchantData() { + bitField0_ = (bitField0_ & ~0x00000020); + merchantData_ = getDefaultInstance().getMerchantData(); + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.PaymentDetails prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + * Protobuf type {@code payments.PaymentDetails} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.PaymentDetails, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.PaymentDetails) + org.dash.wallet.common.payments.bip70.Protos.PaymentDetailsOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.PaymentDetails.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @return Whether the network field is set. + */ + @java.lang.Override + public boolean hasNetwork() { + return instance.hasNetwork(); + } + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @return The network. + */ + @java.lang.Override + public java.lang.String getNetwork() { + return instance.getNetwork(); + } + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @return The bytes for network. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getNetworkBytes() { + return instance.getNetworkBytes(); + } + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @param value The network to set. + * @return This builder for chaining. + */ + public Builder setNetwork( + java.lang.String value) { + copyOnWrite(); + instance.setNetwork(value); + return this; + } + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @return This builder for chaining. + */ + public Builder clearNetwork() { + copyOnWrite(); + instance.clearNetwork(); + return this; + } + /** + *
+       * "main" or "test"
+       * 
+ * + * optional string network = 1 [default = "main"]; + * @param value The bytes for network to set. + * @return This builder for chaining. + */ + public Builder setNetworkBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setNetworkBytes(value); + return this; + } + + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public java.util.List getOutputsList() { + return java.util.Collections.unmodifiableList( + instance.getOutputsList()); + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public int getOutputsCount() { + return instance.getOutputsCount(); + }/** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Output getOutputs(int index) { + return instance.getOutputs(index); + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder setOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.setOutputs(index, value); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder setOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.setOutputs(index, + builderForValue.build()); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder addOutputs(org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.addOutputs(value); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder addOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.addOutputs(index, value); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder addOutputs( + org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.addOutputs(builderForValue.build()); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder addOutputs( + int index, org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.addOutputs(index, + builderForValue.build()); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder addAllOutputs( + java.lang.Iterable values) { + copyOnWrite(); + instance.addAllOutputs(values); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder clearOutputs() { + copyOnWrite(); + instance.clearOutputs(); + return this; + } + /** + *
+       * Where payment should be sent
+       * 
+ * + * repeated .payments.Output outputs = 2; + */ + public Builder removeOutputs(int index) { + copyOnWrite(); + instance.removeOutputs(index); + return this; + } + + /** + *
+       * Timestamp; when payment request created
+       * 
+ * + * required uint64 time = 3; + * @return Whether the time field is set. + */ + @java.lang.Override + public boolean hasTime() { + return instance.hasTime(); + } + /** + *
+       * Timestamp; when payment request created
+       * 
+ * + * required uint64 time = 3; + * @return The time. + */ + @java.lang.Override + public long getTime() { + return instance.getTime(); + } + /** + *
+       * Timestamp; when payment request created
+       * 
+ * + * required uint64 time = 3; + * @param value The time to set. + * @return This builder for chaining. + */ + public Builder setTime(long value) { + copyOnWrite(); + instance.setTime(value); + return this; + } + /** + *
+       * Timestamp; when payment request created
+       * 
+ * + * required uint64 time = 3; + * @return This builder for chaining. + */ + public Builder clearTime() { + copyOnWrite(); + instance.clearTime(); + return this; + } + + /** + *
+       * Timestamp; when this request should be considered invalid
+       * 
+ * + * optional uint64 expires = 4; + * @return Whether the expires field is set. + */ + @java.lang.Override + public boolean hasExpires() { + return instance.hasExpires(); + } + /** + *
+       * Timestamp; when this request should be considered invalid
+       * 
+ * + * optional uint64 expires = 4; + * @return The expires. + */ + @java.lang.Override + public long getExpires() { + return instance.getExpires(); + } + /** + *
+       * Timestamp; when this request should be considered invalid
+       * 
+ * + * optional uint64 expires = 4; + * @param value The expires to set. + * @return This builder for chaining. + */ + public Builder setExpires(long value) { + copyOnWrite(); + instance.setExpires(value); + return this; + } + /** + *
+       * Timestamp; when this request should be considered invalid
+       * 
+ * + * optional uint64 expires = 4; + * @return This builder for chaining. + */ + public Builder clearExpires() { + copyOnWrite(); + instance.clearExpires(); + return this; + } + + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return instance.hasMemo(); + } + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return instance.getMemo(); + } + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return instance.getMemoBytes(); + } + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @param value The memo to set. + * @return This builder for chaining. + */ + public Builder setMemo( + java.lang.String value) { + copyOnWrite(); + instance.setMemo(value); + return this; + } + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @return This builder for chaining. + */ + public Builder clearMemo() { + copyOnWrite(); + instance.clearMemo(); + return this; + } + /** + *
+       * Human-readable description of request for the customer
+       * 
+ * + * optional string memo = 5; + * @param value The bytes for memo to set. + * @return This builder for chaining. + */ + public Builder setMemoBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setMemoBytes(value); + return this; + } + + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @return Whether the paymentUrl field is set. + */ + @java.lang.Override + public boolean hasPaymentUrl() { + return instance.hasPaymentUrl(); + } + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @return The paymentUrl. + */ + @java.lang.Override + public java.lang.String getPaymentUrl() { + return instance.getPaymentUrl(); + } + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @return The bytes for paymentUrl. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPaymentUrlBytes() { + return instance.getPaymentUrlBytes(); + } + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @param value The paymentUrl to set. + * @return This builder for chaining. + */ + public Builder setPaymentUrl( + java.lang.String value) { + copyOnWrite(); + instance.setPaymentUrl(value); + return this; + } + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @return This builder for chaining. + */ + public Builder clearPaymentUrl() { + copyOnWrite(); + instance.clearPaymentUrl(); + return this; + } + /** + *
+       * URL to send Payment and get PaymentACK
+       * 
+ * + * optional string payment_url = 6; + * @param value The bytes for paymentUrl to set. + * @return This builder for chaining. + */ + public Builder setPaymentUrlBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setPaymentUrlBytes(value); + return this; + } + + /** + *
+       * Arbitrary data to include in the Payment message
+       * 
+ * + * optional bytes merchant_data = 7; + * @return Whether the merchantData field is set. + */ + @java.lang.Override + public boolean hasMerchantData() { + return instance.hasMerchantData(); + } + /** + *
+       * Arbitrary data to include in the Payment message
+       * 
+ * + * optional bytes merchant_data = 7; + * @return The merchantData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getMerchantData() { + return instance.getMerchantData(); + } + /** + *
+       * Arbitrary data to include in the Payment message
+       * 
+ * + * optional bytes merchant_data = 7; + * @param value The merchantData to set. + * @return This builder for chaining. + */ + public Builder setMerchantData(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setMerchantData(value); + return this; + } + /** + *
+       * Arbitrary data to include in the Payment message
+       * 
+ * + * optional bytes merchant_data = 7; + * @return This builder for chaining. + */ + public Builder clearMerchantData() { + copyOnWrite(); + instance.clearMerchantData(); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.PaymentDetails) + } + private byte memoizedIsInitialized = 2; + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.PaymentDetails(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "bitField0_", + "network_", + "outputs_", + org.dash.wallet.common.payments.bip70.Protos.Output.class, + "time_", + "expires_", + "memo_", + "paymentUrl_", + "merchantData_", + }; + java.lang.String info = + "\u0001\u0007\u0000\u0001\u0001\u0007\u0007\u0000\u0001\u0002\u0001\u1008\u0000\u0002" + + "\u041b\u0003\u1503\u0001\u0004\u1003\u0002\u0005\u1008\u0003\u0006\u1008\u0004\u0007" + + "\u100a\u0005"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.PaymentDetails.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return memoizedIsInitialized; + } + case SET_MEMOIZED_IS_INITIALIZED: { + memoizedIsInitialized = (byte) (arg0 == null ? 0 : 1); + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.PaymentDetails) + private static final org.dash.wallet.common.payments.bip70.Protos.PaymentDetails DEFAULT_INSTANCE; + static { + PaymentDetails defaultInstance = new PaymentDetails(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + PaymentDetails.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentDetails getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + public interface PaymentRequestOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.PaymentRequest) + com.google.protobuf.MessageLiteOrBuilder { + + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return Whether the paymentDetailsVersion field is set. + */ + boolean hasPaymentDetailsVersion(); + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return The paymentDetailsVersion. + */ + int getPaymentDetailsVersion(); + + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return Whether the pkiType field is set. + */ + boolean hasPkiType(); + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The pkiType. + */ + java.lang.String getPkiType(); + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The bytes for pkiType. + */ + com.google.protobuf.ByteString + getPkiTypeBytes(); + + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + * @return Whether the pkiData field is set. + */ + boolean hasPkiData(); + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + * @return The pkiData. + */ + com.google.protobuf.ByteString getPkiData(); + + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + * @return Whether the serializedPaymentDetails field is set. + */ + boolean hasSerializedPaymentDetails(); + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + * @return The serializedPaymentDetails. + */ + com.google.protobuf.ByteString getSerializedPaymentDetails(); + + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + * @return Whether the signature field is set. + */ + boolean hasSignature(); + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + * @return The signature. + */ + com.google.protobuf.ByteString getSignature(); + } + /** + * Protobuf type {@code payments.PaymentRequest} + */ + public static final class PaymentRequest extends + com.google.protobuf.GeneratedMessageLite< + PaymentRequest, PaymentRequest.Builder> implements + // @@protoc_insertion_point(message_implements:payments.PaymentRequest) + PaymentRequestOrBuilder { + private PaymentRequest() { + paymentDetailsVersion_ = 1; + pkiType_ = "none"; + pkiData_ = com.google.protobuf.ByteString.EMPTY; + serializedPaymentDetails_ = com.google.protobuf.ByteString.EMPTY; + signature_ = com.google.protobuf.ByteString.EMPTY; + } + private int bitField0_; + public static final int PAYMENT_DETAILS_VERSION_FIELD_NUMBER = 1; + private int paymentDetailsVersion_; + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return Whether the paymentDetailsVersion field is set. + */ + @java.lang.Override + public boolean hasPaymentDetailsVersion() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return The paymentDetailsVersion. + */ + @java.lang.Override + public int getPaymentDetailsVersion() { + return paymentDetailsVersion_; + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @param value The paymentDetailsVersion to set. + */ + private void setPaymentDetailsVersion(int value) { + bitField0_ |= 0x00000001; + paymentDetailsVersion_ = value; + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + */ + private void clearPaymentDetailsVersion() { + bitField0_ = (bitField0_ & ~0x00000001); + paymentDetailsVersion_ = 1; + } + + public static final int PKI_TYPE_FIELD_NUMBER = 2; + private java.lang.String pkiType_; + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return Whether the pkiType field is set. + */ + @java.lang.Override + public boolean hasPkiType() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The pkiType. + */ + @java.lang.Override + public java.lang.String getPkiType() { + return pkiType_; + } + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The bytes for pkiType. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPkiTypeBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(pkiType_); + } + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @param value The pkiType to set. + */ + private void setPkiType( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000002; + pkiType_ = value; + } + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + */ + private void clearPkiType() { + bitField0_ = (bitField0_ & ~0x00000002); + pkiType_ = getDefaultInstance().getPkiType(); + } + /** + *
+     * none / x509+sha256 / x509+sha1
+     * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @param value The bytes for pkiType to set. + */ + private void setPkiTypeBytes( + com.google.protobuf.ByteString value) { + pkiType_ = value.toStringUtf8(); + bitField0_ |= 0x00000002; + } + + public static final int PKI_DATA_FIELD_NUMBER = 3; + private com.google.protobuf.ByteString pkiData_; + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + * @return Whether the pkiData field is set. + */ + @java.lang.Override + public boolean hasPkiData() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + * @return The pkiData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getPkiData() { + return pkiData_; + } + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + * @param value The pkiData to set. + */ + private void setPkiData(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000004; + pkiData_ = value; + } + /** + *
+     * depends on pki_type
+     * 
+ * + * optional bytes pki_data = 3; + */ + private void clearPkiData() { + bitField0_ = (bitField0_ & ~0x00000004); + pkiData_ = getDefaultInstance().getPkiData(); + } + + public static final int SERIALIZED_PAYMENT_DETAILS_FIELD_NUMBER = 4; + private com.google.protobuf.ByteString serializedPaymentDetails_; + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + * @return Whether the serializedPaymentDetails field is set. + */ + @java.lang.Override + public boolean hasSerializedPaymentDetails() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + * @return The serializedPaymentDetails. + */ + @java.lang.Override + public com.google.protobuf.ByteString getSerializedPaymentDetails() { + return serializedPaymentDetails_; + } + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + * @param value The serializedPaymentDetails to set. + */ + private void setSerializedPaymentDetails(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000008; + serializedPaymentDetails_ = value; + } + /** + *
+     * PaymentDetails
+     * 
+ * + * required bytes serialized_payment_details = 4; + */ + private void clearSerializedPaymentDetails() { + bitField0_ = (bitField0_ & ~0x00000008); + serializedPaymentDetails_ = getDefaultInstance().getSerializedPaymentDetails(); + } + + public static final int SIGNATURE_FIELD_NUMBER = 5; + private com.google.protobuf.ByteString signature_; + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + * @return Whether the signature field is set. + */ + @java.lang.Override + public boolean hasSignature() { + return ((bitField0_ & 0x00000010) != 0); + } + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + * @return The signature. + */ + @java.lang.Override + public com.google.protobuf.ByteString getSignature() { + return signature_; + } + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + * @param value The signature to set. + */ + private void setSignature(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000010; + signature_ = value; + } + /** + *
+     * pki-dependent signature
+     * 
+ * + * optional bytes signature = 5; + */ + private void clearSignature() { + bitField0_ = (bitField0_ & ~0x00000010); + signature_ = getDefaultInstance().getSignature(); + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.PaymentRequest prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + * Protobuf type {@code payments.PaymentRequest} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.PaymentRequest, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.PaymentRequest) + org.dash.wallet.common.payments.bip70.Protos.PaymentRequestOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.PaymentRequest.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return Whether the paymentDetailsVersion field is set. + */ + @java.lang.Override + public boolean hasPaymentDetailsVersion() { + return instance.hasPaymentDetailsVersion(); + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return The paymentDetailsVersion. + */ + @java.lang.Override + public int getPaymentDetailsVersion() { + return instance.getPaymentDetailsVersion(); + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @param value The paymentDetailsVersion to set. + * @return This builder for chaining. + */ + public Builder setPaymentDetailsVersion(int value) { + copyOnWrite(); + instance.setPaymentDetailsVersion(value); + return this; + } + /** + * optional uint32 payment_details_version = 1 [default = 1]; + * @return This builder for chaining. + */ + public Builder clearPaymentDetailsVersion() { + copyOnWrite(); + instance.clearPaymentDetailsVersion(); + return this; + } + + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return Whether the pkiType field is set. + */ + @java.lang.Override + public boolean hasPkiType() { + return instance.hasPkiType(); + } + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The pkiType. + */ + @java.lang.Override + public java.lang.String getPkiType() { + return instance.getPkiType(); + } + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return The bytes for pkiType. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPkiTypeBytes() { + return instance.getPkiTypeBytes(); + } + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @param value The pkiType to set. + * @return This builder for chaining. + */ + public Builder setPkiType( + java.lang.String value) { + copyOnWrite(); + instance.setPkiType(value); + return this; + } + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @return This builder for chaining. + */ + public Builder clearPkiType() { + copyOnWrite(); + instance.clearPkiType(); + return this; + } + /** + *
+       * none / x509+sha256 / x509+sha1
+       * 
+ * + * optional string pki_type = 2 [default = "none"]; + * @param value The bytes for pkiType to set. + * @return This builder for chaining. + */ + public Builder setPkiTypeBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setPkiTypeBytes(value); + return this; + } + + /** + *
+       * depends on pki_type
+       * 
+ * + * optional bytes pki_data = 3; + * @return Whether the pkiData field is set. + */ + @java.lang.Override + public boolean hasPkiData() { + return instance.hasPkiData(); + } + /** + *
+       * depends on pki_type
+       * 
+ * + * optional bytes pki_data = 3; + * @return The pkiData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getPkiData() { + return instance.getPkiData(); + } + /** + *
+       * depends on pki_type
+       * 
+ * + * optional bytes pki_data = 3; + * @param value The pkiData to set. + * @return This builder for chaining. + */ + public Builder setPkiData(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setPkiData(value); + return this; + } + /** + *
+       * depends on pki_type
+       * 
+ * + * optional bytes pki_data = 3; + * @return This builder for chaining. + */ + public Builder clearPkiData() { + copyOnWrite(); + instance.clearPkiData(); + return this; + } + + /** + *
+       * PaymentDetails
+       * 
+ * + * required bytes serialized_payment_details = 4; + * @return Whether the serializedPaymentDetails field is set. + */ + @java.lang.Override + public boolean hasSerializedPaymentDetails() { + return instance.hasSerializedPaymentDetails(); + } + /** + *
+       * PaymentDetails
+       * 
+ * + * required bytes serialized_payment_details = 4; + * @return The serializedPaymentDetails. + */ + @java.lang.Override + public com.google.protobuf.ByteString getSerializedPaymentDetails() { + return instance.getSerializedPaymentDetails(); + } + /** + *
+       * PaymentDetails
+       * 
+ * + * required bytes serialized_payment_details = 4; + * @param value The serializedPaymentDetails to set. + * @return This builder for chaining. + */ + public Builder setSerializedPaymentDetails(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setSerializedPaymentDetails(value); + return this; + } + /** + *
+       * PaymentDetails
+       * 
+ * + * required bytes serialized_payment_details = 4; + * @return This builder for chaining. + */ + public Builder clearSerializedPaymentDetails() { + copyOnWrite(); + instance.clearSerializedPaymentDetails(); + return this; + } + + /** + *
+       * pki-dependent signature
+       * 
+ * + * optional bytes signature = 5; + * @return Whether the signature field is set. + */ + @java.lang.Override + public boolean hasSignature() { + return instance.hasSignature(); + } + /** + *
+       * pki-dependent signature
+       * 
+ * + * optional bytes signature = 5; + * @return The signature. + */ + @java.lang.Override + public com.google.protobuf.ByteString getSignature() { + return instance.getSignature(); + } + /** + *
+       * pki-dependent signature
+       * 
+ * + * optional bytes signature = 5; + * @param value The signature to set. + * @return This builder for chaining. + */ + public Builder setSignature(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setSignature(value); + return this; + } + /** + *
+       * pki-dependent signature
+       * 
+ * + * optional bytes signature = 5; + * @return This builder for chaining. + */ + public Builder clearSignature() { + copyOnWrite(); + instance.clearSignature(); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.PaymentRequest) + } + private byte memoizedIsInitialized = 2; + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.PaymentRequest(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "bitField0_", + "paymentDetailsVersion_", + "pkiType_", + "pkiData_", + "serializedPaymentDetails_", + "signature_", + }; + java.lang.String info = + "\u0001\u0005\u0000\u0001\u0001\u0005\u0005\u0000\u0000\u0001\u0001\u100b\u0000\u0002" + + "\u1008\u0001\u0003\u100a\u0002\u0004\u150a\u0003\u0005\u100a\u0004"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.PaymentRequest.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return memoizedIsInitialized; + } + case SET_MEMOIZED_IS_INITIALIZED: { + memoizedIsInitialized = (byte) (arg0 == null ? 0 : 1); + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.PaymentRequest) + private static final org.dash.wallet.common.payments.bip70.Protos.PaymentRequest DEFAULT_INSTANCE; + static { + PaymentRequest defaultInstance = new PaymentRequest(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + PaymentRequest.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentRequest getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + public interface X509CertificatesOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.X509Certificates) + com.google.protobuf.MessageLiteOrBuilder { + + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @return A list containing the certificate. + */ + java.util.List getCertificateList(); + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @return The count of certificate. + */ + int getCertificateCount(); + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @param index The index of the element to return. + * @return The certificate at the given index. + */ + com.google.protobuf.ByteString getCertificate(int index); + } + /** + * Protobuf type {@code payments.X509Certificates} + */ + public static final class X509Certificates extends + com.google.protobuf.GeneratedMessageLite< + X509Certificates, X509Certificates.Builder> implements + // @@protoc_insertion_point(message_implements:payments.X509Certificates) + X509CertificatesOrBuilder { + private X509Certificates() { + certificate_ = emptyProtobufList(); + } + public static final int CERTIFICATE_FIELD_NUMBER = 1; + private com.google.protobuf.Internal.ProtobufList certificate_; + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @return A list containing the certificate. + */ + @java.lang.Override + public java.util.List + getCertificateList() { + return certificate_; + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @return The count of certificate. + */ + @java.lang.Override + public int getCertificateCount() { + return certificate_.size(); + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @param index The index of the element to return. + * @return The certificate at the given index. + */ + @java.lang.Override + public com.google.protobuf.ByteString getCertificate(int index) { + return certificate_.get(index); + } + private void ensureCertificateIsMutable() { + com.google.protobuf.Internal.ProtobufList tmp = certificate_; + if (!tmp.isModifiable()) { + certificate_ = + com.google.protobuf.GeneratedMessageLite.mutableCopy(tmp); + } + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @param index The index to set the value at. + * @param value The certificate to set. + */ + private void setCertificate( + int index, com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + ensureCertificateIsMutable(); + certificate_.set(index, value); + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @param value The certificate to add. + */ + private void addCertificate(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + ensureCertificateIsMutable(); + certificate_.add(value); + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + * @param values The certificate to add. + */ + private void addAllCertificate( + java.lang.Iterable values) { + ensureCertificateIsMutable(); + com.google.protobuf.AbstractMessageLite.addAll( + values, certificate_); + } + /** + *
+     * DER-encoded X.509 certificate chain
+     * 
+ * + * repeated bytes certificate = 1; + */ + private void clearCertificate() { + certificate_ = emptyProtobufList(); + } + + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.X509Certificates prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + * Protobuf type {@code payments.X509Certificates} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.X509Certificates, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.X509Certificates) + org.dash.wallet.common.payments.bip70.Protos.X509CertificatesOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.X509Certificates.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @return A list containing the certificate. + */ + @java.lang.Override + public java.util.List + getCertificateList() { + return java.util.Collections.unmodifiableList( + instance.getCertificateList()); + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @return The count of certificate. + */ + @java.lang.Override + public int getCertificateCount() { + return instance.getCertificateCount(); + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @param index The index of the element to return. + * @return The certificate at the given index. + */ + @java.lang.Override + public com.google.protobuf.ByteString getCertificate(int index) { + return instance.getCertificate(index); + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @param value The certificate to set. + * @return This builder for chaining. + */ + public Builder setCertificate( + int index, com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setCertificate(index, value); + return this; + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @param value The certificate to add. + * @return This builder for chaining. + */ + public Builder addCertificate(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.addCertificate(value); + return this; + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @param values The certificate to add. + * @return This builder for chaining. + */ + public Builder addAllCertificate( + java.lang.Iterable values) { + copyOnWrite(); + instance.addAllCertificate(values); + return this; + } + /** + *
+       * DER-encoded X.509 certificate chain
+       * 
+ * + * repeated bytes certificate = 1; + * @return This builder for chaining. + */ + public Builder clearCertificate() { + copyOnWrite(); + instance.clearCertificate(); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.X509Certificates) + } + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.X509Certificates(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "certificate_", + }; + java.lang.String info = + "\u0001\u0001\u0000\u0000\u0001\u0001\u0001\u0000\u0001\u0000\u0001\u001c"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.X509Certificates.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return (byte) 1; + } + case SET_MEMOIZED_IS_INITIALIZED: { + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.X509Certificates) + private static final org.dash.wallet.common.payments.bip70.Protos.X509Certificates DEFAULT_INSTANCE; + static { + X509Certificates defaultInstance = new X509Certificates(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + X509Certificates.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.X509Certificates getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + public interface PaymentOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.Payment) + com.google.protobuf.MessageLiteOrBuilder { + + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + * @return Whether the merchantData field is set. + */ + boolean hasMerchantData(); + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + * @return The merchantData. + */ + com.google.protobuf.ByteString getMerchantData(); + + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @return A list containing the transactions. + */ + java.util.List getTransactionsList(); + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @return The count of transactions. + */ + int getTransactionsCount(); + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @param index The index of the element to return. + * @return The transactions at the given index. + */ + com.google.protobuf.ByteString getTransactions(int index); + + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + java.util.List + getRefundToList(); + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + org.dash.wallet.common.payments.bip70.Protos.Output getRefundTo(int index); + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + int getRefundToCount(); + + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return Whether the memo field is set. + */ + boolean hasMemo(); + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return The memo. + */ + java.lang.String getMemo(); + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return The bytes for memo. + */ + com.google.protobuf.ByteString + getMemoBytes(); + } + /** + * Protobuf type {@code payments.Payment} + */ + public static final class Payment extends + com.google.protobuf.GeneratedMessageLite< + Payment, Payment.Builder> implements + // @@protoc_insertion_point(message_implements:payments.Payment) + PaymentOrBuilder { + private Payment() { + merchantData_ = com.google.protobuf.ByteString.EMPTY; + transactions_ = emptyProtobufList(); + refundTo_ = emptyProtobufList(); + memo_ = ""; + } + private int bitField0_; + public static final int MERCHANT_DATA_FIELD_NUMBER = 1; + private com.google.protobuf.ByteString merchantData_; + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + * @return Whether the merchantData field is set. + */ + @java.lang.Override + public boolean hasMerchantData() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + * @return The merchantData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getMerchantData() { + return merchantData_; + } + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + * @param value The merchantData to set. + */ + private void setMerchantData(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000001; + merchantData_ = value; + } + /** + *
+     * From PaymentDetails.merchant_data
+     * 
+ * + * optional bytes merchant_data = 1; + */ + private void clearMerchantData() { + bitField0_ = (bitField0_ & ~0x00000001); + merchantData_ = getDefaultInstance().getMerchantData(); + } + + public static final int TRANSACTIONS_FIELD_NUMBER = 2; + private com.google.protobuf.Internal.ProtobufList transactions_; + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @return A list containing the transactions. + */ + @java.lang.Override + public java.util.List + getTransactionsList() { + return transactions_; + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @return The count of transactions. + */ + @java.lang.Override + public int getTransactionsCount() { + return transactions_.size(); + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @param index The index of the element to return. + * @return The transactions at the given index. + */ + @java.lang.Override + public com.google.protobuf.ByteString getTransactions(int index) { + return transactions_.get(index); + } + private void ensureTransactionsIsMutable() { + com.google.protobuf.Internal.ProtobufList tmp = transactions_; + if (!tmp.isModifiable()) { + transactions_ = + com.google.protobuf.GeneratedMessageLite.mutableCopy(tmp); + } + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @param index The index to set the value at. + * @param value The transactions to set. + */ + private void setTransactions( + int index, com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + ensureTransactionsIsMutable(); + transactions_.set(index, value); + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @param value The transactions to add. + */ + private void addTransactions(com.google.protobuf.ByteString value) { + java.lang.Class valueClass = value.getClass(); + ensureTransactionsIsMutable(); + transactions_.add(value); + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + * @param values The transactions to add. + */ + private void addAllTransactions( + java.lang.Iterable values) { + ensureTransactionsIsMutable(); + com.google.protobuf.AbstractMessageLite.addAll( + values, transactions_); + } + /** + *
+     * Signed transactions that satisfy PaymentDetails.outputs
+     * 
+ * + * repeated bytes transactions = 2; + */ + private void clearTransactions() { + transactions_ = emptyProtobufList(); + } + + public static final int REFUND_TO_FIELD_NUMBER = 3; + private com.google.protobuf.Internal.ProtobufList refundTo_; + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public java.util.List getRefundToList() { + return refundTo_; + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public java.util.List + getRefundToOrBuilderList() { + return refundTo_; + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public int getRefundToCount() { + return refundTo_.size(); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Output getRefundTo(int index) { + return refundTo_.get(index); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public org.dash.wallet.common.payments.bip70.Protos.OutputOrBuilder getRefundToOrBuilder( + int index) { + return refundTo_.get(index); + } + private void ensureRefundToIsMutable() { + com.google.protobuf.Internal.ProtobufList tmp = refundTo_; + if (!tmp.isModifiable()) { + refundTo_ = + com.google.protobuf.GeneratedMessageLite.mutableCopy(tmp); + } + } + + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void setRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureRefundToIsMutable(); + refundTo_.set(index, value); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void addRefundTo(org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureRefundToIsMutable(); + refundTo_.add(value); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void addRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + value.getClass(); + ensureRefundToIsMutable(); + refundTo_.add(index, value); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void addAllRefundTo( + java.lang.Iterable values) { + ensureRefundToIsMutable(); + com.google.protobuf.AbstractMessageLite.addAll( + values, refundTo_); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void clearRefundTo() { + refundTo_ = emptyProtobufList(); + } + /** + *
+     * Where to send refunds, if a refund is necessary
+     * 
+ * + * repeated .payments.Output refund_to = 3; + */ + private void removeRefundTo(int index) { + ensureRefundToIsMutable(); + refundTo_.remove(index); + } + + public static final int MEMO_FIELD_NUMBER = 4; + private java.lang.String memo_; + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return memo_; + } + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(memo_); + } + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @param value The memo to set. + */ + private void setMemo( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000002; + memo_ = value; + } + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + */ + private void clearMemo() { + bitField0_ = (bitField0_ & ~0x00000002); + memo_ = getDefaultInstance().getMemo(); + } + /** + *
+     * Human-readable message for the merchant
+     * 
+ * + * optional string memo = 4; + * @param value The bytes for memo to set. + */ + private void setMemoBytes( + com.google.protobuf.ByteString value) { + memo_ = value.toStringUtf8(); + bitField0_ |= 0x00000002; + } + + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.Payment parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.Payment prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + * Protobuf type {@code payments.Payment} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.Payment, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.Payment) + org.dash.wallet.common.payments.bip70.Protos.PaymentOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.Payment.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + *
+       * From PaymentDetails.merchant_data
+       * 
+ * + * optional bytes merchant_data = 1; + * @return Whether the merchantData field is set. + */ + @java.lang.Override + public boolean hasMerchantData() { + return instance.hasMerchantData(); + } + /** + *
+       * From PaymentDetails.merchant_data
+       * 
+ * + * optional bytes merchant_data = 1; + * @return The merchantData. + */ + @java.lang.Override + public com.google.protobuf.ByteString getMerchantData() { + return instance.getMerchantData(); + } + /** + *
+       * From PaymentDetails.merchant_data
+       * 
+ * + * optional bytes merchant_data = 1; + * @param value The merchantData to set. + * @return This builder for chaining. + */ + public Builder setMerchantData(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setMerchantData(value); + return this; + } + /** + *
+       * From PaymentDetails.merchant_data
+       * 
+ * + * optional bytes merchant_data = 1; + * @return This builder for chaining. + */ + public Builder clearMerchantData() { + copyOnWrite(); + instance.clearMerchantData(); + return this; + } + + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @return A list containing the transactions. + */ + @java.lang.Override + public java.util.List + getTransactionsList() { + return java.util.Collections.unmodifiableList( + instance.getTransactionsList()); + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @return The count of transactions. + */ + @java.lang.Override + public int getTransactionsCount() { + return instance.getTransactionsCount(); + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @param index The index of the element to return. + * @return The transactions at the given index. + */ + @java.lang.Override + public com.google.protobuf.ByteString getTransactions(int index) { + return instance.getTransactions(index); + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @param value The transactions to set. + * @return This builder for chaining. + */ + public Builder setTransactions( + int index, com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setTransactions(index, value); + return this; + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @param value The transactions to add. + * @return This builder for chaining. + */ + public Builder addTransactions(com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.addTransactions(value); + return this; + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @param values The transactions to add. + * @return This builder for chaining. + */ + public Builder addAllTransactions( + java.lang.Iterable values) { + copyOnWrite(); + instance.addAllTransactions(values); + return this; + } + /** + *
+       * Signed transactions that satisfy PaymentDetails.outputs
+       * 
+ * + * repeated bytes transactions = 2; + * @return This builder for chaining. + */ + public Builder clearTransactions() { + copyOnWrite(); + instance.clearTransactions(); + return this; + } + + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public java.util.List getRefundToList() { + return java.util.Collections.unmodifiableList( + instance.getRefundToList()); + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public int getRefundToCount() { + return instance.getRefundToCount(); + }/** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Output getRefundTo(int index) { + return instance.getRefundTo(index); + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder setRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.setRefundTo(index, value); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder setRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.setRefundTo(index, + builderForValue.build()); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder addRefundTo(org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.addRefundTo(value); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder addRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output value) { + copyOnWrite(); + instance.addRefundTo(index, value); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder addRefundTo( + org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.addRefundTo(builderForValue.build()); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder addRefundTo( + int index, org.dash.wallet.common.payments.bip70.Protos.Output.Builder builderForValue) { + copyOnWrite(); + instance.addRefundTo(index, + builderForValue.build()); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder addAllRefundTo( + java.lang.Iterable values) { + copyOnWrite(); + instance.addAllRefundTo(values); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder clearRefundTo() { + copyOnWrite(); + instance.clearRefundTo(); + return this; + } + /** + *
+       * Where to send refunds, if a refund is necessary
+       * 
+ * + * repeated .payments.Output refund_to = 3; + */ + public Builder removeRefundTo(int index) { + copyOnWrite(); + instance.removeRefundTo(index); + return this; + } + + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return instance.hasMemo(); + } + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return instance.getMemo(); + } + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return instance.getMemoBytes(); + } + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @param value The memo to set. + * @return This builder for chaining. + */ + public Builder setMemo( + java.lang.String value) { + copyOnWrite(); + instance.setMemo(value); + return this; + } + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @return This builder for chaining. + */ + public Builder clearMemo() { + copyOnWrite(); + instance.clearMemo(); + return this; + } + /** + *
+       * Human-readable message for the merchant
+       * 
+ * + * optional string memo = 4; + * @param value The bytes for memo to set. + * @return This builder for chaining. + */ + public Builder setMemoBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setMemoBytes(value); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.Payment) + } + private byte memoizedIsInitialized = 2; + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.Payment(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "bitField0_", + "merchantData_", + "transactions_", + "refundTo_", + org.dash.wallet.common.payments.bip70.Protos.Output.class, + "memo_", + }; + java.lang.String info = + "\u0001\u0004\u0000\u0001\u0001\u0004\u0004\u0000\u0002\u0001\u0001\u100a\u0000\u0002" + + "\u001c\u0003\u041b\u0004\u1008\u0001"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.Payment.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return memoizedIsInitialized; + } + case SET_MEMOIZED_IS_INITIALIZED: { + memoizedIsInitialized = (byte) (arg0 == null ? 0 : 1); + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.Payment) + private static final org.dash.wallet.common.payments.bip70.Protos.Payment DEFAULT_INSTANCE; + static { + Payment defaultInstance = new Payment(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + Payment.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.Payment getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + public interface PaymentACKOrBuilder extends + // @@protoc_insertion_point(interface_extends:payments.PaymentACK) + com.google.protobuf.MessageLiteOrBuilder { + + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + * @return Whether the payment field is set. + */ + boolean hasPayment(); + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + * @return The payment. + */ + org.dash.wallet.common.payments.bip70.Protos.Payment getPayment(); + + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return Whether the memo field is set. + */ + boolean hasMemo(); + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return The memo. + */ + java.lang.String getMemo(); + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return The bytes for memo. + */ + com.google.protobuf.ByteString + getMemoBytes(); + } + /** + * Protobuf type {@code payments.PaymentACK} + */ + public static final class PaymentACK extends + com.google.protobuf.GeneratedMessageLite< + PaymentACK, PaymentACK.Builder> implements + // @@protoc_insertion_point(message_implements:payments.PaymentACK) + PaymentACKOrBuilder { + private PaymentACK() { + memo_ = ""; + } + private int bitField0_; + public static final int PAYMENT_FIELD_NUMBER = 1; + private org.dash.wallet.common.payments.bip70.Protos.Payment payment_; + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + */ + @java.lang.Override + public boolean hasPayment() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Payment getPayment() { + return payment_ == null ? org.dash.wallet.common.payments.bip70.Protos.Payment.getDefaultInstance() : payment_; + } + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + */ + private void setPayment(org.dash.wallet.common.payments.bip70.Protos.Payment value) { + value.getClass(); + payment_ = value; + bitField0_ |= 0x00000001; + } + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + */ + @java.lang.SuppressWarnings({"ReferenceEquality"}) + private void mergePayment(org.dash.wallet.common.payments.bip70.Protos.Payment value) { + value.getClass(); + if (payment_ != null && + payment_ != org.dash.wallet.common.payments.bip70.Protos.Payment.getDefaultInstance()) { + payment_ = + org.dash.wallet.common.payments.bip70.Protos.Payment.newBuilder(payment_).mergeFrom(value).buildPartial(); + } else { + payment_ = value; + } + bitField0_ |= 0x00000001; + } + /** + *
+     * Payment message that triggered this ACK
+     * 
+ * + * required .payments.Payment payment = 1; + */ + private void clearPayment() { payment_ = null; + bitField0_ = (bitField0_ & ~0x00000001); + } + + public static final int MEMO_FIELD_NUMBER = 2; + private java.lang.String memo_; + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return memo_; + } + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return com.google.protobuf.ByteString.copyFromUtf8(memo_); + } + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @param value The memo to set. + */ + private void setMemo( + java.lang.String value) { + java.lang.Class valueClass = value.getClass(); + bitField0_ |= 0x00000002; + memo_ = value; + } + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + */ + private void clearMemo() { + bitField0_ = (bitField0_ & ~0x00000002); + memo_ = getDefaultInstance().getMemo(); + } + /** + *
+     * human-readable message for customer
+     * 
+ * + * optional string memo = 2; + * @param value The bytes for memo to set. + */ + private void setMemoBytes( + com.google.protobuf.ByteString value) { + memo_ = value.toStringUtf8(); + bitField0_ |= 0x00000002; + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, data, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input); + } + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessageLite.parseFrom( + DEFAULT_INSTANCE, input, extensionRegistry); + } + + public static Builder newBuilder() { + return (Builder) DEFAULT_INSTANCE.createBuilder(); + } + public static Builder newBuilder(org.dash.wallet.common.payments.bip70.Protos.PaymentACK prototype) { + return (Builder) DEFAULT_INSTANCE.createBuilder(prototype); + } + + /** + * Protobuf type {@code payments.PaymentACK} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessageLite.Builder< + org.dash.wallet.common.payments.bip70.Protos.PaymentACK, Builder> implements + // @@protoc_insertion_point(builder_implements:payments.PaymentACK) + org.dash.wallet.common.payments.bip70.Protos.PaymentACKOrBuilder { + // Construct using org.dash.wallet.common.payments.bip70.Protos.PaymentACK.newBuilder() + private Builder() { + super(DEFAULT_INSTANCE); + } + + + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + @java.lang.Override + public boolean hasPayment() { + return instance.hasPayment(); + } + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + @java.lang.Override + public org.dash.wallet.common.payments.bip70.Protos.Payment getPayment() { + return instance.getPayment(); + } + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + public Builder setPayment(org.dash.wallet.common.payments.bip70.Protos.Payment value) { + copyOnWrite(); + instance.setPayment(value); + return this; + } + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + public Builder setPayment( + org.dash.wallet.common.payments.bip70.Protos.Payment.Builder builderForValue) { + copyOnWrite(); + instance.setPayment(builderForValue.build()); + return this; + } + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + public Builder mergePayment(org.dash.wallet.common.payments.bip70.Protos.Payment value) { + copyOnWrite(); + instance.mergePayment(value); + return this; + } + /** + *
+       * Payment message that triggered this ACK
+       * 
+ * + * required .payments.Payment payment = 1; + */ + public Builder clearPayment() { copyOnWrite(); + instance.clearPayment(); + return this; + } + + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @return Whether the memo field is set. + */ + @java.lang.Override + public boolean hasMemo() { + return instance.hasMemo(); + } + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @return The memo. + */ + @java.lang.Override + public java.lang.String getMemo() { + return instance.getMemo(); + } + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @return The bytes for memo. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getMemoBytes() { + return instance.getMemoBytes(); + } + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @param value The memo to set. + * @return This builder for chaining. + */ + public Builder setMemo( + java.lang.String value) { + copyOnWrite(); + instance.setMemo(value); + return this; + } + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @return This builder for chaining. + */ + public Builder clearMemo() { + copyOnWrite(); + instance.clearMemo(); + return this; + } + /** + *
+       * human-readable message for customer
+       * 
+ * + * optional string memo = 2; + * @param value The bytes for memo to set. + * @return This builder for chaining. + */ + public Builder setMemoBytes( + com.google.protobuf.ByteString value) { + copyOnWrite(); + instance.setMemoBytes(value); + return this; + } + + // @@protoc_insertion_point(builder_scope:payments.PaymentACK) + } + private byte memoizedIsInitialized = 2; + @java.lang.Override + @java.lang.SuppressWarnings({"unchecked", "fallthrough"}) + protected final java.lang.Object dynamicMethod( + com.google.protobuf.GeneratedMessageLite.MethodToInvoke method, + java.lang.Object arg0, java.lang.Object arg1) { + switch (method) { + case NEW_MUTABLE_INSTANCE: { + return new org.dash.wallet.common.payments.bip70.Protos.PaymentACK(); + } + case NEW_BUILDER: { + return new Builder(); + } + case BUILD_MESSAGE_INFO: { + java.lang.Object[] objects = new java.lang.Object[] { + "bitField0_", + "payment_", + "memo_", + }; + java.lang.String info = + "\u0001\u0002\u0000\u0001\u0001\u0002\u0002\u0000\u0000\u0001\u0001\u1509\u0000\u0002" + + "\u1008\u0001"; + return newMessageInfo(DEFAULT_INSTANCE, info, objects); + } + // fall through + case GET_DEFAULT_INSTANCE: { + return DEFAULT_INSTANCE; + } + case GET_PARSER: { + com.google.protobuf.Parser parser = PARSER; + if (parser == null) { + synchronized (org.dash.wallet.common.payments.bip70.Protos.PaymentACK.class) { + parser = PARSER; + if (parser == null) { + parser = + new DefaultInstanceBasedParser( + DEFAULT_INSTANCE); + PARSER = parser; + } + } + } + return parser; + } + case GET_MEMOIZED_IS_INITIALIZED: { + return memoizedIsInitialized; + } + case SET_MEMOIZED_IS_INITIALIZED: { + memoizedIsInitialized = (byte) (arg0 == null ? 0 : 1); + return null; + } + } + throw new UnsupportedOperationException(); + } + + + // @@protoc_insertion_point(class_scope:payments.PaymentACK) + private static final org.dash.wallet.common.payments.bip70.Protos.PaymentACK DEFAULT_INSTANCE; + static { + PaymentACK defaultInstance = new PaymentACK(); + // New instances are implicitly immutable so no need to make + // immutable. + DEFAULT_INSTANCE = defaultInstance; + com.google.protobuf.GeneratedMessageLite.registerDefaultInstance( + PaymentACK.class, defaultInstance); + } + + public static org.dash.wallet.common.payments.bip70.Protos.PaymentACK getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static volatile com.google.protobuf.Parser PARSER; + + public static com.google.protobuf.Parser parser() { + return DEFAULT_INSTANCE.getParserForType(); + } + } + + + static { + } + + // @@protoc_insertion_point(outer_class_scope) +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/TrustStoreLoader.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/TrustStoreLoader.java new file mode 100644 index 0000000000..2e82f5f175 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/TrustStoreLoader.java @@ -0,0 +1,118 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.TrustStoreLoader, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +/* + * Copyright 2014 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.payments.bip70; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; + +/** + * An implementation of TrustStoreLoader handles fetching a KeyStore from the operating system, a file, etc. It's + * necessary because the Java {@link KeyStore} abstraction is not completely seamless and for example + * we sometimes need slightly different techniques to load the key store on different versions of Android, MacOS, + * Windows, etc. + */ +public interface TrustStoreLoader { + KeyStore getKeyStore() throws FileNotFoundException, KeyStoreException; + + String DEFAULT_KEYSTORE_TYPE = KeyStore.getDefaultType(); + String DEFAULT_KEYSTORE_PASSWORD = "changeit"; + + class DefaultTrustStoreLoader implements TrustStoreLoader { + @Override + public KeyStore getKeyStore() throws FileNotFoundException, KeyStoreException { + + String keystorePath = null; + String keystoreType = DEFAULT_KEYSTORE_TYPE; + try { + // Check if we are on Android. + Class version = Class.forName("android.os.Build$VERSION"); + // Build.VERSION_CODES.ICE_CREAM_SANDWICH is 14. + if (version.getDeclaredField("SDK_INT").getInt(version) >= 14) { + return loadIcsKeyStore(); + } else { + keystoreType = "BKS"; + keystorePath = System.getProperty("java.home") + + "/etc/security/cacerts.bks".replace('/', File.separatorChar); + } + } catch (ClassNotFoundException e) { + // NOP. android.os.Build is not present, so we are not on Android. Fall through. + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); // Should never happen. + } catch (IllegalAccessException e) { + throw new RuntimeException(e); // Should never happen. + } + if (keystorePath == null) { + keystorePath = System.getProperty("javax.net.ssl.trustStore"); + } + if (keystorePath == null) { + return loadFallbackStore(); + } + try { + return X509Utils.loadKeyStore(keystoreType, DEFAULT_KEYSTORE_PASSWORD, + new FileInputStream(keystorePath)); + } catch (FileNotFoundException e) { + // If we failed to find a system trust store, load our own fallback trust store. This can fail on + // Android but we should never reach it there. + return loadFallbackStore(); + } + } + + private KeyStore loadIcsKeyStore() throws KeyStoreException { + try { + // After ICS, Android provided this nice method for loading the keystore, + // so we don't have to specify the location explicitly. + KeyStore keystore = KeyStore.getInstance("AndroidCAStore"); + keystore.load(null, null); + return keystore; + } catch (IOException x) { + throw new KeyStoreException(x); + } catch (GeneralSecurityException x) { + throw new KeyStoreException(x); + } + } + + private KeyStore loadFallbackStore() throws FileNotFoundException, KeyStoreException { + return X509Utils.loadKeyStore("JKS", DEFAULT_KEYSTORE_PASSWORD, getClass().getResourceAsStream("cacerts")); + } + } + + class FileTrustStoreLoader implements TrustStoreLoader { + private final File path; + + public FileTrustStoreLoader(@Nonnull File path) throws FileNotFoundException { + if (!path.exists()) + throw new FileNotFoundException(path.toString()); + this.path = path; + } + + @Override + public KeyStore getKeyStore() throws FileNotFoundException, KeyStoreException { + return X509Utils.loadKeyStore(DEFAULT_KEYSTORE_TYPE, DEFAULT_KEYSTORE_PASSWORD, new FileInputStream(path)); + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/bip70/X509Utils.java b/common/src/main/java/org/dash/wallet/common/payments/bip70/X509Utils.java new file mode 100644 index 0000000000..e046532db8 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/bip70/X509Utils.java @@ -0,0 +1,109 @@ +/* + * Copied verbatim from dashj-core 22.0.3 (org.dash.wallet.common.payments.bip70.X509Utils, Apache License 2.0), + * with only the package renamed, to preserve BIP70 payment-protocol support + * independently of the dashj library ahead of its removal. + */ +/* + * Copyright 2014 The bitcoinj authors. + * + * 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. + */ + +package org.dash.wallet.common.payments.bip70; + +import com.google.common.base.Joiner; +import org.dash.wallet.common.payments.bip70.PaymentSession; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.x500.AttributeTypeAndValue; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.RFC4519Style; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; + +/** + * X509Utils provides tools for working with X.509 certificates and keystores, as used in the BIP 70 payment protocol. + * For more details on this, see {@link PaymentSession}, the article "Working with + * the payment protocol" on the bitcoinj website, or the Bitcoin developer guide. + */ +public class X509Utils { + /** + * Returns either a string that "sums up" the certificate for humans, in a similar manner to what you might see + * in a web browser, or null if one cannot be extracted. This will typically be the common name (CN) field, but + * can also be the org (O) field, org+location+country if withLocation is set, or the email + * address for S/MIME certificates. + */ + @Nullable + public static String getDisplayNameFromCertificate(@Nonnull X509Certificate certificate, boolean withLocation) throws CertificateParsingException { + X500Name name = new X500Name(certificate.getSubjectX500Principal().getName()); + String commonName = null, org = null, location = null, country = null; + for (RDN rdn : name.getRDNs()) { + AttributeTypeAndValue pair = rdn.getFirst(); + String val = ((ASN1String) pair.getValue()).getString(); + ASN1ObjectIdentifier type = pair.getType(); + if (type.equals(RFC4519Style.cn)) + commonName = val; + else if (type.equals(RFC4519Style.o)) + org = val; + else if (type.equals(RFC4519Style.l)) + location = val; + else if (type.equals(RFC4519Style.c)) + country = val; + } + final Collection> subjectAlternativeNames = certificate.getSubjectAlternativeNames(); + String altName = null; + if (subjectAlternativeNames != null) + for (final List subjectAlternativeName : subjectAlternativeNames) + if ((Integer) subjectAlternativeName.get(0) == 1) // rfc822name + altName = (String) subjectAlternativeName.get(1); + + if (org != null) { + return withLocation ? Joiner.on(", ").skipNulls().join(org, location, country) : org; + } else if (commonName != null) { + return commonName; + } else { + return altName; + } + } + + /** Returns a key store loaded from the given stream. Just a convenience around the Java APIs. */ + public static KeyStore loadKeyStore(String keystoreType, @Nullable String keystorePassword, InputStream is) + throws KeyStoreException { + try { + KeyStore keystore = KeyStore.getInstance(keystoreType); + keystore.load(is, keystorePassword != null ? keystorePassword.toCharArray() : null); + return keystore; + } catch (IOException x) { + throw new KeyStoreException(x); + } catch (GeneralSecurityException x) { + throw new KeyStoreException(x); + } finally { + try { + is.close(); + } catch (IOException x) { + // Ignored. + } + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressFormatException.java b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressFormatException.java new file mode 100644 index 0000000000..5d14c33d03 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressFormatException.java @@ -0,0 +1,105 @@ +/* + * Copyright 2011 Google Inc. + * Copyright 2018 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.payments.parsers; + +/** + * Self-contained port of {@code org.bitcoinj.core.AddressFormatException} (dashj 22.0.3) so that + * modules depending on {@code common} need no dashj on their classpath. Same exception hierarchy, + * same messages. + */ +public class AddressFormatException extends IllegalArgumentException { + public AddressFormatException() { + super(); + } + + public AddressFormatException(String message) { + super(message); + } + + /** + * This exception is thrown by Base58, Bech32 and the address classes when you try to decode data and a character + * isn't valid. You shouldn't allow the user to proceed in this case. + */ + public static class InvalidCharacter extends AddressFormatException { + public final char character; + public final int position; + + public InvalidCharacter(char character, int position) { + super("Invalid character '" + Character.toString(character) + "' at position " + position); + this.character = character; + this.position = position; + } + } + + /** + * This exception is thrown by Base58, Bech32 and the address classes when you try to decode data and the data + * isn't of the right size. You shouldn't allow the user to proceed in this case. + */ + public static class InvalidDataLength extends AddressFormatException { + public InvalidDataLength() { + super(); + } + + public InvalidDataLength(String message) { + super(message); + } + } + + /** + * This exception is thrown by Base58, Bech32 and the address classes when you try to decode data and the checksum + * isn't valid. You shouldn't allow the user to proceed in this case. + */ + public static class InvalidChecksum extends AddressFormatException { + public InvalidChecksum() { + super("Checksum does not validate"); + } + + public InvalidChecksum(String message) { + super(message); + } + } + + /** + * This exception is thrown by the address classes when you try to decode data and the data isn't a valid prefix or + * version. You shouldn't allow the user to proceed in this case. + */ + public static class InvalidPrefix extends AddressFormatException { + public InvalidPrefix() { + super(); + } + + public InvalidPrefix(String message) { + super(message); + } + } + + /** + * This exception is thrown by the address classes when you try and decode an address or private key with an + * invalid prefix (version header or human-readable part) for the network you are on. The client of this library + * should handle this exception in a "safe" way by informing the user, requesting them to check the network, etc. + */ + public static class WrongNetwork extends InvalidPrefix { + public WrongNetwork(int versionHeader) { + super("Version code of address did not match acceptable versions for network: " + versionHeader); + } + + public WrongNetwork(String hrp) { + super("Human readable part of address did not match acceptable HRPs for network: " + hrp); + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressNetwork.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressNetwork.kt new file mode 100644 index 0000000000..003f325cc7 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressNetwork.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +/** + * Minimal, dashj-free network descriptor replacing `org.bitcoinj.core.NetworkParameters` in the + * `common` module's APIs. Header values, ids and URI schemes are exactly those of the dashj + * network parameter classes (dashj 22.0.3), so address validation and script/address round-trips + * behave identically. + */ +class AddressNetwork( + /** Network id string, exactly as `NetworkParameters.getId()` returns it. */ + val id: String, + /** Payment URI scheme, exactly as `NetworkParameters.getUriScheme()` returns it. */ + val uriScheme: String, + /** First byte of a base58check-encoded P2PKH address. */ + val addressHeader: Int, + /** First byte of a base58check-encoded P2SH address. */ + val p2shHeader: Int, + /** Human-readable part of bech32 segwit addresses, or null when the network has none. */ + val segwitHrp: String? = null, + /** Largest representable monetary amount, in smallest units (`NetworkParameters.getMaxMoney()`). */ + val maxMoney: Long = MAX_MONEY_DUFFS, + /** BIP70 network id, exactly as `NetworkParameters.getPaymentProtocolId()` returns it. */ + val paymentProtocolId: String = PAYMENT_PROTOCOL_ID_MAINNET, + /** + * First byte of a base58check-encoded WIF private key, exactly as + * `NetworkParameters.getDumpedPrivateKeyHeader()` returns it (dashj 22.0.3: + * `MainNetParams` = 204/0xcc, `TestNet3Params`/`DevNetParams` = 239/0xef, Bitcoin = 128/0x80). + */ + val dumpedPrivateKeyHeader: Int = 128 +) { + companion object { + /** `Coin.COIN.multiply(22_000_000)` — dashj's `NetworkParameters.MAX_MONEY`. */ + const val MAX_MONEY_DUFFS = 22_000_000L * 100_000_000L + + const val ID_MAINNET = "org.darkcoin.production" + const val ID_TESTNET = "org.darkcoin.test" + const val ID_DEVNET = "org.dash.dev" + + const val DASH_SCHEME = "dash" + const val BITCOIN_SCHEME = "bitcoin" + + const val PAYMENT_PROTOCOL_ID_MAINNET = "main" + const val PAYMENT_PROTOCOL_ID_TESTNET = "test" + const val PAYMENT_PROTOCOL_ID_DEVNET = "dev" + + @JvmField + val DASH_MAINNET = AddressNetwork(ID_MAINNET, DASH_SCHEME, 76, 16, dumpedPrivateKeyHeader = 204) + + @JvmField + val DASH_TESTNET = AddressNetwork( + ID_TESTNET, DASH_SCHEME, 140, 19, + paymentProtocolId = PAYMENT_PROTOCOL_ID_TESTNET, + dumpedPrivateKeyHeader = 239 + ) + + /** Devnets share testnet's address space. */ + @JvmField + val DASH_DEVNET = AddressNetwork( + ID_DEVNET, DASH_SCHEME, 140, 19, + paymentProtocolId = PAYMENT_PROTOCOL_ID_DEVNET, + dumpedPrivateKeyHeader = 239 + ) + + @JvmField + val BITCOIN_MAINNET = AddressNetwork(ID_MAINNET_BITCOIN, BITCOIN_SCHEME, 0, 5, "bc", dumpedPrivateKeyHeader = 128) + + /** Mirrors `NetworkParameters.fromPmtProtocolID` for the networks the app supports; null when unknown. */ + @JvmStatic + fun fromPaymentProtocolId(pmtProtocolId: String): AddressNetwork? = when (pmtProtocolId) { + PAYMENT_PROTOCOL_ID_MAINNET -> DASH_MAINNET + PAYMENT_PROTOCOL_ID_TESTNET -> DASH_TESTNET + PAYMENT_PROTOCOL_ID_DEVNET -> DASH_DEVNET + else -> null + } + + /** + * Resolves a network descriptor from a dashj network id + * (`WalletDataProvider.networkId` / `NetworkParameters.getId()`). + */ + @JvmStatic + fun fromId(id: String): AddressNetwork = when { + id == ID_MAINNET -> DASH_MAINNET + id == ID_TESTNET -> DASH_TESTNET + id.startsWith(ID_DEVNET) -> DASH_DEVNET + else -> throw IllegalArgumentException("Unknown network id: $id") + } + + /** + * The Dash network whose address space contains the given base58 address, mirroring + * `Address.getParametersFromAddress` over dashj's default network set (testnet is + * matched before mainnet; devnets share testnet's version bytes and thus resolve to + * [DASH_TESTNET], exactly like the dashj original). + * + * @throws AddressFormatException if the string is not a valid base58check address on any Dash network. + */ + @JvmStatic + fun fromDashAddress(address: String): AddressNetwork { + val version = AddressUtils.versionOf(address) + for (network in listOf(DASH_TESTNET, DASH_MAINNET)) { + if (version == network.addressHeader || version == network.p2shHeader) { + return network + } + } + throw AddressFormatException.InvalidPrefix("No network found for $address") + } + } + + /** True if [version] is this network's P2PKH or P2SH address version byte. */ + fun acceptsVersion(version: Int): Boolean = version == addressHeader || version == p2shHeader + + override fun equals(other: Any?): Boolean = other is AddressNetwork && other.id == id + override fun hashCode(): Int = id.hashCode() + override fun toString(): String = id +} + +private const val ID_MAINNET_BITCOIN = "org.bitcoin.production" + +/** + * Dashj-free base58 address helpers mirroring the behavior of dashj's `Address`/`LegacyAddress`. + */ +object AddressUtils { + + /** Decoded (version, hash160) of a base58check address. */ + class DecodedAddress(val version: Int, val hash160: ByteArray) + + /** + * Decodes and checksum-validates a base58 address without any network check + * (mirrors `LegacyAddress` decoding: 20-byte payload required). + */ + @JvmStatic + @Throws(AddressFormatException::class) + fun decode(address: String): DecodedAddress { + val versionAndDataBytes = Base58.decodeChecked(address) + val version = versionAndDataBytes[0].toInt() and 0xFF + val payload = versionAndDataBytes.copyOfRange(1, versionAndDataBytes.size) + if (payload.size != 20) { + throw AddressFormatException.InvalidDataLength("Legacy addresses are 20 byte (160 bit) hashes, but got: " + payload.size) + } + return DecodedAddress(version, payload) + } + + /** The version byte of a base58check address (checksum-validated). */ + @JvmStatic + @Throws(AddressFormatException::class) + fun versionOf(address: String): Int = decode(address).version + + /** + * Validates [address] against [network], mirroring `Address.fromString(params, address)`: + * base58check first and, when the network has a segwit HRP, bech32 as a fallback. + * + * @throws AddressFormatException on invalid input or wrong network. + */ + @JvmStatic + @Throws(AddressFormatException::class) + fun verify(network: AddressNetwork, address: String) { + try { + val decoded = decode(address) + if (!network.acceptsVersion(decoded.version)) { + throw AddressFormatException.WrongNetwork(decoded.version) + } + } catch (e: AddressFormatException.WrongNetwork) { + throw e + } catch (e: AddressFormatException) { + if (network.segwitHrp != null) { + SegwitAddress.fromBech32(network, address) + } else { + throw e + } + } + } + + /** True when [address] passes [verify] for [network]. */ + @JvmStatic + fun isValid(network: AddressNetwork, address: String): Boolean { + return try { + verify(network, address) + true + } catch (e: Exception) { + false + } + } + + /** Encodes a (version, hash160) pair back to base58check. */ + @JvmStatic + fun encode(version: Int, hash160: ByteArray): String = Base58.encodeChecked(version, hash160) +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressParser.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressParser.kt index 54b29eb444..da018179e9 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressParser.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/AddressParser.kt @@ -17,20 +17,19 @@ package org.dash.wallet.common.payments.parsers -import org.bitcoinj.core.Address -import org.bitcoinj.core.Base58 -import org.bitcoinj.core.NetworkParameters +open class AddressParser(pattern: String, val params: AddressNetwork?, private val ignoreCase: Boolean = false) { + /** Pattern-only constructor for parsers that skip network validation. */ + constructor(pattern: String) : this(pattern, null) -open class AddressParser(pattern: String, val params: NetworkParameters?) { companion object { val PATTERN_BITCOIN_ADDRESS = "[${Base58.ALPHABET.joinToString(separator = "")}]{20,40}" private const val PATTERN_ETHEREUM_ADDRESS = "0x[a-fA-F0-9]{40}" const val PATTERN_BECH32_ADDRESS = "1[a-z0-9]{39,59}" // taproot goes to 59 - fun getDashAddressParser(params: NetworkParameters): AddressParser { + fun getDashAddressParser(params: AddressNetwork): AddressParser { return AddressParser(PATTERN_BITCOIN_ADDRESS, params) } - fun getBase58AddressParser(params: NetworkParameters? = null): AddressParser { + fun getBase58AddressParser(params: AddressNetwork? = null): AddressParser { return AddressParser(PATTERN_BITCOIN_ADDRESS, params) } @@ -39,10 +38,10 @@ open class AddressParser(pattern: String, val params: NetworkParameters?) { } } - private val addressPattern = Regex(pattern) + private val addressPattern = Regex(pattern, if (ignoreCase) setOf(RegexOption.IGNORE_CASE) else emptySet()) open fun exactMatch(inputText: String): Boolean { - return addressPattern.matches(inputText) + return addressPattern.matches(inputText) && isAddressValid(inputText) } open fun findAll(inputText: String): List { @@ -67,7 +66,52 @@ open class AddressParser(pattern: String, val params: NetworkParameters?) { return validRanges } + /** + * Canonicalizes the case of a scanned or pasted address: bech32 QR codes commonly carry + * the all-uppercase form (alphanumeric mode), which is lowercased when the lowercase form + * is a valid address. Only inputs reported by [isCaseInsensitiveFormat] are ever rewritten: + * Base58 and EIP-55 are case-significant, and where they are validated by pattern only + * (no [params], so no checksum) an all-caps corrupt address could otherwise be "repaired" + * into a different, plausible-looking one instead of being rejected. + */ + fun normalizeCase(input: String): String { + return if (isCaseInsensitiveFormat(input) && + input.any { it.isUpperCase() } && + input.none { it.isLowerCase() } && + exactMatch(input.lowercase()) + ) { + input.lowercase() + } else { + input + } + } + + /** + * Whether [input] is a candidate for a case-insensitive address format. Parsers that mix + * case-insensitive bech32 with case-sensitive alternatives override this per input. + */ + protected open fun isCaseInsensitiveFormat(input: String): Boolean = ignoreCase + protected open fun verifyAddress(addressCandidate: String) { - params?.let { Address.fromString(params, addressCandidate) } + params?.let { AddressUtils.verify(it, addressCandidate) } + } + + /** + * True if [addressCandidate] passes [verifyAddress] without throwing. + */ + fun isValidAddress(addressCandidate: String): Boolean { + return try { + verifyAddress(addressCandidate) + true + } catch (e: Exception) { + false + } + } + + protected fun isAddressValid(addressCandidate: String) = try { + verifyAddress(addressCandidate) + true + } catch (_: Exception) { + false } } diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/Base58.java b/common/src/main/java/org/dash/wallet/common/payments/parsers/Base58.java new file mode 100644 index 0000000000..60c7c34f4f --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/Base58.java @@ -0,0 +1,200 @@ +/* + * Copyright 2011 Google Inc. + * Copyright 2018 Andreas Schildbach + * + * 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. + */ + +package org.dash.wallet.common.payments.parsers; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +/** + * Base58 is a way to encode Dash addresses (or arbitrary data) as alphanumeric strings. + * + *

Self-contained port of {@code org.bitcoinj.core.Base58} (dashj 22.0.3) so that modules + * depending on {@code common} need no dashj on their classpath. Behavior is identical.

+ */ +public class Base58 { + public static final char[] ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); + private static final char ENCODED_ZERO = ALPHABET[0]; + private static final int[] INDEXES = new int[128]; + static { + Arrays.fill(INDEXES, -1); + for (int i = 0; i < ALPHABET.length; i++) { + INDEXES[ALPHABET[i]] = i; + } + } + + /** + * Encodes the given bytes as a base58 string (no checksum is appended). + * + * @param input the bytes to encode + * @return the base58-encoded string + */ + public static String encode(byte[] input) { + if (input.length == 0) { + return ""; + } + // Count leading zeros. + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) { + ++zeros; + } + // Convert base-256 digits to base-58 digits (plus conversion to ASCII characters) + input = Arrays.copyOf(input, input.length); // since we modify it in-place + char[] encoded = new char[input.length * 2]; // upper bound + int outputStart = encoded.length; + for (int inputStart = zeros; inputStart < input.length; ) { + encoded[--outputStart] = ALPHABET[divmod(input, inputStart, 256, 58)]; + if (input[inputStart] == 0) { + ++inputStart; // optimization - skip leading zeros + } + } + // Preserve exactly as many leading encoded zeros in output as there were leading zeros in input. + while (outputStart < encoded.length && encoded[outputStart] == ENCODED_ZERO) { + ++outputStart; + } + while (--zeros >= 0) { + encoded[--outputStart] = ENCODED_ZERO; + } + // Return encoded string (including encoded leading zeros). + return new String(encoded, outputStart, encoded.length - outputStart); + } + + /** + * Encodes the given version and bytes as a base58 string. A checksum is appended. + * + * @param version the version to encode + * @param payload the bytes to encode, e.g. pubkey hash + * @return the base58-encoded string + */ + public static String encodeChecked(int version, byte[] payload) { + if (version < 0 || version > 255) + throw new IllegalArgumentException("Version not in range."); + + // A stringified buffer is: + // 1 byte version + data bytes + 4 bytes check code (a truncated hash) + byte[] addressBytes = new byte[1 + payload.length + 4]; + addressBytes[0] = (byte) version; + System.arraycopy(payload, 0, addressBytes, 1, payload.length); + byte[] checksum = hashTwice(addressBytes, 0, payload.length + 1); + System.arraycopy(checksum, 0, addressBytes, payload.length + 1, 4); + return Base58.encode(addressBytes); + } + + /** + * Decodes the given base58 string into the original data bytes. + * + * @param input the base58-encoded string to decode + * @return the decoded data bytes + * @throws AddressFormatException if the given string is not a valid base58 string + */ + public static byte[] decode(String input) throws AddressFormatException { + if (input.length() == 0) { + return new byte[0]; + } + // Convert the base58-encoded ASCII chars to a base58 byte sequence (base58 digits). + byte[] input58 = new byte[input.length()]; + for (int i = 0; i < input.length(); ++i) { + char c = input.charAt(i); + int digit = c < 128 ? INDEXES[c] : -1; + if (digit < 0) { + throw new AddressFormatException.InvalidCharacter(c, i); + } + input58[i] = (byte) digit; + } + // Count leading zeros. + int zeros = 0; + while (zeros < input58.length && input58[zeros] == 0) { + ++zeros; + } + // Convert base-58 digits to base-256 digits. + byte[] decoded = new byte[input.length()]; + int outputStart = decoded.length; + for (int inputStart = zeros; inputStart < input58.length; ) { + decoded[--outputStart] = divmod(input58, inputStart, 58, 256); + if (input58[inputStart] == 0) { + ++inputStart; // optimization - skip leading zeros + } + } + // Ignore extra leading zeroes that were added during the calculation. + while (outputStart < decoded.length && decoded[outputStart] == 0) { + ++outputStart; + } + // Return decoded data (including original number of leading zeros). + return Arrays.copyOfRange(decoded, outputStart - zeros, decoded.length); + } + + public static BigInteger decodeToBigInteger(String input) throws AddressFormatException { + return new BigInteger(1, decode(input)); + } + + /** + * Decodes the given base58 string into the original data bytes, using the checksum in the + * last 4 bytes of the decoded data to verify that the rest are correct. The checksum is + * removed from the returned data. + * + * @param input the base58-encoded string to decode (which should include the checksum) + * @throws AddressFormatException if the input is not base 58 or the checksum does not validate. + */ + public static byte[] decodeChecked(String input) throws AddressFormatException { + byte[] decoded = decode(input); + if (decoded.length < 4) + throw new AddressFormatException.InvalidDataLength("Input too short: " + decoded.length); + byte[] data = Arrays.copyOfRange(decoded, 0, decoded.length - 4); + byte[] checksum = Arrays.copyOfRange(decoded, decoded.length - 4, decoded.length); + byte[] actualChecksum = Arrays.copyOfRange(hashTwice(data, 0, data.length), 0, 4); + if (!Arrays.equals(checksum, actualChecksum)) + throw new AddressFormatException.InvalidChecksum(); + return data; + } + + /** + * Divides a number, represented as an array of bytes each containing a single digit + * in the specified base, by the given divisor. The given number is modified in-place + * to contain the quotient, and the return value is the remainder. + * + * @param number the number to divide + * @param firstDigit the index within the array of the first non-zero digit + * (this is used for optimization by skipping the leading zeros) + * @param base the base in which the number's digits are represented (up to 256) + * @param divisor the number to divide by (up to 256) + * @return the remainder of the division operation + */ + private static byte divmod(byte[] number, int firstDigit, int base, int divisor) { + // this is just long division which accounts for the base of the input digits + int remainder = 0; + for (int i = firstDigit; i < number.length; i++) { + int digit = (int) number[i] & 0xFF; + int temp = remainder * base + digit; + number[i] = (byte) (temp / divisor); + remainder = temp % divisor; + } + return (byte) remainder; + } + + /** Double SHA-256 of the byte range (mirrors {@code Sha256Hash.hashTwice}). */ + static byte[] hashTwice(byte[] input, int offset, int length) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(input, offset, length); + return digest.digest(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); // Can't happen. + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32.java b/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32.java index 58efcd99f9..89d3721ae5 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32.java +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32.java @@ -20,7 +20,6 @@ import static com.google.common.base.Preconditions.checkArgument; -import org.bitcoinj.core.AddressFormatException; import java.util.Arrays; import java.util.Locale; @@ -153,10 +152,19 @@ public static String encode(String hrp, byte[] values) { /** Decode a Bech32 string. */ public static Bech32Data decode(final String str) throws AddressFormatException { + return decode(str, 90); + } + + /** + * Decode with a caller-supplied maximum length. BIP-173 caps bech32 strings at 90 + * characters, but some chains reuse the encoding beyond that limit (Cardano Shelley + * addresses are ~103 characters). + */ + public static Bech32Data decode(final String str, final int maxLength) throws AddressFormatException { boolean lower = false, upper = false; if (str.length() < 8) throw new AddressFormatException.InvalidDataLength("Input too short: " + str.length()); - if (str.length() > 90) + if (str.length() > maxLength) throw new AddressFormatException.InvalidDataLength("Input too long: " + str.length()); for (int i = 0; i < str.length(); ++i) { char c = str.charAt(i); diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32AddressParser.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32AddressParser.kt index b330046392..caae03bf9d 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32AddressParser.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/Bech32AddressParser.kt @@ -17,23 +17,34 @@ package org.dash.wallet.common.payments.parsers -import org.bitcoinj.core.NetworkParameters - -open class Bech32AddressParser(hrp: String, regex: String, params: NetworkParameters? = null) : AddressParser( +// BIP-173 allows a bech32 string to be all-lowercase or all-uppercase (QR codes use upper +// for the denser alphanumeric mode), so match case-insensitively; mixed case is rejected +// in verifyAddress. +open class Bech32AddressParser(hrp: String, regex: String, params: AddressNetwork? = null) : AddressParser( "${hrp}$regex", - params + params, + ignoreCase = true ) { companion object { private const val BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" } - constructor(hrp: String, length: Int, params: NetworkParameters?) : + constructor(hrp: String, length: Int, params: AddressNetwork?) : this(hrp, "1[$BECH32_ALPHABET]{$length}", params) - constructor(length: Int, params: NetworkParameters) : - this(params.segwitAddressHrp, "1[$BECH32_ALPHABET]{$length}", params) - constructor(min: Int, max: Int, params: NetworkParameters) : - this(params.segwitAddressHrp, "1[$BECH32_ALPHABET]{$min,$max}", params) + + // Pattern-only constructors that skip network validation. + constructor(hrp: String, regex: String) : this(hrp, regex, null) + constructor(hrp: String, length: Int) : this(hrp, length, null) + constructor(length: Int, params: AddressNetwork) : + this(params.segwitHrp!!, "1[$BECH32_ALPHABET]{$length}", params) + constructor(min: Int, max: Int, params: AddressNetwork) : + this(params.segwitHrp!!, "1[$BECH32_ALPHABET]{$min,$max}", params) override fun verifyAddress(addressCandidate: String) { + // BIP-173 forbids mixing cases; enforce it here since the chains without params + // never reach a decoder that would catch it + if (addressCandidate.any { it.isLowerCase() } && addressCandidate.any { it.isUpperCase() }) { + throw AddressFormatException("bech32 must not mix upper and lower case: $addressCandidate") + } params?.let { SegwitAddress.fromBech32(params, addressCandidate) } } } diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinAddressParser.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinAddressParser.kt index 1ca3d4de00..e9d7cb97c5 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinAddressParser.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinAddressParser.kt @@ -17,11 +17,10 @@ package org.dash.wallet.common.payments.parsers -import org.bitcoinj.core.Address -import org.bitcoinj.core.AddressFormatException -import org.bitcoinj.core.NetworkParameters +class BitcoinAddressParser(params: AddressNetwork) : AddressParser(PATTERN_BITCOIN_ADDRESS, params) { + /** Mainnet constructor. */ + constructor() : this(AddressNetwork.BITCOIN_MAINNET) -class BitcoinAddressParser(params: NetworkParameters) : AddressParser(PATTERN_BITCOIN_ADDRESS, params) { private val bech32Parser = Bech32AddressParser(39, 59, params) override fun exactMatch(inputText: String): Boolean { @@ -35,10 +34,21 @@ class BitcoinAddressParser(params: NetworkParameters) : AddressParser(PATTERN_BI return result } + // Only the segwit bech32 alternative is case-insensitive; legacy Base58 keeps its case. + override fun isCaseInsensitiveFormat(input: String): Boolean { + val hrp = params?.segwitHrp ?: return false + return input.startsWith("${hrp}1", ignoreCase = true) + } + override fun verifyAddress(addressCandidate: String) { params?.let { try { - Address.fromString(params, addressCandidate) + val decoded = AddressUtils.decode(addressCandidate) + if (!it.acceptsVersion(decoded.version)) { + throw AddressFormatException.WrongNetwork(decoded.version) + } + } catch (e: AddressFormatException.WrongNetwork) { + throw e } catch (e: AddressFormatException) { SegwitAddress.fromBech32(params, addressCandidate) } diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinMainNetParams.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinMainNetParams.kt deleted file mode 100644 index ab3f81b888..0000000000 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinMainNetParams.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2024 Dash Core Group. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.dash.wallet.common.payments.parsers - -import org.bitcoinj.params.AbstractBitcoinNetParams - -open class SegwitNetworkParams() : AbstractBitcoinNetParams() { - override fun getPaymentProtocolId(): String { - return PAYMENT_PROTOCOL_ID_MAINNET - } -} - -class BitcoinMainNetParams : SegwitNetworkParams() { - companion object { - const val BITCOIN_SCHEME = "bitcoin" - } - init { - addressHeader = 0 // addresses starting with 1 - p2shHeader = 5 // addresses starting with 3 - segwitAddressHrp = "bc" - } - - override fun getUriScheme(): String { - return BITCOIN_SCHEME - } -} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinUris.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinUris.kt new file mode 100644 index 0000000000..953b43023b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/BitcoinUris.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +/** + * Parsing of `bitcoin:` payment URIs. Same accepted URIs as dashj's `BitcoinURI` with + * mainnet Bitcoin parameters. + */ +object BitcoinUris { + + /** + * Extracts the address from a mainnet `bitcoin:` URI. + * + * @throws IllegalArgumentException if the URI can't be parsed, carries no address, + * or the address is not a mainnet Bitcoin address. + */ + fun parseAddress(uri: String): String { + val params = AddressNetwork.BITCOIN_MAINNET + try { + val bitcoinUri = PaymentURI(params, uri) + return bitcoinUri.address + ?: throw IllegalArgumentException("no address in bitcoin uri") + } catch (ex: PaymentURI.ParseException) { + throw IllegalArgumentException(ex.message, ex) + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentIntentParser.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentIntentParser.kt index 42c832f2f3..405486aa24 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentIntentParser.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentIntentParser.kt @@ -22,19 +22,14 @@ import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.UninitializedMessageException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.bitcoin.protocols.payments.Protos.PaymentRequest -import org.bitcoinj.core.Address -import org.bitcoinj.core.AddressFormatException -import org.bitcoinj.core.NetworkParameters -import org.bitcoinj.crypto.TrustStoreLoader.DefaultTrustStoreLoader -import org.bitcoinj.protocols.payments.PaymentProtocol -import org.bitcoinj.protocols.payments.PaymentProtocolException -import org.bitcoinj.protocols.payments.PaymentProtocolException.Expired -import org.bitcoinj.protocols.payments.PaymentProtocolException.InvalidNetwork -import org.bitcoinj.protocols.payments.PaymentProtocolException.InvalidPaymentURL -import org.bitcoinj.protocols.payments.PaymentProtocolException.PkiVerificationException -import org.bitcoinj.uri.BitcoinURI -import org.bitcoinj.uri.BitcoinURIParseException +import org.dash.wallet.common.payments.bip70.Protos.PaymentRequest +import org.dash.wallet.common.payments.bip70.TrustStoreLoader.DefaultTrustStoreLoader +import org.dash.wallet.common.payments.bip70.PaymentProtocol +import org.dash.wallet.common.payments.bip70.PaymentProtocolException +import org.dash.wallet.common.payments.bip70.PaymentProtocolException.Expired +import org.dash.wallet.common.payments.bip70.PaymentProtocolException.InvalidNetwork +import org.dash.wallet.common.payments.bip70.PaymentProtocolException.InvalidPaymentURL +import org.dash.wallet.common.payments.bip70.PaymentProtocolException.PkiVerificationException import org.dash.wallet.common.R import org.dash.wallet.common.data.PaymentIntent import org.dash.wallet.common.util.AddressUtil @@ -50,7 +45,7 @@ import java.io.InputStream import java.security.KeyStoreException import java.util.* -class DashPaymentIntentParser(params: NetworkParameters) : PaymentIntentParser("dash", "dash", params) { +class DashPaymentIntentParser(params: AddressNetwork) : PaymentIntentParser("dash", "dash", params) { private val log = LoggerFactory.getLogger(DashPaymentIntentParser::class.java) private val addressParser = AddressParser.getDashAddressParser(params) @@ -88,15 +83,15 @@ class DashPaymentIntentParser(params: NetworkParameters) : PaymentIntentParser(" return@withContext parseRequest(serializedPaymentRequest) } else if (inputStr.startsWith(Constants.DASH_SCHEME + ":")) { try { - val bitcoinUri = BitcoinURI(null, inputStr) - val address = AddressUtil.getCorrectAddress(bitcoinUri, params) + val paymentUri = PaymentURI(null, inputStr) + val address = AddressUtil.getCorrectAddress(paymentUri, params) - if (address != null && params != address.parameters) { - throw BitcoinURIParseException("mismatched network") + if (address != null && !params!!.acceptsVersion(AddressUtils.decode(address).version)) { + throw PaymentURI.ParseException("mismatched network") } - return@withContext PaymentIntent.fromBitcoinUri(bitcoinUri) - } catch (ex: BitcoinURIParseException) { + return@withContext PaymentIntent.fromPaymentUri(paymentUri) + } catch (ex: PaymentURI.ParseException) { log.info("got invalid bitcoin uri: '$inputStr'", ex) throw PaymentIntentParserException( ex, @@ -108,8 +103,8 @@ class DashPaymentIntentParser(params: NetworkParameters) : PaymentIntentParser(" } } else if (addressParser.exactMatch(inputStr)) { try { - val address = Address.fromString(params, inputStr) - return@withContext PaymentIntent.fromAddress(address, null) + AddressUtils.verify(params!!, inputStr) + return@withContext PaymentIntent.fromAddress(inputStr, null) } catch (ex: AddressFormatException) { log.info("got invalid address", ex) throw PaymentIntentParserException( diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentParsers.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentParsers.kt index f401e7412d..81d242bc04 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentParsers.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashPaymentParsers.kt @@ -17,10 +17,9 @@ package org.dash.wallet.common.payments.parsers -import org.bitcoinj.core.NetworkParameters import org.dash.wallet.common.util.Constants -class DashPaymentParsers(val params: NetworkParameters) : PaymentParsers() { +class DashPaymentParsers(val params: AddressNetwork) : PaymentParsers() { init { add( Constants.DASH_CURRENCY, diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/DashUri.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashUri.kt new file mode 100644 index 0000000000..da4f1c8fea --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/DashUri.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.util.Constants + +/** + * Thrown by [DashUri.parse] on invalid input. Wraps [PaymentURI.ParseException] + * (same message) so callers can catch parse failures without knowing the parser type. + */ +class DashUriParseException(message: String?, cause: Throwable) : Exception(message, cause) + +/** + * Minimal representation of a `dash:` payment URI, for feature/integration modules. + * Parsing uses the self-contained [PaymentURI] against the wallet's network, so accepted + * URIs are exactly those the wallet accepts. + */ +data class DashUri(val address: String?, val amount: Dash?, val message: String?) { + companion object { + /** Mirrors `BitcoinURI(Constants.NETWORK_PARAMETERS, uri)`. */ + @Throws(DashUriParseException::class) + fun parse(uri: String): DashUri { + val parsed = try { + PaymentURI(Constants.NETWORK, uri) + } catch (e: PaymentURI.ParseException) { + throw DashUriParseException(e.message, e) + } + return DashUri(parsed.address, parsed.amount?.let { Dash(it.value) }, parsed.message) + } + + /** + * Builds a `dash:` payment-request URI for [address] (base58, wallet's network) with an + * optional [amount]. Mirrors `BitcoinURI.convertToBitcoinURI`; null and empty [label]/[message] + * are both omitted, exactly like the dashj original. + */ + fun toUri(address: String, amount: Dash? = null, label: String? = null, message: String? = null): String { + AddressUtils.verify(Constants.NETWORK, address) + return PaymentURI.convertToPaymentURI( + Constants.NETWORK, + address, + amount?.let { org.dash.wallet.common.money.Coin.valueOf(it.duffs) }, + label, + message + ) + } + } +} + +/** + * True when this throwable is a payment-URI parse failure ([PaymentURI.ParseException] + * or the wrapping [DashUriParseException]). + */ +val Throwable.isPaymentUriParseError: Boolean + get() = this is PaymentURI.ParseException || this is DashUriParseException diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntentParser.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntentParser.kt index 300639c838..eeabb764b7 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntentParser.kt +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntentParser.kt @@ -17,8 +17,7 @@ package org.dash.wallet.common.payments.parsers -import org.bitcoinj.core.NetworkParameters import org.dash.wallet.common.data.PaymentIntent -abstract class PaymentIntentParser(val currency: String, val uriPrefix: String, val params: NetworkParameters?) { +abstract class PaymentIntentParser(val currency: String, val uriPrefix: String, val params: AddressNetwork?) { abstract suspend fun parse(input: String): PaymentIntent } diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntents.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntents.kt new file mode 100644 index 0000000000..edc8bc4f87 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentIntents.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +import org.dash.wallet.common.data.PaymentIntent +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.Dash + +// --------------------------------------------------------------------------------------------- +// Construction and inspection helpers for PaymentIntent. The produced scripts are identical to +// the ones dashj's ScriptBuilder would emit. +// --------------------------------------------------------------------------------------------- + +object PaymentIntents { + + /** + * Payment intent with a single zero-value OP_RETURN output carrying [memoData] + * (e.g. Maya swap memos). Mirrors `PaymentIntent.Output(Coin.ZERO, + * ScriptBuilder.createOpReturnScript(memoData))`. + */ + fun forOpReturnMemo(payeeName: String?, memoData: ByteArray, memo: String?): PaymentIntent { + val outputScript = Scripts.opReturnScript(memoData) + return PaymentIntent( + null, payeeName, null, + arrayOf(PaymentIntent.Output(Coin.ZERO, outputScript)), + memo, null, null, null, null, + null, null, null + ) + } +} + +/** + * Copy of this intent with a pay-to-address output of [amount] to base58 [address] appended + * (the network is inferred from the address version byte, mirroring `Address.fromBase58(null, address)`). + */ +fun PaymentIntent.withOutputAdded(amount: Dash, address: String): PaymentIntent { + val outputList = (outputs ?: emptyArray()).toMutableList() + outputList.add( + PaymentIntent.Output(Coin.valueOf(amount.duffs), Scripts.outputScriptForAddress(address)) + ) + return PaymentIntent( + standard, + payeeName, + payeeVerifiedBy, + outputList.toTypedArray(), + memo, + paymentUrl, + payeeData, + paymentRequestUrl, + paymentRequestHash, + null, + null, + null + ) +} + +/** + * UTF-8 payload of this output's OP_RETURN script (mirrors reading `script.chunks[1].data`), + * or null if the output is not an OP_RETURN carrying data. + */ +val PaymentIntent.Output.opReturnMessage: String? + get() = if (Scripts.isOpReturn(scriptData)) { + Scripts.secondChunkData(scriptData)?.let { String(it) } + } else { + null + } diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentURI.java b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentURI.java new file mode 100644 index 0000000000..784e559401 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/PaymentURI.java @@ -0,0 +1,443 @@ +/* + * Copyright 2012, 2014 the original author or authors. + * + * 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. + */ + +package org.dash.wallet.common.payments.parsers; + +import androidx.annotation.Nullable; + +import org.dash.wallet.common.money.Coin; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Provides a standard implementation of a Dash URI with support for the following: + * + *
    + *
  • URLEncoded URIs (as passed in by IE on the command line)
  • + *
  • BIP21 names (including the "req-" prefix handling requirements)
  • + *
+ * + *

Self-contained port of dashj's {@code org.bitcoinj.uri.BitcoinURI} (22.0.3, including the + * dashj-specific {@code user} field) so that modules depending on {@code common} need no dashj on + * their classpath. Accepted URIs, produced URIs and error messages are identical; addresses are + * held as validated base58/bech32 strings.

+ */ +public class PaymentURI { + /** + * Thrown when the URI cannot be parsed. Mirrors {@code org.bitcoinj.uri.BitcoinURIParseException}. + */ + public static class ParseException extends Exception { + public ParseException(String s) { + super(s); + } + + public ParseException(String s, Throwable throwable) { + super(s, throwable); + } + } + + /** Mirrors {@code org.bitcoinj.uri.OptionalFieldValidationException}. */ + public static class OptionalFieldValidationException extends ParseException { + public OptionalFieldValidationException(String s) { + super(s); + } + + public OptionalFieldValidationException(String s, Throwable throwable) { + super(s, throwable); + } + } + + /** Mirrors {@code org.bitcoinj.uri.RequiredFieldValidationException}. */ + public static class RequiredFieldValidationException extends ParseException { + public RequiredFieldValidationException(String s) { + super(s); + } + + public RequiredFieldValidationException(String s, Throwable throwable) { + super(s, throwable); + } + } + + public static final String FIELD_MESSAGE = "message"; + public static final String FIELD_LABEL = "label"; + public static final String FIELD_AMOUNT = "amount"; + public static final String FIELD_ADDRESS = "address"; + public static final String FIELD_PAYMENT_REQUEST_URL = "r"; + public static final String FIELD_USER = "user"; + + public static final String DASH_SCHEME = "dash"; + private static final String ENCODED_SPACE_CHARACTER = "%20"; + private static final String AMPERSAND_SEPARATOR = "&"; + private static final String QUESTION_MARK_SEPARATOR = "?"; + + /** + * Contains all the parameters in the order in which they appeared. + */ + private final Map parameterMap = new LinkedHashMap<>(); + + /** + * Constructs a new PaymentURI from the given string. Can be for any network. The address is + * validated against whichever Dash network its version byte belongs to. + * + * @param uri The raw URI data to be parsed (see class comments for accepted formats) + * @throws ParseException if the URI is not syntactically or semantically valid. + */ + public PaymentURI(String uri) throws ParseException { + this(null, uri); + } + + /** + * Constructs a new object by trying to parse the input as a valid payment URI. + * + * @param network The network the URI is from, or null if you don't have any expectation about what network the URI + * is for and wish to check yourself. + * @param input The raw URI data to be parsed (see class comments for accepted formats) + * @throws ParseException If the input fails payment URI syntax and semantic checks. + */ + public PaymentURI(@Nullable AddressNetwork network, String input) throws ParseException { + checkNotNull(input); + + String scheme = null == network ? DASH_SCHEME : network.getUriScheme(); + + // Attempt to form the URI (fail fast syntax checking to official standards). + URI uri; + try { + uri = new URI(input); + } catch (URISyntaxException e) { + throw new ParseException("Bad URI syntax", e); + } + + // URI is formed as dash:
? + // blockchain.info generates URIs of non-BIP compliant form dash://address?.... + String blockchainInfoScheme = scheme + "://"; + String correctScheme = scheme + ":"; + String schemeSpecificPart; + if (input.toLowerCase(Locale.US).startsWith(blockchainInfoScheme)) { + schemeSpecificPart = input.substring(blockchainInfoScheme.length()); + } else if (input.toLowerCase(Locale.US).startsWith(correctScheme)) { + schemeSpecificPart = input.substring(correctScheme.length()); + } else { + throw new ParseException("Unsupported URI scheme: " + uri.getScheme()); + } + + // Split off the address from the rest of the query parameters. + String[] addressSplitTokens = schemeSpecificPart.split("\\?", 2); + if (addressSplitTokens.length == 0) + throw new ParseException("No data found after the dash: prefix"); + String addressToken = addressSplitTokens[0]; // may be empty! + + String[] nameValuePairTokens; + if (addressSplitTokens.length == 1) { + // Only an address is specified without any additional parameters. + nameValuePairTokens = new String[]{}; + } else { + // Split into '=' tokens. + nameValuePairTokens = addressSplitTokens[1].split("&"); + } + + // Attempt to parse the rest of the URI parameters. + parseParameters(network, addressToken, nameValuePairTokens); + + if (!addressToken.isEmpty()) { + // Attempt to parse the addressToken as a base58 address for this network + // (mirrors Address.fromBase58 — segwit addresses are not accepted here). + try { + if (network != null) { + AddressUtils.DecodedAddress decoded = AddressUtils.decode(addressToken); + if (!network.acceptsVersion(decoded.getVersion())) { + throw new AddressFormatException.WrongNetwork(decoded.getVersion()); + } + } else { + AddressNetwork.fromDashAddress(addressToken); + } + putWithValidation(FIELD_ADDRESS, addressToken); + } catch (AddressFormatException e) { + throw new ParseException("Bad address", e); + } + } + + if (addressToken.isEmpty() && getPaymentRequestUrl() == null) { + throw new ParseException("No address and no r= parameter found"); + } + } + + /** + * @param network The network the URI is from + * @param nameValuePairTokens The tokens representing the name value pairs (assumed to be + * separated by '=' e.g. 'amount=0.2') + */ + private void parseParameters(@Nullable AddressNetwork network, String addressToken, + String[] nameValuePairTokens) throws ParseException { + // Attempt to decode the rest of the tokens into a parameter map. + for (String nameValuePairToken : nameValuePairTokens) { + final int sepIndex = nameValuePairToken.indexOf('='); + if (sepIndex == -1) + throw new ParseException("Malformed Dash URI - no separator in '" + nameValuePairToken + "'"); + if (sepIndex == 0) + throw new ParseException("Malformed Dash URI - empty name '" + nameValuePairToken + "'"); + final String nameToken = nameValuePairToken.substring(0, sepIndex).toLowerCase(Locale.ENGLISH); + final String valueToken = nameValuePairToken.substring(sepIndex + 1); + + // Parse the amount. + if (FIELD_AMOUNT.equals(nameToken)) { + // Decode the amount (contains an optional decimal component to 8dp). + try { + Coin amount = Coin.parseCoin(valueToken); + if (network != null && amount.isGreaterThan(Coin.valueOf(network.getMaxMoney()))) + throw new ParseException("Max number of coins exceeded"); + if (amount.signum() < 0) + throw new ArithmeticException("Negative coins specified"); + putWithValidation(FIELD_AMOUNT, amount); + } catch (IllegalArgumentException e) { + throw new OptionalFieldValidationException( + String.format(Locale.US, "'%s' is not a valid amount", valueToken), e); + } catch (ArithmeticException e) { + throw new OptionalFieldValidationException( + String.format(Locale.US, "'%s' has too many decimal places", valueToken), e); + } + } else { + if (nameToken.startsWith("req-")) { + // A required parameter that we do not know about. + throw new RequiredFieldValidationException( + "'" + nameToken + "' is required but not known, this URI is not valid"); + } else { + // Known fields and unknown parameters that are optional. + try { + if (valueToken.length() > 0) + putWithValidation(nameToken, URLDecoder.decode(valueToken, "UTF-8")); + } catch (UnsupportedEncodingException e) { + // Unreachable. + throw new RuntimeException(e); + } + } + } + } + + // Note to the future: when you want to implement 'req-expires' have a look at commit 410a53791841 + // which had it in. + } + + /** + * Put the value against the key in the map checking for duplication. This avoids address field overwrite etc. + * + * @param key The key for the map + * @param value The value to store + */ + private void putWithValidation(String key, Object value) throws ParseException { + if (parameterMap.containsKey(key)) { + throw new ParseException(String.format(Locale.US, "'%s' is duplicated, URI is invalid", key)); + } else { + parameterMap.put(key, value); + } + } + + /** + * The base58/bech32 address from the URI, if one was present. It's possible to have Dash URI's + * with no address if a r= payment protocol parameter is specified, though this form is not recommended as older + * wallets can't understand it. + */ + @Nullable + public String getAddress() { + return (String) parameterMap.get(FIELD_ADDRESS); + } + + /** + * @return The amount name encoded using a pure integer value based at + * 10,000,000 units is 1 DASH. May be null if no amount is specified + */ + @Nullable + public Coin getAmount() { + return (Coin) parameterMap.get(FIELD_AMOUNT); + } + + /** + * @return The label from the URI. + */ + @Nullable + public String getLabel() { + return (String) parameterMap.get(FIELD_LABEL); + } + + /** + * @return The message from the URI. + */ + @Nullable + public String getMessage() { + return (String) parameterMap.get(FIELD_MESSAGE); + } + + /** + * @return The user from the URI (dashj addition). + */ + @Nullable + public String getUser() { + return (String) parameterMap.get(FIELD_USER); + } + + /** + * @return The URL where a payment request (as specified in BIP 70) may be fetched. + */ + @Nullable + public final String getPaymentRequestUrl() { + return (String) parameterMap.get(FIELD_PAYMENT_REQUEST_URL); + } + + /** + * Returns the URLs where a payment request (as specified in BIP 70) may be fetched. The first URL is the main URL, + * all subsequent URLs are fallbacks. + */ + public List getPaymentRequestUrls() { + ArrayList urls = new ArrayList<>(); + while (true) { + int i = urls.size(); + String paramName = FIELD_PAYMENT_REQUEST_URL + (i > 0 ? Integer.toString(i) : ""); + String url = (String) parameterMap.get(paramName); + if (url == null) + break; + urls.add(url); + } + java.util.Collections.reverse(urls); + return urls; + } + + /** + * @param name The name of the parameter + * @return The parameter value, or null if not present + */ + @Nullable + public Object getParameterByName(String name) { + return parameterMap.get(name); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("DashURI["); + boolean first = true; + for (Map.Entry entry : parameterMap.entrySet()) { + if (first) { + first = false; + } else { + builder.append(","); + } + builder.append("'").append(entry.getKey()).append("'=").append("'").append(entry.getValue()).append("'"); + } + builder.append("]"); + return builder.toString(); + } + + /** + * Simple payment URI builder using known good fields. + * + * @param network The network the address is for. + * @param address The base58/bech32 address. + * @param amount The amount. + * @param label A label. + * @param message A message. + * @return A String containing the payment URI. + */ + public static String convertToPaymentURI(AddressNetwork network, String address, @Nullable Coin amount, + @Nullable String label, @Nullable String message) { + return convertToPaymentURI(network, address, amount, label, message, null); + } + + /** + * Simple payment URI builder using known good fields. + * + * @param network The network the address is for. + * @param address The base58/bech32 address. + * @param amount The amount. + * @param label A label. + * @param message A message. + * @param user A DashPay user (dashj addition). + * @return A String containing the payment URI. + */ + public static String convertToPaymentURI(AddressNetwork network, String address, @Nullable Coin amount, + @Nullable String label, @Nullable String message, @Nullable String user) { + checkNotNull(network); + checkNotNull(address); + if (amount != null && amount.signum() < 0) { + throw new IllegalArgumentException("Coin must be positive"); + } + + StringBuilder builder = new StringBuilder(); + String scheme = network.getUriScheme(); + builder.append(scheme).append(":").append(address); + + boolean questionMarkHasBeenOutput = false; + + if (amount != null) { + builder.append(QUESTION_MARK_SEPARATOR).append(FIELD_AMOUNT).append("="); + builder.append(amount.toPlainString()); + questionMarkHasBeenOutput = true; + } + + if (label != null && !"".equals(label)) { + if (questionMarkHasBeenOutput) { + builder.append(AMPERSAND_SEPARATOR); + } else { + builder.append(QUESTION_MARK_SEPARATOR); + questionMarkHasBeenOutput = true; + } + builder.append(FIELD_LABEL).append("=").append(encodeURLString(label)); + } + + if (message != null && !"".equals(message)) { + if (questionMarkHasBeenOutput) { + builder.append(AMPERSAND_SEPARATOR); + } else { + builder.append(QUESTION_MARK_SEPARATOR); + questionMarkHasBeenOutput = true; + } + builder.append(FIELD_MESSAGE).append("=").append(encodeURLString(message)); + } + + if (user != null && !"".equals(user)) { + if (questionMarkHasBeenOutput) { + builder.append(AMPERSAND_SEPARATOR); + } else { + builder.append(QUESTION_MARK_SEPARATOR); + } + builder.append(FIELD_USER).append("=").append(encodeURLString(user)); + } + + return builder.toString(); + } + + /** + * Encode a string using URL encoding + * + * @param stringToEncode The string to URL encode + */ + static String encodeURLString(String stringToEncode) { + try { + return URLEncoder.encode(stringToEncode, "UTF-8").replace("+", ENCODED_SPACE_CHARACTER); + } catch (UnsupportedEncodingException e) { + // should not happen - UTF-8 is a valid encoding + throw new RuntimeException(e); + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/Scripts.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/Scripts.kt new file mode 100644 index 0000000000..f6b159e293 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/Scripts.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +import org.bouncycastle.crypto.digests.RIPEMD160Digest +import java.security.MessageDigest + +/** + * Dashj-free helpers over raw output-script bytes, mirroring the exact recognition and + * construction rules of dashj's `ScriptPattern`/`ScriptBuilder` for the script shapes the + * app deals with (P2PKH, P2SH, P2PK, OP_RETURN). + */ +object Scripts { + private const val OP_DUP = 0x76 + private const val OP_HASH160 = 0xa9 + private const val OP_EQUAL = 0x87 + private const val OP_EQUALVERIFY = 0x88 + private const val OP_CHECKSIG = 0xac + private const val OP_CHECKMULTISIG = 0xae + private const val OP_RETURN = 0x6a + private const val OP_PUSHDATA1 = 0x4c + private const val OP_PUSHDATA2 = 0x4d + private const val OP_PUSHDATA4 = 0x4e + + private fun at(script: ByteArray, index: Int): Int = script[index].toInt() and 0xFF + + /** One parsed script element: a plain opcode (data == null) or a data push. */ + private class Chunk(val opcode: Int, val data: ByteArray?) + + /** + * Parses [script] into chunks exactly like dashj's `Script.parse`, or returns null where + * dashj would throw a `ScriptException`: a direct push (opcode 1..75) must have that many + * bytes remaining, OP_PUSHDATA1/2/4 must have their length bytes plus payload remaining. + * Unknown non-push opcodes are fine. OP_0 yields an empty (not null) data array, matching + * dashj. + */ + private fun parseChunks(script: ByteArray): List? { + val chunks = mutableListOf() + var cursor = 0 + while (cursor < script.size) { + val opcode = at(script, cursor) + cursor++ + var dataToRead = -1 + when { + opcode in 0 until OP_PUSHDATA1 -> dataToRead = opcode + opcode == OP_PUSHDATA1 -> { + if (cursor + 1 > script.size) return null + dataToRead = at(script, cursor) + cursor++ + } + opcode == OP_PUSHDATA2 -> { + if (cursor + 2 > script.size) return null + dataToRead = at(script, cursor) or (at(script, cursor + 1) shl 8) + cursor += 2 + } + opcode == OP_PUSHDATA4 -> { + if (cursor + 4 > script.size) return null + dataToRead = at(script, cursor) or (at(script, cursor + 1) shl 8) or + (at(script, cursor + 2) shl 16) or (at(script, cursor + 3) shl 24) + cursor += 4 + } + } + if (dataToRead == -1) { + chunks.add(Chunk(opcode, null)) + } else { + if (dataToRead < 0 || dataToRead > script.size - cursor) return null + chunks.add(Chunk(opcode, script.copyOfRange(cursor, cursor + dataToRead))) + cursor += dataToRead + } + } + return chunks + } + + /** + * Structural validation of a raw output script, accepting exactly what dashj's + * `Script(byte[])` constructor parses without throwing: truncated pushes and PUSHDATA + * length overruns are parse failures, unknown non-push opcodes are fine. + */ + @JvmStatic + fun isParseable(script: ByteArray): Boolean = parseChunks(script) != null + + /** Mirrors `ScriptPattern.isP2PKH`: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG. */ + @JvmStatic + fun isP2PKH(script: ByteArray): Boolean = + script.size == 25 && + at(script, 0) == OP_DUP && + at(script, 1) == OP_HASH160 && + at(script, 2) == 20 && + at(script, 23) == OP_EQUALVERIFY && + at(script, 24) == OP_CHECKSIG + + /** Mirrors `ScriptPattern.isP2SH`: OP_HASH160 <20 bytes> OP_EQUAL. */ + @JvmStatic + fun isP2SH(script: ByteArray): Boolean = + script.size == 23 && + at(script, 0) == OP_HASH160 && + at(script, 1) == 20 && + at(script, 22) == OP_EQUAL + + /** + * Mirrors `ScriptPattern.isP2PK`: exactly two chunks — any data push of more than one byte + * (direct or PUSHDATA-encoded, like dashj) followed by OP_CHECKSIG. + */ + @JvmStatic + fun isP2PK(script: ByteArray): Boolean { + val chunks = parseChunks(script) ?: return false + if (chunks.size != 2) return false + val pubKey = chunks[0] + if (pubKey.opcode > OP_PUSHDATA4) return false // first chunk must be a data push + val data = pubKey.data ?: return false + if (data.size <= 1) return false + return chunks[1].opcode == OP_CHECKSIG && chunks[1].data == null + } + + /** Mirrors `ScriptPattern.isOpReturn`: first opcode is OP_RETURN. */ + @JvmStatic + fun isOpReturn(script: ByteArray): Boolean = script.isNotEmpty() && at(script, 0) == OP_RETURN + + /** Rough mirror of `ScriptPattern.isSentToMultisig`: last opcode is OP_CHECKMULTISIG. */ + @JvmStatic + fun isMultisig(script: ByteArray): Boolean = script.isNotEmpty() && at(script, script.size - 1) == OP_CHECKMULTISIG + + /** The 20-byte hash of a P2PKH script (mirrors `ScriptPattern.extractHashFromP2PKH`). */ + @JvmStatic + fun extractHashFromP2PKH(script: ByteArray): ByteArray = script.copyOfRange(3, 23) + + /** The 20-byte hash of a P2SH script (mirrors `ScriptPattern.extractHashFromP2SH`). */ + @JvmStatic + fun extractHashFromP2SH(script: ByteArray): ByteArray = script.copyOfRange(2, 22) + + /** The pushed public key of a P2PK script (mirrors `ScriptPattern.extractKeyFromP2PK`: chunk 0's data). */ + @JvmStatic + fun extractKeyFromP2PK(script: ByteArray): ByteArray = + requireNotNull(parseChunks(script)?.firstOrNull()?.data) { "not a P2PK script" } + + /** + * Destination address of this script on [network], mirroring + * `Script.getToAddress(params, forcePayToPubKey)`; null where the dashj original would + * throw a `ScriptException` (unrecognized script shape). + */ + @JvmStatic + @JvmOverloads + fun addressOf(script: ByteArray, network: AddressNetwork, forcePayToPubKey: Boolean = false): String? = when { + isP2PKH(script) -> AddressUtils.encode(network.addressHeader, extractHashFromP2PKH(script)) + isP2SH(script) -> AddressUtils.encode(network.p2shHeader, extractHashFromP2SH(script)) + forcePayToPubKey && isP2PK(script) -> + AddressUtils.encode(network.addressHeader, hash160(extractKeyFromP2PK(script))) + else -> null + } + + /** + * Builds the output script paying to base58 [address] (P2PKH or P2SH by version byte), + * mirroring `ScriptBuilder.createOutputScript(address)`. The version byte must belong + * to [network] when given. + */ + @JvmStatic + @JvmOverloads + @Throws(AddressFormatException::class) + fun outputScriptForAddress(address: String, network: AddressNetwork? = null): ByteArray { + val decoded = AddressUtils.decode(address) + if (network != null && !network.acceptsVersion(decoded.version)) { + throw AddressFormatException.WrongNetwork(decoded.version) + } + val resolved = network ?: AddressNetwork.fromDashAddress(address) + return when (decoded.version) { + resolved.p2shHeader -> p2shScript(decoded.hash160) + else -> p2pkhScript(decoded.hash160) + } + } + + /** OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG. */ + @JvmStatic + fun p2pkhScript(hash160: ByteArray): ByteArray { + require(hash160.size == 20) + val script = ByteArray(25) + script[0] = OP_DUP.toByte() + script[1] = OP_HASH160.toByte() + script[2] = 20 + hash160.copyInto(script, 3) + script[23] = OP_EQUALVERIFY.toByte() + script[24] = OP_CHECKSIG.toByte() + return script + } + + /** OP_HASH160 <hash> OP_EQUAL. */ + @JvmStatic + fun p2shScript(hash160: ByteArray): ByteArray { + require(hash160.size == 20) + val script = ByteArray(23) + script[0] = OP_HASH160.toByte() + script[1] = 20 + hash160.copyInto(script, 2) + script[22] = OP_EQUAL.toByte() + return script + } + + /** OP_RETURN <data push>, mirroring `ScriptBuilder.createOpReturnScript(data)` incl. its 80-byte limit. */ + @JvmStatic + fun opReturnScript(data: ByteArray): ByteArray { + require(data.size <= 80) { "data is too long: ${data.size}" } + return byteArrayOf(OP_RETURN.toByte()) + pushData(data) + } + + /** Shortest-possible data push, mirroring `ScriptBuilder.data(data)` chunk encoding. */ + private fun pushData(data: ByteArray): ByteArray = when { + data.isEmpty() -> byteArrayOf(0) // OP_0 + data.size < OP_PUSHDATA1 -> byteArrayOf(data.size.toByte()) + data + data.size <= 0xFF -> byteArrayOf(OP_PUSHDATA1.toByte(), data.size.toByte()) + data + data.size <= 0xFFFF -> + byteArrayOf(OP_PUSHDATA2.toByte(), (data.size and 0xFF).toByte(), (data.size shr 8).toByte()) + data + else -> byteArrayOf( + OP_PUSHDATA4.toByte(), + (data.size and 0xFF).toByte(), + ((data.size shr 8) and 0xFF).toByte(), + ((data.size shr 16) and 0xFF).toByte(), + ((data.size ushr 24) and 0xFF).toByte() + ) + data + } + + /** + * The data payload of the second script chunk (mirrors reading `script.chunks[1].data`), + * or null if the script doesn't parse, has no second chunk, or the second chunk is a plain + * opcode. An OP_0 second chunk yields an empty (not null) array, exactly like dashj. + */ + @JvmStatic + fun secondChunkData(script: ByteArray): ByteArray? { + val chunks = parseChunks(script) ?: return null + if (chunks.size < 2) return null + return chunks[1].data + } + + /** SHA-256 followed by RIPEMD-160, as used for address derivation (mirrors `Utils.sha256hash160`). */ + @JvmStatic + fun hash160(input: ByteArray): ByteArray { + val sha256 = MessageDigest.getInstance("SHA-256").digest(input) + val digest = RIPEMD160Digest() + digest.update(sha256, 0, sha256.size) + val out = ByteArray(20) + digest.doFinal(out, 0) + return out + } +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/SegwitAddress.java b/common/src/main/java/org/dash/wallet/common/payments/parsers/SegwitAddress.java index 7f2f512f92..ee49542bd3 100644 --- a/common/src/main/java/org/dash/wallet/common/payments/parsers/SegwitAddress.java +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/SegwitAddress.java @@ -33,29 +33,18 @@ * limitations under the License. */ -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkState; - import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.List; import javax.annotation.Nullable; -import com.google.common.primitives.UnsignedBytes; - -import org.bitcoinj.core.AbstractAddress; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.params.Networks; -import org.bitcoinj.script.Script; - /** *

Implementation of native segwit addresses. They are composed of two parts:

* *
    - *
  • A human-readable part (HRP) which is a string the specifies the network. See - * {@link NetworkParameters#getSegwitAddressHrp()}.
  • + *
  • A human-readable part (HRP) which is a string that specifies the network. See + * {@link AddressNetwork#getSegwitHrp()}.
  • *
  • A data part, containing the witness version (encoded as an OP_N operator) and program (encoded by re-arranging * bits into groups of 5).
  • *
@@ -63,31 +52,26 @@ *

See BIP350 and * BIP173 for details.

* - *

However, you don't need to care about the internals. Use {@link #fromBech32(NetworkParameters, String)}, - * {@link #fromHash(NetworkParameters, byte[])} or {@link #fromKey(NetworkParameters, ECKey)} to construct a native - * segwit address.

+ *

Fully self-contained (no dashj): validation logic is byte-identical to the previous + * bitcoinj-based copy of this class.

*/ -public class SegwitAddress extends AbstractAddress { +public class SegwitAddress { public static final int WITNESS_PROGRAM_LENGTH_PKH = 20; public static final int WITNESS_PROGRAM_LENGTH_SH = 32; public static final int WITNESS_PROGRAM_LENGTH_TR = 32; public static final int WITNESS_PROGRAM_MIN_LENGTH = 2; public static final int WITNESS_PROGRAM_MAX_LENGTH = 40; - /** - * Private constructor. Use {@link #fromBech32(NetworkParameters, String)}, - * {@link #fromHash(NetworkParameters, byte[])} or {@link #fromKey(NetworkParameters, ECKey)}. - * - * @param params - * network this address is valid for - * @param witnessVersion - * version number between 0 and 16 - * @param witnessProgram - * hash of pubkey, pubkey or script (depending on version) - */ - private SegwitAddress(NetworkParameters params, int witnessVersion, byte[] witnessProgram) + /** Networks with a segwit address space known to this module. */ + private static final List SEGWIT_NETWORKS = + Collections.singletonList(AddressNetwork.BITCOIN_MAINNET); + + private final AddressNetwork network; + private final byte[] bytes; + + private SegwitAddress(AddressNetwork network, int witnessVersion, byte[] witnessProgram) throws AddressFormatException { - this(params, encode(witnessVersion, witnessProgram)); + this(network, encode(witnessVersion, witnessProgram)); } /** @@ -102,18 +86,16 @@ private static byte[] encode(int witnessVersion, byte[] witnessProgram) throws A } /** - * Private constructor. Use {@link #fromBech32(NetworkParameters, String)}, - * {@link #fromHash(NetworkParameters, byte[])} or {@link #fromKey(NetworkParameters, ECKey)}. - * - * @param params + * @param network * network this address is valid for * @param data * in segwit address format, before bit re-arranging and bech32 encoding * @throws AddressFormatException * if any of the sanity checks fail */ - private SegwitAddress(NetworkParameters params, byte[] data) throws AddressFormatException { - super(params, data); + private SegwitAddress(AddressNetwork network, byte[] data) throws AddressFormatException { + this.network = network; + this.bytes = data; if (data.length < 1) throw new AddressFormatException.InvalidDataLength("Zero data found"); final int witnessVersion = getWitnessVersion(); @@ -148,34 +130,12 @@ public byte[] getWitnessProgram() { return convertBits(bytes, 1, bytes.length - 1, 5, 8, false); } - @Override public byte[] getHash() { return getWitnessProgram(); } - /** - * Get the type of output script that will be used for sending to the address. This is either - * {@link Script.ScriptType#P2WPKH} or {@link Script.ScriptType#P2WSH}. - * - * @return type of output script - */ - @Override - public Script.ScriptType getOutputScriptType() { - int version = getWitnessVersion(); - if (version == 0) { - int programLength = getWitnessProgram().length; - if (programLength == WITNESS_PROGRAM_LENGTH_PKH) - return Script.ScriptType.P2WPKH; - if (programLength == WITNESS_PROGRAM_LENGTH_SH) - return Script.ScriptType.P2WSH; - throw new IllegalStateException(); // cannot happen - } else if (version == 1) { - int programLength = getWitnessProgram().length; - //if (programLength == WITNESS_PROGRAM_LENGTH_TR) - // return Script.ScriptType.P2TR; - throw new IllegalStateException(); // cannot happen - } - throw new IllegalStateException("cannot handle: " + version); + public AddressNetwork getNetwork() { + return network; } @Override @@ -186,7 +146,7 @@ public String toString() { /** * Construct a {@link SegwitAddress} from its textual form. * - * @param params + * @param network * expected network this address is valid for, or null if the network should be derived from the bech32 * @param bech32 * bech32-encoded textual form of the address @@ -194,24 +154,24 @@ public String toString() { * @throws AddressFormatException * if something about the given bech32 address isn't right */ - public static SegwitAddress fromBech32(@Nullable NetworkParameters params, String bech32) + public static SegwitAddress fromBech32(@Nullable AddressNetwork network, String bech32) throws AddressFormatException { Bech32.Bech32Data bechData = Bech32.decode(bech32); - if (params == null) { - for (NetworkParameters p : Networks.get()) { - if (bechData.hrp.equals(p.getSegwitAddressHrp())) + if (network == null) { + for (AddressNetwork p : SEGWIT_NETWORKS) { + if (bechData.hrp.equals(p.getSegwitHrp())) return fromBechData(p, bechData); } throw new AddressFormatException.InvalidPrefix("No network found for " + bech32); } else { - if (bechData.hrp.equals(params.getSegwitAddressHrp())) - return fromBechData(params, bechData); + if (bechData.hrp.equals(network.getSegwitHrp())) + return fromBechData(network, bechData); throw new AddressFormatException("Wrong Network: ${bechData.hrp}"); } } - private static SegwitAddress fromBechData(NetworkParameters params, Bech32.Bech32Data bechData) { - final SegwitAddress address = new SegwitAddress(params, bechData.data); + private static SegwitAddress fromBechData(AddressNetwork network, Bech32.Bech32Data bechData) { + final SegwitAddress address = new SegwitAddress(network, bechData.data); final int witnessVersion = address.getWitnessVersion(); if ((witnessVersion == 0 && bechData.encoding != Bech32.Encoding.BECH32) || (witnessVersion != 0 && bechData.encoding != Bech32.Encoding.BECH32M)) @@ -223,14 +183,14 @@ private static SegwitAddress fromBechData(NetworkParameters params, Bech32.Bech3 * Construct a {@link SegwitAddress} that represents the given hash, which is either a pubkey hash or a script hash. * The resulting address will be either a P2WPKH or a P2WSH type of address. * - * @param params + * @param network * network this address is valid for * @param hash * 20-byte pubkey hash or 32-byte script hash * @return constructed address */ - public static SegwitAddress fromHash(NetworkParameters params, byte[] hash) { - return new SegwitAddress(params, 0, hash); + public static SegwitAddress fromHash(AddressNetwork network, byte[] hash) { + return new SegwitAddress(network, 0, hash); } /** @@ -238,7 +198,7 @@ public static SegwitAddress fromHash(NetworkParameters params, byte[] hash) { * or a script hash – depending on the script version. The resulting address will be either a P2WPKH, a P2WSH or * a P2TR type of address. * - * @param params + * @param network * network this address is valid for * @param witnessVersion * version number between 0 and 16 @@ -246,23 +206,8 @@ public static SegwitAddress fromHash(NetworkParameters params, byte[] hash) { * version dependent witness program * @return constructed address */ - public static SegwitAddress fromProgram(NetworkParameters params, int witnessVersion, byte[] witnessProgram) { - return new SegwitAddress(params, witnessVersion, witnessProgram); - } - - /** - * Construct a {@link SegwitAddress} that represents the public part of the given {@link ECKey}. Note that an - * address is derived from a hash of the public key and is not the public key itself. - * - * @param params - * network this address is valid for - * @param key - * only the public part is used - * @return constructed address - */ - public static SegwitAddress fromKey(NetworkParameters params, ECKey key) { - checkArgument(key.isCompressed(), "only compressed keys allowed"); - return fromHash(params, key.getPubKeyHash()); + public static SegwitAddress fromProgram(AddressNetwork network, int witnessVersion, byte[] witnessProgram) { + return new SegwitAddress(network, witnessVersion, witnessProgram); } /** @@ -272,9 +217,9 @@ public static SegwitAddress fromKey(NetworkParameters params, ECKey key) { */ public String toBech32() { if (getWitnessVersion() == 0) - return Bech32.encode(Bech32.Encoding.BECH32, params.getSegwitAddressHrp(), bytes); + return Bech32.encode(Bech32.Encoding.BECH32, network.getSegwitHrp(), bytes); else - return Bech32.encode(Bech32.Encoding.BECH32M, params.getSegwitAddressHrp(), bytes); + return Bech32.encode(Bech32.Encoding.BECH32M, network.getSegwitHrp(), bytes); } /** @@ -308,19 +253,4 @@ private static byte[] convertBits(final byte[] in, final int inStart, final int } return out.toByteArray(); } - -// /** -// * {@inheritDoc} -// * -// * @param o other {@code Address} object -// * @return comparison result -// */ -// @Override -// public int compareTo(Address o) { -// int result = compareAddressPartial(o); -// if (result != 0) return result; -// -// // Compare the bytes -// return UnsignedBytes.lexicographicalComparator().compare(this.bytes, o.bytes); -// } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/payments/parsers/WifKey.kt b/common/src/main/java/org/dash/wallet/common/payments/parsers/WifKey.kt new file mode 100644 index 0000000000..937009759b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/payments/parsers/WifKey.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.payments.parsers + +/** + * WIF ("dumped") private key encode/decode on top of the Step A [Base58] port — a dashj-free + * mirror of `org.bitcoinj.core.DumpedPrivateKey` (dashj 22.0.3): + * + * - base58check payload = 1 version byte ([AddressNetwork.dumpedPrivateKeyHeader]: Dash mainnet + * 204/0xcc, testnet+devnets 239/0xef) + 32 key bytes + optional compressed-pubkey flag byte 0x01; + * - decoding accepts 32- or 33-byte key payloads, anything else is + * [AddressFormatException.InvalidDataLength] — same rule as the `DumpedPrivateKey` constructor; + * - [compressed] is true only for a 33-byte payload whose last byte is exactly 1, matching + * `DumpedPrivateKey.isPubKeyCompressed()`; + * - network-checked decode throws [AddressFormatException.WrongNetwork] like + * `DumpedPrivateKey.fromBase58(params, base58)`. + */ +class WifKey private constructor( + /** The WIF version byte that was encoded (e.g. 204 for Dash mainnet). */ + val version: Int, + private val bytes: ByteArray +) { + init { + if (bytes.size != 32 && bytes.size != 33) { + throw AddressFormatException.InvalidDataLength( + "Wrong number of bytes for a private key (32 or 33): " + bytes.size + ) + } + } + + /** The raw 32-byte private key. */ + val keyBytes: ByteArray + get() = bytes.copyOf(32) + + /** Mirrors `DumpedPrivateKey.isPubKeyCompressed()`. */ + val compressed: Boolean + get() = bytes.size == 33 && bytes[32].toInt() == 1 + + /** Re-encodes with the version byte this key was decoded/created with. */ + fun toBase58(): String = Base58.encodeChecked(version, bytes) + + override fun equals(other: Any?): Boolean = + other is WifKey && other.version == version && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = 31 * version + bytes.contentHashCode() + + override fun toString(): String = toBase58() + + companion object { + /** + * Decodes a WIF string without any network check (checksum still enforced). + * + * @throws AddressFormatException on bad base58, bad checksum or bad payload length + */ + @JvmStatic + @Throws(AddressFormatException::class) + fun decode(wif: String): WifKey { + val versionAndDataBytes = Base58.decodeChecked(wif) + val version = versionAndDataBytes[0].toInt() and 0xFF + val bytes = versionAndDataBytes.copyOfRange(1, versionAndDataBytes.size) + return WifKey(version, bytes) + } + + /** + * Decodes a WIF string, requiring [network]'s private key version byte — the equivalent + * of `DumpedPrivateKey.fromBase58(params, wif)`. + * + * Like dashj, the version byte is checked before the payload length, so a wrong-network + * string with a malformed payload throws [AddressFormatException.WrongNetwork], not + * [AddressFormatException.InvalidDataLength]. + * + * @throws AddressFormatException.WrongNetwork when the version byte belongs to another network + */ + @JvmStatic + @Throws(AddressFormatException::class) + fun decode(wif: String, network: AddressNetwork): WifKey { + val versionAndDataBytes = Base58.decodeChecked(wif) + val version = versionAndDataBytes[0].toInt() and 0xFF + if (version != network.dumpedPrivateKeyHeader) { + throw AddressFormatException.WrongNetwork(version) + } + return WifKey(version, versionAndDataBytes.copyOfRange(1, versionAndDataBytes.size)) + } + + /** + * Decodes a WIF string on whichever Dash network matches its version byte, mirroring + * `DumpedPrivateKey.fromBase58(null, wif)` over dashj's default network set + * (testnet is tried before mainnet; devnets share testnet's version byte). + * + * Like dashj, the version byte is inspected before the payload length, so an unknown + * version byte with a malformed payload throws [AddressFormatException.InvalidPrefix], + * not [AddressFormatException.InvalidDataLength]. + * + * @return the key and the matching network + * @throws AddressFormatException.InvalidPrefix when no Dash network matches + */ + @JvmStatic + @Throws(AddressFormatException::class) + fun decodeDash(wif: String): Pair { + val versionAndDataBytes = Base58.decodeChecked(wif) + val version = versionAndDataBytes[0].toInt() and 0xFF + for (network in listOf(AddressNetwork.DASH_TESTNET, AddressNetwork.DASH_MAINNET)) { + if (version == network.dumpedPrivateKeyHeader) { + return Pair(WifKey(version, versionAndDataBytes.copyOfRange(1, versionAndDataBytes.size)), network) + } + } + throw AddressFormatException.InvalidPrefix("No network found for version " + version) + } + + /** + * Encodes a raw 32-byte private key as WIF for [network], appending the 0x01 + * compressed-pubkey flag when [compressed] — the equivalent of + * `new DumpedPrivateKey(params, keyBytes, compressed).toBase58()` for ECDSA keys. + */ + @JvmStatic + fun encode(network: AddressNetwork, keyBytes: ByteArray, compressed: Boolean): String { + require(keyBytes.size == 32) { "Private keys must be 32 bytes" } + val payload = if (compressed) keyBytes.copyOf(33).also { it[32] = 1 } else keyBytes + return Base58.encodeChecked(network.dumpedPrivateKeyHeader, payload) + } + } +} diff --git a/common/src/main/java/org/dash/wallet/common/services/AuthenticationManager.kt b/common/src/main/java/org/dash/wallet/common/services/AuthenticationManager.kt index 85fffd0252..894f3d95f8 100644 --- a/common/src/main/java/org/dash/wallet/common/services/AuthenticationManager.kt +++ b/common/src/main/java/org/dash/wallet/common/services/AuthenticationManager.kt @@ -19,13 +19,21 @@ package org.dash.wallet.common.services import androidx.fragment.app.FragmentActivity import kotlinx.coroutines.flow.Flow -import org.bitcoinj.core.Address import org.dash.wallet.common.data.SecuritySystemStatus interface AuthenticationManager { fun authenticate(activity: FragmentActivity, pinOnly: Boolean = false, callback: (String?) -> Unit) suspend fun authenticate(activity: FragmentActivity, pinOnly: Boolean = false): String? - suspend fun signMessage(address: Address, message: String): String + /** + * Sign [message] with the private key of [address], returning the + * base64 signature. + * + * Throws [MessageSigningException] on every failure — implementations + * must NOT return an empty string when the wallet cannot sign (see that + * type's doc for why). [message] must contain no unpaired UTF-16 + * surrogate. + */ + suspend fun signMessage(address: String, message: String): String fun getHealth(): SecuritySystemStatus fun observeHealth(): Flow } diff --git a/common/src/main/java/org/dash/wallet/common/services/BlockchainStateProvider.kt b/common/src/main/java/org/dash/wallet/common/services/BlockchainStateProvider.kt index a5aa326e1d..3248884919 100644 --- a/common/src/main/java/org/dash/wallet/common/services/BlockchainStateProvider.kt +++ b/common/src/main/java/org/dash/wallet/common/services/BlockchainStateProvider.kt @@ -18,10 +18,9 @@ package org.dash.wallet.common.services import kotlinx.coroutines.flow.Flow -import org.bitcoinj.core.AbstractBlockChain -import org.bitcoinj.core.PeerGroup -import org.dash.wallet.common.data.entity.BlockchainState import org.dash.wallet.common.data.NetworkStatus +import org.dash.wallet.common.data.SyncStage +import org.dash.wallet.common.data.entity.BlockchainState /** * Blockchain state provider @@ -42,9 +41,6 @@ interface BlockchainStateProvider { fun getNetworkStatus(): NetworkStatus fun observeNetworkStatus(): Flow - fun getBlockChain(): AbstractBlockChain? - fun observeBlockChain(): Flow - - fun observeSyncStage(): Flow - fun getSyncStage(): PeerGroup.SyncStage + fun observeSyncStage(): Flow + fun getSyncStage(): SyncStage } diff --git a/common/src/main/java/org/dash/wallet/common/services/ConfirmTransactionService.kt b/common/src/main/java/org/dash/wallet/common/services/ConfirmTransactionService.kt index 360c6a708d..7ad66fe87c 100644 --- a/common/src/main/java/org/dash/wallet/common/services/ConfirmTransactionService.kt +++ b/common/src/main/java/org/dash/wallet/common/services/ConfirmTransactionService.kt @@ -17,7 +17,9 @@ package org.dash.wallet.common.services import androidx.fragment.app.FragmentActivity -import org.bitcoinj.utils.ExchangeRate +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.ExchangeRate +import org.dash.wallet.common.data.entity.ExchangeRate as ExchangeRateEntity interface ConfirmTransactionService { suspend fun showTransactionDetailsPreview( @@ -31,4 +33,30 @@ interface ConfirmTransactionService { payeeVerifiedBy: String? = null, buttonText: String? = null ): Boolean + + /** + * Neutral counterpart of [showTransactionDetailsPreview] taking the app's + * [ExchangeRateEntity] instead of a dashj rate, for modules that don't depend on dashj. + */ + suspend fun showTransactionDetailsPreview( + activity: FragmentActivity, + address: String, + amount: String, + exchangeRate: ExchangeRateEntity?, + fee: String, + total: String, + payeeName: String? = null, + payeeVerifiedBy: String? = null, + buttonText: String? = null + ): Boolean = showTransactionDetailsPreview( + activity, + address, + amount, + exchangeRate?.let { ExchangeRate(Coin.COIN, it.fiat) }, + fee, + total, + payeeName, + payeeVerifiedBy, + buttonText + ) } diff --git a/common/src/main/java/org/dash/wallet/common/services/LockScreenBroadcaster.kt b/common/src/main/java/org/dash/wallet/common/services/LockScreenBroadcaster.kt index 7149e8b3b7..88a41c90c5 100644 --- a/common/src/main/java/org/dash/wallet/common/services/LockScreenBroadcaster.kt +++ b/common/src/main/java/org/dash/wallet/common/services/LockScreenBroadcaster.kt @@ -24,4 +24,11 @@ import org.dash.wallet.common.data.SingleLiveEvent // That way, it will be dismissed automatically. class LockScreenBroadcaster { val activatingLockScreen = SingleLiveEvent() + + /** + * Fired when the lock screen is dismissed (successful unlock). Lets feature modules restore + * UI the lock screen tore down — e.g. re-show a result dialog that was auto-dismissed when + * the wallet locked (LockScreenActivity dismisses all DialogFragments on lock). + */ + val deactivatingLockScreen = SingleLiveEvent() } diff --git a/common/src/main/java/org/dash/wallet/common/services/MessageSigningException.kt b/common/src/main/java/org/dash/wallet/common/services/MessageSigningException.kt new file mode 100644 index 0000000000..768b9d84c4 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/services/MessageSigningException.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.services + +/** + * The failure contract of [AuthenticationManager.signMessage]. + * + * ## Why this type exists in `:common` + * + * The signing implementation lives in the `:wallet` module and delegates to + * the Dash Platform Kotlin SDK, whose typed errors + * (`org.dashfoundation.dashsdk.errors.DashSdkError`) are NOT on the + * classpath of the feature modules that call [AuthenticationManager] + * (`:integrations:crowdnode` depends on `:common` only). A caller therefore + * has no way to `catch` the SDK type. Since the interface being implemented + * is declared here, its error contract has to be expressible here too — so + * the wallet-side implementation maps every signing failure onto this type + * and keeps the SDK error as [cause] for logging/analytics. + * + * ## Behavior change vs. the previous dashj implementation + * + * The dashj implementation returned an EMPTY STRING when the wallet did not + * own the requested address. That silently produced a valid-looking request + * carrying no signature, which the CrowdNode server then rejected with an + * opaque error — the real cause (wrong/foreign address) never reached the + * user or the logs. Signing failures are now thrown, never swallowed; there + * is no dashj fallback (the codebase's fail-closed cutover philosophy, cf. + * `cutoverSendRoute` in `SendCoinsTaskRunner`). + * + * @property reason machine-readable classification, for callers that want + * to distinguish "this address isn't ours" from a generic failure. + */ +class MessageSigningException( + val reason: Reason, + message: String, + cause: Throwable? = null +) : Exception(message, cause) { + + enum class Reason { + /** + * The wallet cannot produce a signature for the requested address: + * it does not own the corresponding private key, or the key is not + * derivable/available. Maps from the SDK's + * `DashSdkError.PlatformWallet.SigningKeyUnavailable`. + * + * This is the case the old dashj code answered with `""`. + */ + SIGNING_KEY_UNAVAILABLE, + + /** + * The address (or message) was rejected as malformed before any key + * lookup happened. Maps from the SDK's platform-wallet + * `ErrorInvalidParameter` (native code 2, surfaced as + * `DashSdkError.PlatformWallet.Generic` with `nativeCode == 2`). + */ + INVALID_ADDRESS, + + /** + * Anything else: the SDK was not startable, no wallet was bound, or + * the signing call failed for an unclassified reason. Always carries + * a [cause]. + */ + UNAVAILABLE + } +} diff --git a/common/src/main/java/org/dash/wallet/common/services/SendPaymentService.kt b/common/src/main/java/org/dash/wallet/common/services/SendPaymentService.kt index 8e8b0797c9..b88252b9c6 100644 --- a/common/src/main/java/org/dash/wallet/common/services/SendPaymentService.kt +++ b/common/src/main/java/org/dash/wallet/common/services/SendPaymentService.kt @@ -17,49 +17,85 @@ package org.dash.wallet.common.services -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.InsufficientMoneyException -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.uri.BitcoinURI -import org.bitcoinj.wallet.CoinSelector -import org.bitcoinj.wallet.SendRequest -import java.util.function.Consumer -import java.util.function.Predicate +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.transactions.TxInfo + +/** + * Thrown when a send would leave less than the required leftover balance in the wallet + * (e.g. for CrowdNode withdrawals). [missing] is the amount that is missing to satisfy + * the requirement. + */ +class LeftoverBalanceException(val missing: Dash, message: String) : InsufficientFundsException(message) -class LeftoverBalanceException(missing: Coin, message: String) : InsufficientMoneyException(missing, message) class DirectPayException(message: String) : Exception(message) +/** + * Describes which outputs a send may draw on. The wallet module maps these onto its + * dashj coin selectors. + */ +sealed class SpendSelection { + /** Default selection over all spendable outputs. */ + object Any : SpendSelection() + + /** Only outputs paying the given base58 address (mirrors `ByAddressCoinSelector`). */ + data class ByAddress(val address: String) : SpendSelection() + + /** Exactly the given output of the given transaction (mirrors `ExactOutputsSelector`). */ + data class ExactOutput(val txId: String, val outputIndex: Int) : SpendSelection() +} + +/** + * Neutral (dashj-free) payment service facade: amounts are [Dash], addresses are base58 + * strings, created transactions are returned as [TxInfo] snapshots or hex tx ids. + */ interface SendPaymentService { + + /** + * Sends [amount] to the base58 [address] and returns the created transaction's txId + * as a hex string. Failures surface as exceptions classifiable with the neutral + * `Throwable.is*` helpers. + */ @Throws(LeftoverBalanceException::class) suspend fun sendCoins( - address: Address, - amount: Coin, - coinSelector: CoinSelector? = null, + address: String, + amount: Dash, + emptyWallet: Boolean = false, + checkBalanceConditions: Boolean = true + ): String + + /** + * Full-control send used by integrations that steer coin selection and output locking + * (CrowdNode). Returns the created transaction as a [TxInfo]. + * + * @param selection which outputs the send may draw on. + * @param lockSentOutputsTo before broadcasting, lock the created transaction's P2PKH outputs + * paying this base58 address (mirrors the CrowdNode account-output locking). + * @param canSpendLockedOutputsTo allow spending locked outputs that pay this base58 address. + */ + @Throws(LeftoverBalanceException::class) + suspend fun sendCoinsSelected( + address: String, + amount: Dash, + selection: SpendSelection = SpendSelection.Any, emptyWallet: Boolean = false, checkBalanceConditions: Boolean = true, - beforeSending: Consumer? = null, - canSendLockedOutput: Predicate? = null - ): Transaction + lockSentOutputsTo: String? = null, + canSpendLockedOutputsTo: String? = null + ): TxInfo + /** Fee/total estimate for sending [amount] to [address]. */ suspend fun estimateNetworkFee( - address: Address, - amount: Coin, + address: String, + amount: Dash, emptyWallet: Boolean = false - ): TransactionDetails + ): TransactionEstimate - data class TransactionDetails( + data class TransactionEstimate( val fee: String, - val amountToSend: Coin, + val amountToSend: Dash, val totalAmount: String ) - suspend fun payWithDashUrl(dashUri: String, serviceName: String?): Transaction - fun isFeeTooHigh(tx: Transaction): Boolean - - /** support manual tx creation */ - suspend fun completeTransaction(sendRequest: SendRequest) - suspend fun signTransaction(sendRequest: SendRequest) - suspend fun sendTransaction(sendRequest: SendRequest): Transaction + /** Pays the given `dash:` payment URI and returns the created transaction. */ + suspend fun payWithDashUrl(dashUri: String, serviceName: String?): TxInfo } diff --git a/common/src/main/java/org/dash/wallet/common/services/SendPaymentServiceExt.kt b/common/src/main/java/org/dash/wallet/common/services/SendPaymentServiceExt.kt new file mode 100644 index 0000000000..833b556a1b --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/services/SendPaymentServiceExt.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.services + +/** + * Neutral counterpart of dashj's `InsufficientMoneyException` for feature/integration + * modules: thrown by [payAndGetTxId] when the wallet balance can't cover the payment. + */ +open class InsufficientFundsException(message: String?, cause: Throwable? = null) : Exception(message, cause) + +// --------------------------------------------------------------------------------------------- +// Neutral classifiers for send failures. Feature/integration modules can't reference the dashj +// exception types that wallet-side code may throw, so these helpers classify them by class name +// (mirroring `catch (e: )` blocks exactly) in addition to the neutral types. +// --------------------------------------------------------------------------------------------- + +private fun Throwable.hasAncestorNamed(vararg fqcn: String): Boolean { + var c: Class<*>? = javaClass + while (c != null) { + if (fqcn.contains(c.name)) return true + c = c.superclass + } + return false +} + +/** True when the wallet balance can't cover the payment (dashj's `InsufficientMoneyException` or the neutral equivalent). */ +val Throwable.isInsufficientMoney: Boolean + get() = this is InsufficientFundsException || hasAncestorNamed("org.bitcoinj.core.InsufficientMoneyException") + +/** True when this is dashj's `Wallet.DustySendRequested` or `Wallet.CouldNotAdjustDownwards` (dusty send). */ +val Throwable.isDustySend: Boolean + get() = hasAncestorNamed( + "org.bitcoinj.wallet.Wallet\$DustySendRequested", + "org.bitcoinj.wallet.Wallet\$CouldNotAdjustDownwards" + ) + +/** True when this is the [LeftoverBalanceException] thrown by the leftover-balance check. */ +val Throwable.isLeftoverBalanceWarning: Boolean + get() = this is LeftoverBalanceException + +/** + * Pays the given payment URI and returns the created transaction's txId as a hex string. + * + * Dashj-side insufficient-funds failures (including [LeftoverBalanceException]) are rethrown as + * the neutral [InsufficientFundsException]; all other exceptions propagate unchanged. + */ +suspend fun SendPaymentService.payAndGetTxId(dashUri: String, serviceName: String?): String { + val transaction = try { + payWithDashUrl(dashUri, serviceName) + } catch (e: Exception) { + if (e.isInsufficientMoney && e !is InsufficientFundsException) { + throw InsufficientFundsException(e.message, e) + } + throw e + } + return transaction.txId +} diff --git a/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProvider.kt b/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProvider.kt index 8837e6a101..d4cc510c17 100644 --- a/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProvider.kt +++ b/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProvider.kt @@ -19,8 +19,7 @@ package org.dash.wallet.common.services import android.graphics.Bitmap import com.google.zxing.BarcodeFormat import kotlinx.coroutines.flow.Flow -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction +import org.dash.wallet.common.data.TxId import org.dash.wallet.common.data.PresentableTxMetadata import org.dash.wallet.common.data.TaxCategory import org.dash.wallet.common.data.entity.ExchangeRate @@ -29,44 +28,51 @@ import org.dash.wallet.common.data.entity.TransactionMetadata interface TransactionMetadataProvider { suspend fun setTransactionMetadata(transactionMetadata: TransactionMetadata) - suspend fun importTransactionMetadata(txId: Sha256Hash) + suspend fun importTransactionMetadata(txId: TxId) - suspend fun setTransactionTaxCategory(txId: Sha256Hash, taxCategory: TaxCategory, isSyncingPlatform: Boolean = false) - suspend fun setTransactionType(txId: Sha256Hash, type: Int, isSyncingPlatform: Boolean = false) - suspend fun setTransactionExchangeRate(txId: Sha256Hash, exchangeRate: ExchangeRate, isSyncingPlatform: Boolean = false) - suspend fun setTransactionMemo(txId: Sha256Hash, memo: String, isSyncingPlatform: Boolean = false) - suspend fun setTransactionService(txId: Sha256Hash, service: String, isSyncingPlatform: Boolean = false) - suspend fun setTransactionSentTime(txId: Sha256Hash, timestamp: Long, isSyncingPlatform: Boolean = false) + /** + * @param fallbackMetadata a minimal row to create when the tx has no existing metadata + * AND no dashj wallet Transaction to derive one from (an SDK-only tx). Ignored when a + * row already exists or the dashj wallet holds the tx. Lets user edits persist for + * transactions the dashj wallet does not hold. + */ + suspend fun setTransactionTaxCategory( + txId: TxId, + taxCategory: TaxCategory, + isSyncingPlatform: Boolean = false, + fallbackMetadata: TransactionMetadata? = null + ) + suspend fun setTransactionType(txId: TxId, type: Int, isSyncingPlatform: Boolean = false) + suspend fun setTransactionExchangeRate(txId: TxId, exchangeRate: ExchangeRate, isSyncingPlatform: Boolean = false) + suspend fun setTransactionMemo( + txId: TxId, + memo: String, + isSyncingPlatform: Boolean = false, + fallbackMetadata: TransactionMetadata? = null + ) + suspend fun setTransactionService(txId: TxId, service: String, isSyncingPlatform: Boolean = false) + suspend fun setTransactionSentTime(txId: TxId, timestamp: Long, isSyncingPlatform: Boolean = false) suspend fun syncPlatformMetadata( - txId: Sha256Hash, + txId: TxId, metadata: TransactionMetadata, giftCard: GiftCard?, iconUrl: String? ) - /** - * Checks for missing data in the metadata cache vs the Transaction and ensures that both - * are the same. - * - * @param tx The transaction to sync with the transaction metadata cache - */ - suspend fun syncTransaction(tx: Transaction) - fun syncTransactionBlocking(tx: Transaction) - - suspend fun getTransactionMetadata(txId: Sha256Hash): TransactionMetadata? - fun observeTransactionMetadata(txId: Sha256Hash): Flow + suspend fun getTransactionMetadata(txId: TxId): TransactionMetadata? + fun observeTransactionMetadata(txId: TxId): Flow /** * Mark a transaction as DashSpend gift card expense with an icon */ - suspend fun markGiftCardTransaction(txId: Sha256Hash, service: String, iconUrl: String?) + suspend fun markGiftCardTransaction(txId: TxId, service: String, iconUrl: String?) suspend fun updateGiftCardMetadata(giftCard: GiftCard) - suspend fun updateGiftCardBarcode(txId: Sha256Hash, index: Int, barcodeValue: String, barcodeFormat: BarcodeFormat) + suspend fun updateGiftCardBarcode(txId: TxId, index: Int, barcodeValue: String, barcodeFormat: BarcodeFormat) suspend fun getAllTransactionMetadata(): List - fun observePresentableMetadata(): Flow> - suspend fun getIcon(iconId: Sha256Hash): Bitmap? + fun observePresentableMetadata(): Flow> + suspend fun getIcon(iconId: TxId): Bitmap? // Address methods /** @@ -121,7 +127,7 @@ interface TransactionMetadataProvider { /** * check if the tx metadata table has metadata for the given tx. */ - suspend fun exists(txId: Sha256Hash): Boolean + suspend fun exists(txId: TxId): Boolean // Reset methods suspend fun clear() diff --git a/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProviderExt.kt b/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProviderExt.kt new file mode 100644 index 0000000000..773b90c20f --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/services/TransactionMetadataProviderExt.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.services + +import android.graphics.Bitmap +import com.google.zxing.BarcodeFormat +import kotlinx.coroutines.flow.Flow +import org.dash.wallet.common.data.TxId +import org.dash.wallet.common.data.entity.TransactionMetadata + +// --------------------------------------------------------------------------------------------- +// Neutral (dashj-free) adapters over TransactionMetadataProvider taking hex tx ids +// , for feature/integration modules that must not depend on dashj. +// They delegate to the Sha256Hash-typed interface methods, so behavior is identical. +// --------------------------------------------------------------------------------------------- + +/** Neutral counterpart of [TransactionMetadataProvider.getTransactionMetadata]. */ +suspend fun TransactionMetadataProvider.getTransactionMetadata(txId: String): TransactionMetadata? = + getTransactionMetadata(TxId.wrap(txId)) + +/** Neutral counterpart of [TransactionMetadataProvider.observeTransactionMetadata]. */ +fun TransactionMetadataProvider.observeTransactionMetadata(txId: String): Flow = + observeTransactionMetadata(TxId.wrap(txId)) + +/** Neutral counterpart of [TransactionMetadataProvider.markGiftCardTransaction]. */ +suspend fun TransactionMetadataProvider.markGiftCardTransaction(txId: String, service: String, iconUrl: String?) = + markGiftCardTransaction(TxId.wrap(txId), service, iconUrl) + +/** Neutral counterpart of [TransactionMetadataProvider.updateGiftCardBarcode]. */ +suspend fun TransactionMetadataProvider.updateGiftCardBarcode( + txId: String, + index: Int, + barcodeValue: String, + barcodeFormat: BarcodeFormat +) = updateGiftCardBarcode(TxId.wrap(txId), index, barcodeValue, barcodeFormat) + +/** Neutral counterpart of [TransactionMetadataProvider.getIcon] taking the icon id as a hex string. */ +suspend fun TransactionMetadataProvider.getIcon(iconId: String): Bitmap? = getIcon(TxId.wrap(iconId)) diff --git a/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsConstants.kt b/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsConstants.kt index 95024e65b4..b90b029a5f 100644 --- a/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsConstants.kt +++ b/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsConstants.kt @@ -83,7 +83,6 @@ object AnalyticsConstants { const val RESCAN_BLOCKCHAIN_DISMISS = "settings_rescan" const val ABOUT = "settings_about" const val ABOUT_SUPPORT = "settings_about_contact_support" - const val COINJOIN = "settings_coinjoin" } object Tools { @@ -133,6 +132,7 @@ object AnalyticsConstants { const val SHORTCUT_RECEIVE = "shortcut_receive" const val SHORTCUT_SEND = "shortcut_send" const val SHORTCUT_BUY_AND_SELL = "shortcut_buy_and_sell_dash" + const val SHORTCUT_DASH_DEX = "shortcut_dash_dex" const val SHORTCUT_EXPLORE = "shortcut_explore" const val HIDE_BALANCE = "home_hide_balance" const val SHOW_BALANCE = "home_show_balance" @@ -374,18 +374,6 @@ object AnalyticsConstants { const val QUOTE_CONFIRM = "coinbase_buy_quote_b_confirm" } - object CoinJoinPrivacy { - const val COINJOIN_START_MIXING = "settings_coinjoin_btn_start_mixing" - const val COINJOIN_STOP_MIXING = "settings_coinjoin_btn_stop_mixing" - const val COINJOIN_MIXING_SUCCESS = "settings_coinjoin_mixed_success" - const val COINJOIN_MIXING_FAIL = "settings_coinjoin_mixed_fail" - const val USERNAME_PRIVACY_BTN_CONTINUE = "username_privacy_btn_continue" - const val USERNAME_PRIVACY_WIFI_BTN_CONTINUE = "username_privacy_wifi_btn_continue" - const val USERNAME_PRIVACY_WIFI_BTN_CANCEL = "username_privacy_wifi_btn_cancel" - const val USERNAME_PRIVACY_CONFIRMATION_BTN_CONFIRM = "username_privacy_confirm_btn_confirm" - const val USERNAME_PRIVACY_CONFIRMATION_BTN_CANCEL = "username_privacy_confirm_btn_cancel" - } - object UsernameVoting { const val BLOCK = "username_voting_btn_block" const val DETAILS = "username_voting_details_open" diff --git a/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsService.kt b/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsService.kt index 042f2c031d..fe55fecb70 100644 --- a/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsService.kt +++ b/common/src/main/java/org/dash/wallet/common/services/analytics/AnalyticsService.kt @@ -31,11 +31,23 @@ interface AnalyticsService { } class FirebaseAnalyticsServiceImpl @Inject constructor() : AnalyticsService { - private val firebaseAnalytics = Firebase.analytics - private val crashlytics = Firebase.crashlytics - - init { - crashlytics.setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) + // Firebase is only configured when the build included google-services.json + // (see gradle/google-services.gradle). Builds without it must not crash — + // analytics simply no-ops. Resolved lazily so construction never throws. + private val firebaseAnalytics by lazy { + try { + Firebase.analytics + } catch (ex: IllegalStateException) { + Log.w("FIREBASE", "FirebaseApp not initialized (built without google-services.json); analytics disabled") + null + } + } + private val crashlytics by lazy { + try { + Firebase.crashlytics.also { it.setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) } + } catch (ex: IllegalStateException) { + null + } } override fun logEvent(event: String, params: Map) { @@ -50,7 +62,7 @@ class FirebaseAnalyticsServiceImpl @Inject constructor() : AnalyticsService { } try { - firebaseAnalytics.logEvent(event, bundleOf(*params.map { it.key.paramName to it.value }.toTypedArray())) + firebaseAnalytics?.logEvent(event, bundleOf(*params.map { it.key.paramName to it.value }.toTypedArray())) } catch (ex: Exception) { logError(ex) } @@ -63,7 +75,7 @@ class FirebaseAnalyticsServiceImpl @Inject constructor() : AnalyticsService { return } - details?.let { crashlytics.log(details) } - crashlytics.recordException(error) + details?.let { crashlytics?.log(details) } + crashlytics?.recordException(error) } } diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionCategory.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionCategory.kt index e47945bea0..be06739fa3 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionCategory.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/TransactionCategory.kt @@ -16,9 +16,6 @@ package org.dash.wallet.common.transactions -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Transaction - private const val EXPENSE = 0x10000000L private const val INCOME = 0x20000000L @@ -50,22 +47,5 @@ enum class TransactionCategory(val value: Long) { fun fromValue(value: Long): TransactionCategory { return values().find { it.value == value } ?: Invalid } - - fun fromTransaction(type: Transaction.Type, value: Coin, isInternal: Boolean): TransactionCategory { - return when (type) { - Transaction.Type.TRANSACTION_COINBASE -> MiningReward - Transaction.Type.TRANSACTION_PROVIDER_REGISTER -> MasternodeRegister - Transaction.Type.TRANSACTION_PROVIDER_UPDATE_REGISTRAR -> MasternodeUpdateRegistrar - Transaction.Type.TRANSACTION_PROVIDER_UPDATE_SERVICE -> MasternodeUpdateService - Transaction.Type.TRANSACTION_PROVIDER_UPDATE_REVOKE -> MasternodeUpdateRevoke - else -> { - when { - value.isPositive -> Received - isInternal -> Internal - else -> Sent - } - } - } - } } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionComparator.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionComparator.kt deleted file mode 100644 index 84cfd367c4..0000000000 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionComparator.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2022 Dash Core Group. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.dash.wallet.common.transactions - -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionConfidence - -class TransactionComparator: Comparator { - override fun compare(tx1: Transaction, tx2: Transaction): Int { - val pending1 = tx1.confidence.confidenceType == TransactionConfidence.ConfidenceType.PENDING - val pending2 = tx2.confidence.confidenceType == TransactionConfidence.ConfidenceType.PENDING - - if (pending1 != pending2) return if (pending1) -1 else 1 - - val updateTime1 = tx1.updateTime - val time1 = updateTime1?.time ?: 0 - val updateTime2 = tx2.updateTime - val time2 = updateTime2?.time ?: 0 - - if (time1 != time2) { - return if (time1 > time2) -1 else 1 - } - - return tx1.txId.compareTo(tx2.txId) - } -} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionObserver.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionObserver.kt deleted file mode 100644 index 34f7e96159..0000000000 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionObserver.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.dash.wallet.common.transactions - -import kotlinx.coroutines.suspendCancellableCoroutine -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionConfidence -import org.bitcoinj.utils.Threading -import org.dash.wallet.common.transactions.filters.TransactionFilter -import kotlin.coroutines.resume - -suspend fun Transaction.waitToMatchFilters(vararg filters: TransactionFilter) { - return suspendCancellableCoroutine { continuation -> - var transactionConfidenceListener: TransactionConfidence.Listener? = null - transactionConfidenceListener = TransactionConfidence.Listener { _, _ -> - if (filters.isEmpty() || filters.any { it.matches(this) }) { - confidence.removeEventListener(transactionConfidenceListener) - - if (continuation.isActive) { - continuation.resume(Unit) - } - } - } - - // Check if already matches - if (filters.isEmpty() || filters.any { it.matches(this) }) { - if (continuation.isActive) { - continuation.resume(Unit) - } - return@suspendCancellableCoroutine - } - - this.confidence.addEventListener(Threading.USER_THREAD, transactionConfidenceListener) - - continuation.invokeOnCancellation { - confidence.removeEventListener(transactionConfidenceListener) - } - } -} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionUtils.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionUtils.kt index c814c7aade..194edbb6a3 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionUtils.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/TransactionUtils.kt @@ -15,156 +15,19 @@ * along with this program. If not, see . */ -@file:OptIn(FlowPreview::class) - package org.dash.wallet.common.transactions -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.sample -import org.bitcoinj.core.Address -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionBag -import org.bitcoinj.script.ScriptException -import java.util.concurrent.ConcurrentHashMap -import org.bitcoinj.script.ScriptPattern - object TransactionUtils { - fun getWalletAddressOfReceived(tx: Transaction, bag: TransactionBag): Address? { - for (output in tx.outputs) { - try { - if (output.isMine(bag)) { - return output.scriptPubKey.getToAddress(tx.params, true) - } - } catch (x: ScriptException) { - // swallow - } - } - return null - } - - fun getFromAddressOfSent(tx: Transaction): List
{ - val result = mutableListOf
() - - for (input in tx.inputs) { - try { - val connectedTransaction = input.connectedTransaction - if (connectedTransaction != null) { - val output = connectedTransaction.getOutput(input.outpoint.index) - result.add(output.scriptPubKey.getToAddress(tx.params, true)) - } - } catch (x: ScriptException) { - // swallow - } - } - - return result - } - - fun getToAddressOfReceived(tx: Transaction, bag: TransactionBag): List
{ - val result = mutableListOf
() - - for (output in tx.outputs) { - try { - if (output.isMine(bag)) { - result.add(output.scriptPubKey.getToAddress(tx.params, true)) - } - } catch (x: ScriptException) { - // swallow - } - } - - return result - } - - fun getToAddressOfSent(tx: Transaction, bag: TransactionBag): List
{ - val result = mutableListOf
() - - for (output in tx.outputs) { - try { - if (!output.isMine(bag)) { - result.add(output.scriptPubKey.getToAddress(tx.params, true)) - } - } catch (x: ScriptException) { - // swallow - } - } - - return result + /** + * The wallet address the transaction was received to: the destination of the first + * output that pays the wallet AND has a resolvable address (base58), or null. Mine + * outputs whose address can't be resolved are skipped, like the old dashj code did. + * + * Delta vs the dashj original: P2PK outputs resolve to a null address in the neutral + * model (full P2PK address derivation was intentionally not ported), so they are + * skipped here where the old code could derive an address from the pubkey. + */ + fun getWalletAddressOfReceived(tx: TxInfo): String? { + return tx.outputs.firstOrNull { it.isMine && it.address != null }?.address } - - /** get OP_RETURNS of sent tx's */ - fun getOpReturnsOfSent( - tx: Transaction, - bag: TransactionBag - ): List { - val result = mutableListOf() - if (!tx.isCoinBase) { - for (output in tx.outputs) { - try { - if (!output.isMine(bag) && ScriptPattern.isOpReturn(output.scriptPubKey)) { - result.add("OP RETURN") - } - } catch (x: ScriptException) { - // swallow - } - } - } - - return result - } - - fun Transaction.isEntirelySelf(bag: TransactionBag): Boolean { - for (input in inputs) { - val connectedOutput = input.connectedOutput - - if (connectedOutput == null || !connectedOutput.isMine(bag)) { - return false - } - } - - for (output in outputs) { - if (!output.isMine(bag)) { - return false - } - } - - return true - } - - val Transaction.allOutputAddresses: List
- get() { - val result = mutableListOf
() - - outputs.forEach { - try { - val script = it.scriptPubKey - result.add(script.getToAddress(this.params, true)) - } catch (x: ScriptException) { - // swallow - } - } - return result - } -} - -fun Flow.batchAndFilterUpdates(timeInterval: Long = 500): Flow> { - val latestTransactions = ConcurrentHashMap() - - return this - .onEach { transaction -> - // Update the latest transaction for the hash - latestTransactions[transaction.txId] = transaction - } - .sample(timeInterval) // Emit events every [timeInterval] - .map { - latestTransactions.values.toList().also { - latestTransactions.clear() - } - } - .filter { it.isNotEmpty() } } diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapper.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapper.kt index 208b21c6d1..66283958f1 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapper.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapper.kt @@ -17,16 +17,17 @@ package org.dash.wallet.common.transactions -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionBag +import org.dash.wallet.common.money.Dash import java.time.LocalDate interface TransactionWrapper { val id: String - val transactions: HashMap + + /** Included transactions, keyed by hex txId. */ + val transactions: HashMap val groupDate: LocalDate - fun tryInclude(tx: Transaction): Boolean - fun getValue(bag: TransactionBag): Coin -} \ No newline at end of file + fun tryInclude(tx: TxInfo): Boolean + + /** Sum of the included transactions' net wallet values. */ + fun getValue(): Dash +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapperFactory.kt b/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapperFactory.kt index 09fb7e7127..06dfdc9373 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapperFactory.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/TransactionWrapperFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Dash Core Group. + * Copyright 2022 Dash Core Group. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,10 +17,8 @@ package org.dash.wallet.common.transactions -import org.bitcoinj.core.Transaction - interface TransactionWrapperFactory { val averageTransactions: Long val wrappers: List - fun tryInclude(tx: Transaction): Pair + fun tryInclude(tx: TxInfo): Pair } diff --git a/common/src/main/java/org/dash/wallet/common/transactions/TxInfo.kt b/common/src/main/java/org/dash/wallet/common/transactions/TxInfo.kt new file mode 100644 index 0000000000..63809519d9 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/transactions/TxInfo.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.transactions + +import java.time.LocalDate +import java.time.ZoneId + +/** + * A wallet-relative view of one transaction input, dashj-free. + * + * @param connectedAddress base58 destination of the connected (spent) output when it is a + * standard P2PKH/P2SH script, null when the connected output is unknown or non-standard. + * @param connectedIsMine whether the connected output belongs to the wallet; null when the + * connected output is unknown. + */ +class TxInputInfo( + val connectedAddress: String?, + val connectedIsMine: Boolean? +) + +/** + * A wallet-relative view of one transaction output, dashj-free. + * + * @param valueDuffs output value in duffs. + * @param address base58 destination when the script is standard P2PKH/P2SH, null otherwise. + * @param isOpReturn true when the output script is an OP_RETURN. + * @param isMine true when the output pays a wallet address. + * @param index the output's index in the transaction. + * @param spentBy the transaction spending this output, if known (populated one level deep). + */ +class TxOutputInfo( + val valueDuffs: Long, + val address: String?, + val isOpReturn: Boolean = false, + val isMine: Boolean = false, + val index: Int = 0, + val spentBy: TxInfo? = null +) + +/** + * A wallet-relative, dashj-free view of a transaction, produced by the wallet module for + * consumption by feature/integration modules (tx matchers, wrappers, filters). + * + * Snapshot semantics differ per field: [isLocked] and [isPending] are true conversion-time + * snapshots, while [netValueDuffs], [feeDuffs], [isEntirelySelf], [inputs], [outputs] and + * [rawHex] are lazy closures over the LIVE underlying transaction, frozen at first access — + * so they reflect the transaction's state at whatever point they are first read, not at + * conversion time. This keeps filtering large transaction sets as cheap as it was on the + * dashj types. + * + * @param raw an OPAQUE handle to the underlying wallet transaction. Only the wallet module, + * which created this snapshot, may cast it back; dashj-free modules must ignore it. + */ +class TxInfo( + /** Transaction id as a hex string (`Sha256Hash.toString()`). */ + val txId: String, + /** `Transaction.getUpdateTime().getTime()`, or 0 when unknown. */ + val updateTimeMillis: Long, + /** + * Whether the transaction counts as locked/confirmed for matching purposes — IS-locked, + * mined (BUILDING), or pending but seen by more than one broadcast peer; snapshot at + * conversion time. + */ + val isLocked: Boolean = false, + /** Mirrors `Transaction.isPending` (confidence type PENDING); snapshot at conversion time. */ + val isPending: Boolean = false, + netValueDuffs: () -> Long = { 0L }, + feeDuffs: () -> Long? = { null }, + isEntirelySelf: () -> Boolean = { false }, + inputs: () -> List = { emptyList() }, + outputs: () -> List = { emptyList() }, + rawHex: () -> String? = { null }, + val raw: Any? = null +) { + /** Net value to the wallet in duffs (`Transaction.getValue(bag)`). */ + val netValueDuffs: Long by lazy(netValueDuffs) + + /** Transaction fee in duffs, or null when unknown (`Transaction.getFee()`). */ + val feeDuffs: Long? by lazy(feeDuffs) + + /** True when every input spends a wallet output and every output pays the wallet. */ + val isEntirelySelf: Boolean by lazy(isEntirelySelf) + + val inputs: List by lazy(inputs) + + val outputs: List by lazy(outputs) + + /** Serialized transaction hex (`Transaction.toStringHex()`), or null when unavailable. */ + val rawHex: String? by lazy(rawHex) + + /** The transaction's update time as a local date, as used for grouping. */ + val groupDate: LocalDate + get() = java.util.Date(updateTimeMillis).toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + + override fun equals(other: Any?): Boolean = other is TxInfo && other.txId == txId + override fun hashCode(): Int = txId.hashCode() + override fun toString(): String = "TxInfo($txId)" +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsFromAddressTxFilter.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsFromAddressTxFilter.kt index 0994127126..ea68868206 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsFromAddressTxFilter.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsFromAddressTxFilter.kt @@ -17,39 +17,31 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Transaction -import org.bitcoinj.script.ScriptPattern +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.transactions.TxInfo open class CoinsFromAddressTxFilter( - private val fromAddress: Address, - private val coins: Coin, + private val fromAddress: String, + private val coins: Dash, private val includeFee: Boolean = false -): TransactionFilter { - var toAddress: Address? = null +) : TransactionFilter { + var toAddress: String? = null private set - override fun matches(tx: Transaction): Boolean { - val actualValue = if (includeFee && tx.fee != null) coins - tx.fee else coins - val networkParameters = fromAddress.parameters + override fun matches(tx: TxInfo): Boolean { + val fee = tx.feeDuffs + val actualValue = if (includeFee && fee != null) coins.duffs - fee else coins.duffs for (input in tx.inputs) { - input.outpoint.connectedOutput?.let { connectedOutput -> - val script = connectedOutput.scriptPubKey - - if ((ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) && - script.getToAddress(networkParameters) == fromAddress - ) { - val output = tx.outputs.firstOrNull { it.value == actualValue } - output?.run { - toAddress = this.scriptPubKey.getToAddress(networkParameters) - return true - } + if (input.connectedAddress != null && input.connectedAddress == fromAddress) { + val output = tx.outputs.firstOrNull { it.valueDuffs == actualValue } + output?.run { + toAddress = this.address + return true } } } return false } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsReceivedTxFilter.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsReceivedTxFilter.kt index 04ee08df92..2e0d954cfb 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsReceivedTxFilter.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsReceivedTxFilter.kt @@ -17,37 +17,29 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.* -import org.bitcoinj.script.ScriptPattern -import org.dash.wallet.common.transactions.TransactionUtils -import org.dash.wallet.common.transactions.TransactionUtils.isEntirelySelf +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.transactions.TxInfo open class CoinsReceivedTxFilter( - private val bag: TransactionBag, - private val coins: Coin -): TransactionFilter { - var toAddress: Address? = null + private val coins: Dash +) : TransactionFilter { + var toAddress: String? = null private set - override fun matches(tx: Transaction): Boolean { + override fun matches(tx: TxInfo): Boolean { // this check prevents a CoinJoin TX from being marked as a Crowdnode TX - if (tx.isEntirelySelf(bag) || tx.getValue(bag).signum() < 0) { + if (tx.isEntirelySelf || tx.netValueDuffs < 0) { // Not an incoming transaction return false } - val output = tx.outputs.firstOrNull { it.isMine(bag) && it.value == coins } + val output = tx.outputs.firstOrNull { it.isMine && it.valueDuffs == coins.duffs } if (output != null) { - val script = output.scriptPubKey - - if (ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) { - toAddress = script.getToAddress(tx.params) - } - + toAddress = output.address return true } return false } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsToAddressTxFilter.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsToAddressTxFilter.kt index 6f7068fc47..b1a28a6243 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsToAddressTxFilter.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/CoinsToAddressTxFilter.kt @@ -17,38 +17,28 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Transaction -import org.bitcoinj.script.ScriptPattern +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.transactions.TxInfo open class CoinsToAddressTxFilter( - val toAddress: Address, - val coins: Coin, + val toAddress: String, + val coins: Dash, val includeFee: Boolean = false -): TransactionFilter { - var fromAddresses = listOf
() +) : TransactionFilter { + var fromAddresses = listOf() private set - override fun matches(tx: Transaction): Boolean { - val actualValue = if (includeFee && tx.fee != null) coins - tx.fee else coins - val networkParameters = toAddress.parameters + override fun matches(tx: TxInfo): Boolean { + val fee = tx.feeDuffs + val actualValue = if (includeFee && fee != null) coins.duffs - fee else coins.duffs for (output in tx.outputs) { - val script = output.scriptPubKey - - if ((ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) && - script.getToAddress(networkParameters) == toAddress && - output.value == actualValue - ) { - fromAddresses = tx.inputs.mapNotNull { - it.outpoint.connectedOutput?.scriptPubKey?.getToAddress(networkParameters) - }.distinct() - + if (output.address != null && output.address == toAddress && output.valueDuffs == actualValue) { + fromAddresses = tx.inputs.mapNotNull { it.connectedAddress }.distinct() return true } } return false } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/LockedTransaction.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/LockedTransaction.kt index 6cf0515fe9..c7b976e484 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/LockedTransaction.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/LockedTransaction.kt @@ -17,24 +17,20 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionConfidence +import org.dash.wallet.common.transactions.TxInfo -class LockedTransaction(private val topUpTxId: Sha256Hash? = null): TransactionFilter { +class LockedTransaction(topUpTxId: String? = null) : TransactionFilter { constructor() : this(null) - override fun matches(tx: Transaction): Boolean { - val confidence = tx.confidence - val type = confidence.confidenceType - val isLocked = confidence.isTransactionLocked || - type == TransactionConfidence.ConfidenceType.BUILDING || - (type == TransactionConfidence.ConfidenceType.PENDING && confidence.numBroadcastPeers() > 1) + // TxInfo.txId is always lowercase hex (Sha256Hash.toString()); normalize the caller's id + // so a mixed-case argument can't silently fail to match. + private val topUpTxId: String? = topUpTxId?.lowercase() + override fun matches(tx: TxInfo): Boolean { return if (topUpTxId != null) { - tx.txId == topUpTxId && isLocked + tx.txId == topUpTxId && tx.isLocked } else { - isLocked + tx.isLocked } } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/NotFromAddressTxFilter.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/NotFromAddressTxFilter.kt index 6194fa7ec3..e9a0fe4cac 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/NotFromAddressTxFilter.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/NotFromAddressTxFilter.kt @@ -17,36 +17,22 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Address -import org.bitcoinj.core.Transaction -import org.bitcoinj.script.ScriptPattern - -class NotFromAddressTxFilter(private val ignoreAddress: Address): TransactionFilter { - override fun matches(tx: Transaction): Boolean { - val networkParameters = ignoreAddress.parameters +import org.dash.wallet.common.transactions.TxInfo +class NotFromAddressTxFilter(private val ignoreAddress: String) : TransactionFilter { + override fun matches(tx: TxInfo): Boolean { for (input in tx.inputs) { - input.outpoint.connectedOutput?.let { connectedOutput -> - val script = connectedOutput.scriptPubKey - - if ((ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) && - script.getToAddress(networkParameters) == ignoreAddress - ) { - return false - } + if (input.connectedAddress != null && input.connectedAddress == ignoreAddress) { + return false } } for (output in tx.outputs) { - val script = output.scriptPubKey - - if ((ScriptPattern.isP2PKH(script) || ScriptPattern.isP2SH(script)) && - script.getToAddress(networkParameters) == ignoreAddress - ) { + if (output.address != null && output.address == ignoreAddress) { return false } } return true } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/TransactionFilter.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/TransactionFilter.kt index ded9e9dfc3..b6bd8bc3a1 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/TransactionFilter.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/TransactionFilter.kt @@ -17,8 +17,8 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Transaction +import org.dash.wallet.common.transactions.TxInfo interface TransactionFilter { - fun matches(tx: Transaction): Boolean -} \ No newline at end of file + fun matches(tx: TxInfo): Boolean +} diff --git a/common/src/main/java/org/dash/wallet/common/transactions/filters/TxWithinTimePeriod.kt b/common/src/main/java/org/dash/wallet/common/transactions/filters/TxWithinTimePeriod.kt index c45be6a56e..f73c471c48 100644 --- a/common/src/main/java/org/dash/wallet/common/transactions/filters/TxWithinTimePeriod.kt +++ b/common/src/main/java/org/dash/wallet/common/transactions/filters/TxWithinTimePeriod.kt @@ -17,11 +17,12 @@ package org.dash.wallet.common.transactions.filters -import org.bitcoinj.core.Transaction +import org.dash.wallet.common.transactions.TxInfo import java.util.* -class TxWithinTimePeriod(private val from: Date, private val to: Date): TransactionFilter { - override fun matches(tx: Transaction): Boolean { - return tx.updateTime.after(from) && tx.updateTime.before(to) +class TxWithinTimePeriod(private val from: Date, private val to: Date) : TransactionFilter { + override fun matches(tx: TxInfo): Boolean { + val updateTime = Date(tx.updateTimeMillis) + return updateTime.after(from) && updateTime.before(to) } -} \ No newline at end of file +} diff --git a/common/src/main/java/org/dash/wallet/common/ui/BalanceUIState.kt b/common/src/main/java/org/dash/wallet/common/ui/BalanceUIState.kt index 0d829c8ef7..e959ce8763 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/BalanceUIState.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/BalanceUIState.kt @@ -17,11 +17,11 @@ package org.dash.wallet.common.ui -import org.bitcoinj.core.Coin -import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.FiatValue data class BalanceUIState( - val balance: Coin = Coin.ZERO, - val balanceFiat: Fiat? = null, + val balance: Dash = Dash.ZERO, + val balanceFiat: FiatValue? = null, val isUpdating: Boolean = false ) diff --git a/common/src/main/java/org/dash/wallet/common/ui/CurrencyAmountView.java b/common/src/main/java/org/dash/wallet/common/ui/CurrencyAmountView.java index dff41745c8..b98373dd58 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/CurrencyAmountView.java +++ b/common/src/main/java/org/dash/wallet/common/ui/CurrencyAmountView.java @@ -17,9 +17,9 @@ package org.dash.wallet.common.ui; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.Monetary; -import org.bitcoinj.utils.MonetaryFormat; +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.money.Monetary; +import org.dash.wallet.common.money.MonetaryFormat; import org.dash.wallet.common.R; import org.dash.wallet.common.util.Constants; import org.dash.wallet.common.util.GenericUtils; diff --git a/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextView.java b/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextView.java index 9b9479dcf7..20bbcf1852 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextView.java +++ b/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextView.java @@ -26,10 +26,10 @@ import androidx.appcompat.widget.AppCompatTextView; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.Monetary; -import org.bitcoinj.utils.ExchangeRate; -import org.bitcoinj.utils.MonetaryFormat; +import org.dash.wallet.common.money.Coin; +import org.dash.wallet.common.money.Monetary; +import org.dash.wallet.common.money.ExchangeRate; +import org.dash.wallet.common.money.MonetaryFormat; import org.dash.wallet.common.util.Constants; import org.dash.wallet.common.util.MonetarySpannable; diff --git a/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextViewExt.kt b/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextViewExt.kt new file mode 100644 index 0000000000..666d62bece --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/CurrencyTextViewExt.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui + +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.MoneyFormat +import org.dash.wallet.common.money.toCoin + +// Neutral counterparts of CurrencyTextView.setFormat/setAmount for feature/integration +// modules that must not depend on dashj. They delegate to the dashj-typed members, +// so rendering is identical. + +/** Sets the format of this view from a neutral [MoneyFormat]. Mirrors [CurrencyTextView.setFormat]. */ +fun CurrencyTextView.setFormat(format: MoneyFormat) { + setFormat(format.delegate) +} + +/** Sets the displayed amount from a neutral [Dash] value. Mirrors [CurrencyTextView.setAmount]. */ +fun CurrencyTextView.setAmount(amount: Dash) { + setAmount(amount.toCoin()) +} diff --git a/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputFragment.kt b/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputFragment.kt index c431790073..21cfb9ebd4 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputFragment.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputFragment.kt @@ -193,6 +193,10 @@ abstract class AddressInputFragment : Fragment(R.layout.fragment_address_input) binding.errorText.isVisible = false } catch (ex: Exception) { log.error("problem processing $input", ex) + // Reset to the address-format error: continueAction() (a subclass) may have replaced + // errorText with a swap-specific message on a previous, valid-format attempt, so an + // unparseable address must restore the correct "not a valid address" copy. + binding.errorText.text = getString(R.string.not_valid_address, viewModel.currency) binding.inputWrapper.isErrorEnabled = true binding.errorText.isVisible = true } diff --git a/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputViewModel.kt b/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputViewModel.kt index 1c741fd4c0..b42d677d55 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputViewModel.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/address_input/AddressInputViewModel.kt @@ -19,6 +19,7 @@ package org.dash.wallet.common.ui.address_input import android.content.ClipDescription import android.content.ClipboardManager +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -51,7 +52,7 @@ data class AddressInputResult( return if (separator == -1) { addressInput } else { - addressInput.substring(separator) + addressInput.substring(separator + 1) } } } @@ -60,13 +61,22 @@ data class AddressInputResult( class AddressInputViewModel @Inject constructor( private val clipboardManager: ClipboardManager, private val analyticsService: AnalyticsService, - walletDataProvider: WalletDataProvider + walletDataProvider: WalletDataProvider, + private val savedStateHandle: SavedStateHandle ): ViewModel() { + companion object { + private const val KEY_ADDRESS_INPUT = "address_input" + } + lateinit var paymentParsers: PaymentParsers var currency: String = Constants.DASH_CURRENCY val addressSources = arrayListOf() - private val _uiState = MutableStateFlow(AddressInputUIState()) + // The entered address survives process death via SavedStateHandle; everything else in the + // UI state is re-derived (clipboard) or transient. + private val _uiState = MutableStateFlow( + AddressInputUIState(addressInput = savedStateHandle[KEY_ADDRESS_INPUT] ?: "") + ) val uiState: StateFlow = _uiState.asStateFlow() private var paymentIntent: PaymentIntent? = null @@ -92,6 +102,7 @@ class AddressInputViewModel @Inject constructor( fun setInput(text: String) { _uiState.value = _uiState.value.copy(addressInput = text) + savedStateHandle[KEY_ADDRESS_INPUT] = text analyticsService.logEvent(AnalyticsConstants.AddressInput.ADDRESS_TAP, mapOf()) } diff --git a/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureHelper.kt b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureHelper.kt index af84239d5c..b176c0ac45 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureHelper.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureHelper.kt @@ -33,7 +33,7 @@ import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.util.Util import com.google.common.base.Stopwatch import com.google.common.io.BaseEncoding -import org.bitcoinj.core.Sha256Hash +import org.dash.wallet.common.data.TxId import org.slf4j.LoggerFactory import java.io.File import java.math.BigInteger @@ -41,6 +41,7 @@ import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.* +import java.util.concurrent.TimeUnit class ProfilePictureHelper { @@ -51,6 +52,9 @@ class ProfilePictureHelper { private const val ZOOM_PARAM_KEY = "dashpay-profile-pic-zoom" + /** Cap on the blocking raw-avatar fetch in [avatarBytesBlocking]. */ + private const val AVATAR_FETCH_TIMEOUT_SECONDS = 10L + fun avatarHashAndFingerprint(context: Context, pictureUrl: Uri, profileAvatarHash: ByteArray?, listener: OnResourceReadyListener? = null) { val watch = Stopwatch.createStarted() Glide.with(context) @@ -59,12 +63,12 @@ class ProfilePictureHelper { .into(object : CustomTarget() { override fun onResourceReady(resource: File, transition: Transition?) { - val serverAvatarHash = Sha256Hash.of(resource) + val serverAvatarHash = TxId.of(resource) watch.stop() val encoding = BaseEncoding.base64().omitPadding() log.debug("server avatarHash: '{}', took {}", encoding.encode(serverAvatarHash.bytes), watch) if (profileAvatarHash != null && !(profileAvatarHash contentEquals serverAvatarHash.bytes)) { - val profileAvatarHashBase64 = encoding.encode(Sha256Hash.wrap(profileAvatarHash).bytes) + val profileAvatarHashBase64 = encoding.encode(TxId.wrap(profileAvatarHash).bytes) log.info("server avatarHash ({}) doesn't match the profile avatarHash ({})", encoding.encode(serverAvatarHash.bytes), profileAvatarHashBase64) } val avatarFingerprint = CocoaImageDHash.of(BitmapFactory.decodeFile(resource.path)) @@ -81,6 +85,30 @@ class ProfilePictureHelper { }) } + /** + * The RAW bytes of the avatar at [pictureUrl] — the same file + * [avatarHashAndFingerprint] hashes, so a SHA-256 of the result equals + * the profile's `avatarHash`. Needed by the Kotlin-SDK profile write, + * which computes the avatar hash + perceptual fingerprint Rust-side + * from the raw image instead of taking the app's precomputed pair. + * + * BLOCKING (Glide's future) and bounded by [AVATAR_FETCH_TIMEOUT_SECONDS] + * — background threads only. Returns null on any failure/timeout; the + * caller then falls back to the dashj profile path, which carries the + * precomputed digest instead. + */ + fun avatarBytesBlocking(context: Context, pictureUrl: Uri): ByteArray? = try { + Glide.with(context) + .asFile() + .load(pictureUrl) + .submit() + .get(AVATAR_FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readBytes() + } catch (e: Exception) { + log.warn("failed to fetch raw avatar bytes", e) + null + } + fun setPicZoomParameter(uri: Uri, newValue: String): Uri { return setUriParameter(uri, ZOOM_PARAM_KEY, newValue) } @@ -214,6 +242,6 @@ class ProfilePictureHelper { } interface OnResourceReadyListener { - fun onResourceReady(avatarHash: Sha256Hash?, avatarFingerprint: BigInteger?) + fun onResourceReady(avatarHash: TxId?, avatarFingerprint: BigInteger?) } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt new file mode 100644 index 0000000000..37b4e52807 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/avatar/ProfilePictureZoomTransformation.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.dash.wallet.common.ui.avatar + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.RectF +import coil.size.Size +import coil.transform.Transformation + +/** + * Coil transformation that crops a bitmap to the normalized zoom rect encoded in a + * dashpay profile picture URL, then scales the result to 300x300. Mirrors the Glide + * transform in [ProfilePictureTransformation] so cached avatars look identical. + */ +class ProfilePictureZoomTransformation(private val zoomedRect: RectF) : Transformation { + + override val cacheKey: String = + "ProfilePictureZoomTransformation(${zoomedRect.left},${zoomedRect.top}," + + "${zoomedRect.right},${zoomedRect.bottom})" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + // A zero-dimension bitmap would make the coerceIn bounds below invalid (min > max) and crash. + if (input.width == 0 || input.height == 0) { + return input + } + // A malformed/empty zoom rect (right<=left or bottom<=top) would otherwise produce a 1px + // sliver, since the cropWidth/cropHeight coercion floors at 1. Reject it up front. + if (zoomedRect.right <= zoomedRect.left || zoomedRect.bottom <= zoomedRect.top) { + return input + } + val x = Math.round(zoomedRect.left * input.width).coerceIn(0, input.width - 1) + val y = Math.round(zoomedRect.top * input.height).coerceIn(0, input.height - 1) + val cropWidth = Math.round(input.width * (zoomedRect.right - zoomedRect.left)) + .coerceIn(1, input.width - x) + val cropHeight = Math.round(input.height * (zoomedRect.bottom - zoomedRect.top)) + .coerceIn(1, input.height - y) + val zoomX = TARGET / cropWidth + val zoomY = TARGET / cropHeight + val matrix = Matrix().apply { setScale(zoomX, zoomY) } + return Bitmap.createBitmap(input, x, y, cropWidth, cropHeight, matrix, true) + } + + private companion object { + const val TARGET = 300f + } +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/AddressField.kt b/common/src/main/java/org/dash/wallet/common/ui/components/AddressField.kt new file mode 100644 index 0000000000..e7605be0aa --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/AddressField.kt @@ -0,0 +1,414 @@ + +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.dash.wallet.common.R + +/** + * Figma: `addressField` (Design system - Android, node 7961:1052; states 7961:1062). + * + * A crypto-address text field with an optional label above and an optional message below. + * Visual state is driven by focus, content and [isError]: + * - **Default** (unfocused, empty): translucent gray background + placeholder + QR-scan icon. + * - **Focused**: white background with a hairline border; a cursor shows. Empty keeps the QR + * icon; with text the trailing icon becomes a clear (✕) button. + * - **Filled** (unfocused, with text): translucent gray background, no trailing icon. + * - **Error**: the field keeps its normal look; only the [message] below renders in red. + * + * The trailing icon is automatic: QR (when empty, calls [onScanClick]) or clear (when non-empty + * and focused, resets the value via [onValueChange]). Pass [onLongPress] to support long-press + * to paste, and [onPasteClick] to show a plain-blue "Paste" button next to the QR icon while + * the field is empty (Figma node 36320:8288). + * + * [innerLabel] renders a permanent small label inside the field, above the text line (the + * "TextField-Base" style, e.g. "BTC address" on the Maya enter-address screen) — unlike + * [placeholder], it stays visible once text is entered. + */ +@Composable +fun AddressField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + innerLabel: String? = null, + placeholder: String = "", + message: String? = null, + isError: Boolean = false, + showScanIcon: Boolean = true, + enabled: Boolean = true, + onScanClick: () -> Unit = {}, + onLongPress: (() -> Unit)? = null, + // Optional: shows a "Paste" text button next to the QR icon while the field is empty. + onPasteClick: (() -> Unit)? = null, + // Optional: lets callers focus the field programmatically (e.g. auto-open the keyboard). + focusRequester: FocusRequester? = null, + // Optional: invoked when the keyboard's Done action is pressed. + onImeAction: (() -> Unit)? = null +) { + // Focus is owned here so the field can switch between its default/filled and focused looks. + // The rendering lives in the stateless [AddressFieldContent] so previews can force any state. + var focused by remember { mutableStateOf(false) } + + AddressFieldContent( + value = value, + onValueChange = onValueChange, + focused = focused, + onFocusChanged = { focused = it }, + modifier = modifier, + label = label, + innerLabel = innerLabel, + placeholder = placeholder, + message = message, + isError = isError, + showScanIcon = showScanIcon, + enabled = enabled, + onScanClick = onScanClick, + onLongPress = onLongPress, + onPasteClick = onPasteClick, + focusRequester = focusRequester, + onImeAction = onImeAction + ) +} + +@Composable +private fun AddressFieldContent( + value: String, + onValueChange: (String) -> Unit, + focused: Boolean, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + innerLabel: String? = null, + placeholder: String = "", + message: String? = null, + isError: Boolean = false, + showScanIcon: Boolean = true, + enabled: Boolean = true, + onScanClick: () -> Unit = {}, + onLongPress: (() -> Unit)? = null, + onPasteClick: (() -> Unit)? = null, + focusRequester: FocusRequester? = null, + onImeAction: (() -> Unit)? = null +) { + val backgroundColor = when { + isError -> LocalDashColors.current.red.copy(alpha = 0.1f) + focused -> LocalDashColors.current.backgroundSecondary + else -> LocalDashColors.current.gray300.copy(alpha = 0.1f) + } + val borderColor = if (focused && !isError) { + LocalDashColors.current.gray300.copy(alpha = 0.3f) + } else { + Color.Transparent + } + // pointerInput(Unit) never restarts, so the gesture detector would otherwise keep the + // lambda captured on first composition; this keeps it current across recompositions. + val currentOnLongPress by rememberUpdatedState(onLongPress) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (label != null) { + Text( + text = label, + style = MyTheme.Body2Medium, + color = LocalDashColors.current.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 50.dp) + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .border(1.dp, borderColor, RoundedCornerShape(16.dp)) + .then( + if (onLongPress != null) { + Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { currentOnLongPress?.invoke() }) + } + } else { + Modifier + } + ) + .padding(start = 20.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 15.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (innerLabel != null) { + Text( + text = innerLabel, + style = MyTheme.Body2Regular, + color = LocalDashColors.current.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Box { + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + textStyle = MyTheme.Body2Regular.copy(color = LocalDashColors.current.textPrimary), + cursorBrush = SolidColor(LocalDashColors.current.textPrimary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = if (onImeAction != null) { + KeyboardActions(onDone = { onImeAction() }) + } else { + KeyboardActions.Default + }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { onFocusChanged(it.isFocused) } + .then( + if (focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + ) + ) + + if (value.isEmpty()) { + Text( + text = placeholder, + style = MyTheme.Body2Regular, + color = LocalDashColors.current.textPrimary.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + // Trailing controls (Figma 36320:8288): while empty — an optional "Paste" text button + // next to the QR-scan affordance; while focused with text — the clear (✕) button. + // None in the unfocused filled state. + val trailing: Pair Unit>? = when { + value.isNotEmpty() && focused -> R.drawable.ic_clear_input to { onValueChange("") } + value.isEmpty() && showScanIcon -> R.drawable.ic_scan_qr to onScanClick + else -> null + } + val showPasteButton = value.isEmpty() && onPasteClick != null + + if (trailing != null || showPasteButton) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (showPasteButton) { + DashButton( + text = stringResource(R.string.button_paste), + style = Style.PlainBlue, + size = Size.Medium, + stretch = false, + isEnabled = enabled, + onClick = { onPasteClick?.invoke() } + ) + } + + if (trailing != null) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .clickable(onClick = trailing.second), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(trailing.first), + contentDescription = null, + tint = LocalDashColors.current.textPrimary, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } + + if (message != null) { + Text( + text = message, + style = MyTheme.Body2Regular, + color = if (isError) LocalDashColors.current.red else LocalDashColors.current.textSecondary, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +// Previews mirror the six examples in Figma node 7961:1062 (Design system - Android). They render +// the stateless [AddressFieldContent] directly so the focused states (which depend on real focus at +// runtime) can be shown statically. + +/** Default — the state where the user doesn't interact with the field (Paste button + QR). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldDefaultPreview() { + AddressFieldContent( + value = "", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Address", + placeholder = "Long press to paste", + onPasteClick = {} + ) +} + +/** Pressed — the user tapped on the field (focused, empty). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldPressedPreview() { + AddressFieldContent( + value = "", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Address", + placeholder = "Long press to paste" + ) +} + +/** Entered — the user entered something in the field (focused, short text → clear icon). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldEnteredShortPreview() { + AddressFieldContent( + value = "TJvRMiThoqM", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Address" + ) +} + +/** Entered — the user entered something in the field (focused, long text wraps). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldEnteredLongPreview() { + AddressFieldContent( + value = "TJvRMiThoqMM97PnnA4qCAx7XQo8wNxjY3", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Address" + ) +} + +/** Error — the error message appears below the field in red; the field itself looks normal. */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldErrorPreview() { + AddressFieldContent( + value = "TJvRMiThoqMM97PnnA4qCAx7XQo8wNxjY3", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Address", + message = "BTC address is not valid", + isError = true + ) +} + +/** Inner label — permanent label inside the field, above the text line; stays when filled. */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldInnerLabelPreview() { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + AddressFieldContent( + value = "", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + innerLabel = "BTC address" + ) + AddressFieldContent( + value = "TJvRMiThoqMM97PnnA4qCAx7XQo8wNxjY3", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + innerLabel = "BTC address" + ) + } +} + +/** Filled — the user tapped outside the field area (unfocused, has text, no icon). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun AddressFieldFilledPreview() { + AddressFieldContent( + value = "TJvRMiThoqMM97PnnA4qCAx7XQo8wNxjY3", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Address" + ) +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt b/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt new file mode 100644 index 0000000000..db21dd65ae --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/CoinSelect.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Visual/interaction state of a [CoinSelect] row (DashDEX "Coin select" design system). + */ +enum class CoinSelectState { + /** Selectable. Full-colour icon; shows the trailing price/network block. */ + Active, + + /** Trading is halted for this chain — not selectable. Desaturated, with a "halted" badge. */ + HaltedChain, + + /** Not selectable for another reason. Desaturated, no trailing block. */ + Disabled +} + +/** + * A single selectable coin row used on the DashDEX "Select coin" screen. + * Implements the design-system component (Figma node `7872:1756`) and its four states + * (`7872:1765`): Active (single / multiple network), Halted chain, and Disabled. + * + * Non-active states are rendered exactly as specified by the design: a black saturation + * overlay desaturates the whole row (so the colour logo turns grey), the logo drops to 50% + * opacity, and the name/symbol switch to the tertiary text colour. Halted additionally shows + * a "halted" badge; Disabled shows no trailing content. Only [CoinSelectState.Active] rows + * are clickable. + * + * @param coinIcon slot for the coin logo (e.g. a Coil `AsyncImage`); sized to 30dp. + * @param price trailing price text, shown only in the Active state. + * @param network trailing network label (e.g. "NEAR", "Multiple"), shown only in the Active state. + * @param haltedLabel text of the badge shown in the [CoinSelectState.HaltedChain] state. + */ +@Composable +fun CoinSelect( + name: String, + symbol: String, + modifier: Modifier = Modifier, + coinIcon: @Composable () -> Unit = { CoinSelectPlaceholderIcon() }, + state: CoinSelectState = CoinSelectState.Active, + price: String? = null, + network: String? = null, + haltedLabel: String = "halted", + onClick: (() -> Unit)? = null +) { + val colors = LocalDashColors.current + val isGreyed = state != CoinSelectState.Active + val nameColor = if (isGreyed) colors.textTertiary else colors.textPrimary + val symbolColor = if (isGreyed) colors.textTertiary else colors.textSecondary + + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (state == CoinSelectState.Active && onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } + ) + // mix-blend-saturation with a black source desaturates everything below it, + // turning the colour logo grey for the non-selectable states. Offscreen + // compositing isolates the blend to this row's content. + .then(if (isGreyed) Modifier.desaturate() else Modifier) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(30.dp) + .then(if (isGreyed) Modifier.alpha(0.5f) else Modifier), + contentAlignment = Alignment.Center + ) { + coinIcon() + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = name, + style = MyTheme.Typography.BodyMediumMedium, + color = nameColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = symbol, + style = MyTheme.Typography.BodySmall, + color = symbolColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + when (state) { + CoinSelectState.Active -> { + if (price != null || network != null) { + Column(horizontalAlignment = Alignment.End) { + price?.let { + Text( + text = it, + style = MyTheme.Typography.BodyMedium, + color = colors.textPrimary + ) + } + network?.let { + Text( + text = it, + style = MyTheme.Typography.BodySmall, + color = colors.textSecondary + ) + } + } + } + } + + CoinSelectState.HaltedChain -> CoinSelectBadge(haltedLabel) + + CoinSelectState.Disabled -> Unit + } + } +} + +/** The small pill shown in the halted state (primary-8% background, secondary text). */ +@Composable +private fun CoinSelectBadge(label: String) { + Text( + text = label, + style = MyTheme.Typography.BodySmallMedium, + color = LocalDashColors.current.textSecondary, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(LocalDashColors.current.primary8) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +/** Neutral 30dp placeholder used when no [coinIcon] is supplied. */ +@Composable +fun CoinSelectPlaceholderIcon() { + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(LocalDashColors.current.lightGray) + ) +} + +/** + * Desaturate this content (the design's black `mix-blend-saturation` overlay). Implemented + * as a saturation-0 colour matrix applied to an offscreen layer, so only drawn pixels (the + * colour logo, text) turn grey — transparent areas stay transparent rather than going black. + */ +private fun Modifier.desaturate(): Modifier = this.drawWithCache { + val paint = Paint().apply { + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + } + onDrawWithContent { + drawIntoCanvas { canvas -> + canvas.saveLayer(Rect(Offset.Zero, size), paint) + drawContent() + canvas.restore() + } + } +} + +// ── Previews ──────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun CoinSelectStatesPreview() { + Column( + modifier = Modifier + .background(LocalDashColors.current.backgroundSecondary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + CoinSelectStateLabel("Active — single network") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Active, + price = "$0.00", + network = "NEAR", + onClick = {} + ) + + CoinSelectStateLabel("Active — multiple networks") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Active, + price = "$0.00", + network = "Multiple", + onClick = {} + ) + + CoinSelectStateLabel("Halted chain") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.HaltedChain, + haltedLabel = "halted" + ) + + CoinSelectStateLabel("Disabled") + CoinSelect( + name = "Binance coin", + symbol = "BNB", + coinIcon = { PreviewCoinIcon() }, + state = CoinSelectState.Disabled + ) + } +} + +@Composable +private fun CoinSelectStateLabel(text: String) { + Text( + text = text, + style = MyTheme.Typography.BodySmall, + color = LocalDashColors.current.textTertiary, + modifier = Modifier.padding(top = 8.dp, start = 10.dp) + ) +} + +/** Colour preview icon so the desaturation in the non-active states is visible. */ +@Composable +private fun PreviewCoinIcon() { + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color(0xFFF3BA2F)) + ) +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt index 850e988c63..77e02a5227 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashButton.kt @@ -24,18 +24,15 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import android.content.res.Configuration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.dash.wallet.common.R import org.dash.wallet.common.ui.components.MyTheme.OverlineSemibold -import org.dash.wallet.common.ui.components.MyTheme.SubtitleSemibold @Composable fun DashButton( @@ -50,43 +47,46 @@ fun DashButton( isLoading: Boolean = false, onClick: () -> Unit ) { + val colors = LocalDashColors.current + val backgroundColor = when { - !isEnabled -> Color(0xFF191C1F).copy(alpha = 0.05f) - style == Style.Filled -> MyTheme.Colors.dashBlue - style == Style.FilledBlue -> MyTheme.Colors.dashBlue - style == Style.FilledOrange -> MyTheme.Colors.orange - style == Style.FilledRed -> MyTheme.Colors.red - style == Style.TintedBlue -> MyTheme.Colors.dashBlue5 - style == Style.TintedGray -> Color(0x1AB0B6BC) - style == Style.TintedRed -> MyTheme.Colors.red5 - style == Style.FilledWhiteBlue -> MyTheme.Colors.backgroundPrimary + !isEnabled -> colors.disabledButtonBg + style == Style.Filled -> colors.dashBlue + style == Style.FilledBlue -> colors.dashBlue + style == Style.FilledOrange -> colors.orange + style == Style.FilledRed -> colors.red + style == Style.FilledGreen -> colors.green + style == Style.TintedBlue -> colors.dashBlue5 + style == Style.TintedGray -> colors.gray.copy(alpha = 0.10f) + style == Style.TintedRed -> colors.red5 + style == Style.FilledWhiteBlue -> colors.backgroundPrimary style == Style.TintedWhite -> Color(0x1AFFFFFF) - style == Style.PlainRed -> MyTheme.Colors.red5 + style == Style.PlainRed -> colors.red5 else -> Color.Transparent } val contentColor = when { - !isEnabled -> MyTheme.Colors.textPrimary.copy(alpha = 0.40f) + !isEnabled -> colors.contentDisabled style == Style.Filled -> Color.White style == Style.FilledBlue -> Color.White style == Style.FilledOrange -> Color.White style == Style.FilledRed -> Color.White - style == Style.TintedBlue -> MyTheme.Colors.dashBlue - style == Style.PlainBlue -> MyTheme.Colors.dashBlue - style == Style.PlainBlack -> MyTheme.Colors.textPrimary - style == Style.PlainRed -> MyTheme.Colors.red - style == Style.TintedRed -> MyTheme.Colors.red - style == Style.TintedGray -> MyTheme.Colors.textPrimary - style == Style.StrokeGray -> MyTheme.Colors.textPrimary - style == Style.FilledWhiteBlue -> MyTheme.Colors.dashBlue + style == Style.FilledGreen -> Color.White + style == Style.TintedBlue -> colors.dashBlue + style == Style.PlainBlue -> colors.dashBlue + style == Style.PlainBlack -> colors.textPrimary + style == Style.PlainRed -> colors.red + style == Style.TintedRed -> colors.red + style == Style.TintedGray -> colors.textPrimary + style == Style.StrokeGray -> colors.textPrimary + style == Style.FilledWhiteBlue -> colors.dashBlue style == Style.TintedWhite -> Color.White - - else -> MyTheme.Colors.textPrimary + else -> colors.textPrimary } val borderColor = when { !isEnabled -> Color.Transparent - style == Style.Outlined -> MyTheme.Colors.textTertiary.copy(alpha = 0.25f) + style == Style.Outlined -> colors.textTertiary.copy(alpha = 0.25f) style == Style.StrokeGray -> Color(0x4DB3BDC7) else -> Color.Transparent } @@ -161,6 +161,7 @@ enum class Style { Filled, FilledBlue, FilledOrange, + FilledGreen, TintedBlue, TintedGray, PlainBlue, @@ -189,17 +190,16 @@ enum class Size( ExtraSmall(12.sp, 16.sp,13.dp, 6.dp, 8.dp, 4.dp, 6.dp, 28.dp) } -val DashBlue = Color(0xFF008DE4) -val PrimaryText = Color(0xFF000000) -val TertiaryText = Color(0xFF888888) - +@Preview(name = "Dash Button Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dash Button Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview fun DashButtonPreview() { + DashWalletTheme { + val colors = LocalDashColors.current Column( modifier = Modifier .fillMaxWidth() - .background(colorResource(R.color.white)) + .background(colors.backgroundPrimary) .padding(20.dp, 10.dp, 20.dp, 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -261,7 +261,7 @@ fun DashButtonPreview() { onClick = { } ) Column(modifier = Modifier - .background(MyTheme.Colors.dashBlue) + .background(colors.dashBlue) .padding(10.dp, 20.dp)) { DashButton( text = "TintedWhite", @@ -351,4 +351,5 @@ fun DashButtonPreview() { onClick = { } ) } + } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashCheckBox.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashCheckBox.kt index 768f53324b..fd018c2452 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashCheckBox.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashCheckBox.kt @@ -1,5 +1,6 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -32,15 +33,16 @@ fun DashCheckbox( trailingHelpText: String? = null, ) { val interactionSource = remember { MutableInteractionSource() } - val textPrimary = MyTheme.Colors.textPrimary - val textSecondary = MyTheme.Colors.textSecondary + val colors = LocalDashColors.current + val textPrimary = colors.textPrimary + val textSecondary = colors.textSecondary Row( modifier = modifier .fillMaxWidth() .defaultMinSize(minHeight = 50.dp) //.clip(RoundedCornerShape(8.dp)) - .background(Color.White) + .background(colors.backgroundSecondary) .padding(horizontal = 10.dp, vertical = 8.dp) .clickable( interactionSource = interactionSource, @@ -89,7 +91,7 @@ fun DashCheckbox( subtitle?.let { Text( text = it, - color = MyTheme.Colors.darkGray, + color = colors.darkGray, style = MyTheme.OverlineCaptionMedium, textAlign = TextAlign.Start ) @@ -120,7 +122,7 @@ fun DashCheckbox( trailingHelpText?.let { Text( text = it, - color = MyTheme.Colors.darkGray, + color = colors.darkGray, style = MyTheme.OverlineCaptionMedium, textAlign = TextAlign.End ) @@ -135,11 +137,11 @@ fun DashCheckbox( .clip(RoundedCornerShape(6.dp)) .border( width = 1.5.dp, - color = if (checked) MyTheme.Colors.dashBlue else MyTheme.Colors.darkerGray50, + color = if (checked) colors.dashBlue else colors.darkerGray50, shape = RoundedCornerShape(6.dp) ) .background( - if (checked) MyTheme.Colors.dashBlue else Color.Transparent + if (checked) colors.dashBlue else Color.Transparent ), contentAlignment = Alignment.Center ) { @@ -156,9 +158,17 @@ fun DashCheckbox( } } +@Preview(name = "Checkbox Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Checkbox Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview fun CheckboxSample() { + DashWalletTheme { + CheckboxSampleContent() + } +} + +@Composable +private fun CheckboxSampleContent() { var isChecked by remember { mutableStateOf(false) } var isChecked1 by remember { mutableStateOf(false) } var isChecked2 by remember { mutableStateOf(false) } @@ -168,7 +178,7 @@ fun CheckboxSample() { Column( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashList.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashList.kt index db0e0c15fb..a06dd25494 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashList.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashList.kt @@ -16,6 +16,7 @@ */ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -46,6 +47,7 @@ fun DashList( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit ) { + val colors = LocalDashColors.current Column( modifier = modifier .fillMaxWidth() @@ -56,19 +58,22 @@ fun DashList( spotColor = DashListShadowColor.copy(alpha = 0.10f) ) .clip(DashListShape) - .background(MyTheme.Colors.backgroundSecondary) + .background(colors.backgroundSecondary) .padding(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), content = content ) } -@Preview(showBackground = true, backgroundColor = 0xFFF5F6F7) +@Preview(name = "Dash List Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dash List Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DashListPreview() { - DashList { - ListItem(label = "Original purchase", trailingText = "$50.00") - ListItem(label = "Card number", trailingText = "6006491727005748") - ListItem(label = "Card PIN", trailingText = "1411") + DashWalletTheme { + DashList { + ListItem(label = "Original purchase", trailingText = "$50.00") + ListItem(label = "Card number", trailingText = "6006491727005748") + ListItem(label = "Card PIN", trailingText = "1411") + } } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashModelDialog.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashModelDialog.kt index 6020d90f8f..34f8f45182 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashModelDialog.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashModelDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import android.content.res.Configuration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,166 +62,174 @@ fun ModalDialog( horizontalPadding: androidx.compose.ui.unit.Dp = 15.dp ) { if (showDialog) { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true - ), - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White - ) + DashWalletTheme { + val colors = LocalDashColors.current + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ), ) { - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 32.dp, bottom = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = horizontalPadding), + shape = RoundedCornerShape(16.dp), + // Pin elevation to 0 so Material3 does not blend a surfaceColorAtElevation + // tint over the container color — the dialog must render as exactly backgroundSecondary. + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors( + containerColor = colors.backgroundSecondary, + contentColor = colors.textPrimary + ) ) { - // Info icon if provided - icon?.let { - Box( - modifier = Modifier - .size(46.dp), - //.background(iconBackgroundColor, CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(46.dp) - ) - } - Spacer(modifier = Modifier.height(20.dp)) - } - - // Content wrapper Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .background(colors.backgroundSecondary) + .padding(start = 20.dp, end = 20.dp, top = 32.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // Heading and text blocks + // Info icon if provided + icon?.let { + Box( + modifier = Modifier + .size(46.dp), + //.background(iconBackgroundColor, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(46.dp) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + } + + // Content wrapper Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Text( - text = heading, - style = MyTheme.SubtitleSemibold, - textAlign = textAlign, - color = Color(0xFF191C1F) - ) - - // First part of text blocks (before limitation items) - val textBlocksPart1 = if (limitationItems.isEmpty()) textBlocks else textBlocks.take(2) - textBlocksPart1.forEach { textBlock -> + // Heading and text blocks + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { Text( - text = textBlock, - style = MyTheme.Body2Regular, + text = heading, + style = MyTheme.SubtitleSemibold, textAlign = textAlign, - color = Color(0xFF525C66) + color = colors.textPrimary ) + + // First part of text blocks (before limitation items) + val textBlocksPart1 = if (limitationItems.isEmpty()) textBlocks else textBlocks.take(2) + textBlocksPart1.forEach { textBlock -> + Text( + text = textBlock, + style = MyTheme.Body2Regular, + textAlign = textAlign, + color = colors.textPrimary + ) + } + Spacer(modifier = Modifier.height(20.dp)) } - Spacer(modifier = Modifier.height(20.dp)) - } - // Limitation items if provided - if (limitationItems.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth(), + // Limitation items if provided + if (limitationItems.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth(), //.padding(vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - limitationItems.forEach { item -> - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + horizontalArrangement = Arrangement.SpaceBetween + ) { + limitationItems.forEach { item -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = item.value, - style = MyTheme.OverlineCaptionMedium, - color = Color(0xFF191C1F) - ) - if (item.showDashIcon) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_dash_d_black), - contentDescription = null, - tint = Color(0xFF191C1F), - modifier = Modifier.size(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = item.value, + style = MyTheme.OverlineCaptionMedium, + color = colors.textPrimary //Color(0xFF191C1F) ) + if (item.showDashIcon) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_dash_d_black), + contentDescription = null, + tint = colors.textPrimary, //Color(0xFF191C1F), + modifier = Modifier.size(12.dp) + ) + } } + Text( + text = item.label, + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.sp, + color = colors.textPrimary + ) } + } + } + + // Remaining text blocks after limitation items + if (textBlocks.size > 2) { + textBlocks.drop(2).forEach { textBlock -> Text( - text = item.label, - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 16.sp, - color = Color(0xFF525C66) + text = textBlock, + style = MyTheme.Body2Regular, + textAlign = textAlign, + color = colors.textPrimary ) } } } - // Remaining text blocks after limitation items - if (textBlocks.size > 2) { - textBlocks.drop(2).forEach { textBlock -> - Text( - text = textBlock, - style = MyTheme.Body2Regular, - textAlign = textAlign, - color = Color(0xFF525C66) - ) - } - } + // Custom content (if provided) + content?.invoke() } - // Custom content (if provided) - content?.invoke() - } - - // Learn More Button - moreInfoButton?.let { - DashButton( - text = moreInfoButton.label, - style = Style.PlainBlue, - size = Size.Small, - onClick = moreInfoButton.onClick, - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Bottom buttons group - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - buttons.forEach { buttonData -> + // Learn More Button + moreInfoButton?.let { DashButton( - text = buttonData.label, - onClick = buttonData.onClick, - modifier = Modifier.fillMaxWidth(), - size = Size.Medium, - style = buttonData.style, - isEnabled = buttonData.enabled, - isLoading = buttonData.progress + text = moreInfoButton.label, + style = Style.PlainBlue, + size = Size.Small, + onClick = moreInfoButton.onClick, ) } + + Spacer(modifier = Modifier.height(32.dp)) + + // Bottom buttons group + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + buttons.forEach { buttonData -> + DashButton( + text = buttonData.label, + onClick = buttonData.onClick, + modifier = Modifier.fillMaxWidth(), + size = Size.Medium, + style = buttonData.style, + isEnabled = buttonData.enabled, + isLoading = buttonData.progress + ) + } + } } } } @@ -249,29 +258,32 @@ data class ButtonData( val progress: Boolean = false ) -@Preview(showBackground = true) +@Preview(name = "Modal Dialog Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Modal Dialog Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun ModalDialogPreview() { - ModalDialog( - showDialog = true, - onDismissRequest = { }, - icon = ImageVector.vectorResource(id = R.drawable.ic_info_blue), - heading = "Heading", - textBlocks = listOf( - "This is the first text block with some information for the user", - "This is the second text block with additional details", - "And a final text block at the bottom of the dialog" - ), - limitationItems = listOf( - LimitationItem("0", "text", true), - LimitationItem("0", "text", true), - LimitationItem("0", "text", true) - ), - moreInfoButton = ButtonData("Learn more", {}), - buttons = listOf( - ButtonData("Primary Action", {}, true), - ButtonData("Secondary Action", {}), - ButtonData("Tertiary Action", {}) + DashWalletTheme { + ModalDialog( + showDialog = true, + onDismissRequest = { }, + icon = ImageVector.vectorResource(id = R.drawable.ic_info_blue), + heading = "Heading", + textBlocks = listOf( + "This is the first text block with some information for the user", + "This is the second text block with additional details", + "And a final text block at the bottom of the dialog" + ), + limitationItems = listOf( + LimitationItem("0", "text", true), + LimitationItem("0", "text", true), + LimitationItem("0", "text", true) + ), + moreInfoButton = ButtonData("Learn more", {}), + buttons = listOf( + ButtonData("Primary Action", {}, true), + ButtonData("Secondary Action", {}), + ButtonData("Tertiary Action", {}) + ) ) - ) + } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashRadioButton.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashRadioButton.kt index e335417dd1..bd59f29949 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashRadioButton.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashRadioButton.kt @@ -1,5 +1,6 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.selectable @@ -20,6 +21,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.dash.wallet.common.R +import org.dash.wallet.common.ui.components.MyTheme +import org.dash.wallet.common.ui.components.MyTheme.Typography + /** * Custom radio button component with text and optional helper text * Matches the design system from Figma @@ -44,10 +48,11 @@ fun DashRadioButton( enabled: Boolean = true, onlyOption: Boolean = false ) { - val primaryTextColor = MyTheme.Colors.textPrimary - val secondaryTextColor = MyTheme.Colors.textSecondary - val radioButtonColor = MyTheme.Colors.dashBlue - val borderColor = if (selected) radioButtonColor else Color(0xFFCED2D5) // #CED2D5 from Figma + val colors = LocalDashColors.current + val primaryTextColor = colors.textPrimary + val secondaryTextColor = colors.textSecondary + val radioButtonColor = colors.dashBlue + val borderColor = if (selected) radioButtonColor else colors.lightGray //Color(0xFFCED2D5) // #CED2D5 from Figma val contentAlpha = if (enabled) 1f else 0.6f @@ -174,9 +179,17 @@ private fun TextContent( } } -@Preview(showBackground = true) +@Preview(name = "Radio Button Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Radio Button Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun RadioButtonPreview() { + DashWalletTheme { + RadioButtonPreviewContent() + } +} + +@Composable +private fun RadioButtonPreviewContent() { var selectedOption by remember { mutableIntStateOf(1) } Column(modifier = Modifier.padding(16.dp)) { @@ -221,7 +234,7 @@ fun RadioButtonPreview() { // Text on the left, radio button on the right HorizontalDivider() - Text("RadioGroup (simple)", style = MyTheme.OverlineSemibold) + Text("RadioGroup (simple)", style = Typography.LabelMediumSemibold) HorizontalDivider() val selectedFrequency = remember { mutableStateOf("Once per month") } RadioGroup( @@ -230,7 +243,7 @@ fun RadioButtonPreview() { { selectedFrequency.value = it } ) HorizontalDivider() - Text("RadioGroup (local currencies)", style = MyTheme.OverlineSemibold) + Text("RadioGroup (local currencies)", style = Typography.LabelMediumSemibold) HorizontalDivider() val selectedCurrency = remember { mutableIntStateOf(1) } RadioGroup( diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/DashToggle.kt b/common/src/main/java/org/dash/wallet/common/ui/components/DashToggle.kt index 0e38344964..f471380426 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/DashToggle.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/DashToggle.kt @@ -17,9 +17,11 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.selection.toggleable import androidx.compose.ui.semantics.Role @@ -56,6 +58,7 @@ fun DashSwitch( enabled: Boolean = true, ) { val density = LocalDensity.current + val colors = LocalDashColors.current // Dimensions based on Figma design with larger thumb val trackWidth = 32.dp @@ -78,9 +81,9 @@ fun DashSwitch( // Colors based on Figma design val trackColor = if (checked) { - MyTheme.Colors.dashBlue + colors.dashBlue } else { - MyTheme.Colors.gray300 + colors.gray300 } val thumbColor = Color.White @@ -92,7 +95,7 @@ fun DashSwitch( role = Role.Switch, enabled = enabled, interactionSource = remember { MutableInteractionSource() }, - indication = null, + indication = null, // no ripple to match Figma onValueChange = { onCheckedChange?.invoke(it) } ), contentAlignment = Alignment.Center @@ -134,9 +137,17 @@ fun DashSwitch( } } -@Preview(showBackground = true) +@Preview(name = "Dash Switch Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dash Switch Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DashSwitchPreview() { + DashWalletTheme { + DashSwitchPreviewContent() + } +} + +@Composable +private fun DashSwitchPreviewContent() { Box( modifier = Modifier.padding(16.dp) ) { diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt b/common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt index 6d3584f976..fb0b4f0d63 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/EnterAmount.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -24,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -75,6 +79,10 @@ const val DASH_CURRENCY_CODE: String = "DASH" * * The currency symbol (or Dash logo) is placed before or after the number based on [locale]'s * standard currency-format pattern — e.g. `$1,234.56` (en-US) vs `1 234,56 €` (fr-FR). + * + * The currency picker ([showCurrencyPicker]) offers only the currencies that are NOT currently + * selected — the selected one is already displayed as the primary amount. The index passed to + * [onCurrencyPickerSelect] is always a position in the full [currencyCodes] list. */ @Composable fun EnterAmount( @@ -97,6 +105,7 @@ fun EnterAmount( onCurrencyPickerSelect: (SegmentedOption, Int) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { + val colors = LocalDashColors.current val primaryIndex = selectedCurrencyIndex.coerceIn(0, currencyCodes.lastIndex.coerceAtLeast(0)) val primaryCode = currencyCodes.getOrNull(primaryIndex) ?: DASH_CURRENCY_CODE val secondaryCode = currencyCodes @@ -126,7 +135,7 @@ fun EnterAmount( Text( text = helpTextTop, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary, + color = colors.textTertiary, textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 2.dp) ) @@ -155,7 +164,7 @@ fun EnterAmount( Text( text = helpTextBottom, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary, + color = colors.textTertiary, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 2.dp) ) @@ -167,13 +176,32 @@ fun EnterAmount( } if (showCurrencyPicker && currencyCodes.size >= 2) { + // Only the unselected currencies are offered — the selected one is already displayed + // as the primary amount, so it's not a meaningful option. Tap indices are remapped + // back to positions in the caller's full [currencyCodes] list. + val pickerIndices = currencyCodes.indices.filter { it != primaryIndex } + + // Wrap the picker to its content instead of letting its options' fillMaxWidth grab the + // whole row: width = widest option label, height = the stacked options' natural height + // (so it sits compact on the right rather than stretching across the amount area). SegmentedPicker( - options = currencyCodes.map { SegmentedOption(it) }, - selectedIndex = primaryIndex, + options = pickerIndices.map { SegmentedOption(currencyCodes[it]) }, + showSelection = false, style = SegmentedPickerStyle( displayMode = PickerDisplayMode.Vertical, + cornerRadius = 8f, + backgroundColor = Color.Transparent, + thumbColor = MyTheme.Colors.primary5, + textStyle = MyTheme.Typography.LabelSmallMedium, // caption-2 11sp per Figma + + shadowElevation = 0 ), - onOptionSelected = onCurrencyPickerSelect + onOptionSelected = { option, index -> + onCurrencyPickerSelect(option, pickerIndices[index]) + }, + modifier = Modifier + .width(IntrinsicSize.Max) + .height(IntrinsicSize.Min) ) } } @@ -189,10 +217,11 @@ private fun MaxBtn(onClick: () -> Unit) { .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { + val colors = LocalDashColors.current Text( text = "Max", style = MyTheme.Typography.LabelSmallSemibold, - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, textAlign = TextAlign.Center ) } @@ -200,11 +229,12 @@ private fun MaxBtn(onClick: () -> Unit) { @Composable private fun ShowBalanceBtn(onClick: () -> Unit) { + val colors = LocalDashColors.current Box( modifier = Modifier .size(40.dp) .clip(CircleShape) - .background(MyTheme.Colors.primary8) + .background(colors.primary8) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { @@ -226,6 +256,7 @@ private fun AmountPrimary( onClick: () -> Unit ) { val mode = modeFor(currencyCode) + val colors = LocalDashColors.current Row( modifier = Modifier .clickable(onClick = onClick) @@ -239,7 +270,7 @@ private fun AmountPrimary( Text( text = amount, style = MyTheme.Typography.HeadlineLargeMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) if (!symbolBeforeAmount) { PrimarySymbol(mode = mode, currencyCode = currencyCode, locale = locale) @@ -256,6 +287,7 @@ private fun AmountPrimary( @Composable private fun PrimarySymbol(mode: EnterAmountMode, currencyCode: String, locale: Locale) { + val colors = LocalDashColors.current when (mode) { EnterAmountMode.Dash -> Image( painter = painterResource(R.drawable.ic_dash_d_black), @@ -265,7 +297,7 @@ private fun PrimarySymbol(mode: EnterAmountMode, currencyCode: String, locale: L EnterAmountMode.Fiat -> Text( text = fiatSymbolForCode(currencyCode, locale), style = MyTheme.Typography.HeadlineLargeMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) } } @@ -278,6 +310,7 @@ private fun AmountSecondary( symbolBeforeAmount: Boolean, onClick: () -> Unit ) { + val colors = LocalDashColors.current val mode = modeFor(currencyCode) Row( modifier = Modifier @@ -296,7 +329,7 @@ private fun AmountSecondary( Text( text = amount, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) if (!symbolBeforeAmount) { SecondarySymbol(mode = mode, currencyCode = currencyCode, locale = locale) @@ -305,7 +338,7 @@ private fun AmountSecondary( Image( painter = painterResource(R.drawable.ic_chevron_down_small), contentDescription = null, - colorFilter = ColorFilter.tint(MyTheme.Colors.textTertiary), + colorFilter = ColorFilter.tint(colors.textTertiary), modifier = Modifier.size(width = 5.dp, height = 2.5.dp) ) } @@ -313,6 +346,7 @@ private fun AmountSecondary( @Composable private fun SecondarySymbol(mode: EnterAmountMode, currencyCode: String, locale: Locale) { + val colors = LocalDashColors.current when (mode) { EnterAmountMode.Dash -> Image( painter = painterResource(R.drawable.ic_dash_d_gray), @@ -322,7 +356,7 @@ private fun SecondarySymbol(mode: EnterAmountMode, currencyCode: String, locale: EnterAmountMode.Fiat -> Text( text = fiatSymbolForCode(currencyCode, locale), style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) } } @@ -349,73 +383,91 @@ private fun isCurrencySymbolPrefix(locale: Locale): Boolean { return true } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "Fiat Primary Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Fiat Primary Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountFiatPrimaryPreview() { - EnterAmount( - primaryAmount = "1,234.00", - secondaryAmount = "12.3456", - currencyCodes = listOf("USD", DASH_CURRENCY_CODE), - selectedCurrencyIndex = 0, - locale = Locale.US - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "1,234.00", + secondaryAmount = "12.3456", + currencyCodes = listOf("USD", DASH_CURRENCY_CODE), + selectedCurrencyIndex = 0, + locale = Locale.US + ) + } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "Dash Primary Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dash Primary Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountDashPrimaryPreview() { - EnterAmount( - primaryAmount = "12.3456", - secondaryAmount = "1,234.00", - currencyCodes = listOf("USD", DASH_CURRENCY_CODE), - selectedCurrencyIndex = 1, - locale = Locale.US - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "12.3456", + secondaryAmount = "1,234.00", + currencyCodes = listOf("USD", DASH_CURRENCY_CODE), + selectedCurrencyIndex = 1, + locale = Locale.US + ) + } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "French Locale Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "French Locale Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountFrenchLocalePreview() { // fr-FR puts the symbol AFTER the amount: "1 234,00 €" - EnterAmount( - primaryAmount = "1 234,00", - secondaryAmount = "12,3456", - currencyCodes = listOf("EUR", DASH_CURRENCY_CODE), - selectedCurrencyIndex = 0, - locale = Locale.FRANCE - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "1 234,00", + secondaryAmount = "12,3456", + currencyCodes = listOf("EUR", DASH_CURRENCY_CODE), + selectedCurrencyIndex = 0, + locale = Locale.FRANCE + ) + } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "With Help Text Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "With Help Text Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountWithHelpTextPreview() { - EnterAmount( - primaryAmount = "1,234.00", - secondaryAmount = "12.3456", - helpTextTop = "Available balance: $1,234", - helpTextBottom = "Network fee: $0.10" - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "1,234.00", + secondaryAmount = "12.3456", + helpTextTop = "Available balance: $1,234", + helpTextBottom = "Network fee: $0.10" + ) + } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "Minimal Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Minimal Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountMinimalPreview() { - EnterAmount( - primaryAmount = "25", - showMaxButton = false, - showBalanceButton = false, - showSecondary = false - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "25", + showMaxButton = false, + showBalanceButton = false, + showSecondary = false + ) + } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 393) +@Preview(name = "With Currency Picker Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "With Currency Picker Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EnterAmountWithCurrencyPickerPreview() { - EnterAmount( - primaryAmount = "100", - secondaryAmount = "1.0023", - currencyCodes = listOf("USD", "EUR", DASH_CURRENCY_CODE), - selectedCurrencyIndex = 0, - showCurrencyPicker = true - ) + DashWalletTheme { + EnterAmount( + primaryAmount = "100", + secondaryAmount = "1.0023", + currencyCodes = listOf("USD", "EUR", DASH_CURRENCY_CODE), + selectedCurrencyIndex = 0, + showCurrencyPicker = true + ) + } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/FeatureList.kt b/common/src/main/java/org/dash/wallet/common/ui/components/FeatureList.kt index 865df84e51..d8ee9f4658 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/FeatureList.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/FeatureList.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -51,11 +52,12 @@ fun FeatureItemNumber( number: String, modifier: Modifier = Modifier ) { + val colors = LocalDashColors.current Box( modifier = modifier .size(24.dp) .background( - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, shape = RoundedCornerShape(8.dp) ), contentAlignment = Alignment.Center @@ -63,7 +65,7 @@ fun FeatureItemNumber( Text( text = number, fontSize = 14.sp, - color = Color.White, + color = colors.textPrimary, textAlign = TextAlign.Center ) } @@ -77,6 +79,7 @@ fun FeatureSingleItem( icon: ImageVector? = null, number: String? = null ) { + val colors = LocalDashColors.current Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -97,7 +100,7 @@ fun FeatureSingleItem( imageVector = icon, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MyTheme.Colors.gray300 + tint = colors.gray300 ) } else -> { @@ -106,7 +109,7 @@ fun FeatureSingleItem( .size(20.dp) .border( width = 2.5.dp, - color = MyTheme.Colors.gray300, + color = colors.gray300, shape = RoundedCornerShape(5.dp) ) ) @@ -123,13 +126,13 @@ fun FeatureSingleItem( Text( text = heading, style = MyTheme.Typography.TitleSmallMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) if (text != null) { Text( text = text, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) } } @@ -156,13 +159,21 @@ fun FeatureList( } } -@Preview(showBackground = true) +@Preview(name = "Feature Single Item Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Feature Single Item Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureSingleItemPreview() { + DashWalletTheme { + FeatureSingleItemPreviewContent() + } +} + +@Composable +private fun FeatureSingleItemPreviewContent() { Box( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(20.dp) ) { FeatureSingleItem( @@ -172,13 +183,21 @@ private fun FeatureSingleItemPreview() { } } -@Preview(showBackground = true) +@Preview(name = "Feature List Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Feature List Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureListPreview() { + DashWalletTheme { + FeatureListPreviewContent() + } +} + +@Composable +private fun FeatureListPreviewContent() { Box( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(20.dp) ) { FeatureList( @@ -192,13 +211,21 @@ private fun FeatureListPreview() { } } -@Preview(showBackground = true) +@Preview(name = "Feature List w/ Icons Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Feature List w/ Icons Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureListWithIconsPreview() { + DashWalletTheme { + FeatureListWithIconsPreviewContent() + } +} + +@Composable +private fun FeatureListWithIconsPreviewContent() { Box( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(20.dp) ) { FeatureList( @@ -228,13 +255,21 @@ private fun FeatureListWithIconsPreview() { } } -@Preview(showBackground = true) +@Preview(name = "Feature List w/ Numbers Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Feature List w/ Numbers Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureListWithNumbersPreview() { + DashWalletTheme { + FeatureListWithNumbersPreviewContent() + } +} + +@Composable +private fun FeatureListWithNumbersPreviewContent() { Box( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(20.dp) ) { FeatureList( diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/FeatureTopText.kt b/common/src/main/java/org/dash/wallet/common/ui/components/FeatureTopText.kt index ba68612dc3..a170f1213b 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/FeatureTopText.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/FeatureTopText.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -52,6 +53,7 @@ fun FeatureTopText( buttonTrailingIcon: ImageVector? = null, onButtonClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current Column( modifier = modifier .fillMaxWidth() @@ -62,7 +64,7 @@ fun FeatureTopText( Text( text = heading, style = textStyle, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) @@ -71,7 +73,7 @@ fun FeatureTopText( Text( text = text, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, textAlign = textAlign, modifier = Modifier.fillMaxWidth() ) @@ -92,7 +94,7 @@ fun FeatureTopText( imageVector = buttonLeadingIcon, contentDescription = null, modifier = Modifier.size(13.dp), - tint = MyTheme.Colors.dashBlue + tint = colors.dashBlue ) } @@ -100,7 +102,7 @@ fun FeatureTopText( text = buttonLabel, fontSize = 13.sp, lineHeight = 18.sp, - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, textAlign = TextAlign.Center ) @@ -109,7 +111,7 @@ fun FeatureTopText( imageVector = buttonTrailingIcon, contentDescription = null, modifier = Modifier.size(13.dp), - tint = MyTheme.Colors.dashBlue + tint = colors.dashBlue ) } } @@ -118,13 +120,21 @@ fun FeatureTopText( } } -@Preview(showBackground = true) +@Preview(name = "Feature Top Text Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Feature Top Text Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureTopTextPreview() { + DashWalletTheme { + FeatureTopTextPreviewContent() + } +} + +@Composable +private fun FeatureTopTextPreviewContent() { Column( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(16.dp) ) { FeatureTopText( @@ -140,13 +150,21 @@ private fun FeatureTopTextPreview() { } } -@Preview(showBackground = true) +@Preview(name = "No Button Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "No Button Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FeatureTopTextNoButtonPreview() { + DashWalletTheme { + FeatureTopTextNoButtonPreviewContent() + } +} + +@Composable +private fun FeatureTopTextNoButtonPreviewContent() { Column( modifier = Modifier .fillMaxWidth() - .background(Color.White) + .background(LocalDashColors.current.backgroundPrimary) .padding(16.dp) ) { FeatureTopText( diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Grapper.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Grabber.kt similarity index 83% rename from common/src/main/java/org/dash/wallet/common/ui/components/Grapper.kt rename to common/src/main/java/org/dash/wallet/common/ui/components/Grabber.kt index d59d112185..057fd8ddf1 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/Grapper.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Grabber.kt @@ -16,6 +16,7 @@ */ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,6 +37,7 @@ import androidx.compose.ui.unit.dp */ @Composable fun Grabber(modifier: Modifier = Modifier) { + val colors = LocalDashColors.current Column(modifier = modifier.fillMaxWidth()) { Spacer(modifier = Modifier.height(6.dp)) Box( @@ -43,7 +45,7 @@ fun Grabber(modifier: Modifier = Modifier) { .align(Alignment.CenterHorizontally) .size(width = 36.dp, height = 5.dp) .background( - color = MyTheme.Colors.lightGray, + color = colors.lightGray, shape = RoundedCornerShape(2.dp) ) ) @@ -51,8 +53,11 @@ fun Grabber(modifier: Modifier = Modifier) { } } -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Preview(name = "Grabber Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Grabber Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun GrapperPreview() { - Grabber() +private fun GrabberPreview() { + DashWalletTheme { + Grabber() + } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/InfoPanel.kt b/common/src/main/java/org/dash/wallet/common/ui/components/InfoPanel.kt index 303a38a6cc..72e37b73f8 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/InfoPanel.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/InfoPanel.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -49,10 +50,11 @@ fun InfoPanel( @DrawableRes actionIconRes: Int? = null, onAction: (() -> Unit)? = null ) { + val colors = LocalDashColors.current Box( modifier = modifier .fillMaxWidth() - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(16.dp)) + .background(colors.backgroundSecondary, RoundedCornerShape(16.dp)) .shadow(elevation = 20.dp, spotColor = Color(0x1AB8C1CC), ambientColor = Color(0x1AB8C1CC)), ) { Row( @@ -82,13 +84,14 @@ fun InfoPanel( ) { Text( text = title, - style = MyTheme.CaptionMedium + style = MyTheme.CaptionMedium, + color = colors.textPrimary ) Text( text = description, style = MyTheme.Caption, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) } @@ -102,7 +105,7 @@ fun InfoPanel( Icon( painter = painterResource(id = actionIconRes), contentDescription = "Close", - tint = MyTheme.Colors.gray + tint = colors.gray ) } } @@ -110,14 +113,17 @@ fun InfoPanel( } } -@Preview +@Preview(name = "Info Panel Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Info Panel Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun InfoPanelPreview() { - InfoPanel( - title = "Customize shortcut bar", - description = "Hold any button above to replace it with the function you need", - leftIconRes = R.drawable.ic_dash_blue_filled, - actionIconRes = R.drawable.ic_popup_close, - onAction = {} - ) + DashWalletTheme { + InfoPanel( + title = "Customize shortcut bar", + description = "Hold any button above to replace it with the function you need", + leftIconRes = R.drawable.ic_dash_blue_filled, + actionIconRes = R.drawable.ic_popup_close, + onAction = {} + ) + } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Label.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Label.kt index b11246d79b..d227db66e2 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/Label.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Label.kt @@ -33,6 +33,7 @@ fun Label( text: String = "Label", modifier: Modifier = Modifier ) { + val colors = LocalDashColors.current Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -42,7 +43,7 @@ fun Label( style = MyTheme.Subtitle2Semibold.copy( textAlign = TextAlign.Center ), - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis ) diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/ListItem.kt b/common/src/main/java/org/dash/wallet/common/ui/components/ListItem.kt index b6532531ac..e440e07aff 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/ListItem.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/ListItem.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -140,7 +141,7 @@ fun ListItem( !trailingTextLines.isNullOrEmpty() || trailingHelpText != null || trailingActionText != null || trailingLabel != null || trailingLeadingIcon != null || trailingTrailingIcon != null - + val colors = LocalDashColors.current Column( modifier = modifier .fillMaxWidth() @@ -150,7 +151,7 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary, + color = colors.textTertiary, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp) ) } @@ -175,7 +176,7 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) } title?.let { titleText -> @@ -186,13 +187,13 @@ fun ListItem( Text( text = titleText, style = MyTheme.Typography.LabelLarge, - color = titleColor ?: MyTheme.Colors.textPrimary + color = titleColor ?: colors.textPrimary ) if (showInfoIcon) { Icon( painter = painterResource(android.R.drawable.ic_dialog_info), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(14.dp) ) } @@ -202,14 +203,14 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) } bottomHelpText?.let { Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) } } @@ -227,13 +228,13 @@ fun ListItem( Text( text = label, style = MyTheme.Typography.LabelLarge, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) if (showInfoIcon) { Icon( painter = painterResource(android.R.drawable.ic_dialog_info), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(14.dp) ) } @@ -264,7 +265,7 @@ fun ListItem( Text( text = line, style = MyTheme.Body2Regular, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) } } @@ -273,7 +274,7 @@ fun ListItem( Text( text = it, style = MyTheme.Body2Regular, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) } } @@ -291,14 +292,14 @@ fun ListItem( Icon( painter = painterResource(iconRes), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(12.dp) ) } Text( text = helpText, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) } } @@ -308,7 +309,7 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.LabelLarge, - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, modifier = if (onTrailingActionClick != null) { Modifier.clickable { onTrailingActionClick() } } else { @@ -322,11 +323,11 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier .border( width = 1.dp, - color = MyTheme.Colors.textTertiary.copy(alpha = 0.4f), + color = colors.textTertiary.copy(alpha = 0.4f), shape = RoundedCornerShape(6.dp) ) .padding(horizontal = 6.dp, vertical = 4.dp) @@ -341,7 +342,7 @@ fun ListItem( Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary, + color = colors.textTertiary, modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 4.dp) ) } @@ -363,6 +364,7 @@ fun ListEmptyState( body: String? = null, actions: (@Composable RowScope.() -> Unit)? = null ) { + val colors = LocalDashColors.current Column( modifier = modifier .fillMaxWidth() @@ -371,12 +373,12 @@ fun ListEmptyState( verticalArrangement = Arrangement.spacedBy(8.dp) ) { icon() - Text(heading, style = MyTheme.Typography.LabelLarge, color = MyTheme.Colors.textPrimary) + Text(heading, style = MyTheme.Typography.LabelLarge, color = colors.textPrimary) body?.let { Text( text = it, style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textTertiary + color = colors.textTertiary ) } actions?.let { @@ -387,15 +389,23 @@ fun ListEmptyState( // ── Previews ────────────────────────────────────────────────────────────────── -@Preview(showBackground = true) +@Preview(name = "List Item Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "List Item Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ListItemPreview() { + DashWalletTheme { + ListItemPreviewContent() + } +} + +@Composable +private fun ListItemPreviewContent() { var checked1 by remember { mutableStateOf(false) } var checked2 by remember { mutableStateOf(true) } - + val colors = LocalDashColors.current Column( modifier = Modifier - .background(MyTheme.Colors.backgroundSecondary) + .background(colors.backgroundSecondary) .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { @@ -495,7 +505,7 @@ private fun ListItemPreview() { Icon( painter = painterResource(R.drawable.ic_dash_blue_filled), contentDescription = null, - tint = MyTheme.Colors.dashBlue, + tint = colors.dashBlue, modifier = Modifier.size(32.dp) ) } @@ -525,8 +535,8 @@ private fun ListItemPreview() { ) { CheckboxIcon(checked = true, onToggle = {}) Column { - Text("text", style = MyTheme.Body2Regular, color = MyTheme.Colors.textPrimary) - Text("help text", style = MyTheme.Typography.BodySmall, color = MyTheme.Colors.textTertiary) + Text("text", style = MyTheme.Body2Regular, color = colors.textPrimary) + Text("help text", style = MyTheme.Typography.BodySmall, color = colors.textTertiary) } } } @@ -534,16 +544,25 @@ private fun ListItemPreview() { } } -@Preview(showBackground = true) +@Preview(name = "List Empty State Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "List Empty State Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ListEmptyStatePreview() { + DashWalletTheme { + ListEmptyStatePreviewContent() + } +} + +@Composable +private fun ListEmptyStatePreviewContent() { + val colors = LocalDashColors.current ListEmptyState( - modifier = Modifier.background(MyTheme.Colors.backgroundSecondary), + modifier = Modifier.background(colors.backgroundSecondary), icon = { Icon( painter = painterResource(R.drawable.ic_dash_blue_filled), contentDescription = null, - tint = MyTheme.Colors.dashBlue, + tint = colors.dashBlue, modifier = Modifier.size(48.dp) ) }, @@ -553,18 +572,18 @@ private fun ListEmptyStatePreview() { Text( text = "Label", style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, modifier = Modifier - .border(1.dp, MyTheme.Colors.dashBlue, RoundedCornerShape(6.dp)) + .border(1.dp, colors.dashBlue, RoundedCornerShape(6.dp)) .padding(horizontal = 6.dp, vertical = 4.dp) ) Spacer(Modifier.width(4.dp)) Text( text = "Label", style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.dashBlue, + color = colors.dashBlue, modifier = Modifier - .border(1.dp, MyTheme.Colors.dashBlue, RoundedCornerShape(6.dp)) + .border(1.dp, colors.dashBlue, RoundedCornerShape(6.dp)) .padding(horizontal = 6.dp, vertical = 4.dp) ) } @@ -580,16 +599,17 @@ private fun ListEmptyStatePreview() { */ @Composable private fun CheckboxIcon(checked: Boolean, onToggle: (Boolean) -> Unit) { + val colors = LocalDashColors.current Box( modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(6.dp)) .border( width = 1.5.dp, - color = if (checked) MyTheme.Colors.dashBlue else MyTheme.Colors.darkerGray50, + color = if (checked) colors.dashBlue else colors.darkerGray50, shape = RoundedCornerShape(6.dp) ) - .background(if (checked) MyTheme.Colors.dashBlue else Color.Transparent) + .background(if (checked) colors.dashBlue else Color.Transparent) .clickable { onToggle(!checked) }, contentAlignment = Alignment.Center ) { diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/ListItemVariants.kt b/common/src/main/java/org/dash/wallet/common/ui/components/ListItemVariants.kt index 139423a690..5e182f585e 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/ListItemVariants.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/ListItemVariants.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -82,20 +83,23 @@ private fun ListItemRow( @Composable private fun KeyLabel(text: String) { - Text(text = text, style = MyTheme.Typography.BodyMediumMedium, color = MyTheme.Colors.textTertiary) + val colors = LocalDashColors.current + Text(text = text, style = MyTheme.Typography.BodyMediumMedium, color = colors.textTertiary) } @Composable private fun ValueText(text: String) { - Text(text = text, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textPrimary) + val colors = LocalDashColors.current + Text(text = text, style = MyTheme.Typography.BodyMedium, color = colors.textPrimary) } @Composable private fun Chevron() { + val colors = LocalDashColors.current Icon( painter = painterResource(R.drawable.ic_list_chevron_right), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(16.dp) ) } @@ -180,14 +184,15 @@ fun ListItem4( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { - Text(text = label, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textPrimary) + Text(text = label, style = MyTheme.Typography.BodyMedium, color = colors.textPrimary) Spacer(Modifier.weight(1f)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Text(text = value, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textTertiary) + Text(text = value, style = MyTheme.Typography.BodyMedium, color = colors.textTertiary) Chevron() } } @@ -202,8 +207,9 @@ fun ListItem5( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { - Text(text = action, style = MyTheme.Typography.BodyMediumMedium, color = MyTheme.Colors.textPrimary) + Text(text = action, style = MyTheme.Typography.BodyMediumMedium, color = colors.textPrimary) Spacer(Modifier.weight(1f)) Chevron() } @@ -220,17 +226,18 @@ fun ListItem6( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = title, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textPrimary) - Text(text = helpText, style = MyTheme.Typography.BodySmall, color = MyTheme.Colors.textTertiary) + Text(text = title, style = MyTheme.Typography.BodyMedium, color = colors.textPrimary) + Text(text = helpText, style = MyTheme.Typography.BodySmall, color = colors.textTertiary) } Spacer(Modifier.weight(1f)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(text = value, style = MyTheme.Typography.BodySmall, color = MyTheme.Colors.textPrimary) + Text(text = value, style = MyTheme.Typography.BodySmall, color = colors.textPrimary) Chevron() } } @@ -248,16 +255,17 @@ fun ListItem7( onTrailingIconClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = helpText, style = MyTheme.Typography.BodySmall, color = MyTheme.Colors.textSecondary) - Text(text = value, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textPrimary) + Text(text = helpText, style = MyTheme.Typography.BodySmall, color = colors.textSecondary) + Text(text = value, style = MyTheme.Typography.BodyMedium, color = colors.textPrimary) } Spacer(Modifier.weight(1f)) Icon( painter = painterResource(trailingIcon), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier .then(if (onTrailingIconClick != null) Modifier.clickable { onTrailingIconClick() } else Modifier) .size(14.dp) @@ -276,6 +284,7 @@ fun ListItem8( @DrawableRes amountIcon: Int = R.drawable.ic_dash_d_black, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { KeyLabel(label) Spacer(Modifier.weight(1f)) @@ -287,7 +296,7 @@ fun ListItem8( Icon( painter = painterResource(amountIcon), contentDescription = null, - tint = MyTheme.Colors.textPrimary, + tint = colors.textPrimary, modifier = Modifier.size(14.dp) ) } @@ -312,16 +321,17 @@ fun ListItem9( @DrawableRes trailingHelpIcon: Int = R.drawable.ic_left_right_arrows, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { // Inline LabelLarge/LabelMedium (Figma List9 uses Label L/M Regular) — do NOT // route the title through the shared ValueText helper (List1/2/3/4/6/8 use it). Column(modifier = Modifier.weight(1f)) { - Text(text = title, style = MyTheme.Typography.LabelLarge, color = MyTheme.Colors.textPrimary) - Text(text = subtitle1, style = MyTheme.Typography.LabelMedium, color = MyTheme.Colors.textTertiary) - Text(text = subtitle2, style = MyTheme.Typography.LabelMedium, color = MyTheme.Colors.textTertiary) + Text(text = title, style = MyTheme.Typography.LabelLarge, color = colors.textPrimary) + Text(text = subtitle1, style = MyTheme.Typography.LabelMedium, color = colors.textTertiary) + Text(text = subtitle2, style = MyTheme.Typography.LabelMedium, color = colors.textTertiary) } Column(horizontalAlignment = Alignment.End) { - Text(text = trailingTitle, style = MyTheme.Typography.LabelLarge, color = MyTheme.Colors.textPrimary) + Text(text = trailingTitle, style = MyTheme.Typography.LabelLarge, color = colors.textPrimary) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) @@ -329,10 +339,10 @@ fun ListItem9( Icon( painter = painterResource(trailingHelpIcon), contentDescription = null, - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(10.dp) ) - Text(text = trailingHelpText, style = MyTheme.Typography.LabelMedium, color = MyTheme.Colors.textTertiary) + Text(text = trailingHelpText, style = MyTheme.Typography.LabelMedium, color = colors.textTertiary) } } } @@ -349,10 +359,11 @@ fun ListItem10( primaryColor: Color? = null, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = secondaryText, style = MyTheme.Typography.BodyMedium, color = MyTheme.Colors.textSecondary) - Text(text = primaryText, style = MyTheme.Typography.BodyMedium, color = primaryColor ?: MyTheme.Colors.textPrimary) + Text(text = secondaryText, style = MyTheme.Typography.BodyMedium, color = colors.textSecondary) + Text(text = primaryText, style = MyTheme.Typography.BodyMedium, color = primaryColor ?: colors.textPrimary) } } } @@ -371,16 +382,17 @@ fun ListItem11( primaryMaxLines: Int = Int.MAX_VALUE, onClick: (() -> Unit)? = null ) { + val colors = LocalDashColors.current ListItemRow(modifier, onClick) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text(text = label, style = MyTheme.Typography.BodyMediumMedium, color = MyTheme.Colors.textTertiary) + Text(text = label, style = MyTheme.Typography.BodyMediumMedium, color = colors.textTertiary) Text( text = primaryText, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, maxLines = primaryMaxLines, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() @@ -391,43 +403,47 @@ fun ListItem11( // ── Preview ─────────────────────────────────────────────────────────────────── -@Preview(showBackground = true) +@Preview(name = "Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ListItemVariantsPreview() { - Column( - modifier = Modifier - .background(MyTheme.Colors.backgroundSecondary) - .padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(0.dp) - ) { - Text(text = "list1") - ListItem1(label = "Label", value = "Text") - Text(text = "list2") - ListItem2(label = "Label", valueLines = listOf("Text", "Text")) - Text(text = "list3") - ListItem3(label = "Label", value = "Text") - Text(text = "list4") - ListItem4(label = "Label", value = "Text") - Text(text = "list5") - ListItem5(action = "Action") - Text(text = "list6") - ListItem6(title = "Label", helpText = "Help text", value = "Text") - Text(text = "list7") - ListItem7(helpText = "Help text", value = "Value") - Text(text = "list8") - ListItem8(label = "Label", amount = "0.00") - Text(text = "list9") - ListItem9( - title = "00.000.00.00", - subtitle1 = "/Dash Core:00.0.0/", - subtitle2 = "protocol: 00000", - trailingTitle = "X blocks", - trailingHelpText = "00 ms" - ) - Text(text = "list10") - ListItem10(secondaryText = "Secondary text", primaryText = "Primary text") - Text(text = "list11") - ListItem11(label = "Label", primaryText = "Primary text") - Spacer(Modifier.width(0.dp)) + DashWalletTheme { + val colors = LocalDashColors.current + Column( + modifier = Modifier + .background(colors.backgroundSecondary) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Text(text = "list1", color = colors.textPrimary) + ListItem1(label = "Label", value = "Text") + Text(text = "list2", color = colors.textPrimary) + ListItem2(label = "Label", valueLines = listOf("Text", "Text")) + Text(text = "list3", color = colors.textPrimary) + ListItem3(label = "Label", value = "Text") + Text(text = "list4", color = colors.textPrimary) + ListItem4(label = "Label", value = "Text") + Text(text = "list5", color = colors.textPrimary) + ListItem5(action = "Action") + Text(text = "list6", color = colors.textPrimary) + ListItem6(title = "Label", helpText = "Help text", value = "Text") + Text(text = "list7", color = colors.textPrimary) + ListItem7(helpText = "Help text", value = "Value") + Text(text = "list8", color = colors.textPrimary) + ListItem8(label = "Label", amount = "0.00") + Text(text = "list9", color = colors.textPrimary) + ListItem9( + title = "00.000.00.00", + subtitle1 = "/Dash Core:00.0.0/", + subtitle2 = "protocol: 00000", + trailingTitle = "X blocks", + trailingHelpText = "00 ms" + ) + Text(text = "list10", color = colors.textPrimary) + ListItem10(secondaryText = "Secondary text", primaryText = "Primary text") + Text(text = "list11", color = colors.textPrimary) + ListItem11(label = "Label", primaryText = "Primary text") + Spacer(Modifier.width(0.dp)) + } } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Menu.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Menu.kt index 0e8a34c27d..4f60830449 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/Menu.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Menu.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import android.content.res.Configuration import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -19,15 +20,15 @@ import org.dash.wallet.common.R fun Menu( menuItems: @Composable () -> Unit ) { + val colors = LocalDashColors.current Box( modifier = Modifier.fillMaxWidth() .padding(horizontal = 20.dp) - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)), + .background(colors.backgroundSecondary, RoundedCornerShape(20.dp)), ) { Column( modifier = Modifier.fillMaxWidth() - .padding(6.dp) - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(20.dp)), + .padding(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { menuItems.invoke() @@ -35,30 +36,36 @@ fun Menu( } } +@Preview(name = "Menu Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Menu Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview fun MenuPreview() { - Column(Modifier.fillMaxWidth() - .background(MyTheme.Colors.backgroundPrimary)) { - Spacer(Modifier.fillMaxWidth().height(20.dp)) - Menu { - // With balance display - MenuItem( - title = "Wallet Balance", - subtitle = "Available balance", - icon = R.drawable.ic_dash_blue_filled, - dashAmount = "0.00", - fiatAmount = "0.00 US$" - ) + DashWalletTheme { + val colors = LocalDashColors.current + Column( + Modifier.fillMaxWidth() + .background(colors.backgroundPrimary) + ) { + Spacer(Modifier.fillMaxWidth().height(20.dp)) + Menu { + // With balance display + MenuItem( + title = "Wallet Balance", + subtitle = "Available balance", + icon = R.drawable.ic_dash_blue_filled, + dashAmount = "0.00", + fiatAmount = "0.00 US$" + ) - // With trailing button - MenuItem( - title = "More Action Item", - icon = R.drawable.ic_dash_blue_filled, - onTrailingButtonClick = { }, - showChevron = true - ) + // With trailing button + MenuItem( + title = "More Action Item", + icon = R.drawable.ic_dash_blue_filled, + onTrailingButtonClick = { }, + showChevron = true + ) + } + Spacer(Modifier.fillMaxWidth().height(20.dp)) } - Spacer(Modifier.fillMaxWidth().height(20.dp)) } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/MenuItem.kt b/common/src/main/java/org/dash/wallet/common/ui/components/MenuItem.kt index a95113ad46..517d051786 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/MenuItem.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/MenuItem.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import android.content.res.Configuration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.dash.wallet.common.R @@ -46,8 +48,11 @@ fun MenuItem( title: String, helpTextAbove: String? = null, subtitle: String? = null, + subtitleMaxLines: Int = Int.MAX_VALUE, subtitle2: String? = null, icon: Int? = null, + // Custom icon slot (e.g. a Coil AsyncImage for coin logos); used when `icon` is null + customIcon: (@Composable () -> Unit)? = null, showDirectionIndicator: Boolean = false, showInfo: Boolean = false, onInfoClick: (() -> Unit)? = null, @@ -71,6 +76,7 @@ fun MenuItem( ) { var internalChecked by remember(checked) { mutableStateOf(checked ?: isToggled?.invoke() ?: false) } val effectiveChecked = checked ?: internalChecked + val colors = LocalDashColors.current Row( modifier = Modifier .fillMaxWidth() @@ -84,12 +90,14 @@ fun MenuItem( ) { // Icon with direction indicator Box(modifier = Modifier.size(26.dp)) { - icon?.let { + if (icon != null) { Image( - painter = painterResource(id = it), + painter = painterResource(id = icon), contentDescription = null, modifier = Modifier.size(30.dp) ) + } else { + customIcon?.invoke() } // Direction indicator overlay @@ -97,7 +105,7 @@ fun MenuItem( Box( modifier = Modifier .size(19.dp) - .background(MyTheme.Colors.backgroundSecondary, RoundedCornerShape(32.dp)) + .background(colors.backgroundSecondary, RoundedCornerShape(32.dp)) .align(Alignment.BottomEnd) .offset(x = 8.dp, y = 8.dp), contentAlignment = Alignment.Center @@ -109,7 +117,7 @@ fun MenuItem( .background(Color.Transparent, RoundedCornerShape(7.dp)) .border( width = 2.dp, - color = MyTheme.Colors.gray300, + color = colors.gray300, shape = RoundedCornerShape(7.dp) ) ) @@ -127,7 +135,7 @@ fun MenuItem( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, modifier = Modifier.fillMaxWidth() ) } @@ -140,7 +148,7 @@ fun MenuItem( Text( text = title, style = MyTheme.Typography.LabelLargeMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) if (showInfo) { @@ -163,7 +171,9 @@ fun MenuItem( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, + maxLines = subtitleMaxLines, + overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() ) } @@ -173,7 +183,7 @@ fun MenuItem( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, modifier = Modifier.fillMaxWidth() ) } @@ -204,7 +214,7 @@ fun MenuItem( Text( text = dashAmount, style = MyTheme.CaptionMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) // Dash logo dashIcon?.let { dashIcon -> @@ -222,7 +232,7 @@ fun MenuItem( Text( text = it, style = MyTheme.OverlineCaptionRegular, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) } } @@ -254,41 +264,44 @@ fun MenuItem( Icon( painter = painterResource(id = R.drawable.ic_menu_row_arrow), contentDescription = "Chevron", - tint = MyTheme.Colors.textTertiary, + tint = colors.textTertiary, modifier = Modifier.size(16.dp) ) } } } -@Preview(showBackground = true) +@Preview(name = "MenuItem Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "MenuItem Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun PreviewMenuItem() { - Column( - modifier = Modifier - .padding(16.dp) - .background(MyTheme.Colors.backgroundPrimary), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Basic with help text above - MenuItem( - helpTextAbove = "help text 1", - title = "title", - subtitle = "help text 2", - subtitle2 = "help text 3", - icon = R.drawable.ic_dash_blue_filled, - showInfo = true, - showDirectionIndicator = true - ) + DashWalletTheme { + val colors = LocalDashColors.current + Column( + modifier = Modifier + .padding(16.dp) + .background(colors.backgroundPrimary), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Basic with help text above + MenuItem( + helpTextAbove = "help text 1", + title = "title", + subtitle = "help text 2", + subtitle2 = "help text 3", + icon = R.drawable.ic_dash_blue_filled, + showInfo = true, + showDirectionIndicator = true + ) // With toggle ON - MenuItem( + MenuItem( title = "Toggle Setting ON", - subtitle = "Enable this feature", - icon = R.drawable.ic_dash_blue_filled, - isToggled = { true }, - onToggleChanged = { } - ) + subtitle = "Enable this feature", + icon = R.drawable.ic_dash_blue_filled, + isToggled = { true }, + onToggleChanged = { } + ) // With toggle OFF MenuItem( @@ -299,31 +312,31 @@ fun PreviewMenuItem() { onToggleChanged = { } ) - // With balance display - MenuItem( - title = "Wallet Balance", - subtitle = "Available balance", - icon = R.drawable.ic_dash_blue_filled, - dashAmount = "0.00", - fiatAmount = "0.00 US$" - ) + // With balance display + MenuItem( + title = "Wallet Balance", + subtitle = "Available balance", + icon = R.drawable.ic_dash_blue_filled, + dashAmount = "0.00", + fiatAmount = "0.00 US$" + ) - // With trailing button - MenuItem( - title = "Action Item w/ Chevron", - icon = R.drawable.ic_dash_blue_filled, - onTrailingButtonClick = { }, - showChevron = true - ) + // With trailing button + MenuItem( + title = "Action Item w/ Chevron", + icon = R.drawable.ic_dash_blue_filled, + onTrailingButtonClick = { }, + showChevron = true + ) - // With trailing button - MenuItem( - title = "Action Item", - subtitle = "Tap button to proceed", - icon = R.drawable.ic_dash_blue_filled, - trailingButtonText = "Label", - onTrailingButtonClick = { } - ) + // With trailing button + MenuItem( + title = "Action Item", + subtitle = "Tap button to proceed", + icon = R.drawable.ic_dash_blue_filled, + trailingButtonText = "Label", + onTrailingButtonClick = { } + ) // Complex example matching Figma MenuItem( @@ -342,13 +355,14 @@ fun PreviewMenuItem() { onTrailingButtonClick = { } ) - // Complex example matching Figma - MenuItem( - title = "CoinJoin", - subtitle = "Mixing", - icon = R.drawable.ic_dash_blue_filled, - dashAmount = "0.0011 of 1.0000", - //fiatAmount = "0.0011 of 1.0000" - ) + // Complex example matching Figma + MenuItem( + title = "Balance", + subtitle = "Syncing", + icon = R.drawable.ic_dash_blue_filled, + dashAmount = "0.0011 of 1.0000", + //fiatAmount = "0.0011 of 1.0000" + ) + } } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt b/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt index f539a91f3e..77e2d2bcc8 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt @@ -17,6 +17,10 @@ package org.dash.wallet.common.ui.components +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -66,7 +70,7 @@ object MyTheme { fontWeight = FontWeight(500), ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.LabelMediumSemibold")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.LabelMediumSemibold")) val OverlineSemibold = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, @@ -74,7 +78,7 @@ object MyTheme { fontWeight = FontWeight(600), ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.LabelMediumMedium")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.LabelMediumMedium")) val OverlineMedium = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, @@ -82,7 +86,7 @@ object MyTheme { fontWeight = FontWeight(500) ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.LabelMediumMedium")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.LabelMediumMedium")) val OverlineCaptionRegular = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, @@ -90,14 +94,14 @@ object MyTheme { fontWeight = FontWeight(500) ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.LabelMedium")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.LabelMedium")) val OverlineCaptionMedium = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, fontFamily = interRegular, fontWeight = FontWeight(400) ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.BodyMedium")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.BodyMedium")) val Body2Regular = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, @@ -105,7 +109,7 @@ object MyTheme { fontWeight = FontWeight(400) ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.BodyMediumMedium")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.BodyMediumMedium")) val Body2Medium = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, @@ -113,7 +117,7 @@ object MyTheme { fontWeight = FontWeight(500) ) - @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("Typography.TitleSmallSemibold")) + @Deprecated(message = "obsolete font", replaceWith = ReplaceWith("MyTheme.Typography.TitleSmallSemibold")) val Subtitle2Semibold = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, @@ -561,30 +565,277 @@ object MyTheme { val HeadlineSBold = HeadlineSmallBold } - object Colors { - val backgroundPrimary = Color(0xFFF5F6F7) - val textPrimary = Color(0xFF191C1F) - val backgroundSecondary = Color(0xFFFFFFFF) - val textSecondary = Color(0xFF6E757C) - val textTertiary = Color(0xff75808A) - val divider = Color(0x1A191C1F) - val primary4 = Color(0x14191C1F) - val primary8 = Color(0x14191C1F) - val primary5 = Color(0x0D191C1F) - val primary40 = Color(0x66191C1F) - val dashBlue = Color(0xFF008DE4) - val dashBlue5 = Color(0x0D008DE4) - val orange = Color(0xFFFA9269) - val yellow = Color(0xFFFFC043) - val green = Color(0xFF3CB878) - val gray = Color(0xFFB0B6BC) - val gray300 = Color(0xFFB0B6BC) - val gray400 = Color(0xFF75808A) - val red = Color(0xFFEA3943) - val red5 = Color(0x0DEA3943) - val extraLightGray = Color(0xFFEBEDEE) - val lightGray = Color(0xFFCED2D5) - val darkGray = Color(0xFF75808A) - val darkerGray50 = Color(0x80B0B6BC) + data class ColorScheme( + // Backgrounds + val backgroundPrimary: Color, + val backgroundSecondary: Color, + val backgroundTertiary: Color, + val overlayPrimary: Color, + // Text / content + val textPrimary: Color, + val textSecondary: Color, + val textTertiary: Color, + val contentDisabled: Color, + val contentWarning: Color, + // Brand + val dashBlue: Color, + val dashBlue5: Color, + // Palette + val orange: Color, + val yellow: Color, + val green: Color, + val systemRed: Color, + val systemTeal: Color, + val purple: Color, + val red: Color, + val red5: Color, + val gray: Color, + val gray300: Color, + val gray400: Color, + val extraLightGray: Color, + val lightGray: Color, + val extraDarkGray: Color, + val ultraDarkGray: Color, + val ultraLightGray: Color, + val darkGray: Color, + val darkerGray50: Color, + // Gray scale (design system) + val gray40: Color, + val gray100: Color, + val gray200: Color, + val gray600: Color, + // Utility + val blue50: Color, + // Dividers / strokes + val divider: Color, + val inputFocusedStroke: Color, + val inputErrorStroke: Color, + // Inputs / buttons + val inputBackground: Color, + val inputErrorBackground: Color, + val disabledButtonBg: Color, + val buttonRipple: Color, + val warningYellow: Color, + // Transaction row backgrounds + val txSentBackground: Color, + val txReceivedBackground: Color, + val txOrangeBackground: Color, + // Legacy alpha tokens (kept for existing callers) + val primary4: Color, + val primary5: Color, + val primary8: Color, + val primary40: Color, + ) { + /** + * Base color palette exported from Figma (the "General" collection). + * + * These are the primitive, theme-independent colors that every semantic / + * component token aliases to. The same primitive may serve different roles + * depending on the theme — e.g. [Black] is the primary text color in light + * mode while [WhiteAlpha90] is the primary text color in dark mode. + * + * Light/dark [ColorScheme] instances should reference these constants rather + * than repeating raw hex literals. + */ + companion object { + // Blue + val Blue = Color(0xFF008DE4) + val BlueAlpha5 = Color(0x0D008DE4) + val BlueAlpha10 = Color(0x1A008DE4) + + // Gray / Black + val Black = Color(0xFF0A0B0D) + val Black800 = Color(0xFF1E1F24) + val Black900 = Color(0xFF141519) + val BlackAlpha5 = Color(0x0D0A0B0D) + val BlackAlpha8 = Color(0x140A0B0D) + val BlackAlpha10 = Color(0x1A0A0B0D) + val BlackAlpha15 = Color(0x260A0B0D) + val BlackAlpha20 = Color(0x330A0B0D) + val BlackAlpha30 = Color(0x4D0A0B0D) + val BlackAlpha40 = Color(0x660A0B0D) + val BlackAlpha50 = Color(0x800A0B0D) + val BlackAlpha90 = Color(0xE60A0B0D) + + // Gray + val Gray50 = Color(0xFFF5F6F7) + val Gray100 = Color(0xFFEBEDEE) + val Gray300 = Color(0xFFB0B6BC) + val Gray300Alpha10 = Color(0x1AB0B6BC) + val Gray300Alpha20 = Color(0x33B0B6BC) + val Gray300Alpha30 = Color(0x4DB0B6BC) + val Gray300Alpha50 = Color(0x80B0B6BC) + val Gray400 = Color(0xFF75808A) + val Gray400Alpha10 = Color(0x1A75808A) + val Gray400Alpha25 = Color(0x4075808A) + val Gray500 = Color(0xFF525C66) + + // Green + val Green = Color(0xFF3EB489) + + // Orange + val Orange = Color(0xFFFA9269) + + // Red + val Red = Color(0xFFEA3943) + + // White + val White = Color(0xFFFFFFFF) + val WhiteAlpha5 = Color(0x0DFFFFFF) + val WhiteAlpha10 = Color(0x1AFFFFFF) + val WhiteAlpha15 = Color(0x26FFFFFF) + val WhiteAlpha20 = Color(0x33FFFFFF) + val WhiteAlpha30 = Color(0x4DFFFFFF) + val WhiteAlpha40 = Color(0x66FFFFFF) + val WhiteAlpha50 = Color(0x80FFFFFF) + val WhiteAlpha60 = Color(0x99FFFFFF) + val WhiteAlpha80 = Color(0xCCFFFFFF) + val WhiteAlpha90 = Color(0xE6FFFFFF) + } } + + val Colors = ColorScheme( + // Backgrounds + backgroundPrimary = ColorScheme.Gray50, + backgroundSecondary = ColorScheme.White, + backgroundTertiary = ColorScheme.Gray100, + overlayPrimary = ColorScheme.BlackAlpha50, + // Text / content + textPrimary = ColorScheme.Black, + textSecondary = ColorScheme.Gray500, + textTertiary = ColorScheme.Gray400, + contentDisabled = Color(0xFF92929C), // no Figma primitive + contentWarning = Color(0xFFE85C4A), // no Figma primitive (distinct from Red) + // Brand + dashBlue = ColorScheme.Blue, + dashBlue5 = ColorScheme.BlueAlpha5, + // Palette + orange = ColorScheme.Orange, + yellow = Color(0xFFFFC043), // no Figma primitive + green = ColorScheme.Green, + systemRed = Color(0xFFE85C4A), // no Figma primitive + systemTeal = Color(0xFF78C4F5), // no Figma primitive + purple = Color(0xFF6273BD), // no Figma primitive + red = ColorScheme.Red, + red5 = Color(0x0DEA3943), // Red @5% — no Figma alpha primitive + gray = ColorScheme.Gray300, + gray300 = ColorScheme.Gray300, + gray400 = ColorScheme.Gray400, + extraLightGray = ColorScheme.Gray100, + lightGray = Color(0xFFCED2D5), // no Figma primitive + extraDarkGray = ColorScheme.Gray500, + ultraDarkGray = Color(0xFF2D3033), // no Figma primitive + ultraLightGray = ColorScheme.Gray50, + darkGray = ColorScheme.Gray400, + darkerGray50 = ColorScheme.Gray300Alpha50, + // Gray scale (design system) — distinct from Figma Gray* primitives + gray40 = Color(0xFFF2F3F5), + gray100 = Color(0xFFE1E3E6), + gray200 = Color(0xFFC4C8CC), + gray600 = Color(0xFF5D5F61), + // Utility + blue50 = Color(0xFFF0F8FE), + // Dividers / strokes + divider = Color(0x1A191C1F), + inputFocusedStroke = Color(0x33008DE4), + inputErrorStroke = Color(0x33E85C4A), + // Inputs / buttons + inputBackground = ColorScheme.Gray100, + inputErrorBackground = Color(0x1AE85C4A), + disabledButtonBg = Color(0xFFEEEEEE), + buttonRipple = Color(0x1F000000), + warningYellow = Color(0xFFFFF9ED), + // Transaction row backgrounds + txSentBackground = Color(0xFFE7F4FB), + txReceivedBackground = Color(0xFFEDF8F2), + txOrangeBackground = Color(0xFFFDF5F1), + // Legacy alpha tokens + primary4 = ColorScheme.BlackAlpha8, + primary5 = ColorScheme.BlackAlpha5, + primary8 = ColorScheme.BlackAlpha8, + primary40 = ColorScheme.BlackAlpha40, + ) + + val DarkColors = ColorScheme( + // Backgrounds + backgroundPrimary = ColorScheme.Black, + backgroundSecondary = ColorScheme.Black800, + // Figma dark export reports Tertiary as #EBEDEE (a light gray), which looks + // like an un-overridden variable artifact; keep Transparent as before. + backgroundTertiary = Color.Transparent, + overlayPrimary = ColorScheme.BlackAlpha50, + // Text / content (Figma dark uses white-with-opacity rather than solid grays) + textPrimary = ColorScheme.WhiteAlpha90, + textSecondary = ColorScheme.WhiteAlpha80, + textTertiary = ColorScheme.WhiteAlpha60, + contentDisabled = Color(0xFF92929C), // no Figma primitive + contentWarning = Color(0xFFE96453), // no Figma primitive (distinct from Red) + // Brand + dashBlue = ColorScheme.Blue, + dashBlue5 = ColorScheme.BlueAlpha5, + // Palette + orange = ColorScheme.Orange, + yellow = Color(0xFFFFC043), // no Figma primitive + green = ColorScheme.Green, + systemRed = Color(0xFFE96453), // no Figma primitive + systemTeal = Color(0xFF84C9F6), // no Figma primitive + purple = Color(0xFF6A7CCC), // no Figma primitive + red = ColorScheme.Red, + red5 = Color(0x0DE96453), // no Figma alpha primitive + gray = Color(0xFF45494D), // dark-only gray, no Figma primitive + gray300 = Color(0xFF45494D), + gray400 = Color(0xFF757A80), + extraLightGray = Color(0xFFA4ABB2), + lightGray = Color(0xFF8D9399), + extraDarkGray = Color(0xFF45494D), + ultraDarkGray = Color(0xFF2D3033), + ultraLightGray = ColorScheme.Gray50, + darkGray = Color(0xFF757A80), + darkerGray50 = Color(0x8045494D), + // Gray scale (no night override — same as light) + gray40 = Color(0xFFF2F3F5), + gray100 = Color(0xFFE1E3E6), + gray200 = Color(0xFFC4C8CC), + gray600 = Color(0xFF5D5F61), + // Utility + blue50 = Color(0xFFF0F8FE), + // Dividers / strokes + divider = Color(0xFF45494D), + inputFocusedStroke = Color(0x33008DE4), + inputErrorStroke = Color(0x33E85C4A), + // Inputs / buttons + inputBackground = Color(0xFF45494D), + inputErrorBackground = Color(0x1AE85C4A), + disabledButtonBg = Color(0xFF3C3C3C), + buttonRipple = Color(0x30FFFFFF), + warningYellow = Color(0xFFFFF9ED), + // Transaction row backgrounds + txSentBackground = Color(0xFF20262E), + txReceivedBackground = Color(0xFF232826), + txOrangeBackground = Color(0xFF302D2D), + // Legacy alpha tokens + primary4 = Color(0x14FFFFFF), // 8% white — no Figma alpha primitive + primary5 = ColorScheme.WhiteAlpha5, + primary8 = Color(0x14FFFFFF), // 8% white — no Figma alpha primitive + primary40 = ColorScheme.WhiteAlpha40, + ) +} + +val LocalDashColors = staticCompositionLocalOf { MyTheme.Colors } + +@Composable +fun DashWalletTheme(content: @Composable () -> Unit) { + val colors = if (isSystemInDarkTheme()) MyTheme.DarkColors else MyTheme.Colors + CompositionLocalProvider(LocalDashColors provides colors, content = content) +} + +@Composable +fun DarkPreviewTheme(composable: @Composable () -> Unit) { + CompositionLocalProvider(LocalDashColors provides MyTheme.DarkColors, composable) } + +@Composable +fun LightPreviewTheme(composable: @Composable () -> Unit) { + CompositionLocalProvider(LocalDashColors provides MyTheme.Colors, composable) +} + diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt b/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt new file mode 100644 index 0000000000..29e4e3172e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/SearchField.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.dash.wallet.common.R + +// Figma: colors/gray/gray400/gray400alpha10 — the search field background. +private val SearchFieldBackground = Color(0x1A75808A) + +// Figma: colors/gray/black/black1000alpha30 — placeholder text. +private val SearchPlaceholderColor = Color(0x4D0A0B0D) + +/** + * Design-system search field. + * + * Mirrors the "search - states" component in the Android design system + * (Figma node 4249-12620). Two optional affordances: + * - **Clear** ("x") icon inside the field — shown when [showClearButton] is true and + * [query] is non-empty; tapping it clears the text via [onQueryChange]. + * - **Cancel** button to the right of the field — shown only when [onCancel] is non-null + * (typically while the field is focused). + */ +@Composable +fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = stringResource(R.string.search_hint), + showClearButton: Boolean = true, + onCancel: (() -> Unit)? = null, + cancelText: String = stringResource(R.string.button_cancel), + imeAction: ImeAction = ImeAction.Search, + onSearch: (() -> Unit)? = null +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(16.dp)) + .background(SearchFieldBackground), + singleLine = true, + textStyle = MyTheme.Body2Regular.copy(color = LocalDashColors.current.textPrimary), + cursorBrush = SolidColor(LocalDashColors.current.dashBlue), + keyboardOptions = KeyboardOptions(imeAction = imeAction), + keyboardActions = KeyboardActions(onSearch = { onSearch?.invoke() }), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = LocalDashColors.current.textTertiary, + modifier = Modifier.size(20.dp) + ) + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + text = placeholder, + style = MyTheme.Body2Regular, + color = SearchPlaceholderColor + ) + } + innerTextField() + } + if (showClearButton && query.isNotEmpty()) { + Icon( + painter = painterResource(R.drawable.ic_clear_input), + contentDescription = stringResource(R.string.button_clear), + tint = Color.Unspecified, + modifier = Modifier + .size(20.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onQueryChange("") } + ) + } + } + } + ) + + if (onCancel != null) { + Text( + text = cancelText, + style = MyTheme.CaptionMedium, + color = LocalDashColors.current.textPrimary, + modifier = Modifier + .clip(RoundedCornerShape(11.dp)) + .clickable { onCancel() } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +// ── Previews ──────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, widthDp = 393) +@Composable +private fun SearchFieldStatesPreview() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Empty, no Cancel + SearchField(query = "", onQueryChange = {}) + // Empty, with Cancel (focused) + SearchField(query = "", onQueryChange = {}, onCancel = {}) + // Filled, with clear + Cancel + SearchField(query = "some text", onQueryChange = {}, onCancel = {}) + // Filled, no Cancel + SearchField(query = "some text", onQueryChange = {}) + } +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/SheetButtonGroup.kt b/common/src/main/java/org/dash/wallet/common/ui/components/SheetButtonGroup.kt index 2d276ed648..e258412fcd 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/SheetButtonGroup.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/SheetButtonGroup.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -144,50 +145,59 @@ enum class ButtonGroupOrientation { } // Preview examples -@Preview(showBackground = true) +@Preview(name = "Vertical Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Vertical Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SheetButtonGroupVerticalPreview() { - SheetButtonGroup( - primaryButton = SheetButton( - text = "Continue", - style = Style.FilledBlue, - onClick = {} - ), - secondaryButton = SheetButton( - text = "Cancel", - style = Style.StrokeGray, - onClick = {} - ), - orientation = ButtonGroupOrientation.Vertical - ) + DashWalletTheme { + SheetButtonGroup( + primaryButton = SheetButton( + text = "Continue", + style = Style.FilledBlue, + onClick = {} + ), + secondaryButton = SheetButton( + text = "Cancel", + style = Style.StrokeGray, + onClick = {} + ), + orientation = ButtonGroupOrientation.Vertical + ) + } } -@Preview(showBackground = true) +@Preview(name = "Horizontal Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Horizontal Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SheetButtonGroupHorizontalPreview() { - SheetButtonGroup( - primaryButton = SheetButton( - text = "Continue", - style = Style.FilledBlue, - onClick = {} - ), - secondaryButton = SheetButton( - text = "Cancel", - style = Style.StrokeGray, - onClick = {} - ), - orientation = ButtonGroupOrientation.Horizontal - ) + DashWalletTheme { + SheetButtonGroup( + primaryButton = SheetButton( + text = "Continue", + style = Style.FilledBlue, + onClick = {} + ), + secondaryButton = SheetButton( + text = "Cancel", + style = Style.StrokeGray, + onClick = {} + ), + orientation = ButtonGroupOrientation.Horizontal + ) + } } -@Preview(showBackground = true) +@Preview(name = "Single Button Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Single Button Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SheetButtonGroupSingleButtonPreview() { - SheetButtonGroup( - primaryButton = SheetButton( - text = "Got it", - style = Style.FilledBlue, - onClick = {} + DashWalletTheme { + SheetButtonGroup( + primaryButton = SheetButton( + text = "Got it", + style = Style.FilledBlue, + onClick = {} + ) ) - ) + } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Template.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Template.kt index 8ff7916325..700da803aa 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/Template.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Template.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon +import android.content.res.Configuration import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +38,7 @@ fun Template( icon: ImageVector? = null, contentDescription: String? = null ) { + val colors = LocalDashColors.current Box( modifier = modifier .size(34.dp) @@ -46,7 +48,7 @@ fun Template( ) .border( width = 1.5.dp, - color = MyTheme.Colors.gray300.copy(alpha = 0.30f), + color = colors.gray300.copy(alpha = 0.30f), shape = CircleShape ), contentAlignment = Alignment.Center @@ -56,14 +58,14 @@ fun Template( imageVector = icon, contentDescription = contentDescription, modifier = Modifier, - tint = MyTheme.Colors.textPrimary + tint = colors.textPrimary ) } else { Box( modifier = Modifier .size(5.dp) .background( - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, shape = CircleShape ) ) @@ -71,17 +73,22 @@ fun Template( } } +@Preview(name = "Template Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Template Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview private fun TemplatePreview() { - Box(Modifier - .size(44.dp) - .background(MyTheme.Colors.backgroundPrimary), - contentAlignment = Alignment.Center - ) { - Template( - Modifier, + DashWalletTheme { + val colors = LocalDashColors.current + Box( + Modifier + .size(44.dp) + .background(colors.backgroundPrimary), + contentAlignment = Alignment.Center + ) { + Template( + Modifier, MyImages.MenuChevron - ) + ) + } } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/TextField.kt b/common/src/main/java/org/dash/wallet/common/ui/components/TextField.kt new file mode 100644 index 0000000000..7fbdfadb6f --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/TextField.kt @@ -0,0 +1,565 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.dash.wallet.common.R + +/** + * Figma: `TextField-Base` (Design system - Android, node 4111:12913; variants 4112:13707). + * + * General-purpose design-system text field with a floating [label] inside the field. + * Visual state is driven by focus, content, [isError] and [enabled]: + * - **Default** (unfocused, empty): translucent gray background; the [label] renders on the + * text line, acting as the placeholder. + * - **Focused**: white background with a 1dp dash-blue border and a 3dp translucent blue + * focus ring; a cursor shows. + * - **Typing** (focused, with text): as focused; the [label] shrinks to a small line above + * the text and a trailing clear (✕) button appears (disable with [showClearButton]). + * - **Filled** (unfocused, with text): translucent gray background, small label above the text. + * - **Error** ([isError]): red border on a translucent red background; the [message] below + * renders in red. + * - **Disabled** ([enabled] = false): content renders at reduced opacity and input is ignored. + * + * Slots (matching the Figma component's properties): + * - [label] — floating label inside the field (Figma `label`). + * - [innerLabel] — permanent small label inside the field, above the text line (the + * [AddressField.innerLabel] behavior, e.g. "BTC address") — always visible, even when empty. + * Don't combine with [label]. + * - [placeholder] — text-line placeholder when empty; only used when [label] is null. + * - [helperTextInside] — right-aligned small text inside the field, below the text line + * (Figma `helpTextInside`). When null and [maxLength] is set, a `n/max` counter renders here. + * - [message] — help text below the field (Figma `helpTextOutside`); red when [isErrorMessage] + * (which defaults to [isError] — pass `isErrorMessage = false` to keep an error-styled field + * with neutral gray help text, as some Figma error variants show). + * - [trailingIcon] + [onTrailingIconClick] — custom trailing button (Figma `buttonIcon`). + * The automatic clear button takes precedence over it while typing. + * - [maxLength] enforces a character limit. + * + * All user-visible strings are caller-provided so they can come from string resources. + */ +@Composable +fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + innerLabel: String? = null, + placeholder: String? = null, + helperTextInside: String? = null, + message: String? = null, + isError: Boolean = false, + isErrorMessage: Boolean = isError, + enabled: Boolean = true, + singleLine: Boolean = true, + maxLength: Int? = null, + showClearButton: Boolean = true, + @DrawableRes trailingIcon: Int? = null, + onTrailingIconClick: (() -> Unit)? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + visualTransformation: VisualTransformation = VisualTransformation.None, + // Optional: lets callers focus the field programmatically (e.g. auto-open the keyboard). + focusRequester: FocusRequester? = null, + // Optional: invoked when the keyboard's IME action (Done/Go/Next/Search/Send) is pressed. + onImeAction: (() -> Unit)? = null +) { + // Focus is owned here so the field can switch between its default/filled and focused looks. + // The rendering lives in the stateless [TextFieldContent] so previews can force any state. + var focused by remember { mutableStateOf(false) } + + TextFieldContent( + value = value, + onValueChange = onValueChange, + focused = focused, + onFocusChanged = { focused = it }, + modifier = modifier, + label = label, + innerLabel = innerLabel, + placeholder = placeholder, + helperTextInside = helperTextInside, + message = message, + isError = isError, + isErrorMessage = isErrorMessage, + enabled = enabled, + singleLine = singleLine, + maxLength = maxLength, + showClearButton = showClearButton, + trailingIcon = trailingIcon, + onTrailingIconClick = onTrailingIconClick, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + focusRequester = focusRequester, + onImeAction = onImeAction + ) +} + +@Composable +private fun TextFieldContent( + value: String, + onValueChange: (String) -> Unit, + focused: Boolean, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + innerLabel: String? = null, + placeholder: String? = null, + helperTextInside: String? = null, + message: String? = null, + isError: Boolean = false, + isErrorMessage: Boolean = isError, + enabled: Boolean = true, + singleLine: Boolean = true, + maxLength: Int? = null, + showClearButton: Boolean = true, + @DrawableRes trailingIcon: Int? = null, + onTrailingIconClick: (() -> Unit)? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + visualTransformation: VisualTransformation = VisualTransformation.None, + focusRequester: FocusRequester? = null, + onImeAction: (() -> Unit)? = null +) { + // Figma: default/filled = gray400 @ 10%, focused = white + dash-blue border + 3dp blue ring, + // error = red @ 5% + red border. Error wins over focused. + val backgroundColor = when { + isError -> LocalDashColors.current.red5 + focused && enabled -> LocalDashColors.current.backgroundSecondary + else -> LocalDashColors.current.gray400.copy(alpha = 0.1f) + } + val borderColor = when { + isError -> LocalDashColors.current.red + focused && enabled -> LocalDashColors.current.dashBlue + else -> Color.Transparent + } + val showFocusRing = focused && enabled && !isError + val ringColor = LocalDashColors.current.dashBlue.copy(alpha = 0.1f) + val contentAlpha = if (enabled) 1f else 0.4f + val shape = RoundedCornerShape(16.dp) + + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 58.dp) + // The focus ring sits outside the field bounds (Figma `element/active` shadow, + // 3px spread) — drawn before clipping so it isn't cut off and doesn't shift layout. + .drawBehind { + if (showFocusRing) { + val ring = 3.dp.toPx() + drawRoundRect( + color = ringColor, + topLeft = Offset(-ring / 2, -ring / 2), + size = Size(size.width + ring, size.height + ring), + cornerRadius = CornerRadius(16.dp.toPx() + ring / 2), + style = Stroke(width = ring) + ) + } + } + .clip(shape) + .background(backgroundColor) + .border(1.dp, borderColor, shape) + .padding(start = 16.dp, end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Small label above the text line: [innerLabel] is permanent (always visible); + // the floating [label] only appears here once there's content. + val smallLabel = innerLabel ?: label?.takeIf { value.isNotEmpty() } + if (smallLabel != null) { + Text( + text = smallLabel, + style = MyTheme.Typography.LabelMedium, + color = LocalDashColors.current.textSecondary.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Box { + BasicTextField( + value = value, + onValueChange = { newValue -> + onValueChange(if (maxLength != null) newValue.take(maxLength) else newValue) + }, + enabled = enabled, + singleLine = singleLine, + textStyle = MyTheme.Typography.TitleSmall.copy( + color = LocalDashColors.current.textPrimary.copy(alpha = contentAlpha) + ), + cursorBrush = SolidColor(LocalDashColors.current.textPrimary), + keyboardOptions = keyboardOptions, + keyboardActions = if (onImeAction != null) { + KeyboardActions( + onDone = { onImeAction() }, + onGo = { onImeAction() }, + onNext = { onImeAction() }, + onSearch = { onImeAction() }, + onSend = { onImeAction() } + ) + } else { + KeyboardActions.Default + }, + visualTransformation = visualTransformation, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { onFocusChanged(it.isFocused) } + .then( + if (focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + ) + ) + + // When empty, the label renders full-size on the text line as the + // placeholder (Figma default/focused states). + if (value.isEmpty()) { + val overlay = label ?: placeholder + if (overlay != null) { + Text( + text = overlay, + style = MyTheme.Typography.TitleSmall, + color = LocalDashColors.current.textSecondary.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + // Trailing button: clear (✕) while typing (focused with text), otherwise the + // caller's custom icon. Figma `touch.area`: 30dp, 8dp radius. + val trailing: Pair Unit)?>? = when { + showClearButton && value.isNotEmpty() && focused && enabled -> + R.drawable.ic_clear_input to { onValueChange("") } + trailingIcon != null -> trailingIcon to onTrailingIconClick + else -> null + } + + if (trailing != null) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(8.dp)) + .then( + if (trailing.second != null && enabled) { + Modifier.clickable { trailing.second?.invoke() } + } else { + Modifier + } + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(trailing.first), + contentDescription = null, + // ic_clear_input carries its own translucent styling; custom icons + // are tinted like other field icons. + tint = if (trailing.first == R.drawable.ic_clear_input) { + Color.Unspecified + } else { + LocalDashColors.current.textPrimary.copy(alpha = contentAlpha) + }, + modifier = Modifier.size(16.dp) + ) + } + } + } + + // Inside help text / character counter, right-aligned under the text line. + val insideText = helperTextInside + ?: maxLength?.let { "${value.length}/$it" } + if (insideText != null) { + Text( + text = insideText, + style = MyTheme.Typography.BodySmall, + color = LocalDashColors.current.textSecondary.copy(alpha = contentAlpha), + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + ) + } + } + + if (message != null) { + Text( + text = message, + style = MyTheme.Typography.BodySmall, + color = if (isErrorMessage) { + LocalDashColors.current.red + } else { + LocalDashColors.current.textSecondary.copy(alpha = contentAlpha) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .padding(horizontal = 16.dp) + ) + } + } +} + +// Previews mirror the variant set in Figma node 4112:13707 (Design system - Android). They render +// the stateless [TextFieldContent] directly so the focus-dependent states (which need real focus +// at runtime) can be shown statically. + +/** Core states — default, focused, typing, error, filled (the variant-set columns). */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun TextFieldStatesPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Default — the user doesn't interact with the field; the label is the placeholder. + TextFieldContent( + value = "", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label" + ) + // Focused — blue border + focus ring, cursor shows. + TextFieldContent( + value = "", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label" + ) + // Typing — focused with text; small label above, clear (✕) button shows. + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label" + ) + // Error — red border on a translucent red background, red message below. + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + message = "This value is not valid", + isError = true + ) + // Filled — the user tapped outside the field (unfocused, has text, no icon). + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label" + ) + } +} + +/** Help text slots — inside (right-aligned), outside, and the character counter. */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun TextFieldHelpTextPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Help text inside the field, right-aligned under the text line. + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label", + helperTextInside = "Help text" + ) + // Help text outside, below the field. + TextFieldContent( + value = "", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + message = "Help text" + ) + // Character counter in the inside slot. + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label", + maxLength = 25 + ) + } +} + +/** Custom trailing icon and disabled states. */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun TextFieldIconDisabledPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Custom trailing button (Figma buttonIcon slot). + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + trailingIcon = R.drawable.ic_scan_qr, + onTrailingIconClick = {} + ) + // Disabled — content at reduced opacity, input ignored. + TextFieldContent( + value = "", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + enabled = false + ) + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + enabled = false + ) + } +} + +/** Combinations from the Figma grid — slots and states composed together. */ +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun TextFieldCombinationsPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Inside + outside help text together (Figma row 4). + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label", + helperTextInside = "Help text", + message = "Help text" + ) + // Error field with neutral gray help text (Figma rows 1-4, error column). + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + message = "Help text", + isError = true, + isErrorMessage = false + ) + // Error field with inside help text. + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + helperTextInside = "Help text", + isError = true + ) + // Custom trailing icon while focused and empty (Figma row 6) — the icon stays because + // the clear button only appears once there's text. + TextFieldContent( + value = "", + onValueChange = {}, + focused = true, + onFocusChanged = {}, + label = "Label", + trailingIcon = R.drawable.ic_scan_qr, + onTrailingIconClick = {} + ) + // Custom trailing icon in the error state (Figma row 6, error column). + TextFieldContent( + value = "Some text", + onValueChange = {}, + focused = false, + onFocusChanged = {}, + label = "Label", + message = "Help text", + isError = true, + trailingIcon = R.drawable.ic_scan_qr, + onTrailingIconClick = {} + ) + } +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt index dacf73c019..8c1b6dbd2b 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt @@ -1,5 +1,6 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -139,126 +140,153 @@ fun Toast( } } -@Preview(name = "Toast with action") +@Preview(name = "Toast w/ action Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast w/ action Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastPreview() { - Box(Modifier.width(400.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "The exchange rates are out of date, please do something about it right away", - actionText = "OK", - imageResource = R.drawable.ic_image_placeholder - ) {} + DashWalletTheme { + Box(Modifier.width(400.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "The exchange rates are out of date, please do something about it right away", + actionText = "OK", + imageResource = R.drawable.ic_image_placeholder + ) {} + } } } -@Preview(name = "Toast with action and dismiss") +@Preview(name = "Toast w/ dismiss Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast w/ dismiss Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastWithDismissPreview() { - Box(Modifier.width(400.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Some coins are currently halted", - actionText = "Action", - imageResource = R.drawable.ic_image_placeholder, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(400.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Some coins are currently halted", + actionText = "Action", + imageResource = R.drawable.ic_image_placeholder, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Warning") +@Preview(name = "Toast Warning Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Warning Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastWarningPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Warning", - actionText = "Action", - imageResource = ToastImageResource.Warning.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Warning", + actionText = "Action", + imageResource = ToastImageResource.Warning.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Info") +@Preview(name = "Toast Info Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Info Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastInfoPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Info", - actionText = "Action", - imageResource = ToastImageResource.Information.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Info", + actionText = "Action", + imageResource = ToastImageResource.Information.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Error") +@Preview(name = "Toast Error Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Error Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastErrorPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Error", - actionText = "Action", - imageResource = ToastImageResource.Error.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Error", + actionText = "Action", + imageResource = ToastImageResource.Error.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Success") +@Preview(name = "Toast Success Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Success Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastSuccessPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Success", - actionText = "Action", - imageResource = ToastImageResource.Success.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Success", + actionText = "Action", + imageResource = ToastImageResource.Success.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Copied") +@Preview(name = "Toast Copied Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Copied Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastCopiedPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Copied", - actionText = "Action", - imageResource = ToastImageResource.Copy.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Copied", + actionText = "Action", + imageResource = ToastImageResource.Copy.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – Loading") +@Preview(name = "Toast Loading Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast Loading Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastLoadingPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "Loading", - actionText = "Action", - imageResource = ToastImageResource.Loading.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "Loading", + actionText = "Action", + imageResource = ToastImageResource.Loading.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } -@Preview(name = "Toast – No internet connection") +@Preview(name = "Toast No internet Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Toast No internet Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ToastNoInternetPreview() { - Box(Modifier.width(375.dp).background(Color.White).padding(vertical = 4.dp)) { - Toast( - text = "No internet connection", - actionText = "Action", - imageResource = ToastImageResource.NoInternet.resourceId, - showDismissButton = true, - onDismiss = {} - ) {} + DashWalletTheme { + Box(Modifier.width(375.dp).background(LocalDashColors.current.backgroundPrimary).padding(vertical = 4.dp)) { + Toast( + text = "No internet connection", + actionText = "Action", + imageResource = ToastImageResource.NoInternet.resourceId, + showDismissButton = true, + onDismiss = {} + ) {} + } } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/TopIntro.kt b/common/src/main/java/org/dash/wallet/common/ui/components/TopIntro.kt index 843e20c508..9c9385ab5c 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/TopIntro.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/TopIntro.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import android.content.res.Configuration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,7 +46,7 @@ import androidx.compose.ui.unit.dp // Text( // text = heading, // style = MyTheme.Typography.HeadlineMediumBold, -// color = MyTheme.Colors.textPrimary, +// color = colors.textPrimary, // modifier = Modifier.fillMaxWidth() // ) // @@ -54,7 +55,7 @@ import androidx.compose.ui.unit.dp // Text( // text = it, // style = MyTheme.Typography.BodyMedium, -// color = MyTheme.Colors.textPrimary, +// color = colors.textPrimary, // modifier = Modifier.fillMaxWidth() // ) // } @@ -70,6 +71,7 @@ fun TopIntro( // modifier: Modifier = Modifier.padding(top = 10.dp, start = 20.dp, end = 20.dp, bottom = 20.dp), icon: @Composable () -> Unit = {} ) { + val colors = LocalDashColors.current Column( modifier = Modifier .padding(top = 10.dp, start = 20.dp, end = 20.dp, bottom = 20.dp) @@ -87,7 +89,7 @@ fun TopIntro( Text( text = heading, style = MyTheme.Typography.HeadlineMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) @@ -96,7 +98,7 @@ fun TopIntro( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) @@ -106,7 +108,7 @@ fun TopIntro( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) @@ -116,7 +118,7 @@ fun TopIntro( Text( text = it, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) @@ -125,30 +127,34 @@ fun TopIntro( } } +@Preview(name = "Top Intro Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Top Intro Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview fun TopIntroPreview() { - Column( + DashWalletTheme { + val colors = LocalDashColors.current + Column( modifier = Modifier .padding(16.dp) - .background(MyTheme.Colors.backgroundPrimary), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - // With heading and text - TopIntro( - heading = "Heading", - text = "Text" - ) - - // Heading only - TopIntro( - heading = "Heading Only" - ) - - // Longer examples - TopIntro( - heading = "Welcome to Dash", - text = "Your digital cash for everyday payments" - ) + .background(colors.backgroundPrimary), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // With heading and text + TopIntro( + heading = "Heading", + text = "Text" + ) + + // Heading only + TopIntro( + heading = "Heading Only" + ) + + // Longer examples + TopIntro( + heading = "Welcome to Dash", + text = "Your digital cash for everyday payments" + ) + } } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/TopIntroSend.kt b/common/src/main/java/org/dash/wallet/common/ui/components/TopIntroSend.kt index 019d4eae0e..1f5783f365 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/TopIntroSend.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/TopIntroSend.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -43,7 +44,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -98,6 +98,7 @@ fun TopIntroSend( onToggleVisibility: (() -> Unit)? = null, modifier: Modifier = Modifier.padding(top = 10.dp, start = 20.dp, end = 20.dp, bottom = 20.dp) ) { + val colors = LocalDashColors.current // Internal state used only when the caller does not hoist the toggle. var internalVisible by rememberSaveable { mutableStateOf(true) } val isVisible = balanceVisible ?: internalVisible @@ -109,8 +110,8 @@ fun TopIntroSend( // Heading Text( text = heading, - style = MyTheme.H5Bold, - color = MyTheme.Colors.textPrimary, + style = MyTheme.Typography.HeadlineMediumBold, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) @@ -127,7 +128,7 @@ fun TopIntroSend( Text( text = preposition, style = MyTheme.Body2Regular, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) if (toIconUrl != null) { AsyncImage( @@ -152,7 +153,7 @@ fun TopIntroSend( Text( text = toName, style = MyTheme.Body2Regular, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -162,11 +163,11 @@ fun TopIntroSend( // Address variant: "to [address]" — preposition secondary, address primary Text( text = buildAnnotatedString { - withStyle(MyTheme.Body2Regular.toSpanStyle().copy(color = MyTheme.Colors.textSecondary)) { + withStyle(MyTheme.Body2Regular.toSpanStyle().copy(color = colors.textPrimary)) { append(preposition) append(" ") } - withStyle(MyTheme.Body2Regular.toSpanStyle().copy(color = MyTheme.Colors.textPrimary)) { + withStyle(MyTheme.Body2Regular.toSpanStyle().copy(color = colors.textPrimary)) { append(toAddress) } }, @@ -176,8 +177,8 @@ fun TopIntroSend( ) } - // Per Figma: 2dp gap between merchant/address row and balance row. - Spacer(modifier = Modifier.height(2.dp)) + // Per Figma: 4dp (spacing/4, the root column gap) between the heading block and balance row. + Spacer(modifier = Modifier.height(4.dp)) // Balance availability row BalanceRow( @@ -207,6 +208,7 @@ private fun BalanceRow( isVisible: Boolean, onToggleClick: () -> Unit ) { + val colors = LocalDashColors.current val hiddenPlaceholder = "*****" val balanceLabel = stringResource(R.string.balance) @@ -219,72 +221,82 @@ private fun BalanceRow( Text( text = "$balanceLabel ", style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary + color = colors.textSecondary ) // Toggleable content: icon + amounts, or placeholder if (isVisible) { + // Per Figma node 4251:16531: amount, then Dash logo, then "~ fiat" — + // the whole balance row is text/secondary. Row( modifier = Modifier.weight(1f, fill = false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { + Text( + text = dashBalance, + style = MyTheme.Typography.BodyMedium, + color = MyTheme.Colors.textSecondary + ) Image( painter = painterResource(R.drawable.ic_dash_d_gray), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(14.dp) ) - Text( - text = buildAnnotatedString { - withStyle(MyTheme.Typography.BodyMedium.toSpanStyle().copy(color = MyTheme.Colors.textPrimary)) { - append(dashBalance) - } - if (fiatBalance != null) { - withStyle(MyTheme.Typography.BodyMedium.toSpanStyle().copy(color = MyTheme.Colors.textSecondary)) { - append(" · ") - append(fiatBalance) + if (fiatBalance != null) { + Text( + text = buildAnnotatedString { + withStyle(MyTheme.Typography.BodyMedium.toSpanStyle().copy(color = colors.textPrimary)) { + append(dashBalance) + } + if (fiatBalance != null) { + withStyle( + MyTheme.Typography.BodyMedium.toSpanStyle().copy(color = colors.textSecondary) + ) { + append(" · ") + append(fiatBalance) + } } } - } - ) + ) + } } } else { Text( text = hiddenPlaceholder, style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textSecondary, + color = colors.textSecondary, modifier = Modifier.weight(1f, fill = false) ) } - Spacer(modifier = Modifier.width(6.dp)) + // Per Figma: spacing/8 between the balance text and the eye chip. + Spacer(modifier = Modifier.width(8.dp)) // Eye toggle as a btn-xs chip — rounded-12 with subtle dark tint, per Figma. val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier .clip(RoundedCornerShape(12.dp)) - .background(Color(0x0D0A0B0D)) + .background(MyTheme.Colors.primary5) .clickable( interactionSource = interactionSource, indication = null, onClick = onToggleClick ) - .padding(horizontal = 6.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 4.dp), contentAlignment = Alignment.Center ) { Icon( painter = painterResource( if (isVisible) R.drawable.ic_show else R.drawable.ic_hide ), - contentDescription = if (isVisible) { - "Hide balance" - } else { - "Show balance" - }, - tint = MyTheme.Colors.textSecondary, - modifier = Modifier.size(16.dp) + contentDescription = stringResource( + if (isVisible) R.string.hide_balance else R.string.show_balance + ), + tint = colors.textSecondary, + modifier = Modifier.size(14.dp) ) } } @@ -292,14 +304,30 @@ private fun BalanceRow( // ── Previews ──────────────────────────────────────────────────────────────────── -@Preview(showBackground = true, widthDp = 393) @Composable -private fun TopIntroSendVisiblePreview() { +private fun TopIntroSendPreviewScaffold(content: @Composable () -> Unit) { + val colors = LocalDashColors.current Column( modifier = Modifier - .background(MyTheme.Colors.backgroundPrimary) + .background(colors.backgroundPrimary) .padding(vertical = 8.dp) ) { + content() + } +} + +@Preview(name = "Send Visible Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Send Visible Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TopIntroSendVisiblePreview() { + DashWalletTheme { + TopIntroSendVisiblePreviewContent() + } +} + +@Composable +private fun TopIntroSendVisiblePreviewContent() { + TopIntroSendPreviewScaffold { TopIntroSend( heading = "Send", toAddress = "XqP9vKtSgMnBr7LjN3FcDwYeZh4Ao8uQ1", @@ -309,14 +337,18 @@ private fun TopIntroSendVisiblePreview() { } } -@Preview(showBackground = true, widthDp = 393) +@Preview(name = "Send Hidden Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Send Hidden Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun TopIntroSendHiddenPreview() { - Column( - modifier = Modifier - .background(MyTheme.Colors.backgroundPrimary) - .padding(vertical = 8.dp) - ) { + DashWalletTheme { + TopIntroSendHiddenPreviewContent() + } +} + +@Composable +private fun TopIntroSendHiddenPreviewContent() { + TopIntroSendPreviewScaffold { TopIntroSend( heading = "Send", toAddress = "XqP9vKtSgMnBr7LjN3FcDwYeZh4Ao8uQ1", @@ -328,14 +360,18 @@ private fun TopIntroSendHiddenPreview() { } } -@Preview(showBackground = true, widthDp = 393) +@Preview(name = "Send No Fiat Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Send No Fiat Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun TopIntroSendNoFiatPreview() { - Column( - modifier = Modifier - .background(MyTheme.Colors.backgroundPrimary) - .padding(vertical = 8.dp) - ) { + DashWalletTheme { + TopIntroSendNoFiatPreviewContent() + } +} + +@Composable +private fun TopIntroSendNoFiatPreviewContent() { + TopIntroSendPreviewScaffold { TopIntroSend( heading = "Send", toAddress = "XqP9vKtSgMnBr7LjN3FcDwYeZh4Ao8uQ1", @@ -344,14 +380,18 @@ private fun TopIntroSendNoFiatPreview() { } } -@Preview(showBackground = true, widthDp = 393) +@Preview(name = "Send Icon Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Send Icon Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun TopIntroSendIconPreview() { - Column( - modifier = Modifier - .background(MyTheme.Colors.backgroundPrimary) - .padding(vertical = 8.dp) - ) { + DashWalletTheme { + TopIntroSendIconPreviewContent() + } +} + +@Composable +private fun TopIntroSendIconPreviewContent() { + TopIntroSendPreviewScaffold { TopIntroSend( heading = "Buy gift card", preposition = "at", diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/TopNavBase.kt b/common/src/main/java/org/dash/wallet/common/ui/components/TopNavBase.kt index 6adb7e5fe4..db70c954ba 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/TopNavBase.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/TopNavBase.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import android.content.res.Configuration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -80,6 +81,7 @@ fun TopNavBase( centralPart: Boolean = true, title: String = "Label" ) { + val colors = LocalDashColors.current Box( modifier = modifier .fillMaxWidth() @@ -99,7 +101,7 @@ fun TopNavBase( Text( text = leadingText, style = MyTheme.CaptionMedium, - color = MyTheme.Colors.textPrimary + color = colors.textPrimary ) } } @@ -146,7 +148,7 @@ fun TopNavBase( Text( text = trailingText, style = MyTheme.CaptionMedium, - color = MyTheme.Colors.dashBlue + color = colors.dashBlue ) } } @@ -417,40 +419,44 @@ fun NavBarBackAction( // ── Preview ─────────────────────────────────────────────────────────────────── -@Preview(showBackground = true, widthDp = 393) +@Preview(name = "Nav Bar Light", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Nav Bar Dark", showBackground = true, widthDp = 393, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun NavBarPreview() { - Column( - modifier = Modifier - .background(MyTheme.Colors.backgroundPrimary) - .padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - NavBarBack(onBackClick = {}) - NavBarBackTitle(title = "Label", onBackClick = {}) - NavBarBackTitleInfo(title = "Label", onBackClick = {}, onInfoClick = {}) - NavBarTitleClose(title = "Label", onCloseClick = {}) - NavBarBackTitlePlus(title = "Label", onBackClick = {}, onPlusClick = {}) - NavBarBackPlus(onBackClick = {}, onPlusClick = {}) - NavBarTitle(title = "Title Only") - NavBarClose(onCloseClick = {}) - NavBarActionTitleAction( - title = "Label", - leadingActionText = "Cancel", - onLeadingActionClick = {}, - trailingActionText = "Apply", - onTrailingActionClick = {} - ) - NavBarBackTitleAction( - title = "Label", - onBackClick = {}, - actionText = "Quick voting", - onActionClick = {} - ) - NavBarBackAction( - onBackClick = {}, - actionText = "Quick voting", - onActionClick = {} - ) + DashWalletTheme { + val colors = LocalDashColors.current + Column( + modifier = Modifier + .background(colors.backgroundPrimary) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + NavBarBack(onBackClick = {}) + NavBarBackTitle(title = "Label", onBackClick = {}) + NavBarBackTitleInfo(title = "Label", onBackClick = {}, onInfoClick = {}) + NavBarTitleClose(title = "Label", onCloseClick = {}) + NavBarBackTitlePlus(title = "Label", onBackClick = {}, onPlusClick = {}) + NavBarBackPlus(onBackClick = {}, onPlusClick = {}) + NavBarTitle(title = "Title Only") + NavBarClose(onCloseClick = {}) + NavBarActionTitleAction( + title = "Label", + leadingActionText = "Cancel", + onLeadingActionClick = {}, + trailingActionText = "Apply", + onTrailingActionClick = {} + ) + NavBarBackTitleAction( + title = "Label", + onBackClick = {}, + actionText = "Quick voting", + onActionClick = {} + ) + NavBarBackAction( + onBackClick = {}, + actionText = "Quick voting", + onActionClick = {} + ) + } } } \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/TypographyPreview.kt b/common/src/main/java/org/dash/wallet/common/ui/components/TypographyPreview.kt index 38be506e7e..7a06eb22d8 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/components/TypographyPreview.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/components/TypographyPreview.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.components +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -30,13 +31,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview(showBackground = true) +@Preview(name = "Typography Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Typography Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun TypographyPreview() { + DashWalletTheme { + TypographyPreviewContent() + } +} + +@Composable +private fun TypographyPreviewContent() { + val colors = LocalDashColors.current Column( modifier = Modifier .fillMaxSize() - .background(MyTheme.Colors.backgroundSecondary) + .background(colors.backgroundSecondary) .padding(20.dp) .verticalScroll(rememberScrollState()) ) { @@ -47,37 +57,37 @@ fun TypographyPreview() { Text( text = "Display large", style = MyTheme.Typography.DisplayLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Display large", style = MyTheme.Typography.DisplayLargeBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Display medium", style = MyTheme.Typography.DisplayMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Display medium", style = MyTheme.Typography.DisplayMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Display small", style = MyTheme.Typography.DisplaySmall, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Display small", style = MyTheme.Typography.DisplaySmallBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) } @@ -89,31 +99,31 @@ fun TypographyPreview() { Text( text = "Headline large", style = MyTheme.Typography.HeadlineLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Headline large", style = MyTheme.Typography.HeadlineLargeBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Headline medium", style = MyTheme.Typography.HeadlineMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Headline medium", style = MyTheme.Typography.HeadlineMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Headline small", style = MyTheme.Typography.HeadlineSmallBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) } @@ -125,37 +135,37 @@ fun TypographyPreview() { Text( text = "Title large", style = MyTheme.Typography.TitleLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Title large", style = MyTheme.Typography.TitleLargeBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Title medium", style = MyTheme.Typography.TitleMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Title medium", style = MyTheme.Typography.TitleMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Title small", style = MyTheme.Typography.TitleSmall, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Title small", style = MyTheme.Typography.TitleSmallBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) } @@ -167,37 +177,37 @@ fun TypographyPreview() { Text( text = "Label large", style = MyTheme.Typography.LabelLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Label large", style = MyTheme.Typography.LabelLargeBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Label medium", style = MyTheme.Typography.LabelMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Label medium", style = MyTheme.Typography.LabelMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Label small", style = MyTheme.Typography.LabelSmall, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Label small", style = MyTheme.Typography.LabelSmallBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) } @@ -209,37 +219,37 @@ fun TypographyPreview() { Text( text = "Body large", style = MyTheme.Typography.BodyLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Body large", style = MyTheme.Typography.BodyLargeBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Body medium", style = MyTheme.Typography.BodyMedium, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Body medium", style = MyTheme.Typography.BodyMediumBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Body small", style = MyTheme.Typography.BodySmall, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) Text( text = "Body small", style = MyTheme.Typography.BodySmallBold, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, modifier = Modifier.fillMaxWidth() ) } diff --git a/common/src/main/java/org/dash/wallet/common/ui/dialogs/AdaptiveDialog.kt b/common/src/main/java/org/dash/wallet/common/ui/dialogs/AdaptiveDialog.kt index 5fc7e052d7..dcc3970b84 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/dialogs/AdaptiveDialog.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/dialogs/AdaptiveDialog.kt @@ -99,6 +99,27 @@ open class AdaptiveDialog(@LayoutRes private val layout: Int): DialogFragment() ).apply { isCancelable = false } } + /** + * Indeterminate progress WITH an explicit dismiss button, for + * operations that keep running app-side after the dialog is closed + * (the caller's work must not be tied to the dialog's lifecycle). + * The button resolves the result callback with `false`. + */ + @JvmStatic + fun progress( + message: String, + dismissButtonText: String + ): AdaptiveDialog { + return create( + R.layout.dialog_progress_dismissible, + null, + null, + message, + dismissButtonText, + null + ).apply { isCancelable = true } + } + @JvmStatic fun create( @DrawableRes icon: Int?, @@ -151,6 +172,34 @@ open class AdaptiveDialog(@LayoutRes private val layout: Int): DialogFragment() protected var onResultListener: ((Boolean?) -> Unit)? = null var isMessageSelectable = false + /** + * Optional live secondary status line (layouts that carry a + * `R.id.dialog_secondary_message` view, e.g. the dismissible progress + * dialog). Held here so [updateSecondaryMessage] can be called before + * the view exists (the dialog is shown asynchronously) and re-applied + * after view (re)creation — a long-running operation can push a "why + * this is slow" hint into the dialog the user is watching. Null/blank + * hides the line. + */ + private var secondaryMessage: String? = null + private var secondaryMessageView: TextView? = null + + /** + * Set (or clear) the live secondary status line. Safe to call at any + * time, on the main thread — before the dialog is shown, while it is + * visible, or after it has been dismissed. + */ + fun updateSecondaryMessage(text: String?) { + secondaryMessage = text + secondaryMessageView?.let { applySecondaryMessage(it) } + } + + private fun applySecondaryMessage(view: TextView) { + val text = secondaryMessage + view.text = text ?: "" + view.isVisible = !text.isNullOrEmpty() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -181,6 +230,10 @@ open class AdaptiveDialog(@LayoutRes private val layout: Int): DialogFragment() val positiveButton: TextView? = view.findViewById(R.id.dialog_positive_button) val negativeButton: TextView? = view.findViewById(R.id.dialog_negative_button) + secondaryMessageView = view.findViewById(R.id.dialog_secondary_message)?.also { + applySecondaryMessage(it) + } + showIfNotEmpty(iconView, ICON_RES_ARG) showIfNotEmpty(titleView, TITLE_ARG) val isMessageShown = showIfNotEmpty(messageView, MESSAGE_ARG) @@ -263,6 +316,14 @@ open class AdaptiveDialog(@LayoutRes private val layout: Int): DialogFragment() onResultListener = null } + override fun onDestroyView() { + // Drop the view reference so a later updateSecondaryMessage never + // touches a detached view; the pending text stays in secondaryMessage + // and is re-applied on the next onViewCreated. + secondaryMessageView = null + super.onDestroyView() + } + protected fun showIfNotEmpty(view: TextView?, argKey: String): Boolean { if (view == null) { return false diff --git a/common/src/main/java/org/dash/wallet/common/ui/dialogs/ComposeBottomSheet.kt b/common/src/main/java/org/dash/wallet/common/ui/dialogs/ComposeBottomSheet.kt index 275d1bc304..1f7dfde94a 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/dialogs/ComposeBottomSheet.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/dialogs/ComposeBottomSheet.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.dash.wallet.common.R import org.dash.wallet.common.databinding.DialogComposeSheetBinding +import org.dash.wallet.common.ui.components.DashWalletTheme import org.dash.wallet.common.ui.components.Grabber import org.dash.wallet.common.ui.viewBinding @@ -36,9 +37,11 @@ open class ComposeBottomSheet : OffsetDialogFragment(R.layout.dialog_compose_she override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.composeContainer.setContent { - Column(modifier = Modifier.navigationBarsPadding()) { - Grabber() - Content() + DashWalletTheme { + Column(modifier = Modifier.navigationBarsPadding()) { + Grabber() + Content() + } } } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/dialogs/OffsetDialogFragment.kt b/common/src/main/java/org/dash/wallet/common/ui/dialogs/OffsetDialogFragment.kt index 76ca8e6a65..c12864221a 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/dialogs/OffsetDialogFragment.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/dialogs/OffsetDialogFragment.kt @@ -39,6 +39,15 @@ import androidx.core.view.WindowInsetsCompat open class OffsetDialogFragment(@LayoutRes private val layout: Int) : BottomSheetDialogFragment() { protected open val forceExpand: Boolean = false + + /** + * Expand the sheet to its full CONTENT height on show (bottom-anchored, + * adapts to the device screen), so wrap-content sheets are never shown + * in the half-expanded state with their bottom controls clipped. + * Mutually exclusive with [forceExpand] (which pins a MATCH_PARENT + * sheet below the top offset). + */ + protected open val expandToContent: Boolean = false @StyleRes protected open val backgroundStyle: Int = R.style.SecondaryBackground override fun onCreate(savedInstanceState: Bundle?) { @@ -92,6 +101,17 @@ open class OffsetDialogFragment(@LayoutRes private val layout: Int) : BottomShee } BottomSheetBehavior.from(sheet).apply { + if (expandToContent) { + // Bottom-anchored, full content height: EXPANDED with + // fit-to-contents puts the sheet top at + // (parentHeight - sheetHeight), so the whole content — + // including the bottom buttons — is visible on any + // screen size. + isFitToContents = true + skipCollapsed = true + state = BottomSheetBehavior.STATE_EXPANDED + return@apply + } isFitToContents = false skipCollapsed = true diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountView.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountView.kt index 9f8fd7f275..90696077ce 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountView.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountView.kt @@ -35,10 +35,10 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding -import org.bitcoinj.core.Coin -import org.bitcoinj.utils.ExchangeRate -import org.bitcoinj.utils.Fiat -import org.bitcoinj.utils.MonetaryFormat +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.ExchangeRate +import org.dash.wallet.common.money.Fiat +import org.dash.wallet.common.money.MonetaryFormat import org.dash.wallet.common.R import org.dash.wallet.common.databinding.AmountViewBinding import org.dash.wallet.common.util.Constants diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountViewExt.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountViewExt.kt new file mode 100644 index 0000000000..a0aefb168e --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/AmountViewExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.enter_amount + +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.ExchangeRate +import org.dash.wallet.common.money.FiatValue +import org.dash.wallet.common.money.toFiat + +/** + * Neutral counterpart of [AmountView.exchangeRate] for modules that must not depend on dashj: + * sets the view's conversion rate from the fiat [price] of one Dash (null clears the rate). + * Mirrors `exchangeRate = ExchangeRate(Coin.COIN, price)`. + */ +fun AmountView.setDashPrice(price: FiatValue?) { + exchangeRate = price?.let { ExchangeRate(Coin.COIN, it.toFiat()) } +} diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountCompose.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountCompose.kt index 5cfe5c5ca1..e6aa127e69 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountCompose.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountCompose.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.enter_amount +import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -36,11 +37,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.dash.wallet.common.R +import org.dash.wallet.common.ui.components.DashWalletTheme +import org.dash.wallet.common.ui.components.LocalDashColors import org.dash.wallet.common.ui.components.MyTheme /** @@ -50,6 +54,9 @@ import org.dash.wallet.common.ui.components.MyTheme * and exceeding [maxDecimalPlaces]); "back" removes the last character (floor to "0"); "back_long" * resets to "0". */ +// Opacity applied to the keys when the keyboard is disabled, so it reads as inactive. +private const val DISABLED_KEY_ALPHA = 0.4f + fun processAmountKeyInput(current: String, key: String, maxDecimalPlaces: Int = 2): String { return when (key) { "back" -> if (current.length > 1) current.dropLast(1) else "0" @@ -69,14 +76,22 @@ fun processAmountKeyInput(current: String, key: String, maxDecimalPlaces: Int = * The panel has rounded top corners only and is meant to sit flush with the screen's bottom edge. * [bottomSlot] is rendered inside the same panel below the keyboard rows — typically a primary * action button (e.g. Continue). + * + * When [enabled] is false the keys are dimmed and don't respond to taps (e.g. while offline). The + * [bottomSlot] is not affected — it manages its own enabled state. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun NumericKeyboardCompose( modifier: Modifier = Modifier, + enabled: Boolean = true, + // Label shown on the decimal key (e.g. ',' for a German locale). Display only: the key + // emitted through [onKeyInput] is always ".", so input handling stays locale-independent. + decimalSeparator: Char = '.', bottomSlot: (@Composable ColumnScope.() -> Unit)? = null, onKeyInput: (String) -> Unit ) { + val colors = LocalDashColors.current val rows = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), @@ -87,7 +102,7 @@ fun NumericKeyboardCompose( Column( modifier = modifier .background( - color = MyTheme.Colors.backgroundSecondary, + color = colors.backgroundSecondary, shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp) ) .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 20.dp), @@ -104,15 +119,24 @@ fun NumericKeyboardCompose( modifier = Modifier .weight(1f) .height(56.dp) + .alpha(if (enabled) 1f else DISABLED_KEY_ALPHA) .background( - color = MyTheme.Colors.backgroundSecondary, + color = colors.backgroundSecondary, shape = RoundedCornerShape(10.dp) ) - .combinedClickable( - onClick = { onKeyInput(key) }, - onLongClick = if (isBack) { - { onKeyInput("back_long") } - } else null + .then( + if (enabled) { + Modifier.combinedClickable( + onClick = { onKeyInput(key) }, + onLongClick = if (isBack) { + { onKeyInput("back_long") } + } else { + null + } + ) + } else { + Modifier + } ), contentAlignment = Alignment.Center ) { @@ -120,14 +144,14 @@ fun NumericKeyboardCompose( Icon( painter = painterResource(R.drawable.ic_delete_backward), contentDescription = null, - tint = MyTheme.Colors.textPrimary, + tint = colors.textPrimary, modifier = Modifier.size(24.dp) ) } else { Text( - text = key, + text = if (key == ".") decimalSeparator.toString() else key, style = MyTheme.Typography.TitleLarge, - color = MyTheme.Colors.textPrimary, + color = colors.textPrimary, textAlign = TextAlign.Center ) } @@ -139,15 +163,24 @@ fun NumericKeyboardCompose( } } +@Preview(name = "Numeric Keyboard Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Numeric Keyboard Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -@Preview private fun NumericKeyboardPreview() { - Box(modifier = Modifier.background(MyTheme.Colors.backgroundPrimary)) { + DashWalletTheme { + NumericKeyboardPreviewContent() + } +} + +@Composable +private fun NumericKeyboardPreviewContent() { + val colors = LocalDashColors.current + Box(modifier = Modifier.background(colors.backgroundPrimary)) { Column( modifier = Modifier .width(393.dp) .height(336.dp) - .background(color = MyTheme.Colors.dashBlue) + .background(color = colors.dashBlue) .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.Top), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt index e6035cd7c5..66c8c27a87 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt @@ -34,15 +34,22 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStarted import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import org.bitcoinj.core.Coin -import org.bitcoinj.core.Monetary -import org.bitcoinj.utils.ExchangeRate -import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.Monetary +import org.dash.wallet.common.money.ExchangeRate +import org.dash.wallet.common.money.Fiat import org.dash.wallet.common.R import org.dash.wallet.common.databinding.FragmentEnterAmountBinding +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.toCoin +import org.dash.wallet.common.money.toDash +import org.dash.wallet.common.money.toFiatValue import org.dash.wallet.common.services.AuthenticationManager +import org.dash.wallet.common.ui.components.DashWalletTheme +import org.dash.wallet.common.ui.components.LocalDashColors import org.dash.wallet.common.ui.components.MyTheme import org.dash.wallet.common.ui.exchange_rates.ExchangeRatesDialog import org.dash.wallet.common.ui.segmented_picker.PickerDisplayMode @@ -93,6 +100,28 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { arguments = args } } + + /** Neutral counterpart of [newInstance] for modules that don't depend on dashj. */ + @JvmStatic + fun newInstanceDash( + dashToFiat: Boolean = false, + initialAmount: Dash? = null, + isMaxButtonVisible: Boolean = true, + showCurrencySelector: Boolean = true, + isCurrencyOptionsPickerVisible: Boolean = true, + showAmountResultContainer: Boolean = true, + faitCurrencyCode: String? = null, + requirePinForMaxButton: Boolean = false + ): EnterAmountFragment = newInstance( + dashToFiat, + initialAmount?.toCoin(), + isMaxButtonVisible, + showCurrencySelector, + isCurrencyOptionsPickerVisible, + showAmountResultContainer, + faitCurrencyCode, + requirePinForMaxButton + ) } private val binding by viewBinding(FragmentEnterAmountBinding::bind) @@ -145,10 +174,11 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { binding.keyboardView.onKeyboardActionListener = keyboardActionListener binding.continueBtn.setOnClickListener { - viewModel.onContinueEvent.value = Pair( - binding.amountView.dashAmount, - binding.amountView.fiatAmount - ) + if (binding.continueProgress.isVisible) return@setOnClickListener + val dashAmount = binding.amountView.dashAmount + val fiatAmount = binding.amountView.fiatAmount + viewModel.onContinueEvent.value = Pair(dashAmount, fiatAmount) + viewModel.onContinueDashEvent.value = Pair(dashAmount.toDash(), fiatAmount.toFiatValue()) } viewModel.selectedExchangeRate.observe(viewLifecycleOwner) { rate -> @@ -161,6 +191,7 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { } viewModel.canContinue.observe(viewLifecycleOwner) { canContinue -> + if (continueLoading) return@observe binding.continueBtn.isEnabled = if (!didAuthorize && requirePinForBalance && !viewModel.blockContinue) { viewModel.amount.value?.isPositive == true } else { @@ -186,6 +217,29 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { } } + /** + * Show a progress circle on the continue button and DISABLE it — for + * hosts whose action runs asynchronously after the tap. The disabled + * state is sticky: [canContinue] emissions cannot re-enable the button + * while loading (that observer would otherwise flip it back on within + * milliseconds). + */ + fun setContinueLoading(loading: Boolean) { + continueLoading = loading + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.withStarted { + binding.continueProgress.isVisible = loading + binding.continueBtn.text = if (loading) "" else getString(R.string.button_continue) + // isEnabled alone gives the app's standard disabled look: the + // button theme already maps it to `disabledBackgroundColor`. + binding.continueBtn.isEnabled = !loading + } + } + } + + /** True while [setContinueLoading] holds the button in its busy state. */ + private var continueLoading = false + fun applyMaxAmount() { lifecycleScope.launchWhenStarted { onMaxAmountButtonClick() @@ -218,22 +272,25 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) binding.currencyOptions.setContent { - SegmentedPicker( - currencyOptions, - modifier = Modifier - .height(48.dp) - .width(40.dp), - selectedIndex = pickedCurrencyOption, - style = SegmentedPickerStyle( - displayMode = PickerDisplayMode.Vertical, - cornerRadius = 8f, - backgroundColor = Color.Transparent, - thumbColor = MyTheme.Colors.primary5, - textStyle = MyTheme.Micro, - shadowElevation = 0 - ) - ) { currency, _ -> - binding.amountView.dashToFiat = currency.title == Constants.DASH_CURRENCY + DashWalletTheme { + val colors = LocalDashColors.current + SegmentedPicker( + currencyOptions, + modifier = Modifier + .height(48.dp) + .width(40.dp), + selectedIndex = pickedCurrencyOption, + style = SegmentedPickerStyle( + displayMode = PickerDisplayMode.Vertical, + cornerRadius = 8f, + backgroundColor = Color.Transparent, + thumbColor = colors.primary5, + textStyle = MyTheme.Micro, + shadowElevation = 0 + ) + ) { currency, _ -> + binding.amountView.dashToFiat = currency.title == Constants.DASH_CURRENCY + } } } diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountViewModel.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountViewModel.kt index b90f70de1a..f28cf5e819 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountViewModel.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountViewModel.kt @@ -23,11 +23,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.bitcoinj.core.Coin -import org.bitcoinj.utils.Fiat +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.Fiat import org.dash.wallet.common.data.SingleLiveEvent import org.dash.wallet.common.data.WalletUIConfig import org.dash.wallet.common.data.entity.ExchangeRate +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.FiatValue +import org.dash.wallet.common.money.toCoin +import org.dash.wallet.common.money.toDash +import org.dash.wallet.common.money.toFiatValue import org.dash.wallet.common.services.ExchangeRatesProvider import org.dash.wallet.common.util.Constants import javax.inject.Inject @@ -61,6 +66,9 @@ class EnterAmountViewModel @Inject constructor( val onContinueEvent = SingleLiveEvent>() + /** Neutral mirror of [onContinueEvent] for modules that don't depend on dashj. */ + val onContinueDashEvent = SingleLiveEvent>() + internal val _dashToFiatDirection = MutableLiveData() val dashToFiatDirection: LiveData get() = _dashToFiatDirection @@ -81,6 +89,11 @@ class EnterAmountViewModel @Inject constructor( val amount: LiveData get() = _amount + /** Neutral mirror of [amount] for modules that don't depend on dashj. Kept in sync in [init]. */ + private val _amountDash = MutableLiveData() + val amountDash: LiveData + get() = _amountDash + internal val _fiatAmount = MutableLiveData().apply { savedStateHandle.get(KEY_FIAT_AMOUNT)?.let { fiatString -> try { @@ -93,6 +106,11 @@ class EnterAmountViewModel @Inject constructor( val fiatAmount: LiveData get() = _fiatAmount + /** Neutral mirror of [fiatAmount] for modules that don't depend on dashj. Kept in sync in [init]. */ + private val _fiatAmountValue = MutableLiveData() + val fiatAmountValue: LiveData + get() = _fiatAmountValue + private val _callerBlocksContinue = MutableLiveData(false) var blockContinue: Boolean get() = _callerBlocksContinue.value ?: false @@ -142,11 +160,13 @@ class EnterAmountViewModel @Inject constructor( // Save amount changes to SavedStateHandle _amount.observeForever { coin -> savedStateHandle[KEY_AMOUNT] = coin?.value + _amountDash.value = coin?.toDash() } // Save fiat amount changes to SavedStateHandle _fiatAmount.observeForever { fiat -> savedStateHandle[KEY_FIAT_AMOUNT] = fiat?.toPlainString() + _fiatAmountValue.value = fiat?.toFiatValue() } } @@ -154,11 +174,21 @@ class EnterAmountViewModel @Inject constructor( _maxAmount.value = coin } + /** Neutral counterpart of [setMaxAmount] for modules that don't depend on dashj. */ + fun setMaxAmount(amount: Dash) { + setMaxAmount(amount.toCoin()) + } + fun setMinAmount(coin: Coin, isIncludedMin: Boolean = false) { _minAmount.value = coin _minIsIncluded = isIncludedMin } + /** Neutral counterpart of [setMinAmount] for modules that don't depend on dashj. */ + fun setMinAmount(amount: Dash, isIncludedMin: Boolean = false) { + setMinAmount(amount.toCoin(), isIncludedMin) + } + suspend fun getSelectedCurrencyCode(): String { return walletUIConfig.getExchangeCurrencyCode() } diff --git a/common/src/main/java/org/dash/wallet/common/ui/receive/ReceiveInfoView.kt b/common/src/main/java/org/dash/wallet/common/ui/receive/ReceiveInfoView.kt index bddbc311b5..aa82d2aeab 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/receive/ReceiveInfoView.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/receive/ReceiveInfoView.kt @@ -26,11 +26,11 @@ import android.view.LayoutInflater import android.widget.Toast import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.uri.BitcoinURI -import org.bitcoinj.uri.BitcoinURIParseException import org.dash.wallet.common.R +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.payments.parsers.AddressNetwork +import org.dash.wallet.common.payments.parsers.PaymentURI import org.dash.wallet.common.databinding.ReceiveInfoViewBinding import org.dash.wallet.common.ui.avatar.ProfilePictureDisplay import org.dash.wallet.common.util.Qr @@ -45,8 +45,8 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout private var onSpecifyAmountClicked: (() -> Unit)? = null private var onShareClicked: (() -> Unit)? = null - private var address: Address? = null - private var amount: Coin? = null + private var address: String? = null + private var amount: Dash? = null private var paymentRequestUri: String = "" private var username: String? = null @@ -80,7 +80,7 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout } binding.shareButton.setOnClickListener { onShareClicked?.invoke() - address?.let { handleShare(it.toBase58()) } + address?.let { handleShare(it) } } refresh() @@ -91,7 +91,7 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout } } - fun setInfo(address: Address, amount: Coin?) { + fun setInfo(address: String, amount: Dash?) { if (this.address != address) { this.address = address this.amount = amount @@ -141,7 +141,7 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout if (address != null) { binding.addressPreviewPane.isVisible = true - binding.addressPreview.text = address.toBase58() + binding.addressPreview.text = address } else { binding.addressPreviewPane.isVisible = false } @@ -153,7 +153,14 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout val address = this.address if (address != null) { - paymentRequestUri = BitcoinURI.convertToBitcoinURI(address.parameters, address.toBase58(), amount, null, null, username) + paymentRequestUri = PaymentURI.convertToPaymentURI( + AddressNetwork.fromDashAddress(address), + address, + amount?.let { Coin.valueOf(it.duffs) }, + null, + null, + username + ) val qrCodeBitmap = Qr.themeAwareDrawable(paymentRequestUri, resources) binding.qrPreview.setImageDrawable(qrCodeBitmap) } else { @@ -162,19 +169,17 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout } } - private fun handleCopyAddress(address: Address) { - try { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private fun handleCopyAddress(address: String) { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - if (amount != null && paymentRequestUri.isNotEmpty()) { - clipboardManager.setPrimaryClip(ClipData.newPlainText("Dash payment request", paymentRequestUri)) - } else { - clipboardManager.setPrimaryClip(ClipData.newPlainText("Dash address", address.toBase58())) - } + if (amount != null && paymentRequestUri.isNotEmpty()) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Dash payment request", paymentRequestUri)) + } else { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Dash address", address)) + } - Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() - log.info("address copied to clipboard: {}", address) - } catch (ignore: BitcoinURIParseException) { } + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + log.info("address copied to clipboard: {}", address) } private fun handleCopyUsername(username: String) { diff --git a/common/src/main/java/org/dash/wallet/common/ui/segmented_picker/SegmentedPicker.kt b/common/src/main/java/org/dash/wallet/common/ui/segmented_picker/SegmentedPicker.kt index df28dd278c..7a6a561fd8 100644 --- a/common/src/main/java/org/dash/wallet/common/ui/segmented_picker/SegmentedPicker.kt +++ b/common/src/main/java/org/dash/wallet/common/ui/segmented_picker/SegmentedPicker.kt @@ -17,6 +17,7 @@ package org.dash.wallet.common.ui.segmented_picker +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -44,6 +45,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import org.dash.wallet.common.R +import org.dash.wallet.common.ui.components.DashWalletTheme +import org.dash.wallet.common.ui.components.LocalDashColors import org.dash.wallet.common.ui.components.MyTheme data class SegmentedOption( @@ -58,8 +61,10 @@ enum class PickerDisplayMode { data class SegmentedPickerStyle( val displayMode: PickerDisplayMode = PickerDisplayMode.Horizontal, - val backgroundColor: Color = MyTheme.Colors.gray400.copy(alpha = 0.1f), - val thumbColor: Color = MyTheme.Colors.backgroundSecondary, + // Null means "resolve from the active theme" — see SegmentedPicker. Hardcoding a color here + // would freeze it to the light scheme (data-class defaults can't read LocalDashColors). + val backgroundColor: Color? = null, + val thumbColor: Color? = null, val cornerRadius: Float = 12f, val textStyle: TextStyle = MyTheme.CaptionMedium, val shadowElevation: Int = 2 @@ -70,6 +75,9 @@ fun SegmentedPicker( options: List, modifier: Modifier = Modifier, selectedIndex: Int = 0, + // When false, no option is rendered as current: no thumb, uniform text color. For pickers + // whose options are alternatives to switch to rather than a persistent selection. + showSelection: Boolean = true, style: SegmentedPickerStyle = SegmentedPickerStyle(), onOptionSelected: (SegmentedOption, Int) -> Unit = { _, _ -> }, ) { @@ -84,13 +92,18 @@ fun SegmentedPicker( val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current + // Resolve theme-aware defaults so the picker tracks light/dark; explicit style colors win. + val colors = LocalDashColors.current + val backgroundColor = style.backgroundColor ?: colors.gray400.copy(alpha = 0.1f) + val thumbColor = style.thumbColor ?: colors.backgroundSecondary + var containerWidth by remember { mutableIntStateOf(0) } var containerHeight by remember { mutableIntStateOf(0) } Box( modifier = modifier .clip(RoundedCornerShape(style.cornerRadius.dp)) - .background(style.backgroundColor) + .background(backgroundColor) .padding(1.dp) .onGloballyPositioned { coordinates -> containerWidth = coordinates.size.width @@ -132,7 +145,6 @@ fun SegmentedPicker( } } ) - // Draw dividers between options if (isHorizontal) { Row( @@ -146,7 +158,7 @@ fun SegmentedPicker( .fillMaxHeight() .width(0.6.dp) .padding(vertical = 12.dp) - .background(MyTheme.Colors.divider) + .background(colors.divider) .align(Alignment.CenterVertically) ) } @@ -155,10 +167,10 @@ fun SegmentedPicker( } // Draw the animated selection indicator - if (containerSize > 0) { + if (containerSize > 0 && showSelection) { Surface( shape = RoundedCornerShape((style.cornerRadius - 2).dp), - color = style.thumbColor, + color = thumbColor, shadowElevation = style.shadowElevation.dp, modifier = Modifier .then( @@ -186,7 +198,7 @@ fun SegmentedPicker( options.forEachIndexed { index, option -> OptionContent( option = option, - isSelected = index == internalSelectedIndex, + isSelected = showSelection && index == internalSelectedIndex, textStyle = style.textStyle, onSelect = { isInitialPosition = false @@ -205,7 +217,7 @@ fun SegmentedPicker( options.forEachIndexed { index, option -> OptionContent( option = option, - isSelected = index == internalSelectedIndex, + isSelected = showSelection && index == internalSelectedIndex, textStyle = style.textStyle, onSelect = { isInitialPosition = false @@ -246,18 +258,19 @@ private fun OptionContent( if (isHorizontal) Modifier.fillMaxHeight() else Modifier.fillMaxWidth() ) ) { + val colors = LocalDashColors.current option.icon?.let { Icon( painter = painterResource(id = it), contentDescription = null, - tint = if (isSelected) Color.Unspecified else MyTheme.Colors.textPrimary.copy(alpha = 0.4f), + tint = if (isSelected) Color.Unspecified else colors.textPrimary.copy(alpha = 0.4f), modifier = Modifier.padding(end = 6.dp) ) } Text( text = option.title, - color = if (isSelected) MyTheme.Colors.textPrimary else MyTheme.Colors.textPrimary.copy(alpha = 0.4f), + color = if (isSelected) colors.textPrimary else colors.textPrimary.copy(alpha = 0.4f), style = textStyle, textAlign = TextAlign.Center ) @@ -265,10 +278,19 @@ private fun OptionContent( } } -@Preview(showBackground = true) +@Preview(name = "Light", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SegmentedPickerPreview() { - Surface(color = colorResource(id = R.color.background_primary)) { + DashWalletTheme { + SegmentedPickerPreviewContent() + } +} + +@Composable +private fun SegmentedPickerPreviewContent() { + val colors = LocalDashColors.current + Surface(color = colors.backgroundPrimary) { Column( modifier = Modifier .padding(16.dp) @@ -338,8 +360,8 @@ fun SegmentedPickerPreview() { val customStyle = SegmentedPickerStyle( displayMode = PickerDisplayMode.Vertical, - backgroundColor = MyTheme.Colors.gray400.copy(alpha = 0.15f), - thumbColor = MyTheme.Colors.dashBlue, + backgroundColor = colors.gray400.copy(alpha = 0.15f), + thumbColor = colors.dashBlue, cornerRadius = 16f ) diff --git a/common/src/main/java/org/dash/wallet/common/util/AddressUtil.java b/common/src/main/java/org/dash/wallet/common/util/AddressUtil.java index 3562989e7f..e46f935cbe 100644 --- a/common/src/main/java/org/dash/wallet/common/util/AddressUtil.java +++ b/common/src/main/java/org/dash/wallet/common/util/AddressUtil.java @@ -17,38 +17,57 @@ package org.dash.wallet.common.util; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.params.TestNet3Params; -import org.bitcoinj.uri.BitcoinURI; +import org.dash.wallet.common.payments.parsers.AddressFormatException; +import org.dash.wallet.common.payments.parsers.AddressNetwork; +import org.dash.wallet.common.payments.parsers.AddressUtils; +import org.dash.wallet.common.payments.parsers.PaymentURI; +/** + * Dashj-free port of the previous bitcoinj-typed helpers: addresses are base58 strings and + * networks are {@link AddressNetwork} descriptors. Resolution rules are identical — a testnet + * address is re-interpreted on the current network (testnet and devnets share version bytes). + */ public class AddressUtil { - public static NetworkParameters getParametersFromAddress(String address, NetworkParameters currentNetworkParameters) throws AddressFormatException { - NetworkParameters networkParameters = Address.getParametersFromAddress(address); - if (networkParameters.equals(TestNet3Params.get())) { - return currentNetworkParameters; + public static AddressNetwork getParametersFromAddress(String address, AddressNetwork currentNetwork) + throws AddressFormatException { + AddressNetwork network = AddressNetwork.fromDashAddress(address); + if (network.getId().equals(AddressNetwork.ID_TESTNET)) { + return currentNetwork; } else { - return networkParameters; + return network; } } - public static Address fromString(NetworkParameters params, String base58, NetworkParameters currentNetworkParameters) throws AddressFormatException { - NetworkParameters networkParameters = (params != null) ? params : getParametersFromAddress(base58, currentNetworkParameters); - return Address.fromString(networkParameters, base58); + /** Validates the given base58 address for {@code params} (or the address-derived network when null). */ + public static String fromString(AddressNetwork params, String base58, AddressNetwork currentNetwork) + throws AddressFormatException { + AddressNetwork network = (params != null) ? params : getParametersFromAddress(base58, currentNetwork); + AddressUtils.DecodedAddress decoded = AddressUtils.decode(base58); + if (!network.acceptsVersion(decoded.getVersion())) { + throw new AddressFormatException.WrongNetwork(decoded.getVersion()); + } + return base58; } - public static Address getCorrectAddress(BitcoinURI bitcoinUri, NetworkParameters currentNetworkParameters) { - Address address = bitcoinUri.getAddress(); + /** + * The address of the payment URI, re-validated against the current network when it decodes + * as a testnet/devnet address. Mirrors the previous bitcoinj-typed behavior exactly. + */ + public static String getCorrectAddress(PaymentURI paymentUri, AddressNetwork currentNetwork) { + String address = paymentUri.getAddress(); if (address != null) { - NetworkParameters networkParameters = address.getParameters(); - if (networkParameters.equals(TestNet3Params.get()) && !currentNetworkParameters.equals(TestNet3Params.get())) { - try { - return Address.fromString(currentNetworkParameters, address.toString()); - } catch (AddressFormatException.WrongNetwork x) { - return address; + try { + AddressNetwork network = AddressNetwork.fromDashAddress(address); + if (network.getId().equals(AddressNetwork.ID_TESTNET) + && !currentNetwork.getId().equals(AddressNetwork.ID_TESTNET)) { + AddressUtils.DecodedAddress decoded = AddressUtils.decode(address); + if (!currentNetwork.acceptsVersion(decoded.getVersion())) { + return address; // WrongNetwork: keep the original, like the dashj original + } } + } catch (AddressFormatException x) { + return address; } } return address; diff --git a/common/src/main/java/org/dash/wallet/common/util/Constants.kt b/common/src/main/java/org/dash/wallet/common/util/Constants.kt index 36f95d1819..b5b9630766 100644 --- a/common/src/main/java/org/dash/wallet/common/util/Constants.kt +++ b/common/src/main/java/org/dash/wallet/common/util/Constants.kt @@ -20,11 +20,12 @@ package org.dash.wallet.common.util import com.google.common.io.BaseEncoding import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor -import org.bitcoinj.core.Coin -import org.bitcoinj.core.NetworkParameters -import org.bitcoinj.params.MainNetParams -import org.bitcoinj.utils.MonetaryFormat +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.MonetaryFormat import org.dash.wallet.common.BuildConfig +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.MoneyFormat +import org.dash.wallet.common.payments.parsers.AddressNetwork import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit @@ -42,12 +43,19 @@ object Constants { const val USER_BUY_SELL_DASH = 101 - var MAX_MONEY: Coin = MainNetParams.get().maxMoney + var MAX_MONEY: Coin = Coin.valueOf(AddressNetwork.MAX_MONEY_DUFFS) val ECONOMIC_FEE: Coin = Coin.valueOf(1000) + + /** Neutral mirror of dashj's `Transaction.DEFAULT_TX_FEE`. */ + val DEFAULT_TX_FEE: Dash = Dash.valueOf(1000) val SEND_PAYMENT_LOCAL_FORMAT: MonetaryFormat = MonetaryFormat().withLocale(GenericUtils.getDeviceLocale()).minDecimals(2) .optionalDecimals() + /** Neutral counterpart of [SEND_PAYMENT_LOCAL_FORMAT] for modules that don't depend on dashj. */ + val SEND_PAYMENT_LOCAL_MONEY_FORMAT: MoneyFormat + get() = MoneyFormat(SEND_PAYMENT_LOCAL_FORMAT) + const val ANDROID_KEY_STORE = "AndroidKeyStore" lateinit var EXPLORE_GC_FILE_PATH: String @@ -74,8 +82,9 @@ object Constants { .build() @JvmField val HEX: BaseEncoding = BaseEncoding.base16().lowerCase() + /** The network used for `dash:` URI parsing in this module (mirrors the previous MainNetParams default). */ @JvmField - val NETWORK_PARAMETERS: NetworkParameters = MainNetParams.get() + val NETWORK: AddressNetwork = AddressNetwork.DASH_MAINNET @JvmField val ANYPAY_SCHEME = "pay" @JvmField diff --git a/common/src/main/java/org/dash/wallet/common/util/DashJExt.kt b/common/src/main/java/org/dash/wallet/common/util/DashJExt.kt index 4e7ff2cadf..9cbf47ad08 100644 --- a/common/src/main/java/org/dash/wallet/common/util/DashJExt.kt +++ b/common/src/main/java/org/dash/wallet/common/util/DashJExt.kt @@ -1,5 +1,6 @@ package org.dash.wallet.common.util -import org.bitcoinj.core.NetworkParameters +import org.dash.wallet.common.payments.parsers.AddressNetwork -fun NetworkParameters.isMainNet(): Boolean = id == NetworkParameters.ID_MAINNET \ No newline at end of file +/** True when this network id (`NetworkParameters.getId()`) is the Dash mainnet id. */ +fun String.isMainNetId(): Boolean = this == AddressNetwork.ID_MAINNET diff --git a/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt b/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt index fb58ffeb28..d87a22a854 100644 --- a/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt +++ b/common/src/main/java/org/dash/wallet/common/util/GenericUtils.kt @@ -18,7 +18,8 @@ package org.dash.wallet.common.util import android.os.LocaleList -import org.bitcoinj.utils.MonetaryFormat +import org.dash.wallet.common.money.MonetaryFormat +import org.dash.wallet.common.money.MoneyFormat import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -104,9 +105,36 @@ object GenericUtils { return currency.getSymbol(getDeviceLocale()) } - fun getCoinIcon(code: String): String { - return "https://raw.githubusercontent.com/jsupa/crypto-icons/main/icons/" + - code.lowercase(Locale.getDefault()) + ".png" + /** + * Ordered list of candidate icon URLs for a coin, to be tried in sequence until + * one loads. + * + * When a SwapKit [identifier] is supplied (e.g. "ETH.USDC-0x...") the SwapKit + * token-list bucket is tried first: it keys off the full chain-qualified + * identifier, so it disambiguates same-ticker tokens across chains and has the + * widest coverage of the assets the wallet can route. The bucket only serves + * fully-lowercased identifier filenames. CoinCap (broader generic coverage, + * includes Solana memecoins like WIF that the older jsupa repo lacks) and the + * jsupa repo follow as ticker-keyed fallbacks. + * + * Some assets (e.g. Solana tokens like $WIF) carry a leading '$' or other + * non-alphanumeric characters in their symbol; the ticker-keyed hosts key off + * the plain ticker (wif), so strip anything that isn't alphanumeric. + */ + fun getCoinIconUrls(code: String, identifier: String? = null): List { + val sanitized = code.lowercase(Locale.getDefault()).filter { it.isLetterOrDigit() } + val urls = mutableListOf() + if (!identifier.isNullOrEmpty()) { + val swapKitId = identifier.lowercase(Locale.getDefault()) + urls.add("https://storage.googleapis.com/token-list-swapkit/images/$swapKitId.png") + } + urls.add("https://assets.coincap.io/assets/icons/$sanitized@2x.png") + urls.add("https://raw.githubusercontent.com/jsupa/crypto-icons/main/icons/$sanitized.png") + return urls + } + + fun getCoinIcon(code: String, identifier: String? = null): String { + return getCoinIconUrls(code, identifier).first() } /** @@ -164,6 +192,10 @@ object GenericUtils { val fiatFormat: MonetaryFormat get() = MonetaryFormat().withLocale(getDeviceLocale()).noCode().minDecimals(getCurrencyDigits()) + /** Neutral counterpart of [dashFormat] for modules that don't depend on dashj. Same format, wrapped in [MoneyFormat]. */ + val dashMoneyFormat: MoneyFormat + get() = MoneyFormat(dashFormat) + fun toLocalizedString(value: BigDecimal, isCrypto: Boolean, currencyCode: String): String { return if (isCrypto) { dashFormat.format(value.toCoin()) diff --git a/common/src/main/java/org/dash/wallet/common/util/MonetaryExt.kt b/common/src/main/java/org/dash/wallet/common/util/MonetaryExt.kt index f0d4ffbe60..1c88a26152 100644 --- a/common/src/main/java/org/dash/wallet/common/util/MonetaryExt.kt +++ b/common/src/main/java/org/dash/wallet/common/util/MonetaryExt.kt @@ -17,9 +17,12 @@ package org.dash.wallet.common.util -import org.bitcoinj.core.Coin -import org.bitcoinj.utils.Fiat -import org.bitcoinj.utils.MonetaryFormat +import org.dash.wallet.common.money.Coin +import org.dash.wallet.common.money.Fiat +import org.dash.wallet.common.money.MonetaryFormat +import org.dash.wallet.common.money.Dash +import org.dash.wallet.common.money.FiatValue +import org.dash.wallet.common.money.toFiat import java.math.BigDecimal import java.math.RoundingMode import java.text.NumberFormat @@ -49,6 +52,40 @@ fun BigDecimal.toFiat(currency: String) : Fiat { return Fiat.valueOf(currency, this.scaleByPowerOfTen(Fiat.SMALLEST_UNIT_EXPONENT).toLong()) } +/** Neutral counterpart of [BigDecimal.toCoin] for modules that don't depend on dashj. */ +fun BigDecimal.toDash(): Dash { + return Dash(this.scaleByPowerOfTen(Coin.SMALLEST_UNIT_EXPONENT).toLong()) +} + +/** Neutral counterpart of [BigDecimal.toFiat] for modules that don't depend on dashj. */ +fun BigDecimal.toFiatValue(currency: String): FiatValue { + return FiatValue(currency, this.scaleByPowerOfTen(Fiat.SMALLEST_UNIT_EXPONENT).toLong()) +} + +/** Neutral counterpart of [Fiat.isCurrencyFirst] for modules that don't depend on dashj. */ +fun FiatValue.isCurrencyFirst(): Boolean { + return toFiat().isCurrencyFirst() +} + +/** Neutral counterpart of [Fiat.toFormattedString] for modules that don't depend on dashj. */ +fun FiatValue.toFormattedString(): String { + return toFiat().toFormattedString() +} + +/** Neutral counterpart of [Fiat.toFormattedStringRoundUp] for modules that don't depend on dashj. */ +fun FiatValue.toFormattedStringRoundUp(): String { + return toFiat().toFormattedStringRoundUp() +} + +/** Neutral counterpart of [Fiat.discountBy] for modules that don't depend on dashj. */ +fun FiatValue.discountBy(fraction: Double): FiatValue = + FiatValue(currencyCode, (value * (1.0 - fraction)).toLong()) + +/** Neutral counterpart of [Fiat.toFormattedStringNoCode] for modules that don't depend on dashj. */ +fun FiatValue.toFormattedStringNoCode(): String { + return toFiat().toFormattedStringNoCode() +} + val Fiat.currencySymbol: String get() = GenericUtils.currencySymbol(currencyCode) diff --git a/common/src/main/java/org/dash/wallet/common/util/MonetarySpannable.java b/common/src/main/java/org/dash/wallet/common/util/MonetarySpannable.java index f473d74b0a..638e50b1c1 100644 --- a/common/src/main/java/org/dash/wallet/common/util/MonetarySpannable.java +++ b/common/src/main/java/org/dash/wallet/common/util/MonetarySpannable.java @@ -19,8 +19,8 @@ import java.util.regex.Matcher; -import org.bitcoinj.core.Monetary; -import org.bitcoinj.utils.MonetaryFormat; +import org.dash.wallet.common.money.Monetary; +import org.dash.wallet.common.money.MonetaryFormat; import org.dash.wallet.common.ui.Formats; import android.graphics.Typeface; diff --git a/common/src/main/res/color/keyboard_button.xml b/common/src/main/res/color/keyboard_button.xml index 29b265e945..69481e02bf 100644 --- a/common/src/main/res/color/keyboard_button.xml +++ b/common/src/main/res/color/keyboard_button.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/common/src/main/res/drawable-v21/selectable_round_corners_white.xml b/common/src/main/res/drawable-v21/selectable_round_corners_white.xml index 15536ea5c4..0305d7a327 100644 --- a/common/src/main/res/drawable-v21/selectable_round_corners_white.xml +++ b/common/src/main/res/drawable-v21/selectable_round_corners_white.xml @@ -4,7 +4,7 @@ - + diff --git a/common/src/main/res/drawable/dialog_rounded_bg.xml b/common/src/main/res/drawable/dialog_rounded_bg.xml index 4c5502b854..f8e9d02778 100644 --- a/common/src/main/res/drawable/dialog_rounded_bg.xml +++ b/common/src/main/res/drawable/dialog_rounded_bg.xml @@ -1,9 +1,9 @@ - + + android:color="@color/content_primary" /> \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_dash_d_black.xml b/common/src/main/res/drawable/ic_dash_d_black.xml index fcefaddd5d..1f6c8dbfea 100644 --- a/common/src/main/res/drawable/ic_dash_d_black.xml +++ b/common/src/main/res/drawable/ic_dash_d_black.xml @@ -2,7 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24"> + android:viewportHeight="24" + android:tint="@color/content_primary"> diff --git a/wallet/res/drawable/ic_warning_triangle.xml b/common/src/main/res/drawable/ic_warning_triangle.xml similarity index 100% rename from wallet/res/drawable/ic_warning_triangle.xml rename to common/src/main/res/drawable/ic_warning_triangle.xml diff --git a/common/src/main/res/drawable/progress_horizontal.xml b/common/src/main/res/drawable/progress_horizontal.xml index 4a8819138b..0c285efa40 100644 --- a/common/src/main/res/drawable/progress_horizontal.xml +++ b/common/src/main/res/drawable/progress_horizontal.xml @@ -3,15 +3,14 @@ android:id="@android:id/background" android:gravity="center_vertical|fill_horizontal"> + android:shape="rectangle"> - + - + \ No newline at end of file diff --git a/common/src/main/res/drawable/rounded_background.xml b/common/src/main/res/drawable/rounded_background.xml index 63ac7c796d..35c0948037 100644 --- a/common/src/main/res/drawable/rounded_background.xml +++ b/common/src/main/res/drawable/rounded_background.xml @@ -18,7 +18,7 @@ xmlns:tools="http://schemas.android.com/tools" android:shape="rectangle"> - + - + \ No newline at end of file diff --git a/common/src/main/res/drawable/selectable_round_corners_border.xml b/common/src/main/res/drawable/selectable_round_corners_border.xml index e7c8b8b964..c6ec44cc3d 100644 --- a/common/src/main/res/drawable/selectable_round_corners_border.xml +++ b/common/src/main/res/drawable/selectable_round_corners_border.xml @@ -2,6 +2,6 @@ - - + + diff --git a/common/src/main/res/drawable/selectable_round_corners_white.xml b/common/src/main/res/drawable/selectable_round_corners_white.xml index dbe5865cf9..04e9cad0f5 100644 --- a/common/src/main/res/drawable/selectable_round_corners_white.xml +++ b/common/src/main/res/drawable/selectable_round_corners_white.xml @@ -6,7 +6,7 @@ - + diff --git a/common/src/main/res/drawable/top_separator.xml b/common/src/main/res/drawable/top_separator.xml index 4a4d7ae088..39eeb8a311 100644 --- a/common/src/main/res/drawable/top_separator.xml +++ b/common/src/main/res/drawable/top_separator.xml @@ -12,8 +12,7 @@ - + - \ No newline at end of file diff --git a/common/src/main/res/layout/dialog_progress_dismissible.xml b/common/src/main/res/layout/dialog_progress_dismissible.xml new file mode 100644 index 0000000000..4ea23b3df8 --- /dev/null +++ b/common/src/main/res/layout/dialog_progress_dismissible.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/layout/fragment_enter_amount.xml b/common/src/main/res/layout/fragment_enter_amount.xml index 1c7d8f1fa9..d8a22daf2a 100644 --- a/common/src/main/res/layout/fragment_enter_amount.xml +++ b/common/src/main/res/layout/fragment_enter_amount.xml @@ -128,14 +128,28 @@ android:layout_marginBottom="@dimen/enter_amount_keyboard_spacing" app:nk_decSeparatorEnabled="true" /> -