Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2e4cf91
fix: crash when locale has incomplete string arrays indexed by enum o…
HashEngineering Jul 21, 2026
6cba285
fix: crash from uncaught SocketTimeoutException in Maya fiat rate fetch
HashEngineering Jul 28, 2026
d950630
fix: crash opening single-tx CoinJoin/CrowdNode groups from tx list
HashEngineering Jul 28, 2026
add36c3
chore: enable parallel Gradle sync, add explore test databases
HashEngineering Jul 28, 2026
f0056c4
fix: review fixes for Maya rate refresh and gradle.properties
HashEngineering Jul 29, 2026
bf0e042
fix: surface SwapKit below-minimum quotes as an inline amount error
HashEngineering Aug 5, 2026
e7efda2
fix: keep the sell amount's currency in step with the picker
HashEngineering Aug 7, 2026
3b38d1c
fix: sweep the wallet for a fiat- or crypto-anchored Maya MAX swap
HashEngineering Aug 7, 2026
9eaa900
fix: log Imgur response body on profile picture upload failure
HashEngineering Aug 7, 2026
5583a65
fix: black 'Enter PIN' text on lock screen after dark mode sweep
HashEngineering Aug 7, 2026
70cd6d1
fix(anr): stop touching the dashj wallet on the main thread at 1 Hz d…
HashEngineering Aug 10, 2026
747b701
chore: update dashj to 22.0.5-SNAPSHOT
HashEngineering Aug 11, 2026
b06c064
fix: avoid racy background-thread read of _isBlockchainSynced.value
HashEngineering Aug 12, 2026
71207e7
fix: close Imgur responses and bound the error body read
HashEngineering Aug 12, 2026
ce55219
fix: simplify Maya convert-crypto enter-amount screen per design feed…
HashEngineering Aug 14, 2026
fae563e
fix: make address middle-ellipsis truncation font-scale safe
HashEngineering Aug 14, 2026
f73f214
fix: match convert-crypto direction card to Figma layout
HashEngineering Aug 14, 2026
6be2f68
fix: add Galaxy S22 @ 1.25x font-scale preview
HashEngineering Aug 14, 2026
30e834b
fix: align convert-crypto currency picker with Figma design
HashEngineering Aug 14, 2026
16b6003
fix: middle-ellipsis the address rows on the Maya address-input screen
HashEngineering Aug 14, 2026
1128c43
fix: use device spec for the Galaxy S22 preview's system UI frame
HashEngineering Aug 14, 2026
38086cc
fix: match DEXReceiveScreen to Figma layout, add Galaxy S22 preview
HashEngineering Aug 14, 2026
9b61684
Merge branch 'master' into fix/dash-dex-ui-improvements-1
HashEngineering Aug 23, 2026
e5cd869
fix: address review findings in MenuItem.kt
HashEngineering Aug 23, 2026
3dc25d4
fix: test one-character candidates in middleEllipsizeToFit
HashEngineering Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ service.properties
wallet/assets/explore/*.db
.deploy/*.json
*.keystore
.java-version # used by jenv for choosing java version at the command line
# used by jenv for choosing java version at the command line
.java-version
.bundle/config
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ buildscript {
kotlin_version = '2.1.0'
coroutinesVersion = '1.6.4'
ok_http_version = '4.12.0'
dashjVersion = '22.0.4'
dashjVersion = '22.0.5-SNAPSHOT'
dppVersion = "4.0.0"
hiltVersion = '2.53'
hiltCompilerVersion = '1.2.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,19 @@ fun EnterAmount(
// 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).
// Figma (node 38680:47341) shows these as plain stacked labels with no pill/background
// behind them, unlike the segmented-toggle style this component normally renders.
SegmentedPicker(
options = pickerIndices.map { SegmentedOption(currencyCodes[it]) },
showSelection = false,
style = SegmentedPickerStyle(
displayMode = PickerDisplayMode.Vertical,
backgroundColor = Color.Transparent,
cornerRadius = 0f,
shadowElevation = 0,
textStyle = MyTheme.Typography.LabelSmallMedium,
optionPaddingHorizontal = 6f,
optionPaddingVertical = 4f
),
onOptionSelected = { option, index ->
onCurrencyPickerSelect(option, pickerIndices[index])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
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.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand All @@ -49,6 +53,10 @@ fun MenuItem(
helpTextAbove: String? = null,
subtitle: String? = null,
subtitleMaxLines: Int = Int.MAX_VALUE,
// Truncates `subtitle` from the middle to fit the available width (e.g. for addresses,
// where both the start and end need to stay checkable) instead of the standard end-ellipsis.
// Width-measured so it stays correct at any font scale, unlike a fixed character count.
subtitleMiddleEllipsis: Boolean = false,
subtitle2: String? = null,
icon: Int? = null,
// Custom icon slot (e.g. a Coil AsyncImage for coin logos); used when `icon` is null
Expand Down Expand Up @@ -168,14 +176,23 @@ fun MenuItem(

// Subtitle
subtitle?.let {
Text(
text = it,
style = MyTheme.Typography.BodyMedium,
color = colors.textSecondary,
maxLines = subtitleMaxLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
if (subtitleMiddleEllipsis) {
MiddleEllipsisText(
text = it,
style = MyTheme.Typography.BodyMedium,
color = colors.textSecondary,
modifier = Modifier.fillMaxWidth()
)
} else {
Text(
text = it,
style = MyTheme.Typography.BodyMedium,
color = colors.textSecondary,
maxLines = subtitleMaxLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}

// Second subtitle
Expand Down Expand Up @@ -271,6 +288,44 @@ fun MenuItem(
}
}

/**
* Single-line text that keeps the start and end of [text] visible, truncating the middle
* with "…" only as much as needed to fit the measured width. Unlike a fixed character-count
* cut, this stays correct across screen widths, locales and font scales.
*/
@Composable
private fun MiddleEllipsisText(
text: String,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
val maxWidthPx = with(density) { maxWidth.toPx() }
val display = remember(text, maxWidthPx, style) {
middleEllipsizeToFit(text, maxWidthPx, style, measurer)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Text(text = display, style = style, color = color, maxLines = 1, overflow = TextOverflow.Clip)
}
}

private fun middleEllipsizeToFit(text: String, maxWidthPx: Float, style: TextStyle, measurer: TextMeasurer): String {
fun widthOf(s: String) = measurer.measure(text = s, style = style, softWrap = false).size.width

if (maxWidthPx <= 0f || widthOf(text) <= maxWidthPx) return text

var head = (text.length + 1) / 2
var tail = text.length - head
while (head + tail > 1) {
val candidate = "${text.take(head)}…${text.takeLast(tail)}"
if (widthOf(candidate) <= maxWidthPx) return candidate
if (head >= tail) head-- else tail--
}
return "…"
}

@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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.dash.wallet.common.R
Expand All @@ -67,7 +68,12 @@ data class SegmentedPickerStyle(
val thumbColor: Color? = null,
val cornerRadius: Float = 12f,
val textStyle: TextStyle = MyTheme.CaptionMedium,
val shadowElevation: Int = 2
val shadowElevation: Int = 2,
// Extra inset drawn around each option's text/icon, on top of the option's own weighted
// slot. Zero by default so existing fixed-height horizontal/vertical toggles are unaffected;
// set this for pickers whose options should have visible breathing room between them.
val optionPaddingHorizontal: Float = 0f,
val optionPaddingVertical: Float = 0f
)

@Composable
Expand Down Expand Up @@ -205,7 +211,9 @@ fun SegmentedPicker(
internalSelectedIndex = index
onOptionSelected(option, index)
},
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
paddingHorizontal = style.optionPaddingHorizontal.dp,
paddingVertical = style.optionPaddingVertical.dp
)
}
}
Expand All @@ -225,7 +233,9 @@ fun SegmentedPicker(
onOptionSelected(option, index)
},
modifier = Modifier.weight(1f),
isHorizontal = false
isHorizontal = false,
paddingHorizontal = style.optionPaddingHorizontal.dp,
paddingVertical = style.optionPaddingVertical.dp
)
}
}
Expand All @@ -240,7 +250,9 @@ private fun OptionContent(
textStyle: TextStyle,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
isHorizontal: Boolean = true
isHorizontal: Boolean = true,
paddingHorizontal: Dp = 0.dp,
paddingVertical: Dp = 0.dp
) {
Box(
modifier = modifier
Expand All @@ -254,9 +266,9 @@ private fun OptionContent(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.then(
if (isHorizontal) Modifier.fillMaxHeight() else Modifier.fillMaxWidth()
)
modifier = Modifier
.then(if (isHorizontal) Modifier.fillMaxHeight() else Modifier.fillMaxWidth())
.padding(horizontal = paddingHorizontal, vertical = paddingVertical)
) {
val colors = LocalDashColors.current
option.icon?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ package org.dash.wallet.common.util
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.res.Configuration
import androidx.fragment.app.FragmentActivity
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.ArrayRes
import androidx.annotation.RequiresApi
import java.util.Locale

fun Context.openAppSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
Expand Down Expand Up @@ -59,6 +62,23 @@ fun Context.findFragmentActivity(): FragmentActivity {
throw IllegalStateException("No FragmentActivity found in context chain")
}

/**
* A locale replaces a string array wholesale, so a partially translated array can be shorter
* than the default one. When the localized array has fewer than [expectedSize] items,
* this returns the array from the default (untranslated) resources instead.
*/
fun Context.getStringArrayOrDefault(@ArrayRes id: Int, expectedSize: Int): Array<String> {
val localized = resources.getStringArray(id)

if (localized.size >= expectedSize) {
return localized
}

val config = Configuration(resources.configuration)
config.setLocale(Locale.ROOT)
return createConfigurationContext(config).resources.getStringArray(id)
}

fun Context.shareText(textToShare: String, title: String) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
Expand Down
Binary file not shown.
Binary file added features/exploredash/test/resources/explore.db
Binary file not shown.
53 changes: 45 additions & 8 deletions integrations/maya/SWAPKIT_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,28 @@ Output amounts shown are already net of all fees except inbound.
- A top-level `error` for request-level failures (auth, malformed body, no routes at all).
- `providerErrors[]` for per-provider failures while other providers still produced routes — **do not treat these as fatal**; they're informational.

But when **every** provider declines, the response is still `200` with `routes: []`, a **null** top-level `error`, and the only explanation in `providerErrors[].errorCode` — e.g. a 0.0005 DASH → `THOR.RUNE` quote answers:

```json
{ "routes": [], "providerErrors": [
{ "provider": "MAYACHAIN_STREAMING", "errorCode": "sellAssetAmountTooSmall",
"message": "Sell asset amount too small for provider MAYACHAIN." } ] }
```

So a no-route response must derive its error from `providerErrors[0].errorCode` (see
`SwapKitApiAggregator.noRouteError()`, which renders `"<errorCode>: <message>"` via
`SwapKitErrors.providerErrorMessage()` and falls back to `noRoutesFound` when no provider reported
a code). Forwarding the human `message` alone loses the code and sends the failure to the
generic-error dialog instead of the inline below-minimum banner.

Provider error codes are not enumerated in SwapKit's docs; anything ending in `AmountTooSmall` or
`AmountTooLow` (`sellAssetAmountTooSmall`, …) is classified as below-minimum by
`SwapKitErrors.isAmountTooLow` (which also accepts the ambiguous top-level `noRoutesFound`).

Conversely, `providerErrors` **alongside returned routes** stay informational: `mapToSwapQuote`
sets `SwapQuote.error` only when no route came back, because callers read a non-null `error` as
"unusable quote" (`MayaAddressInputFragment` refuses to continue on it).

### Compatibility With Existing Maya Module

- Asset notation and the general routing model overlap heavily, so `model/Amount.kt`, `model/SwapQuoteRequest.kt`, and the existing fiat-rate stack can mostly be reused.
Expand All @@ -494,19 +516,26 @@ Output amounts shown are already net of all fees except inbound.

## User-Facing Error Display (per flow / screen)

SwapKit failures carry a machine code in the top-level `error` field (§4 quote, §5 swap).
`swapkit/SwapKitErrors.messageResFor()` maps the code to a localized string in
SwapKit failures carry a machine code in the top-level `error` field (§4 quote, §5 swap) — or, when
all providers declined a quote, in `providerErrors[].errorCode` (see "Provider Errors vs Top-Level
Errors"). `swapkit/SwapKitErrors.messageResFor()` maps the code to a localized string in
`res/values/strings-maya.xml`; which screens use that mapping — and which show their own
fixed copy instead — is listed below. English text as of this writing; `%1$s` is the coin
code (e.g. "BTC"). Codes not listed fall back to `dex_error_generic`.

### Buy flow (SwapKit backend only)

**Enter Amount** (`DEXEnterAmountScreen`) — red text under the amount bar. Does NOT use the
code→message table: every Continue-validation quote failure (including `noRoutesFound`) shows
the single fixed string `dex_enter_amount_invalid`:
**Enter Amount** (`DEXEnterAmountScreen`) — red text under the amount bar. Uses the table only for
amount-too-low-classified failures (`SwapProvider.isAmountTooLowError`), since those are the only
ones the user can act on by changing the amount; everything else shows the neutral catch-all, as
the remaining codes either can't distinguish too-low from temporarily-unroutable or describe the
placeholder refund address this screen quotes with rather than anything the user entered:

> This amount can't be swapped right now. Try a different amount, or try again shortly.
| Error | String id | English text |
|---|---|---|
| `*AmountTooSmall` (provider error) | `dex_error_amount_too_small` | This amount is below the minimum for this swap. Please enter a larger amount. |
| `noRoutesFound` | `dex_error_no_route` | This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount. |
| anything else | `dex_enter_amount_invalid` | This amount can't be swapped right now. Try a different amount, or try again shortly. |

**Refund Address** (`DEXRefundAddressScreen`) — red text under the address field. The only buy
screen using the full `SwapKitErrors` table; `createBuyOrder` calls both `/v3/quote` and
Expand All @@ -516,6 +545,7 @@ screen using the full `SwapKitErrors` table; `createBuyOrder` calls both `/v3/qu
|---|---|---|
| local address check (not a SwapKit code) | `not_valid_address` (common) | Not a valid %1$s Address or URL request |
| `noRoutesFound` | `dex_error_no_route` | This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount. |
| `*AmountTooSmall` (provider error, e.g. `sellAssetAmountTooSmall`) | `dex_error_amount_too_small` | This amount is below the minimum for this swap. Please enter a larger amount. |
| `blackListAsset` | `dex_error_blacklisted` | %1$s can't be swapped at the moment. |
| `invalidRequest`, `validation_error` | `dex_error_validation` | We couldn't set up your swap. Please check the amount and address, then try again. |
| `apiKeyInvalid`, `unauthorized` | `dex_error_unavailable` | Swaps are temporarily unavailable. Please try again later. |
Expand Down Expand Up @@ -546,19 +576,26 @@ transaction is built locally).
| Error | String id | English text |
|---|---|---|
| `noRoutesFound` | `dex_error_no_route` | This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount. |
| `*AmountTooSmall` (provider error, e.g. `sellAssetAmountTooSmall`) | `dex_error_amount_too_small` | This amount is below the minimum for this swap. Please enter a larger amount. |
| `blackListAsset` | `dex_error_blacklisted` | %1$s can't be swapped at the moment. |
| `invalidRequest`, `validation_error` | `dex_error_validation` | We couldn't set up your swap. Please check the amount and address, then try again. |
| `apiKeyInvalid`, `unauthorized` | `dex_error_unavailable` | Swaps are temporarily unavailable. Please try again later. |
| anything else | `dex_error_generic` | Something went wrong setting up your swap. Please try again. |

(The bootstrap quote here also *retries* an amount-too-low error at 2× then 4× the indicative
1 DASH before surfacing it — see `MayaAddressInputViewModel.getDefaultQuote`, which classifies via
the same `isAmountTooLowError`.)

**Sell Enter Amount** (`MayaConvertCryptoFragment`) — an amount-too-low-classified error shows
the red banner (no modal, so the user can raise the amount and retry); all other codes pop an
`AdaptiveDialog` with the mapped message. The banner text comes from the active backend's
`errorMessageRes`, so Maya's genuine amount-too-low keeps its minimum copy while SwapKit's
ambiguous `noRoutesFound` gets the neutral no-route copy:
`errorMessageRes`, so Maya's genuine amount-too-low keeps its minimum copy, SwapKit's explicit
`*AmountTooSmall` gets below-the-minimum copy, and its ambiguous `noRoutesFound` gets the neutral
no-route copy:

| Error | Shown as | String id | English text |
|---|---|---|---|
| `*AmountTooSmall` (SwapKit provider error) | banner | `dex_error_amount_too_small` | This amount is below the minimum for this swap. Please enter a larger amount. |
| `noRoutesFound` (SwapKit backend) | banner | `dex_error_no_route` | This amount can't be swapped right now. Routes can be briefly unavailable — try again shortly, or try a different amount. |
| amount too low (Maya backend) | banner | `maya_error_below_allowed_minimum` | Entered amount is lower than the allowed minimum |
| `blackListAsset` | dialog | `dex_error_blacklisted` | %1$s can't be swapped at the moment. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

package org.dash.wallet.integrations.maya.api

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -108,7 +110,7 @@ class FiatExchangeRateAggregatedProvider @Inject constructor(
}

private val responseScope = CoroutineScope(
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
SupervisorJob() + Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
private var poolListLastUpdated: Long = 0
override val fiatExchangeRate = MutableStateFlow(ExchangeRate(MayaConstants.DEFAULT_EXCHANGE_CURRENCY, "1.0"))
Expand All @@ -120,8 +122,14 @@ class FiatExchangeRateAggregatedProvider @Inject constructor(

private fun refreshRates(currencyCode: String) {
responseScope.launch {
updateExchangeRates(currencyCode)
poolListLastUpdated = System.currentTimeMillis()
try {
updateExchangeRates(currencyCode)
poolListLastUpdated = System.currentTimeMillis()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.error("failed to refresh fiat exchange rates", e)
}
}
}

Expand Down
Loading
Loading