From 2e4cf917c458e42acb489bc30df7dfd68ceeecff Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 20 Jul 2026 22:30:34 -0700 Subject: [PATCH 01/24] fix: crash when locale has incomplete string arrays indexed by enum ordinal Several locales ship partially translated string arrays (e.g. usernames_type_options has 1 of 4 items in ar/bg/cs/sv/vi), and Android replaces arrays wholesale per locale. Indexing them by enum ordinal threw ArrayIndexOutOfBoundsException in UsernameRequestsFragment. Add Context.getStringArrayOrDefault which falls back to the default resources when the localized array is shorter than expected, and use it at every site that indexes an option array by ordinal (username voting filters and invite filters). Co-Authored-By: Claude Fable 5 --- .../wallet/common/util/ContextExtensions.kt | 20 +++++++++++++++++++ .../ui/invite/InviteFilterSelectionDialog.kt | 6 +++++- .../ui/invite/InvitesHeaderViewHolder.kt | 6 +++++- .../voting/UsernameRequestFilterDialog.kt | 16 ++++++++++++--- .../voting/UsernameRequestsFragment.kt | 11 ++++++++-- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/org/dash/wallet/common/util/ContextExtensions.kt b/common/src/main/java/org/dash/wallet/common/util/ContextExtensions.kt index ae6bdba455..8db2084ef4 100644 --- a/common/src/main/java/org/dash/wallet/common/util/ContextExtensions.kt +++ b/common/src/main/java/org/dash/wallet/common/util/ContextExtensions.kt @@ -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) @@ -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 { + 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" diff --git a/wallet/src/de/schildbach/wallet/ui/invite/InviteFilterSelectionDialog.kt b/wallet/src/de/schildbach/wallet/ui/invite/InviteFilterSelectionDialog.kt index e69b97c8f7..3703127fe8 100644 --- a/wallet/src/de/schildbach/wallet/ui/invite/InviteFilterSelectionDialog.kt +++ b/wallet/src/de/schildbach/wallet/ui/invite/InviteFilterSelectionDialog.kt @@ -26,6 +26,7 @@ import de.schildbach.wallet_test.R import de.schildbach.wallet_test.databinding.DialogInviteFilterBinding import org.dash.wallet.common.ui.dialogs.OffsetDialogFragment import org.dash.wallet.common.ui.viewBinding +import org.dash.wallet.common.util.getStringArrayOrDefault @AndroidEntryPoint class InviteFilterSelectionDialog( @@ -46,7 +47,10 @@ class InviteFilterSelectionDialog( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val array = view.context.resources.getStringArray(R.array.invite_filter) + val array = view.context.getStringArrayOrDefault( + R.array.invite_filter, + InvitesHistoryViewModel.Filter.entries.size + ) binding.firstItem.text = array[0] binding.secondItem.text = array[1] binding.thirdItem.text = array[2] diff --git a/wallet/src/de/schildbach/wallet/ui/invite/InvitesHeaderViewHolder.kt b/wallet/src/de/schildbach/wallet/ui/invite/InvitesHeaderViewHolder.kt index 9eab93c97f..bff0060e29 100644 --- a/wallet/src/de/schildbach/wallet/ui/invite/InvitesHeaderViewHolder.kt +++ b/wallet/src/de/schildbach/wallet/ui/invite/InvitesHeaderViewHolder.kt @@ -22,6 +22,7 @@ import de.schildbach.wallet.database.entity.Invitation import de.schildbach.wallet.ui.util.SingleLiveEvent import de.schildbach.wallet_test.R import de.schildbach.wallet_test.databinding.InviteHistoryHeaderRowBinding +import org.dash.wallet.common.util.getStringArrayOrDefault open class InvitesHeaderViewHolder(val binding: InviteHistoryHeaderRowBinding, val onFilterListener: OnFilterListener) : @@ -38,7 +39,10 @@ open class InvitesHeaderViewHolder(val binding: InviteHistoryHeaderRowBinding, ) { itemView.apply { - val array = context.resources.getStringArray(R.array.invite_filter) + val array = context.getStringArrayOrDefault( + R.array.invite_filter, + InvitesHistoryViewModel.Filter.entries.size + ) binding.inviteFilterText.text = array[filter.ordinal] binding.inviteFilter.setOnClickListener { filterClick.postValue(filter) diff --git a/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestFilterDialog.kt b/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestFilterDialog.kt index 26892463c0..c35b4e023c 100644 --- a/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestFilterDialog.kt +++ b/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestFilterDialog.kt @@ -28,6 +28,7 @@ import org.dash.wallet.common.ui.radio_group.IconifiedViewItem import org.dash.wallet.common.ui.radio_group.RadioGroupAdapter import org.dash.wallet.common.ui.radio_group.setupRadioGroup import org.dash.wallet.common.ui.viewBinding +import org.dash.wallet.common.util.getStringArrayOrDefault enum class UsernameGroupOption { VotingPeriodNone, @@ -93,11 +94,17 @@ class UsernameRequestFilterDialog : OffsetDialogFragment(R.layout.dialog_usernam private fun setupSortByOptions() { - val sortByOptionNames = binding.root.resources.getStringArray(R.array.usernames_sort_by_options).mapIndexed { i, it -> + val sortByOptionNames = requireContext().getStringArrayOrDefault( + R.array.usernames_sort_by_options, + UsernameSortOption.entries.size + ).mapIndexed { i, it -> IconifiedViewItem(getString(if (i < 2) R.string.date else R.string.votes, it)) } - val groupByOptionNames = binding.root.resources.getStringArray(R.array.usernames_group_by_options).mapIndexed { i, it -> + val groupByOptionNames = requireContext().getStringArrayOrDefault( + R.array.usernames_group_by_options, + UsernameGroupOption.entries.size + ).mapIndexed { i, it -> IconifiedViewItem( if (i == 0) { it @@ -127,7 +134,10 @@ class UsernameRequestFilterDialog : OffsetDialogFragment(R.layout.dialog_usernam } private fun setupTypeOptions() { - val optionNames = binding.root.resources.getStringArray(R.array.usernames_type_options).map { + val optionNames = requireContext().getStringArrayOrDefault( + R.array.usernames_type_options, + UsernameTypeOption.entries.size + ).map { IconifiedViewItem(it) } diff --git a/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestsFragment.kt b/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestsFragment.kt index 21590a740d..39f00eaa5f 100644 --- a/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/username/voting/UsernameRequestsFragment.kt @@ -46,6 +46,7 @@ import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.ui.dialogs.AdaptiveDialog import org.dash.wallet.common.ui.viewBinding import org.dash.wallet.common.util.KeyboardUtil +import org.dash.wallet.common.util.getStringArrayOrDefault import org.dash.wallet.common.util.observe import org.dash.wallet.common.util.safeNavigate import org.dashj.platform.dpp.voting.AbstainVoteChoice @@ -142,7 +143,10 @@ class UsernameRequestsFragment : Fragment(R.layout.fragment_username_requests) { viewModel.filterState.observe(viewLifecycleOwner) { state -> val isDefault = state.isDefault() binding.appliedFiltersPanel.isVisible = !isDefault && !keyboardUtil.isKeyboardShown - val typeOptionNames = binding.root.resources.getStringArray(R.array.usernames_type_options) + val typeOptionNames = requireContext().getStringArrayOrDefault( + R.array.usernames_type_options, + UsernameTypeOption.entries.size + ) binding.filterTitle.text = typeOptionNames[state.typeOption.ordinal] if (!isDefault) { @@ -366,7 +370,10 @@ class UsernameRequestsFragment : Fragment(R.layout.fragment_username_requests) { } private fun populateAppliedFilters(state: FiltersUIState) { - val sortByOptionNames = binding.root.resources.getStringArray(R.array.usernames_sort_by_options) + val sortByOptionNames = requireContext().getStringArrayOrDefault( + R.array.usernames_sort_by_options, + UsernameSortOption.entries.size + ) val appliedFilterNames = mutableListOf() if (state.sortByOption != UsernameSortOption.DateDescending) { From 6cba285f745cc5b2a9aebf9876d4fa63bb154cea Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 28 Jul 2026 15:13:28 -0700 Subject: [PATCH 02/24] fix: crash from uncaught SocketTimeoutException in Maya fiat rate fetch FiatExchangeRateAggregatedProvider launched rate refreshes in a bare CoroutineScope with no exception handling, so a SocketTimeoutException from the CurrencyBeacon/FreeCurrency/ExchangeRate APIs (at TLS handshake or mid-body) crashed the app. Catch and log failures in refreshRates, back the scope with a SupervisorJob, and give the Maya and SwapKit OkHttp clients explicit 20s connect/call/read timeouts to match the CTXSpend client. Co-Authored-By: Claude Fable 5 --- .../integrations/maya/api/FiatExchangeRateApi.kt | 11 ++++++++--- .../wallet/integrations/maya/api/RemoteDataSource.kt | 5 +++++ .../dash/wallet/integrations/maya/di/MayaModule.kt | 5 +++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt index 3f63f192b1..d808b81c40 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt @@ -18,6 +18,7 @@ package org.dash.wallet.integrations.maya.api import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -108,7 +109,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")) @@ -120,8 +121,12 @@ class FiatExchangeRateAggregatedProvider @Inject constructor( private fun refreshRates(currencyCode: String) { responseScope.launch { - updateExchangeRates(currencyCode) - poolListLastUpdated = System.currentTimeMillis() + try { + updateExchangeRates(currencyCode) + poolListLastUpdated = System.currentTimeMillis() + } catch (e: Exception) { + log.error("failed to refresh fiat exchange rates", e) + } } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RemoteDataSource.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RemoteDataSource.kt index 8f14b37a93..2d2d2f6116 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RemoteDataSource.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/RemoteDataSource.kt @@ -23,6 +23,8 @@ import org.dash.wallet.common.BuildConfig import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration class RemoteDataSource @Inject constructor() { fun buildApi( @@ -39,6 +41,9 @@ class RemoteDataSource @Inject constructor() { private fun getRetrofitClient(): OkHttpClient { return OkHttpClient.Builder() + .connectTimeout(20.seconds.toJavaDuration()) + .callTimeout(20.seconds.toJavaDuration()) + .readTimeout(20.seconds.toJavaDuration()) .also { client -> if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor() diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt index 1f862711ef..1203f42b21 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt @@ -46,6 +46,8 @@ import org.dash.wallet.integrations.maya.utils.MayaConstants import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration @Module @InstallIn(SingletonComponent::class) @@ -88,6 +90,9 @@ abstract class MayaModule { @Singleton fun provideSwapKitEndpoint(): SwapKitEndpoint { val client = OkHttpClient.Builder() + .connectTimeout(20.seconds.toJavaDuration()) + .callTimeout(20.seconds.toJavaDuration()) + .readTimeout(20.seconds.toJavaDuration()) .addInterceptor(SwapKitAuthInterceptor(SwapKitConstants.API_KEY)) .also { builder -> if (BuildConfig.DEBUG) { From d9506300a0f949849f6c191230a8f27f91cc87b7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 28 Jul 2026 15:13:42 -0700 Subject: [PATCH 03/24] fix: crash opening single-tx CoinJoin/CrowdNode groups from tx list A row with transactionAmount == 1 could be a group whose rowId is a group id ("coinjoin_", "crowdnode") rather than a 64-char txId, making Sha256Hash.wrap throw. Only treat the row as an individual transaction when the id matches a tx hash; single-tx groups now load through the group path and open the transaction details directly. Co-Authored-By: Claude Fable 5 --- .../wallet/ui/main/WalletTransactionsFragment.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt b/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt index 643d0fe20c..9b102a3c65 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/WalletTransactionsFragment.kt @@ -80,6 +80,7 @@ class WalletTransactionsFragment : Fragment(R.layout.wallet_transactions_fragmen companion object { private const val HEADER_ITEM_TAG = "header" private val log = LoggerFactory.getLogger(WalletTransactionsFragment::class.java) + private val TX_ID_REGEX = Regex("^[0-9a-fA-F]{64}$") } private var firstPageLoadStartTime: Long = 0L @@ -147,8 +148,11 @@ class WalletTransactionsFragment : Fragment(R.layout.wallet_transactions_fragmen } } - rowView.transactionAmount == 1 -> { + rowView.transactionAmount == 1 && TX_ID_REGEX.matches(rowView.id) -> { // Individual transaction — rowId is a 64-char txId hex string. + // A CoinJoin/CrowdNode group can also contain a single tx, but its + // rowId is a group id ("coinjoin_", "crowdnode"), not a hash — + // those fall through to the group loader below. viewModel.logEvent(AnalyticsConstants.Home.TRANSACTION_DETAILS) TransactionDetailsDialogFragment.newInstance(Sha256Hash.wrap(rowView.id)) } @@ -161,7 +165,15 @@ class WalletTransactionsFragment : Fragment(R.layout.wallet_transactions_fragmen val activity = if (isAdded) activity else null if (wrapper != null && activity != null) { viewModel.logEvent(AnalyticsConstants.Home.TRANSACTION_DETAILS) - TransactionGroupDetailsFragment(wrapper).show(activity) + if (wrapper.transactions.size == 1) { + // Same routing as the in-memory path above: a group + // of one opens the transaction detail directly. + TransactionDetailsDialogFragment.newInstance( + wrapper.transactions.keys.first() + ).show(activity) + } else { + TransactionGroupDetailsFragment(wrapper).show(activity) + } } else if (wrapper == null) { log.warn("group {} not found in cache — cannot open details", rowView.id) } From add36c37b03911273e853bb45eedcbfdd70ec01b Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 28 Jul 2026 15:13:51 -0700 Subject: [PATCH 04/24] chore: enable parallel Gradle sync, add explore test databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes the .gitignore entry for .java-version — the trailing inline comment was part of the pattern, so the file was never ignored. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++- .../exploredash/test/resources/empty_explore.db | Bin 0 -> 2137 bytes features/exploredash/test/resources/explore.db | Bin 0 -> 2353 bytes gradle.properties | 3 +++ 4 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 features/exploredash/test/resources/empty_explore.db create mode 100644 features/exploredash/test/resources/explore.db diff --git a/.gitignore b/.gitignore index 03c4c2798f..63d45ce804 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/features/exploredash/test/resources/empty_explore.db b/features/exploredash/test/resources/empty_explore.db new file mode 100644 index 0000000000000000000000000000000000000000..e8c4e320b62ecfb12a5da7c008c1e0db39758a00 GIT binary patch literal 2137 zcmV-f2&VT?O9KQm009VN01t|%TmS$70J{eO004ji015yL0A+Y^Y;SU9E@WZ>nFjy@ z06|3q2mrQ$(Nk`-K5k=2<1;4%SBz!f7YYFRbu(VT4)(m?-=27&XPWFW55hP$C=tiz=;N@NL+NUK` zoV)Xv;r^_+$aiA>UjpFCEzgWasUaYwtfm059$9F&nxy=sC_0kKOi*W_<|5Ci&$GS1 zxFM>w@+idJpGiA38N6!Fr?nGeq>`NNZ#*U-+bGd}A#x3j8*Kc7oUgMGV==ABYz73L zVEa?+P}}Rv?Qvd|8>YJ@EU@}Sx6PfR1uSH*-uCy;%78#aamdJ$fb*vWaf#iIoE(QK z=KQ7rH%=c36C@_$dRo^F{u|w;M-uomP_0i&HhrzfN%fek%lgSV57l(}-A~oz`jB@D z_3eSMir6pmbd?I_i3l)8DPtOu5Zjd84nE8sRsU<_D5jqf%<4d<90A~P_nyS9jVnSj-K>MObNbilYA={{VppD$#Zi# z)i;Pu(L-3C%6^JAi>2S4_E!(9-D6h&$@@8mofREyeVZg~({61BksT%`!;+U|F54s@vore&1Tov>yr6Ud zdlQB|Xvd$MPxEe4bH$K9Ag|LY&2l?T71=<5ycvoX(0)~gCXC>k-u%JGj0OmR_eYy5;os!N_HSA0taU$a-Bm#PZ0sGTXuy z3c5sEv2TtnfKgbQUXmMHFqUSa3!ieyNlAfruy3#f>=4ci0W&AH#O!FfYi-SS4l*x% z^E-u>({fOC|3D7GtAO6WU`0QM#2U`TQvt2r_e_c<+u9pql7TTKLn3UxJFQ))@ODW)x@aH?dt<8+_ZSL z{N(V+a*MUL611*FJF27sZFLuIXlZf|bOrbuzteI|bs-Z!2jm<9a_`oB*yb;z897xPd)F(cDOvEm1;P4Qp8 z*d^GQ-GWklHmrC@Ab*_U4Y|H|-$~~wZU9UfB8ot}(|5{nZETU>%r*_4rdzL8p&F1w zhXmy={gT&R2h> zrUqrfQ-&eQ?Y?9oh-$e=r!bHEZ_F|-?DOtvl9gWyuo`G~nzZ5-`Eq&>tj7^guTIVO zq5u&R2cK0X^k3+DHE+n5QnCu`qBQ&lkh}n#5D1-6Kns&nO1@%X$4>D`GUyOUnm`ZW z%_!qMi1GBvshfw$PsXb(x~kHr8PO8uXAmyKXb`EsOMaZ0dGdm^vXjoIl7Q-?gIRoH zH(q0)dn67z#>{yL70MaGLVIwQS7ru#mgc*y#Q4j9qs_m$7DS%TxQ8o zS$k;66%_Qv`&}EJbntgt&d7og=RU!uD^mI0OiB_AuS6LO^$2o8%?YmJ;vUl~p%n7y z+9P@R(&N-1iwpamhx-Zn-KSRVE`^2D5j1a;A~DZ9;{*)}H+{$xLsFJ^F*O%gf2O!~ zu?vU#uIz6*b z;=XKt*`i|$BHs+}`$ zr{TYVo@LPcN>EDy0y6_M009VN01t|%TmS$70J{eO004ji015yL00000000000Dyr2 z0001GcyMfQa%C=LVgZ>400IC(MFR)`P)h{{000000RRC2LjV8(?gsz>5&$tbH#jjl PIWsviG&wjUbY*jNet+jm literal 0 HcmV?d00001 diff --git a/features/exploredash/test/resources/explore.db b/features/exploredash/test/resources/explore.db new file mode 100644 index 0000000000000000000000000000000000000000..9b95536481e7c3c8f99bd6918f5551dda591da97 GIT binary patch literal 2353 zcmV-13C{LVO9KQm009VN01t|%TmS$70Fwv+004ji015yL0A+Y^Y;SU9E@WZ>nFjy@ z06|3q2mmdGR8D-6!LzL; zF`8(*8k7LVq?DU0b)1Q2)Y)9%$)v^<%5E5K=edM-`yihK5UTqR9lkbiq1g{HkW=XI z_^QV=lqMYxuo~UY@EaWl)PtVJAR+i5(0O|;EGEbK{=H#gM*=TMw!o|p66{av-ZhNK zBrW#-%M>bcqbzyI&GNmi?~I6`0uE`|>4cUv=HkFDui%V5x`OEI#Hww}WKdF;6q@i9ne49bYR>5~4A*`eW$aX#O@IkG4@%H2<#-tvMs~mEk!M0)`2Sa(&M9)Rm*On6rcbqKj3B zw~iN+Mhl3nE~_Jd(If+LdXlDdoK_p4Xuvn(uf&Q-$GSTncyuSQ%gKsD?S>(sGF@t(6xti%MG139BMjbQJK5Rz9 zH*Bv4!6J`Zp}H{>l>ex;v68!l>uP0gg>7Ql>b+vsn^G#Rp&w6(9#`2>rANSFbedGi)#`&iKj<@w3t_Pvgr<*Q-3)ArqI`F;|f!+3( z@n|!;>bi>UXLC&z5d6SMMz={-#p|BuoGK6S!^pY)>l!b_5w}P2dyqmf^4Y=3|Ks`( z7R7ljGoaN#kg%oP)T~C7B&yD73@={quko<%K<`1{*hh8 z#dkK69Fh=vAwCRweeZr>{+Hx>emqf7!S4yku_gA7qy%`T7R2{k1YH!%hBzrG>EUjf z27_z^FO2l=#!jAR8+#|sg9w{t?mSt$mjAX(imljuA2H6y3UhB$GaL1yAqb?_LPa{Q zfL_pz12ho1Q!JdRgux&}WH;Q@u2SIi^oB#jv>l=q`*of=GC?U=H7z;N2laq$Z(Smq zADvT4cQ(2_u+^uv<$Fp|Ra_Hr@DmJd&Qp_=d?6KTYBqwjAgp9UyRLcz(8;9A0U;^~ zxAP%Niob1O1Bis3#12D{@XgX%z_uoUucDGCN+yde8MfEAHVZKWTJXr_UR_Hv!=Vb3 zr_y{GT-vO(i*R4)LQ0i{yGd&PsYv#pTy%Kj^P{(5@UR}1EKS6>x5#W^O~E_+Gu znGUI->Q#s&!=MbB9A&guvf(inJofA5i#s@T%$YCqE6`J}Wrzz0^gsM*bQA#@_T5Kl zXk|-ZWfWMgUdTn9-wp1~3pa~s z{}F~(omiufU5k5w=tWKQ#DSvKuPJo)q)IbK5ZCBp69Rz)TL0B|Glk70d1zVHV z;w66Jm}GODJJ`D{ec{#SS@lTTi+fRq8*|r47d8U(sRcq^C^yQ_t6QA`uy7G`b_(w^ zY{Yodf=Gkz_GgXf~j4f32Hz4|ENU_{?` zv3Sy?uvCxw99=uP;~a-ttR(iV#?PlkKNOZhY3@@Z^5P}mnN2;7a;^400IC(MFR)`P)h{{000000RRC2LjV8(#s~la X5&$tbH#jjlIWsviHaIsUbY*jNxjk%8 literal 0 HcmV?d00001 diff --git a/gradle.properties b/gradle.properties index f3dcb52456..015b0f7187 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,6 @@ kotlin.incremental=true android.nonTransitiveRClass=false android.nonFinalResIds=false org.gradle.configuration-cache=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From f0056c469facd63223d1bfc25f0640ba70527e30 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 29 Jul 2026 11:24:16 -0700 Subject: [PATCH 05/24] fix: review fixes for Maya rate refresh and gradle.properties Rethrow CancellationException from the fiat rate refresh catch block to preserve structured cancellation, and drop the inert org.gradle.tooling.parallel property since the wrapper is still on Gradle 8.9 (the property requires 9.4+). Co-Authored-By: Claude Fable 5 --- gradle.properties | 3 --- .../dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index 015b0f7187..f3dcb52456 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,3 @@ kotlin.incremental=true android.nonTransitiveRClass=false android.nonFinalResIds=false org.gradle.configuration-cache=true - -# Enabled parallel sync for Gradle 9.4+ -org.gradle.tooling.parallel=true diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt index d808b81c40..f2a1386076 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/FiatExchangeRateApi.kt @@ -17,6 +17,7 @@ package org.dash.wallet.integrations.maya.api +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher @@ -124,6 +125,8 @@ class FiatExchangeRateAggregatedProvider @Inject constructor( try { updateExchangeRates(currencyCode) poolListLastUpdated = System.currentTimeMillis() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("failed to refresh fiat exchange rates", e) } From bf0e04295eda68547e5e0dcdedaf4eff8e99ce6b Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 5 Aug 2026 11:22:13 -0700 Subject: [PATCH 06/24] fix: surface SwapKit below-minimum quotes as an inline amount error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every provider declines a quote, SwapKit answers 200 with routes: [], a null top-level error, and the reason only in providerErrors[].errorCode (e.g. sellAssetAmountTooSmall). The aggregator forwarded just the human message, so the code was lost and both SwapKitErrors.messageResFor and isAmountTooLowError fell through -- a 0.0005 DASH -> THOR.RUNE sell popped the generic error dialog instead of the inline "amount too small" banner. Derive the error as ": " (noRouteError), classify the …AmountTooSmall/…AmountTooLow family as below-minimum, and map it to new copy. The address screen's 1 -> 2 -> 4 DASH retry now recognises the rejection too, where the old literal "no route" defeated it. mapToSwapQuote takes the whole response and picks the route itself, so a no-route reason can only be attached when there is no route: callers read a non-null SwapQuote.error as "unusable quote", so stamping one onto a response that did return a route rejected a good route. Buy Enter Amount carries the message resource in its UI state instead of a validationFailed flag, showing the below-minimum copy for that one actionable rejection and keeping the neutral catch-all for the rest. Co-Authored-By: Claude Opus 5 (1M context) --- integrations/maya/SWAPKIT_PROTOCOL.md | 53 +++++++++++++++--- .../integrations/maya/api/SwapProvider.kt | 3 +- .../maya/swapkit/SwapKitApiAggregator.kt | 55 +++++++++++++++---- .../maya/swapkit/SwapKitErrors.kt | 44 ++++++++++----- .../maya/ui/DEXEnterAmountScreen.kt | 54 ++++++++++++++---- .../maya/ui/DEXEnterAmountViewModel.kt | 41 ++++++++++---- .../maya/ui/MayaConvertCryptoFragment.kt | 15 ++--- .../maya/swapkit/SwapKitErrorsTest.kt | 18 ++++++ 8 files changed, 220 insertions(+), 63 deletions(-) diff --git a/integrations/maya/SWAPKIT_PROTOCOL.md b/integrations/maya/SWAPKIT_PROTOCOL.md index d11444a54c..558f338775 100644 --- a/integrations/maya/SWAPKIT_PROTOCOL.md +++ b/integrations/maya/SWAPKIT_PROTOCOL.md @@ -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 `": "` 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. @@ -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 @@ -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. | @@ -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. | diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt index d9cb7ebe74..802810f393 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/SwapProvider.kt @@ -180,7 +180,8 @@ interface SwapProvider { /** * True when [error] means the entered amount is below the route's/pool's minimum — the one case * the UI surfaces inline (a red banner the user can fix by raising the amount) rather than as a - * modal. Backend-specific: Maya's "not enough asset to pay for fees", SwapKit's `noRoutesFound`. + * modal. Backend-specific: Maya's "not enough asset to pay for fees", SwapKit's per-provider + * `…AmountTooSmall` codes and its ambiguous `noRoutesFound`. */ fun isAmountTooLowError(error: String?): Boolean = false } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt index 09383e10e5..3eeda9f86e 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt @@ -52,6 +52,7 @@ import org.dash.wallet.integrations.maya.model.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeUIModel import org.dash.wallet.integrations.maya.swapkit.model.SwapKitFee import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteRequest +import org.dash.wallet.integrations.maya.swapkit.model.SwapKitQuoteResponse import org.dash.wallet.integrations.maya.swapkit.model.SwapKitRoute import org.dash.wallet.integrations.maya.swapkit.model.SwapKitSwapRequest import org.dash.wallet.integrations.maya.utils.MayaConfig @@ -605,7 +606,7 @@ class SwapKitApiAggregator @Inject constructor( slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT ) ) ?: return null - return mapToSwapQuote(response.routes.bestRoute(), toAsset, response.error) + return mapToSwapQuote(response, toAsset) } override suspend fun getDefaultSwapQuote( @@ -623,7 +624,7 @@ class SwapKitApiAggregator @Inject constructor( destinationAddress = destinationAddress ) ) ?: return null - return mapToSwapQuote(response.routes.bestRoute(), toAsset, response.error) + return mapToSwapQuote(response, toAsset) } override suspend fun getSwapInfo(swapRequest: SwapQuoteRequest): ResponseResource { @@ -690,9 +691,7 @@ class SwapKitApiAggregator @Inject constructor( } val route = quote.routes.bestRoute() ?: return ResponseResource.Failure( - MayaException( - SwapKitErrors.providerErrorMessage(quote.providerErrors?.firstOrNull()) ?: "no swapkit route" - ), + MayaException(quote.noRouteError()), false, 0, null @@ -891,9 +890,7 @@ class SwapKitApiAggregator @Inject constructor( } val route = quote.routes.bestRoute() ?: return ResponseResource.Failure( - MayaException( - SwapKitErrors.providerErrorMessage(quote.providerErrors?.firstOrNull()) ?: "no swapkit route" - ), + MayaException(quote.noRouteError()), false, 0, null @@ -1048,7 +1045,14 @@ class SwapKitApiAggregator @Inject constructor( .toPlainString() } - private fun mapToSwapQuote(route: SwapKitRoute?, toAsset: String, topLevelError: String?): SwapQuote? { + /** + * Maps a `/v3/quote` response to Maya's [SwapQuote]. Takes the whole response rather than a + * pre-picked route + error so that only the no-route case synthesizes an error string: callers + * treat a non-null [SwapQuote.error] as "unusable quote", so attaching a no-route reason to a + * response that *did* return a route would reject a perfectly good route. + */ + private fun mapToSwapQuote(response: SwapKitQuoteResponse, toAsset: String): SwapQuote? { + val route = response.routes.bestRoute() if (route == null) { return SwapQuote( dustThreshold = "0", @@ -1065,7 +1069,9 @@ class SwapKitApiAggregator @Inject constructor( recommendedMinAmountIn = "0", slippageBps = 0, warning = "", - error = topLevelError ?: "no route" + // A request-level failure names itself in `error`; when providers merely all + // declined, the reason is only in providerErrors (see noRouteError). + error = response.error ?: response.noRouteError() ) } val expectedBaseUnits = humanToBuyAssetBaseUnits(route.expectedBuyAmount) @@ -1098,7 +1104,9 @@ class SwapKitApiAggregator @Inject constructor( recommendedMinAmountIn = "0", slippageBps = slippageBpsInt, warning = route.warnings?.joinToString().orEmpty(), - error = topLevelError + // A usable route was returned, so this quote carries no error; per-provider + // providerErrors alongside it are informational (other providers still routed). + error = response.error ) } @@ -1186,12 +1194,35 @@ class SwapKitApiAggregator @Inject constructor( ?: first() } + /** + * Why a quote came back with no usable route, in the `": "` shape the error + * helpers parse ([SwapKitErrors.messageResFor], [isAmountTooLowError]). + * + * When every provider declines, SwapKit answers 200 with `routes: []`, a null top-level + * `error`, and the real reason only in `providerErrors[].errorCode` — e.g. + * `sellAssetAmountTooSmall` for a sell below MAYACHAIN's minimum. Passing the human + * `message` on instead (as this used to) lost the code, so every such quote fell through to + * the generic error dialog rather than the inline "amount too small" banner. The rendering + * lives in [SwapKitErrors.providerErrorMessage]; this adds the no-route policy on top: prefer + * a provider error that carries a code, and classify code-less failures as `noRoutesFound`. + */ + private fun SwapKitQuoteResponse.noRouteError(): String { + val providerError = providerErrors?.firstOrNull { !it.errorCode.isNullOrBlank() } + ?: providerErrors?.firstOrNull() + val rendered = SwapKitErrors.providerErrorMessage(providerError) + return when { + rendered == null -> SwapKitErrors.NO_ROUTES_FOUND + providerError?.errorCode.isNullOrBlank() -> "${SwapKitErrors.NO_ROUTES_FOUND}: $rendered" + else -> rendered + } + } + // SwapKit's own error vocabulary → friendly message resource (see SwapKitErrors). @StringRes override fun errorMessageRes(error: String?): Int = SwapKitErrors.messageResFor(error) // Below-minimum sell amounts arrive either as a top-level `noRoutesFound` or as a provider's - // `sellAssetAmountTooSmall`; SwapKitErrors owns that vocabulary. + // `…AmountTooSmall`-family code; SwapKitErrors owns that vocabulary. override fun isAmountTooLowError(error: String?): Boolean = SwapKitErrors.isAmountTooLow(error) override fun applyPoolPrices(pools: List, usdToFiat: Fiat) { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrors.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrors.kt index b7278fe856..17455d93c5 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrors.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrors.kt @@ -38,14 +38,8 @@ import org.dash.wallet.integrations.maya.swapkit.model.SwapKitProviderError * its "User-Facing Error Display" section lists which screens show which of these messages. */ object SwapKitErrors { - /** - * Codes meaning "the sell amount is below what this swap can fill", which the UI surfaces as - * an inline hint (raise the amount and retry) rather than a blocking modal. - * - * `noRoutesFound` is the top-level form; `sellAssetAmountTooSmall` is what providers report - * per-provider in `providerErrors[]` when the amount is under their minimum. - */ - private val AMOUNT_TOO_LOW_CODES = setOf("noRoutesFound", "sellAssetAmountTooSmall") + /** Top-level code for "no provider can carry this pair/amount"; also our no-provider-error fallback. */ + const val NO_ROUTES_FOUND = "noRoutesFound" /** * Friendly message resource for [rawError] — the message carried by the failed swap's @@ -59,10 +53,14 @@ object SwapKitErrors { */ @StringRes fun messageResFor(rawError: String?): Int { - return when (codeOf(rawError)) { + // Match on the code prefix so both a bare "validation_error" and a + // "validation_error: " map to the same friendly message. + val code = codeOf(rawError) + // Per-provider below-minimum codes are family-matched, not enumerated (see isBelowMinimumCode). + if (isBelowMinimumCode(code)) return R.string.dex_error_amount_too_small + return when (code) { // /v3/quote - "noRoutesFound" -> R.string.dex_error_no_route - "sellAssetAmountTooSmall" -> R.string.dex_error_amount_too_small + NO_ROUTES_FOUND -> R.string.dex_error_no_route "blackListAsset" -> R.string.dex_error_blacklisted "invalidRequest", "validation_error" -> R.string.dex_error_validation "apiKeyInvalid", "unauthorized" -> R.string.dex_error_unavailable @@ -79,8 +77,28 @@ object SwapKitErrors { } } - /** True when [rawError] means the sell amount is under the minimum this swap can fill. */ - fun isAmountTooLow(rawError: String?): Boolean = codeOf(rawError) in AMOUNT_TOO_LOW_CODES + /** + * True when [rawError] means the sell amount is under the minimum this swap can fill — the + * case the UI surfaces as an inline hint (raise the amount and retry) rather than a blocking + * modal. `noRoutesFound` is the ambiguous top-level form; the per-provider below-minimum codes + * from `providerErrors[]` are family-matched (see [isBelowMinimumCode]). + */ + fun isAmountTooLow(rawError: String?): Boolean { + val code = codeOf(rawError) + return code == NO_ROUTES_FOUND || isBelowMinimumCode(code) + } + + /** + * True when [code] is a per-provider "amount is below the route's minimum" code. SwapKit + * reports these with a code ending in `AmountTooSmall` (e.g. `sellAssetAmountTooSmall` from + * MAYACHAIN). Matched by suffix rather than an exact list because SwapKit doesn't document the + * per-provider vocabulary and the prefix names whichever side/field was too small; + * `AmountTooLow` is accepted as the same family. Both require "Amount" in the code, so + * unrelated below-threshold codes (a too-low fee, say) stay out. + */ + private fun isBelowMinimumCode(code: String): Boolean = + code.endsWith("AmountTooSmall", ignoreCase = true) || + code.endsWith("AmountTooLow", ignoreCase = true) /** * The failure of a quote that came back with no routes, rendered in the same diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountScreen.kt index db6b6a1c57..6d1492fee2 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountScreen.kt @@ -17,6 +17,7 @@ package org.dash.wallet.integrations.maya.ui +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,7 +65,8 @@ fun DEXEnterAmountScreen( continueEnabled = uiState.continueEnabled, isValidating = uiState.isValidating, isOnline = uiState.isOnline, - validationFailed = uiState.validationFailed, + validationErrorRes = uiState.validationErrorRes, + assetCurrencyCode = uiState.assetCurrencyCode, assetDisplayCode = uiState.assetDisplayCode, onKeyInput = viewModel::onKeyInput, onCurrencySelected = viewModel::onCurrencySelected, @@ -81,7 +83,12 @@ private fun DEXEnterAmountScreenContent( continueEnabled: Boolean, isValidating: Boolean, isOnline: Boolean, - validationFailed: Boolean, + // Non-null when the validation quote rejected the entered amount: the message to show under + // the amount bar (see DEXEnterAmountUIState.validationErrorRes). + @StringRes validationErrorRes: Int?, + // Plain code of the asset being bought ("BTC"), the format argument for validation messages + // that name the coin. + assetCurrencyCode: String, // Heading form of the asset being bought: tokens qualified with their host network // ("USDT (Ethereum)"); native L1 coins just the code ("BTC"). assetDisplayCode: String, @@ -130,13 +137,14 @@ private fun DEXEnterAmountScreenContent( onCurrencyPickerSelect = { _, index -> onCurrencySelected(index) } ) - if (validationFailed) { - // SwapKit's noRoutesFound can't tell us whether the amount is too low, too high, or - // simply unroutable right now, and the entered value is in the selected display - // currency — so any min/max guess would be misleading. Show a single neutral message - // that points the user back at the amount they entered. + validationErrorRes?.let { errorRes -> + // The message is chosen in the ViewModel: a below-minimum rejection names itself, + // anything else falls back to the neutral catch-all (SwapKit's noRoutesFound + // can't tell too-low from temporarily-unroutable, and the entered value is in the + // selected display currency — so any min/max guess would be misleading). Messages + // without a %1$s placeholder simply ignore the coin code. Text( - text = stringResource(R.string.dex_enter_amount_invalid), + text = stringResource(errorRes, assetCurrencyCode), style = MyTheme.Body2Regular, color = LocalDashColors.current.red, modifier = Modifier.padding(top = 8.dp) @@ -197,7 +205,8 @@ private fun DEXEnterAmountScreenZeroPreview() { continueEnabled = false, isValidating = false, isOnline = true, - validationFailed = false, + validationErrorRes = null, + assetCurrencyCode = "BTC", assetDisplayCode = "BTC", onKeyInput = {}, onCurrencySelected = {}, @@ -216,7 +225,29 @@ private fun DEXEnterAmountScreenEnabledPreview() { continueEnabled = true, isValidating = false, isOnline = true, - validationFailed = false, + validationErrorRes = null, + assetCurrencyCode = "BTC", + assetDisplayCode = "BTC", + onKeyInput = {}, + onCurrencySelected = {}, + onBackClick = {}, + onContinueClick = {} + ) +} + +/** Amount rejected by the validation quote as below the route's minimum. */ +@Preview(showBackground = true, widthDp = 393, heightDp = 760) +@Composable +private fun DEXEnterAmountScreenBelowMinimumPreview() { + DEXEnterAmountScreenContent( + amount = "0.01", + currencyCodes = listOf("USD", DASH_CURRENCY_CODE, "BTC"), + selectedCurrencyIndex = 0, + continueEnabled = true, + isValidating = false, + isOnline = true, + validationErrorRes = R.string.dex_error_amount_too_small, + assetCurrencyCode = "BTC", assetDisplayCode = "BTC", onKeyInput = {}, onCurrencySelected = {}, @@ -238,7 +269,8 @@ private fun DEXEnterAmountScreenGermanPreview() { continueEnabled = true, isValidating = false, isOnline = true, - validationFailed = false, + validationErrorRes = null, + assetCurrencyCode = "BTC", assetDisplayCode = "BTC", onKeyInput = {}, onCurrencySelected = {}, diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountViewModel.kt index 7898b04a64..414cb902dd 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXEnterAmountViewModel.kt @@ -17,6 +17,7 @@ package org.dash.wallet.integrations.maya.ui +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -35,6 +36,7 @@ import org.dash.wallet.common.services.NetworkStateInt import org.dash.wallet.common.ui.components.DASH_CURRENCY_CODE import org.dash.wallet.common.ui.enter_amount.processAmountKeyInput import org.dash.wallet.common.util.Constants +import org.dash.wallet.integrations.maya.R import org.dash.wallet.integrations.maya.api.SwapProvider import org.dash.wallet.integrations.maya.model.Amount import org.dash.wallet.integrations.maya.model.CurrencyInputType @@ -75,10 +77,11 @@ data class DEXEnterAmountUIState( val isValidating: Boolean = false, // False when the device has no network connection; the screen shows a no-connection toast. val isOnline: Boolean = true, - // True when the entered amount was rejected by the validation quote (e.g. below the route - // minimum). The provider's raw message is logged, not shown — the screen renders a single - // neutral localized error. - val validationFailed: Boolean = false + // Non-null when the entered amount was rejected by the validation quote: the localized message + // to show under the amount bar, resolved with [assetCurrencyCode] as its format argument. The + // provider's raw message is logged, never shown — see [DEXEnterAmountViewModel.onContinueClicked] + // for which rejections name a reason and which fall back to the neutral catch-all. + @StringRes val validationErrorRes: Int? = null ) @HiltViewModel @@ -200,7 +203,7 @@ class DEXEnterAmountViewModel @Inject constructor( amount = displayString, continueEnabled = anchoredValue.signum() > 0, isValidating = false, - validationFailed = false + validationErrorRes = null ) } persistAmount() @@ -235,7 +238,7 @@ class DEXEnterAmountViewModel @Inject constructor( savedStateHandle.remove(KEY_AMOUNT) savedStateHandle.remove(KEY_ASSET) _uiState.update { - it.copy(amount = "0", continueEnabled = false, isValidating = false, validationFailed = false) + it.copy(amount = "0", continueEnabled = false, isValidating = false, validationErrorRes = null) } } @@ -264,7 +267,7 @@ class DEXEnterAmountViewModel @Inject constructor( it.copy( amount = updated, continueEnabled = isPositive(updated), - validationFailed = false + validationErrorRes = null ) } // Persist so the typed amount survives process death. @@ -324,25 +327,41 @@ class DEXEnterAmountViewModel @Inject constructor( validationJob?.cancel() validationJob = viewModelScope.launch { - _uiState.update { it.copy(isValidating = true, validationFailed = false, continueEnabled = false) } + _uiState.update { it.copy(isValidating = true, validationErrorRes = null, continueEnabled = false) } when (val result = swapProvider.validateBuyOrder(asset, sellAmount, exampleAddress)) { is ResponseResource.Success -> { _uiState.update { - it.copy(isValidating = false, validationFailed = false, continueEnabled = isPositive(it.amount)) + it.copy( + isValidating = false, + validationErrorRes = null, + continueEnabled = isPositive(it.amount) + ) } onValidationPassed.call() } is ResponseResource.Failure -> { + val error = result.throwable.message log.info( "onContinueClicked: amount {} {} rejected: {}", sellAmount, asset, - result.throwable.message + error ) + // A below-minimum rejection is the one reason we can name: the provider says so + // explicitly (SwapKit's per-provider `…AmountTooSmall`), so show the backend's + // own below-minimum copy — the user's fix is to raise the amount. Every other + // code stays neutral: they either can't distinguish too-low from + // temporarily-unroutable, or they describe the placeholder refund address used + // here rather than anything the user entered. + val messageRes = if (swapProvider.isAmountTooLowError(error)) { + swapProvider.errorMessageRes(error) + } else { + R.string.dex_enter_amount_invalid + } _uiState.update { it.copy( isValidating = false, - validationFailed = true, + validationErrorRes = messageRes, continueEnabled = isPositive(it.amount) ) } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 059309db69..6fd0c1e3f6 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -280,13 +280,14 @@ class MayaConvertCryptoFragment : Fragment() { } viewModel.swapTradeFailedCallback.observe(viewLifecycleOwner) { - // An amount-too-low error (SwapKit's `noRoutesFound`, Maya's "amount too low") - // shouldn't pop a modal — surface it in the same inline red error the local - // min-amount check uses, so the user can simply raise the amount and retry without - // dismissing a dialog. The active backend's aggregator classifies and localizes the - // error (see SwapProvider): Maya's amount-too-low keeps its "below the allowed - // minimum" copy, while SwapKit's noRoutesFound — which can also mean the route is - // briefly unavailable — gets the same neutral no-route message the DEX buy screens show. + // An amount-too-low error (SwapKit's `sellAssetAmountTooSmall` / `noRoutesFound`, + // Maya's "amount too low") shouldn't pop a modal — surface it in the same inline red + // error the local min-amount check uses, so the user can simply raise the amount and + // retry without dismissing a dialog. The active backend's aggregator classifies and + // localizes the error (see SwapProvider): Maya's amount-too-low keeps its "below the + // allowed minimum" copy, SwapKit's explicit …AmountTooSmall gets below-the-minimum + // copy, and its ambiguous noRoutesFound — which can also mean the route is briefly + // unavailable — gets the neutral no-route message the DEX buy screens show. if (!it.isNullOrBlank() && viewModel.isAmountTooLowError(it)) { setInlineError(getString(viewModel.errorMessageRes(it))) return@observe diff --git a/integrations/maya/src/test/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrorsTest.kt b/integrations/maya/src/test/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrorsTest.kt index ae8f81a9fa..96a4f74ed7 100644 --- a/integrations/maya/src/test/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrorsTest.kt +++ b/integrations/maya/src/test/java/org/dash/wallet/integrations/maya/swapkit/SwapKitErrorsTest.kt @@ -59,6 +59,23 @@ class SwapKitErrorsTest { ) } + /** + * SwapKit doesn't enumerate the per-provider vocabulary, so below-minimum codes are + * family-matched by suffix: any side/field prefix counts, and `AmountTooLow` reads the same + * as `AmountTooSmall`. + */ + @Test + fun belowMinimumFamily_isMatchedBySuffixNotEnumerated() { + assertTrue(SwapKitErrors.isAmountTooLow("buyAssetAmountTooSmall")) + assertTrue(SwapKitErrors.isAmountTooLow("sellAmountTooLow: below the route minimum")) + assertEquals( + R.string.dex_error_amount_too_small, + SwapKitErrors.messageResFor("buyAssetAmountTooSmall") + ) + // "Amount" is required in the code, so an unrelated below-threshold code stays out. + assertFalse(SwapKitErrors.isAmountTooLow("inboundFeeTooLow")) + } + @Test fun noRoutesFound_staysAnAmountTooLowError() { assertEquals(R.string.dex_error_no_route, SwapKitErrors.messageResFor("noRoutesFound")) @@ -68,6 +85,7 @@ class SwapKitErrorsTest { @Test fun otherErrors_areNotAmountTooLow() { assertFalse(SwapKitErrors.isAmountTooLow(null)) + assertFalse(SwapKitErrors.isAmountTooLow("")) assertFalse(SwapKitErrors.isAmountTooLow("isSanctionedAddress")) // The prose alone must not be treated as too-low; only the code counts. assertFalse(SwapKitErrors.isAmountTooLow("Sell asset amount too small for provider MAYACHAIN.")) From e7efda27eac95265f751d898cedd515733803448 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 09:27:01 -0700 Subject: [PATCH 07/24] fix: keep the sell amount's currency in step with the picker On the sell Enter Amount screen the picker could show DASH while the ViewModel still had the entered digits denominated in fiat, so a typed "4" was read as 4 USD and quoted as a fraction of a DASH -- rejected as below the route minimum. ConvertViewViewModel tracked the picker as a currency code and its init block overwrote that code from an async SELECTED_CURRENCY read. When that landed after the fragment had anchored the picker on DASH, the code said fiat while pickedCurrencyIndex -- the only thing the screen renders -- still said DASH. amount.fiatCode had the same problem from the other end: captured before the config loaded, it stayed "USD" while the picker showed the real currency, and Amount.setAnchoredType silently did nothing when no code matched. Track the picker as a CurrencyInputType instead, derived in the fragment from the displayed index, so the two cannot disagree; the index<->type mapping mirrors the same dashToCrypto condition that builds the options list. setAmount/getAmountValue key off the type -- which also drops the IllegalArgumentException getAmountValue threw when no code matched -- and setAnchoredType(String) is gone. Co-Authored-By: Claude Opus 5 (1M context) --- .../wallet/integrations/maya/model/Amount.kt | 14 +-- .../maya/ui/MayaConvertCryptoFragment.kt | 112 ++++++++++-------- .../convert_currency/ConvertViewViewModel.kt | 77 +++++------- 3 files changed, 98 insertions(+), 105 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/Amount.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/Amount.kt index 9332eca403..1d25ece572 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/Amount.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/Amount.kt @@ -83,6 +83,12 @@ data class Amount( } } + /** + * Which currency the amount is denominated in — the one the user typed. Set it directly from + * the picker's selection; there is deliberately no set-by-currency-code variant, because a + * code that matched none of [dashCode] / [fiatCode] / [cryptoCode] (a stale fiat code, say) + * left the anchor on the previous currency and the entered value was then read as that one. + */ var anchoredType: CurrencyInputType get() = anchor set(value) { @@ -123,14 +129,6 @@ data class Amount( CurrencyInputType.Crypto -> _crypto } - fun setAnchoredType(currencyCode: String) { - when (currencyCode) { - dashCode -> anchor = CurrencyInputType.Dash - fiatCode -> anchor = CurrencyInputType.Fiat - cryptoCode -> anchor = CurrencyInputType.Crypto - } - } - /** 1 Crypto = x Fiat, eg 1 BTC = $65,000 or $65,000/BTC */ var cryptoFiatExchangeRate: BigDecimal get() = _cryptoFiatExchangeRate diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 6fd0c1e3f6..4b784c27b2 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -95,8 +95,33 @@ class MayaConvertCryptoFragment : Fragment() { get() = !convertViewModel.userDashAccountEmpty private var currencyOptions: List = emptyList() private var pickedCurrencyIndex: Int = 0 - private val pickedCurrencyOption: String - get() = currencyOptions.getOrNull(pickedCurrencyIndex) ?: "" + + /** + * The currency the picker is on, derived from the displayed selection ([pickedCurrencyIndex]) + * so the two can never disagree — see [ConvertViewViewModel.selectedPickerCurrency]. The + * option order is fixed by [resetViewSelection]: DASH / fiat / crypto for the sell direction, + * reversed at the ends for a buy. + */ + private val pickedCurrencyType: CurrencyInputType + get() = currencyTypeFor(pickedCurrencyIndex) + + private fun currencyTypeFor(index: Int): CurrencyInputType { + val dashToCrypto = convertViewModel.dashToCrypto.value == true + return when (index) { + 0 -> if (dashToCrypto) CurrencyInputType.Dash else CurrencyInputType.Crypto + 2 -> if (dashToCrypto) CurrencyInputType.Crypto else CurrencyInputType.Dash + else -> CurrencyInputType.Fiat + } + } + + private fun indexOfCurrency(type: CurrencyInputType): Int { + val dashToCrypto = convertViewModel.dashToCrypto.value == true + return when (type) { + CurrencyInputType.Fiat -> 1 + CurrencyInputType.Dash -> if (dashToCrypto) 0 else 2 + CurrencyInputType.Crypto -> if (dashToCrypto) 2 else 0 + } + } // Last crypto amount pair (value, currency code) used for the receive-amount line. private var lastCryptoAmount: Pair? = null @@ -124,7 +149,7 @@ class MayaConvertCryptoFragment : Fragment() { onKeyInput = ::onKeyInput, onContinueClick = { if (!uiState.isProcessing) { - convertViewModel.continueSwap(pickedCurrencyOption) + convertViewModel.continueSwap() } } ) @@ -358,17 +383,15 @@ class MayaConvertCryptoFragment : Fragment() { convertViewModel.amount.anchoredType != CurrencyInputType.Fiat, convertViewModel.amount.anchoredCurrencyCode ) - convertViewModel.selectedPickerCurrencyCode = convertViewModel.amount.anchoredCurrencyCode - pickedCurrencyIndex = when (convertViewModel.amount.anchoredType) { - CurrencyInputType.Dash -> 0 - CurrencyInputType.Fiat -> 1 - CurrencyInputType.Crypto -> 2 - } + // The anchored currency is the one the user last typed in, so it — not the config's + // fiat code — decides which option the picker starts on. + convertViewModel.selectedPickerCurrency = convertViewModel.amount.anchoredType + pickedCurrencyIndex = indexOfCurrency(convertViewModel.amount.anchoredType) uiState = uiState.copy( currencyOptions = currencyOptions, selectedCurrencyIndex = pickedCurrencyIndex ) - applyNewValue(convertViewModel.enteredConvertAmount, pickedCurrencyOption, isLocalized = true) + applyNewValue(convertViewModel.enteredConvertAmount, pickedCurrencyType, isLocalized = true) } } @@ -376,44 +399,26 @@ class MayaConvertCryptoFragment : Fragment() { if (uiState.isProcessing) return pickedCurrencyIndex = index uiState = uiState.copy(selectedCurrencyIndex = index) - val option = pickedCurrencyOption - setAmountValue(option) - convertViewModel.selectedPickerCurrencyCode = option + val type = pickedCurrencyType + convertViewModel.selectedPickerCurrency = type + setAmountValue(type) } - private fun setAmountValue(option: String) { - val value = convertViewModel.getAmountValue(option) - convertViewModel.amount.setAnchoredType(option) - val display = formatAmountForDisplay(option, value, isLocalized = false, isEditing = false) + private fun setAmountValue(type: CurrencyInputType) { + val value = convertViewModel.getAmountValue(type) + convertViewModel.amount.anchoredType = type + val display = formatAmountForDisplay(type, value, isLocalized = false, isEditing = false) convertViewModel.enteredConvertAmount = display uiState = uiState.copy(displayAmount = display) } private fun onMaxClick() { if (uiState.isProcessing) return - convertViewModel.selectedCryptoCurrencyAccount.value?.let { userAccountData -> + convertViewModel.selectedCryptoCurrencyAccount.value?.let { _ -> convertViewModel.getMaxAmount()?.let { maxAmount -> - val cryptoCurrency = userAccountData.coinbaseAccount.currency - - if (convertViewModel.selectedPickerCurrencyCode == cryptoCurrency) { - applyNewValue( - maxAmount.crypto.toString(), - convertViewModel.selectedPickerCurrencyCode, - isLocalized = false - ) - } else { - val cleanedValue = - if (convertViewModel.selectedPickerCurrencyCode == - convertViewModel.selectedLocalCurrencyCode - ) { - maxAmount.fiat - } else { - maxAmount.dash - }.toString() - - applyNewValue(cleanedValue, convertViewModel.selectedPickerCurrencyCode, isLocalized = false) - } - + // Enter the balance in whichever currency the picker is on. + val type = pickedCurrencyType + applyNewValue(maxAmount.getValue(type).toString(), type, isLocalized = false) maxAmountSelected = true } } @@ -447,7 +452,7 @@ class MayaConvertCryptoFragment : Fragment() { if (isFraction) { val lengthOfDecimalPart = value.toString().length - value.toString().indexOf(decimalSeparator) val decimalsThreshold = - if (convertViewModel.selectedLocalCurrencyCode == pickedCurrencyOption) { + if (pickedCurrencyType == CurrencyInputType.Fiat) { GenericUtils.getCurrencyDigits() } else { 8 @@ -461,10 +466,10 @@ class MayaConvertCryptoFragment : Fragment() { if (!maxAmountSelected) { try { appendIfValidAfter(value, number.toString()) - applyNewValue(value.toString(), pickedCurrencyOption, isLocalized = true, isEditing = true) + applyNewValue(value.toString(), pickedCurrencyType, isLocalized = true, isEditing = true) } catch (x: Exception) { value.deleteCharAt(value.length - 1) - applyNewValue(value.toString(), pickedCurrencyOption, isLocalized = true, isEditing = true) + applyNewValue(value.toString(), pickedCurrencyType, isLocalized = true, isEditing = true) } } } @@ -487,7 +492,7 @@ class MayaConvertCryptoFragment : Fragment() { value.deleteCharAt(value.length - 1) convertViewModel.resetSwapValueError() } - applyNewValue(value.toString(), pickedCurrencyOption, isLocalized = true, isEditing = true) + applyNewValue(value.toString(), pickedCurrencyType, isLocalized = true, isEditing = true) maxAmountSelected = false } @@ -502,14 +507,22 @@ class MayaConvertCryptoFragment : Fragment() { } value.append(decimalSeparator) } - applyNewValue(value.toString(), pickedCurrencyOption, isLocalized = true, isEditing = true) + applyNewValue(value.toString(), pickedCurrencyType, isLocalized = true, isEditing = true) } - private fun applyNewValue(value: String, currencyCode: String, isLocalized: Boolean, isEditing: Boolean = false) { + private fun applyNewValue( + value: String, + type: CurrencyInputType, + isLocalized: Boolean, + isEditing: Boolean = false + ) { val newValue = value.ifEmpty { "0" } + // Keep the ViewModel's picker in step with the currency this value is denominated in: the + // amount is anchored on it, so a mismatch would re-read the digits as another currency. + convertViewModel.selectedPickerCurrency = type convertViewModel.setEnteredAmount(newValue, isLocalized) - val display = formatAmountForDisplay(currencyCode, newValue, isLocalized, isEditing) + val display = formatAmountForDisplay(type, newValue, isLocalized, isEditing) convertViewModel.enteredConvertAmount = display uiState = uiState.copy(displayAmount = display) @@ -527,7 +540,7 @@ class MayaConvertCryptoFragment : Fragment() { * (at the fiat's digit count) for fiat. */ private fun formatAmountForDisplay( - currencyCode: String, + type: CurrencyInputType, value: String, isLocalized: Boolean, isEditing: Boolean @@ -536,9 +549,8 @@ class MayaConvertCryptoFragment : Fragment() { return value } val amountBG = GenericUtils.toScaledBigDecimal(value, isLocalized) - return when (currencyCode) { - Constants.DASH_CURRENCY -> convertViewModel.cryptoFormat.format(amountBG) - convertViewModel.selectedLocalCurrencyCode -> { + return when (type) { + CurrencyInputType.Fiat -> { val digits = GenericUtils.getCurrencyDigits() convertViewModel.fiatFormat.format(amountBG.setScale(digits, RoundingMode.HALF_UP)) } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt index 7dc2b00c94..b3050e0bad 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt @@ -50,7 +50,6 @@ import org.dash.wallet.integrations.maya.ui.convert_currency.model.SwapRequest import org.dash.wallet.integrations.maya.ui.convert_currency.model.SwapValueErrorType import org.dash.wallet.integrations.maya.utils.MayaConstants import org.slf4j.LoggerFactory -import java.lang.IllegalArgumentException import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -134,7 +133,15 @@ class ConvertViewViewModel @Inject constructor( val selectedCryptoCurrencyAccount: LiveData get() = this._selectedCryptoCurrencyAccount - var selectedPickerCurrencyCode: String = Constants.USD_CURRENCY + /** + * Which of the three currencies the amount picker is on — the one the typed digits are + * denominated in. Held as a [CurrencyInputType] rather than a currency code so it can't drift + * out of step with what the screen shows: matching codes by string meant a late + * SELECTED_CURRENCY read (or a currency change mid-screen) could leave this pointing at fiat + * while the picker displayed DASH, and the typed amount was then read as fiat and converted + * down to a fraction of a DASH — quoted as an amount far below the route minimum. + */ + var selectedPickerCurrency: CurrencyInputType = CurrencyInputType.Dash private val _enteredAmount = MutableLiveData("0") val enteredAmount: LiveData @@ -174,17 +181,18 @@ class ConvertViewViewModel @Inject constructor( // do we need this? walletUIConfig.observe(WalletUIConfig.SELECTED_CURRENCY) .filterNotNull() - .onEach { selectedLocalCurrencyCode = it } + // Mirror the code into [amount] as well: it is read back as + // [Amount.anchoredCurrencyCode] and drives the fiat label in the picker, and the + // config read is async — leaving amount.fiatCode at its "USD" default while the + // screen shows the real currency made the two disagree. + .onEach { + selectedLocalCurrencyCode = it + amount.fiatCode = it + } .flatMapLatest(exchangeRates::observeExchangeRate) .onEach(_selectedLocalExchangeRate::postValue) .launchIn(viewModelScope) - viewModelScope.launch { - walletUIConfig.get(WalletUIConfig.SELECTED_CURRENCY)?.let { - selectedPickerCurrencyCode = it - } - } - savedStateHandle.get(KEY_AMOUNT)?.let { savedAmount -> when (savedAmount.anchoredType) { CurrencyInputType.Dash -> amount.dash = savedAmount.dash @@ -328,12 +336,11 @@ class ConvertViewViewModel @Inject constructor( savedStateHandle.remove(KEY_AMOUNT) } - fun continueSwap(pickedCurrencyOption: String) { + fun continueSwap() { viewModelScope.launch { analyticsService.logEvent(AnalyticsConstants.Coinbase.CONVERT_CONTINUE, mapOf()) - val currencyInputType = getCurrencyInputType(pickedCurrencyOption) - // val amount = getFiatAmount(currencyInputType) - logEnteredAmountCurrency(currencyInputType) + // What the user typed in is exactly what [amount] is anchored on. + logEnteredAmountCurrency(amount.anchoredType) onContinueEvent.value = selectedCryptoCurrencyAccount.value?.coinbaseAccount?.let { destinationAddress?.let { address -> SwapRequest( @@ -401,8 +408,7 @@ class ConvertViewViewModel @Inject constructor( return convertedValue } - private fun updateDashWalletBalance() { - val balance = walletDataProvider.getWalletBalance() + private fun updateDashWalletBalance(balance: Coin = walletDataProvider.getWalletBalance()) { maxForDashWalletAmount = dashFormat.minDecimals(0) .optionalDecimals(0, 8).format(balance).toString() } @@ -428,20 +434,6 @@ class ConvertViewViewModel @Inject constructor( } } - private suspend fun getCurrencyInputType(currencyCode: String): CurrencyInputType { - val code = currencyCode.lowercase() - val account = selectedCryptoCurrencyAccount.value - val currency = account?.coinbaseAccount?.currency?.lowercase() - - return when { - currency == Constants.DASH_CURRENCY.lowercase() -> CurrencyInputType.Dash - currency == code -> CurrencyInputType.Crypto - (walletUIConfig.get(WalletUIConfig.SELECTED_CURRENCY) ?: Constants.USD_CURRENCY) - .lowercase() == code -> CurrencyInputType.Fiat - else -> CurrencyInputType.Dash - } - } - private fun logEnteredAmountCurrency(inputType: CurrencyInputType) { analyticsService.logEvent( when (inputType) { @@ -462,33 +454,24 @@ class ConvertViewViewModel @Inject constructor( amount.dashFiatExchangeRate = dashRate amount.cryptoFiatExchangeRate = cryptoRate } - private fun setAmount(valueToBind: String, currencyCode: String, isLocalized: Boolean) { + private fun setAmount(valueToBind: String, currency: CurrencyInputType, isLocalized: Boolean) { val value = GenericUtils.toScaledBigDecimal(valueToBind, localized = isLocalized) - when (currencyCode) { - "DASH" -> amount.dash = value - selectedLocalCurrencyCode -> amount.fiat = value - selectedCryptoCurrencyAccount.value!!.coinbaseAccount.currency -> amount.crypto = value + // Assigning re-anchors [amount] on that currency and recomputes the other two. + when (currency) { + CurrencyInputType.Dash -> amount.dash = value + CurrencyInputType.Fiat -> amount.fiat = value + CurrencyInputType.Crypto -> amount.crypto = value } } fun setEnteredAmount(amount: String, isLocalized: Boolean) { _enteredAmount.value = amount - setAmount(amount, selectedPickerCurrencyCode, isLocalized) + setAmount(amount, selectedPickerCurrency, isLocalized) savedStateHandle[KEY_AMOUNT] = this.amount.copy() - log.info("setting amount: {} {}: {}", amount, selectedPickerCurrencyCode, this.amount) + log.info("setting amount: {} {}: {}", amount, selectedPickerCurrency, this.amount) } - fun getAmountValue(currencyCode: String): String { - return when (currencyCode) { - "DASH" -> amount.dash - selectedLocalCurrencyCode -> amount.fiat - selectedCryptoCurrencyAccount.value!!.coinbaseAccount.currency -> amount.crypto - else -> throw IllegalArgumentException( - "Currency code $currencyCode is not found (DASH, $selectedLocalCurrencyCode," + - "$selectedCryptoCurrencyAccount.value!!.coinbaseAccount.currency)" - ) - }.toString() - } + fun getAmountValue(currency: CurrencyInputType): String = amount.getValue(currency).toString() fun reset() { amount.dash = BigDecimal.ZERO From 3b38d1c64707516c583c1fa5355cb2f9d03ac3c5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 09:30:20 -0700 Subject: [PATCH 08/24] fix: sweep the wallet for a fiat- or crypto-anchored Maya MAX swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max enters the balance in whichever currency the picker is on, and continueSwap decided whether to sweep by comparing the entered amount with the balance. Anchored on fiat or crypto, that value round-trips back to DASH through an 8-decimal rounding (GenericUtils.toScaledBigDecimal) and a truncating BigDecimal.toCoin(), so it lands at or below the balance and never equal to it — a fiat Max silently went out as a partial swap instead of a sweep. Carry it as explicit intent instead: ConvertViewViewModel.maxAmountSelected, persisted in SavedStateHandle so it survives a configuration change along with the amount it describes. The equality test stays as a fallback for a full balance the user typed by hand. selectMaxAmount() also pins amount.dash to the exact balance and restores the anchor to the picker's currency afterwards, so the display and the analytics event are unchanged while the Maya quote and the min/max checks see the real balance. It refreshes maxForDashWalletAmount from that same balance too: the ceiling was captured at ViewModel construction from the throttled balance flow, and a stale one would read the Max back as more than the maximum. Invalidation moves into the ViewModel — only a real target-currency or direction change clears the flag, not the re-selection that happens every time the screen's view is created, which is what the fragment's blanket reset did. Also records why the SwapKit /v3/quote call deliberately omits sourceAddress: that endpoint has no disableBalanceCheck/disableBuildTx, so a source address there triggers a single-address balance check that fails for an HD wallet, and the refund destination is only bound by /v3/swap (which does report it) since that is what creates the deposit address. Co-Authored-By: Claude Opus 5 (1M context) --- .../maya/swapkit/SwapKitApiAggregator.kt | 7 ++ .../maya/ui/MayaConvertCryptoFragment.kt | 25 +++++-- .../convert_currency/ConvertViewViewModel.kt | 66 ++++++++++++++++++- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt index 3eeda9f86e..4ec9a6dc3a 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt @@ -681,6 +681,13 @@ class SwapKitApiAggregator @Inject constructor( buyAsset = swapRequest.target_maya_asset, sellAmount = sellAmount, slippage = SwapKitConstants.DEFAULT_SLIPPAGE_PERCENT, + // Deliberately omitted, not an oversight: /v3/quote has no disableBalanceCheck / + // disableBuildTx escape hatch, so handing it a source address makes SwapKit + // balance-check that one address — which fails for an HD wallet whose balance is + // spread across UTXOs (and this is the unfunded current receive address anyway). + // Nothing is lost by leaving it out: the deposit address comes back from + // /v3/swap, so the intent — and the refund destination it refunds to — is only + // created there, and that call does report [sourceAddress]. // sourceAddress = sourceAddress, destinationAddress = swapRequest.targetAddress ) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 4b784c27b2..8f6e1bdc1c 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -84,8 +84,15 @@ class MayaConvertCryptoFragment : Fragment() { private val decimalSeparator = DecimalFormatSymbols.getInstance(GenericUtils.getDeviceLocale()).decimalSeparator - // Amount-entry state ported from the old ConvertViewFragment keyboard listener. - private var maxAmountSelected: Boolean = false + // Amount-entry state ported from the old ConvertViewFragment keyboard listener. The Max flag + // lives in the ViewModel (see [ConvertViewViewModel.maxAmountSelected]): it decides whether + // the swap sweeps the wallet, so it has to survive a configuration change along with the + // amount it belongs to. + private var maxAmountSelected: Boolean + get() = convertViewModel.maxAmountSelected + set(value) { + convertViewModel.maxAmountSelected = value + } private var canContinue: Boolean = false // Hard gate on Get quote independent of the entered value — false when the wallet has no @@ -214,7 +221,8 @@ class MayaConvertCryptoFragment : Fragment() { convertViewModel.selectedCryptoCurrencyAccount.observe(viewLifecycleOwner) { account -> selectedCoinBaseAccount = account - maxAmountSelected = false + // A stale Max entry is dropped by the ViewModel when the target currency actually + // changes — not here, which also fires when this screen's view is recreated. resetViewSelection(account) } @@ -392,6 +400,12 @@ class MayaConvertCryptoFragment : Fragment() { selectedCurrencyIndex = pickedCurrencyIndex ) applyNewValue(convertViewModel.enteredConvertAmount, pickedCurrencyType, isLocalized = true) + // The value re-applied above comes from the formatted display string, which for fiat + // is rounded to the currency's 2 decimals — so a restored Max needs the exact balance + // pinned back on, taken from the balance as it stands now. + if (maxAmountSelected) { + convertViewModel.selectMaxAmount(pickedCurrencyType) + } } } @@ -419,7 +433,10 @@ class MayaConvertCryptoFragment : Fragment() { // Enter the balance in whichever currency the picker is on. val type = pickedCurrencyType applyNewValue(maxAmount.getValue(type).toString(), type, isLocalized = false) - maxAmountSelected = true + // Flag it as a Max and re-pin the exact balance: the line above re-anchors the + // amount on the displayed currency, so for fiat/crypto the DASH value it derives + // back is a satoshi or two short of the balance. + convertViewModel.selectMaxAmount(type) } } } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt index b3050e0bad..3399b8cef3 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/convert_currency/ConvertViewViewModel.kt @@ -70,8 +70,29 @@ class ConvertViewViewModel @Inject constructor( private val log = LoggerFactory.getLogger(ConvertViewViewModel::class.java) private const val KEY_AMOUNT = "amount" private const val KEY_PENDING_RESULT = "pending_conversion_result" + private const val KEY_MAX_SELECTED = "max_amount_selected" } + /** + * True when the entered amount came from the Max button — the user asked to convert the whole + * wallet, which the swap backends carry out as a sweep ([SwapRequest.maximum]). + * + * Held as explicit intent rather than inferred by comparing the entered amount with the + * balance: with the picker on fiat or crypto, [amount] is anchored on that currency, and the + * trip back to DASH loses precision twice — [GenericUtils.toScaledBigDecimal] rounds the + * entered value to 8 decimals, and [toCoin] then *truncates* the recomputed DASH value. The + * result lands a satoshi or more below the balance, so an equality test quietly downgraded a + * fiat-entered Max to a partial swap. Persisted so it survives a configuration change. + * + * Set it through [selectMaxAmount], which also pins the DASH amount; assign it directly only + * to clear it when the entry stops being a Max (an edit, a currency or direction change). + */ + var maxAmountSelected: Boolean + get() = savedStateHandle[KEY_MAX_SELECTED] ?: false + set(value) { + savedStateHandle[KEY_MAX_SELECTED] = value + } + /** * The parameters of a conversion-result sheet the user hasn't acknowledged yet. The lock * screen auto-dismisses all dialogs, so the sheet is re-shown from these when the lock screen @@ -92,6 +113,7 @@ class ConvertViewViewModel @Inject constructor( fun clearSavedState() { savedStateHandle.remove(KEY_AMOUNT) savedStateHandle.remove(KEY_PENDING_RESULT) + savedStateHandle.remove(KEY_MAX_SELECTED) } var destinationCurrency: String? = null var destinationAddress: String? = null @@ -217,6 +239,14 @@ class ConvertViewViewModel @Inject constructor( } } fun setSelectedCryptoCurrency(account: AccountDataUIModel) { + // A different target crypto means whatever was entered — including a Max — no longer + // applies. Only a real change clears it: this is called again with the same currency + // every time the enter-amount screen's view is (re)created, and a Max entry has to + // survive that (see [maxAmountSelected]). + val previousCurrency = if (this::account.isInitialized) this.account.coinbaseAccount.currency else null + if (previousCurrency != null && previousCurrency != account.coinbaseAccount.currency) { + maxAmountSelected = false + } amount.cryptoCode = account.coinbaseAccount.currency amount.fiatCode = selectedLocalCurrencyCode this.account = account @@ -325,6 +355,11 @@ class ConvertViewViewModel @Inject constructor( userDashAccountEmptyError.call() return } + // Flipping the direction changes which balance "max" refers to; as with the target + // currency, only a real change clears it (this is re-asserted on every view creation). + if (_dashToCrypto.value != null && _dashToCrypto.value != dashToCrypto) { + maxAmountSelected = false + } _dashToCrypto.value = dashToCrypto } @@ -333,6 +368,7 @@ class ConvertViewViewModel @Inject constructor( _dashToCrypto.value = false _enteredConvertDashAmount.value = Coin.ZERO _enteredConvertCryptoAmount.value = Pair("", "") + maxAmountSelected = false savedStateHandle.remove(KEY_AMOUNT) } @@ -345,7 +381,14 @@ class ConvertViewViewModel @Inject constructor( destinationAddress?.let { address -> SwapRequest( amount, - amount.dash.toCoin() == walletDataProvider.wallet!!.getBalance(Wallet.BalanceType.ESTIMATED), + // A sweep is what the Max button asked for, so take it from that intent + // ([maxAmountSelected]) rather than inferring it: a fiat- or + // crypto-anchored Max doesn't survive the round trip back to DASH as an + // exact match. The comparison stays as a fallback for a full balance the + // user typed in by hand. + maxAmountSelected || + amount.dash.toCoin() == + walletDataProvider.wallet!!.getBalance(Wallet.BalanceType.ESTIMATED), address, it.currency, it.asset, @@ -420,6 +463,26 @@ class ConvertViewViewModel @Inject constructor( } } + /** + * Records that the entered amount is the whole wallet ([maxAmountSelected]) and re-pins + * [amount]'s DASH component to the exact balance. + * + * Call it right after the Max value has been entered in [displayType]: entering it re-anchors + * [amount] on the picker's currency, and for fiat or crypto the DASH value is then a rounded + * back-conversion rather than the balance (see [maxAmountSelected]) — which the Maya quote and + * the amount checks both read. Assigning [Amount.dash] recomputes fiat and crypto from the + * exact balance; the anchor is restored to [displayType] afterwards (that setter doesn't + * recompute) so the picker's currency still drives what's displayed and logged. + */ + fun selectMaxAmount(displayType: CurrencyInputType) { + val balance = walletDataProvider.wallet?.getBalance(Wallet.BalanceType.ESTIMATED) ?: return + amount.dash = balance.toBigDecimal() + amount.anchoredType = displayType + savedStateHandle[KEY_AMOUNT] = amount.copy() + maxAmountSelected = true + updateAmounts() + } + private fun doesMeetSendingConditions(value: Coin): Boolean { if (dashToCrypto.value != true) { // No need to check @@ -475,5 +538,6 @@ class ConvertViewViewModel @Inject constructor( fun reset() { amount.dash = BigDecimal.ZERO + maxAmountSelected = false } } From 9eaa9005988b236090991242b7e93a4d54ff4198 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 09:31:19 -0700 Subject: [PATCH 09/24] fix: log Imgur response body on profile picture upload failure The failure branch only logged response.message, which Imgur often leaves blank, hiding the actual error (e.g. "These actions are forbidden") needed to diagnose upload failures. Co-Authored-By: Claude Sonnet 5 --- .../src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt index 165f352628..108e1de354 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt @@ -67,7 +67,8 @@ class ImgurService @Inject constructor( throw Exception(response.message) } } else { - log.error("imgur: upload failed (${response.code}): ${response.message}") + val errorBody = responseBody?.string() + log.error("imgur: upload failed (${response.code}): ${response.message}, body: $errorBody") analytics.logError(Exception(response.message), "Failed to upload profile picture: ImgUr") throw Exception(response.message) } From 5583a6530c31ed729e0b099f2d4b497b80b7048f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 09:31:45 -0700 Subject: [PATCH 10/24] fix: black 'Enter PIN' text on lock screen after dark mode sweep The dark mode PR (#1480) switched the lock screen title, progress tint, and subtitle to theme-aware colors, but this screen's background is a static dark photo in both themes, so content_primary rendered as black text under the light palette. Restore fixed white foreground colors; keep the theme-aware numeric keyboard panel background. Co-Authored-By: Claude Fable 5 --- wallet/res/layout/activity_lock_screen.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wallet/res/layout/activity_lock_screen.xml b/wallet/res/layout/activity_lock_screen.xml index 02c3383089..2e51303df3 100644 --- a/wallet/res/layout/activity_lock_screen.xml +++ b/wallet/res/layout/activity_lock_screen.xml @@ -112,7 +112,7 @@ android:gravity="center_horizontal" android:textAlignment="gravity" android:text="@string/lock_unlock_with_fingerprint" - android:textColor="@color/content_primary" /> + android:textColor="@color/dash_white" /> Date: Sun, 9 Aug 2026 17:25:12 -0700 Subject: [PATCH 11/24] fix(anr): stop touching the dashj wallet on the main thread at 1 Hz during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainViewModel's blockchain-state collector runs on launchIn(viewModelScope) (Dispatchers.Main.immediate) and read walletData.wallet.lastBlockSeenHeight on every emission. That dashj accessor takes the wallet's fair ReentrantReadWriteLock, which during a sync can be held for seconds at a time by the autosave serializer or receiveFromBlock — stalling the main thread past the ANR threshold once per second for the whole sync window on large wallets. Move the collector body to Dispatchers.IO via flowOn, stop seeding chainHeight/headersHeight from the wallet lock (both are overwritten by the first emission before anything reads them), and move metadataReminder()'s O(transaction count) wallet scan off Main.immediate for the same reason. Co-Authored-By: Claude Sonnet 5 --- .../wallet/ui/main/MainViewModel.kt | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index ddd2de6e8f..0f02abc308 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -68,10 +68,12 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.bitcoinj.core.Coin import org.bitcoinj.core.PeerGroup import org.bitcoinj.core.PeerGroup.SyncStage @@ -163,8 +165,15 @@ class MainViewModel @Inject constructor( private val _blockchainSyncPercentage = MutableLiveData() val blockchainSyncPercentage: LiveData get() = _blockchainSyncPercentage - private var chainHeight: Int = walletData.wallet?.lastBlockSeenHeight ?: 0 - private var headersHeight: Int = walletData.wallet?.lastBlockSeenHeight ?: 0 + // Diagnostic bookkeeping for the blockchain-state collector below; both are + // overwritten by its first emission and are never read before that, so they seed + // at 0. They deliberately do NOT seed from `walletData.wallet.lastBlockSeenHeight` + // any more: MainViewModel is constructed from MainActivity.onCreate (on the main + // thread, while the lock screen is drawing), and that accessor takes dashj's fair + // wallet read lock — which on a large wallet mid-sync can be held by the autosave + // serializer for seconds. See the ANR note on the collector. + private var chainHeight: Int = 0 + private var headersHeight: Int = 0 private val _syncStage = MutableStateFlow(SyncStage.OFFLINE) val syncStage: StateFlow get() = _syncStage @@ -269,6 +278,28 @@ class MainViewModel @Inject constructor( txDisplayCacheService.setFilter(savedDirection) log.info("STARTUP MainViewModel init at {}", System.currentTimeMillis()) + // ANR FIX — this collector MUST NOT run on the main thread. + // + // The blockchain_state row is rewritten roughly once a SECOND for the whole + // duration of a sync (SdkBlockchainStateService polls the L1 progress feed at + // 1 Hz and BlockchainStateDataProvider.updateSdkBlockchainState saves the row), + // so this body executes ~1 Hz while syncing. `launchIn(viewModelScope)` runs it + // on Dispatchers.Main.immediate, and the body reads + // `walletData.wallet.lastBlockSeenHeight` — a dashj accessor that takes the + // wallet's lock. That lock is a FAIR ReentrantReadWriteLock + // (org.bitcoinj.utils.Threading.readWriteLock passes fair=true), so a reader is + // queued strictly FIFO behind every pending writer. During a sync of a large + // wallet the write lock is held for many seconds at a time by + // Wallet.saveToFileStream (the 5s autosave serializes the ENTIRE wallet + // protobuf — every transaction and every CoinJoin keychain key) and by + // receiveFromBlock. The result on a large wallet is a main-thread stall that + // exceeds the 5s ANR threshold, once per emission, for the whole sync window. + // + // flowOn() applies to everything UPSTREAM of it, i.e. to this onEach, so the + // body now runs on Dispatchers.IO. Everything it touches is safe there: + // updateSyncStatus/updatePercentage only call LiveData.postValue (designed for + // background threads), and headersHeight/chainHeight are confined to this one + // sequential collector. blockchainStateProvider.observeState() .filterNotNull() .onEach { state -> @@ -277,9 +308,15 @@ class MainViewModel @Inject constructor( headersHeight = state.mnlistHeight chainHeight = state.bestChainHeight if (!state.replaying) { - log.info("blockchain state update: {}; {}; {} -> {}", headersHeight, chainHeight, walletData.wallet?.lastBlockSeenHeight) + log.info( + "blockchain state update: mnlist={}; chain={}; wallet={}", + headersHeight, + chainHeight, + walletData.wallet?.lastBlockSeenHeight + ) } } + .flowOn(Dispatchers.IO) .catch { e -> log.error("blockchain state flow error", e) } .launchIn(viewModelScope) @@ -648,10 +685,13 @@ class MainViewModel @Inject constructor( // have there been 10 transactions since the last update? val installedDate = dashPayConfig.getMetadataFeatureInstalled() walletData.wallet?.let { wallet: Wallet -> - var count = 0 - wallet.getTransactions(true).forEach { tx -> - if (tx.updateTime.time > installedDate) { - count++ + // O(transaction count) under dashj's wallet read lock — must never + // run on Dispatchers.Main.immediate, which is what a bare + // viewModelScope.launch gives you. On a large CoinJoin wallet this + // is a multi-second main-thread stall (i.e. a guaranteed ANR). + val count = withContext(Dispatchers.IO) { + wallet.getTransactions(true).count { tx -> + tx.updateTime.time > installedDate } } if (count >= 10) { From 747b701edb72880315f021475bffe2df2175b3ed Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 11 Aug 2026 16:54:05 -0700 Subject: [PATCH 12/24] chore: update dashj to 22.0.5-SNAPSHOT --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3660e47c73..7b8f0a17be 100644 --- a/build.gradle +++ b/build.gradle @@ -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' From b06c06479f025739d7858c5a04d6046fb311d145 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 12 Aug 2026 09:50:04 -0700 Subject: [PATCH 13/24] fix: avoid racy background-thread read of _isBlockchainSynced.value updateSyncStatus runs on the Dispatchers.IO-confined blockchain state collector but deduped against LiveData.value, which is only updated on the main thread once postValue's runnable runs. Track the last synced state in a field confined to that same sequential collector instead. Co-Authored-By: Claude Sonnet 5 --- .../schildbach/wallet/ui/main/MainViewModel.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index 0f02abc308..d707f2058a 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -174,6 +174,7 @@ class MainViewModel @Inject constructor( // serializer for seconds. See the ANR note on the collector. private var chainHeight: Int = 0 private var headersHeight: Int = 0 + private var lastIsBlockchainSynced: Boolean? = null private val _syncStage = MutableStateFlow(SyncStage.OFFLINE) val syncStage: StateFlow get() = _syncStage @@ -298,8 +299,10 @@ class MainViewModel @Inject constructor( // flowOn() applies to everything UPSTREAM of it, i.e. to this onEach, so the // body now runs on Dispatchers.IO. Everything it touches is safe there: // updateSyncStatus/updatePercentage only call LiveData.postValue (designed for - // background threads), and headersHeight/chainHeight are confined to this one - // sequential collector. + // background threads) and never read LiveData.value (which is only updated on + // the main thread and would be racy to read here), and headersHeight/ + // chainHeight/lastIsBlockchainSynced are confined to this one sequential + // collector. blockchainStateProvider.observeState() .filterNotNull() .onEach { state -> @@ -703,8 +706,14 @@ class MainViewModel @Inject constructor( } private fun updateSyncStatus(state: BlockchainState) { - if (_isBlockchainSynced.value != state.isSynced()) { - _isBlockchainSynced.postValue(state.isSynced()) + // Dedup against lastIsBlockchainSynced, NOT _isBlockchainSynced.value: this runs on the + // Dispatchers.IO collector, but LiveData.value is only updated on the main thread once the + // runnable posted by postValue() is processed, so a background-thread read of .value can + // observe a stale value and cause missed/redundant posts. + val isSynced = state.isSynced() + if (lastIsBlockchainSynced != isSynced) { + lastIsBlockchainSynced = isSynced + _isBlockchainSynced.postValue(isSynced) } _isBlockchainSyncFailed.postValue(state.syncFailed()) _isNetworkUnavailable.postValue(state.impediments.contains(BlockchainState.Impediment.NETWORK)) From 71207e730a2870f0e0061ea825a2ef6fa91941c0 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 12 Aug 2026 09:50:40 -0700 Subject: [PATCH 14/24] fix: close Imgur responses and bound the error body read The upload response was only closed implicitly on the happy path, via ResponseBody.string(). The invalid-response branch, the HTTP-error branch, and a Moshi parse failure all leaked the connection; deleteImage never closed its response at all. Wrap both in use {}. Also cap the error-body read used for logging at 8 KiB instead of pulling an arbitrarily large (or endless) body into memory with string(), and switch the deprecated RequestBody.create to the toRequestBody extension. Co-Authored-By: Claude Opus 5 (1M context) --- .../wallet/ui/dashpay/utils/ImgurService.kt | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt index 108e1de354..9d4d4287fa 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/utils/ImgurService.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody import org.dash.wallet.common.Configuration import org.dash.wallet.common.services.analytics.AnalyticsService import org.slf4j.LoggerFactory @@ -41,36 +42,37 @@ class ImgurService @Inject constructor( val avatarBytes = file.readBytes() - val imageBodyPart = RequestBody.create("image/*jpg".toMediaTypeOrNull(), avatarBytes) + val imageBodyPart = avatarBytes.toRequestBody("image/*jpg".toMediaTypeOrNull()) val requestBody = MultipartBody.Builder().setType(MultipartBody.FORM) .addFormDataPart("image", "profile.jpg", imageBodyPart).build() val uploadRequest = requestBuilder.url(imgurUploadUrl).post(requestBody).build() try { uploadProfilePictureCall = client.newCall(uploadRequest) - val response = uploadProfilePictureCall!!.execute() - val responseBody = response.body + uploadProfilePictureCall!!.execute().use { response -> + val responseBody = response.body - if (responseBody != null && response.isSuccessful) { - val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() - val jsonAdapter = moshi.adapter(ImgurUploadResponse::class.java) - val imgurUploadResponse = jsonAdapter.fromJson(responseBody.string()) - log.info("imgur: response: $imgurUploadResponse") - if (imgurUploadResponse?.success == true && imgurUploadResponse.data != null) { - config.imgurDeleteHash = imgurUploadResponse.data.deletehash - val avatarUrl = imgurUploadResponse.data.link - log.info("imgur: upload successful (${response.code})") - return@withContext avatarUrl + if (responseBody != null && response.isSuccessful) { + val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + val jsonAdapter = moshi.adapter(ImgurUploadResponse::class.java) + val imgurUploadResponse = jsonAdapter.fromJson(responseBody.string()) + log.info("imgur: response: $imgurUploadResponse") + if (imgurUploadResponse?.success == true && imgurUploadResponse.data != null) { + config.imgurDeleteHash = imgurUploadResponse.data.deletehash + val avatarUrl = imgurUploadResponse.data.link + log.info("imgur: upload successful (${response.code})") + return@withContext avatarUrl + } else { + log.error("imgur: upload failed: response invalid") + analytics.logError(Exception(response.message), "Failed to upload profile picture: ImgUr") + throw Exception(response.message) + } } else { - log.error("imgur: upload failed: response invalid") + val errorBody = responseBody?.readDiagnosticPrefix() + log.error("imgur: upload failed (${response.code}): ${response.message}, body: $errorBody") analytics.logError(Exception(response.message), "Failed to upload profile picture: ImgUr") throw Exception(response.message) } - } else { - val errorBody = responseBody?.string() - log.error("imgur: upload failed (${response.code}): ${response.message}, body: $errorBody") - analytics.logError(Exception(response.message), "Failed to upload profile picture: ImgUr") - throw Exception(response.message) } } catch (e: Exception) { var canceled = false @@ -91,17 +93,18 @@ class ImgurService @Inject constructor( val deleteRequest = requestBuilder.url(imgurDeleteUrl).delete().build() try { uploadProfilePictureCall = client.newCall(deleteRequest) - val deleteResponse = uploadProfilePictureCall!!.execute() - if (!deleteResponse.isSuccessful) { - // if we cannot delete it, the cause is probably because the IMGUR_CLIENT_* values - // are not specified - // for now, clear the delete hash to allow the next upload operation to succeed - log.info("imgur: attempt to delete last image failed: check IMGUR_CLIENT_* values") - config.imgurDeleteHash = "" - throw Exception(deleteResponse.message) - } else { - log.info("imgur: delete successful ($imgurDeleteUrl)") - config.imgurDeleteHash = "" + uploadProfilePictureCall!!.execute().use { deleteResponse -> + if (!deleteResponse.isSuccessful) { + // if we cannot delete it, the cause is probably because the IMGUR_CLIENT_* values + // are not specified + // for now, clear the delete hash to allow the next upload operation to succeed + log.info("imgur: attempt to delete last image failed: check IMGUR_CLIENT_* values") + config.imgurDeleteHash = "" + throw Exception(deleteResponse.message) + } else { + log.info("imgur: delete successful ($imgurDeleteUrl)") + config.imgurDeleteHash = "" + } } } catch (e: Exception) { var canceled = false @@ -120,4 +123,18 @@ class ImgurService @Inject constructor( fun cancelUploadRequest() { uploadProfilePictureCall?.cancel() } + + /** + * Reads at most [limit] bytes of the body for logging, so that an unexpectedly large + * (or endless) error response cannot be pulled into memory in full. + */ + private fun ResponseBody.readDiagnosticPrefix(limit: Long = DIAGNOSTIC_BODY_LIMIT): String { + val source = source() + source.request(limit) + return source.buffer.readUtf8(minOf(limit, source.buffer.size)) + } + + companion object { + private const val DIAGNOSTIC_BODY_LIMIT = 8L * 1024 + } } \ No newline at end of file From ce55219a1c2188e2d785e99301fec80c236137cb Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 13 Aug 2026 20:06:46 -0700 Subject: [PATCH 15/24] fix: simplify Maya convert-crypto enter-amount screen per design feedback Nav bar title is now just "Convert" instead of "Convert DASH to ", the Dash Wallet balance row drops the "Balance" label, the destination address is truncated from the center so both ends stay checkable, and the max-amount error is shortened to "Max $x.xx". Co-Authored-By: Claude Sonnet 5 --- .../maya/ui/MayaConvertCryptoFragment.kt | 15 ++++---------- .../maya/ui/MayaConvertCryptoScreen.kt | 20 +++++++++++++------ .../maya/src/main/res/values/strings-maya.xml | 4 ++++ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt index 8f6e1bdc1c..dfd1a530bb 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoFragment.kt @@ -54,7 +54,6 @@ import org.dash.wallet.integrations.maya.model.AccountDataUIModel import org.dash.wallet.integrations.maya.model.Balance import org.dash.wallet.integrations.maya.model.CurrencyInputType import org.dash.wallet.integrations.maya.model.getCoinBaseExchangeRateConversion -import org.dash.wallet.integrations.maya.payments.MayaCurrencyList import org.dash.wallet.integrations.maya.ui.convert_currency.ConvertViewViewModel import org.dash.wallet.integrations.maya.ui.convert_currency.model.SwapRequest import org.dash.wallet.integrations.maya.ui.convert_currency.model.SwapValueErrorType @@ -170,14 +169,8 @@ class MayaConvertCryptoFragment : Fragment() { val dashPoolInfo = mayaViewModel.getPoolInfo(Constants.DASH_CURRENCY) val currencyMapper = MayaCurrencyMapper(requireContext()) - // Tokens are qualified with their host network ("USDT (Ethereum)"); native L1 coins - // (BTC.BTC, …) show just the code. - val network = MayaCurrencyList.networkName(args.asset) - val displayCode = if (network != null) "${args.currency} ($network)" else args.currency - uiState = uiState.copy( - // "Convert DASH to " per design, e.g. "Convert DASH to USDT (Ethereum)". - title = getString(R.string.maya_address_input_title, displayCode), + title = getString(R.string.maya_convert_enter_amount_title), toCurrencyName = currencyMapper.getCurrencyName(args.currency), toAddress = getArgAddress(), toIconUrls = GenericUtils.getCoinIconUrls(args.currency.lowercase(), args.asset), @@ -685,17 +678,17 @@ class MayaConvertCryptoFragment : Fragment() { convertViewModel.selectedLocalExchangeRate.value?.let { rate -> val currencyRate = ExchangeRate(Coin.COIN, rate.fiat) val fiatAmount = currencyRate.coinToFiat(dash).toFormattedString() - return "${getString(R.string.entered_amount_is_too_high)} $fiatAmount" + return "${getString(R.string.maya_max_amount_error)} $fiatAmount" } } } else { convertViewModel.selectedLocalExchangeRate.value?.let { rate -> selectedCoinBaseAccount?.getCoinBaseExchangeRateConversion(rate)?.first?.let { - return "${getString(R.string.entered_amount_is_too_high)} $it" + return "${getString(R.string.maya_max_amount_error)} $it" } } } - return getString(R.string.entered_amount_is_too_high) + return getString(R.string.maya_max_amount_error) } private fun minAmountErrorMessage(): String? { diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt index c1a9777a88..bbb49a0744 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt @@ -266,7 +266,7 @@ private fun ConvertDirectionCard( title = stringResource(R.string.dash), subtitle = stringResource(R.string.dash_wallet_name), icon = R.drawable.ic_dash_blue_filled, - dashAmount = "${stringResource(R.string.balance)} $dashBalance", + dashAmount = dashBalance, dashIcon = R.drawable.ic_dash_d_black, fiatAmount = fiatBalance ) @@ -295,16 +295,24 @@ private fun ConvertDirectionCard( } } - // To: destination coin + address. + // To: destination coin + address, truncated from the center so both ends stay + // checkable on one line (the common way of displaying a crypto address). MenuItem( title = toCurrencyName, - subtitle = toAddress, + subtitle = toAddress.middleEllipsize(), subtitleMaxLines = 1, customIcon = { CoinIcon(iconUrls = toIconUrls) } ) } } +/** + * Middle-truncates a long opaque string (keeping [head] leading and [tail] trailing + * characters), e.g. "ygJBkc4373Sodn…uhiV8WqaZZ9HQZPGC". Short strings are returned unchanged. + */ +private fun String.middleEllipsize(head: Int = 14, tail: Int = 14): String = + if (length <= head + tail + 1) this else "${take(head)}…${takeLast(tail)}" + /** * Coin icon that tries each candidate URL in [iconUrls] in order, advancing to the next source * whenever one fails to load; falls back to the neutral coin placeholder. @@ -334,7 +342,7 @@ private fun CoinIcon(iconUrls: List) { private fun MayaConvertCryptoScreenPreview() { MayaConvertCryptoScreen( state = MayaConvertCryptoUIState( - title = "Convert Dash to BTC", + title = "Convert", displayAmount = "0.06", currencyOptions = listOf("DASH", "USD", "BTC"), selectedCurrencyIndex = 2, @@ -359,7 +367,7 @@ private fun MayaConvertCryptoScreenPreview() { private fun MayaConvertCryptoScreenErrorPreview() { MayaConvertCryptoScreen( state = MayaConvertCryptoUIState( - title = "Convert Dash to BTC", + title = "Convert", displayAmount = "0.5", currencyOptions = listOf("DASH", "USD", "BTC"), selectedCurrencyIndex = 0, @@ -367,7 +375,7 @@ private fun MayaConvertCryptoScreenErrorPreview() { fiatBalance = "1.20 US$", toCurrencyName = "Bitcoin", toAddress = "XbBzWvnvSyWFbYXFtjkWwuPApbfDD263uC", - errorMessage = "You don’t have enough balance", + errorMessage = "Max $1.20", continueEnabled = false ), onBackClick = {}, diff --git a/integrations/maya/src/main/res/values/strings-maya.xml b/integrations/maya/src/main/res/values/strings-maya.xml index dbf9bcb6de..6988e9760f 100644 --- a/integrations/maya/src/main/res/values/strings-maya.xml +++ b/integrations/maya/src/main/res/values/strings-maya.xml @@ -29,6 +29,8 @@ From Dash Wallet to any crypto %s Address Convert DASH to %s + + Convert Enter Address @@ -296,6 +298,8 @@ The minimum transaction amount is The maximum transaction amount is + + Max We didn’t find any assets on your Coinbase account. Something went wrong! You don\'t have any Dash in your Dash Wallet. From fae563e202acdad0cb277fd6ff7686ff0d20ff49 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 06:55:15 -0700 Subject: [PATCH 16/24] fix: make address middle-ellipsis truncation font-scale safe The Maya convert-crypto screen truncated the destination address to a fixed 14/14 character budget, which overflowed (triggering a second, end-ellipsis truncation from the shared MenuItem) once the device font scale was increased. Move the truncation into MenuItem itself behind a new subtitleMiddleEllipsis flag, measuring the actual rendered width via rememberTextMeasurer so it stays correct at any font scale. Co-Authored-By: Claude Sonnet 5 --- .../wallet/common/ui/components/MenuItem.kt | 71 ++++++++++++++++--- .../maya/ui/MayaConvertCryptoScreen.kt | 16 ++--- 2 files changed, 68 insertions(+), 19 deletions(-) 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 9ed4f0f2c7..ecf4d61a0b 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 @@ -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 import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -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 @@ -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 @@ -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) + } + 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 diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt index bbb49a0744..38ed6c4dc0 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt @@ -299,20 +299,14 @@ private fun ConvertDirectionCard( // checkable on one line (the common way of displaying a crypto address). MenuItem( title = toCurrencyName, - subtitle = toAddress.middleEllipsize(), + subtitle = toAddress, subtitleMaxLines = 1, + subtitleMiddleEllipsis = true, customIcon = { CoinIcon(iconUrls = toIconUrls) } ) } } -/** - * Middle-truncates a long opaque string (keeping [head] leading and [tail] trailing - * characters), e.g. "ygJBkc4373Sodn…uhiV8WqaZZ9HQZPGC". Short strings are returned unchanged. - */ -private fun String.middleEllipsize(head: Int = 14, tail: Int = 14): String = - if (length <= head + tail + 1) this else "${take(head)}…${takeLast(tail)}" - /** * Coin icon that tries each candidate URL in [iconUrls] in order, advancing to the next source * whenever one fails to load; falls back to the neutral coin placeholder. @@ -337,7 +331,7 @@ private fun CoinIcon(iconUrls: List) { // ── Previews ──────────────────────────────────────────────────────────────────── -@Preview(showBackground = true, widthDp = 393, heightDp = 850) +@Preview(showBackground = true, widthDp = 393, heightDp = 850, fontScale = 1.25f) @Composable private fun MayaConvertCryptoScreenPreview() { MayaConvertCryptoScreen( @@ -346,8 +340,8 @@ private fun MayaConvertCryptoScreenPreview() { displayAmount = "0.06", currencyOptions = listOf("DASH", "USD", "BTC"), selectedCurrencyIndex = 2, - dashBalance = "0.00", - fiatBalance = "0.00 US$", + dashBalance = "4.00", + fiatBalance = "$140.00", toCurrencyName = "Bitcoin", toAddress = "XbBzWvnvSyWFbYXFtjkWwuPApbfDD263uC", receiveAmount = "~ 0.0053 BTC", From f73f214d36196174133bce220f5737154dcde9df Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 07:07:49 -0700 Subject: [PATCH 17/24] fix: match convert-crypto direction card to Figma layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direction card used one white Menu with an internal divider and a 24dp circular arrow badge; the design (node 38680:47497) actually uses two separate cards with a 5dp gap and a 30dp rounded-square badge (5dp border in the screen background color) floating over the seam. Also stop tinting the arrow icon gray — its drawable already bakes in the design's blue fill. Co-Authored-By: Claude Sonnet 5 --- .../maya/ui/MayaConvertCryptoScreen.kt | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt index 38ed6c4dc0..5bc2d09d6a 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt @@ -28,8 +28,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,6 +40,7 @@ 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -248,9 +249,10 @@ fun MayaConvertCryptoScreen( } /** - * White card showing the conversion direction: the Dash wallet (with its balance) on top and - * the destination coin + address below, separated by a divider with an arrow-down badge. - * Built from the design-system [Menu]/[MenuItem] components; supplies its own horizontal inset. + * The conversion direction: the Dash wallet (with its balance) in its own white card, then the + * destination coin + address in a second card 5dp below it, with a direction (arrow-down) badge + * floating over the seam between the two. Matches Figma node 38680:47497 — two separate [Menu] + * cards, not one card with an internal divider. */ @Composable private fun ConvertDirectionCard( @@ -260,50 +262,52 @@ private fun ConvertDirectionCard( toAddress: String, toIconUrls: List ) { - Menu { - // From: Dash Wallet with its balance. - MenuItem( - title = stringResource(R.string.dash), - subtitle = stringResource(R.string.dash_wallet_name), - icon = R.drawable.ic_dash_blue_filled, - dashAmount = dashBalance, - dashIcon = R.drawable.ic_dash_d_black, - fiatAmount = fiatBalance - ) + Box(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + // From: Dash Wallet with its balance. + Menu { + MenuItem( + title = stringResource(R.string.dash), + subtitle = stringResource(R.string.dash_wallet_name), + icon = R.drawable.ic_dash_blue_filled, + dashAmount = dashBalance, + dashIcon = R.drawable.ic_dash_d_black, + fiatAmount = fiatBalance + ) + } + + // To: destination coin + address, truncated from the center so both ends stay + // checkable on one line (the common way of displaying a crypto address). + Menu { + MenuItem( + title = toCurrencyName, + subtitle = toAddress, + subtitleMaxLines = 1, + subtitleMiddleEllipsis = true, + customIcon = { CoinIcon(iconUrls = toIconUrls) } + ) + } + } - // Divider with the direction (arrow-down) badge in the middle. + // Direction badge: a white rounded-square button ringed by a screen-background-colored + // border, so it reads as inset into the gap between the two cards. Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp), + .align(Alignment.Center) + .size(30.dp) + .background(LocalDashColors.current.backgroundSecondary, RoundedCornerShape(10.dp)) + .border(5.dp, LocalDashColors.current.backgroundPrimary, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center ) { - HorizontalDivider(thickness = 1.dp, color = LocalDashColors.current.extraLightGray) - Box( - modifier = Modifier - .size(24.dp) - .background(LocalDashColors.current.backgroundSecondary, CircleShape) - .border(1.dp, LocalDashColors.current.extraLightGray, CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_downward_blue_24dp), - contentDescription = null, - tint = LocalDashColors.current.textTertiary, - modifier = Modifier.size(12.dp) - ) - } + Icon( + painter = painterResource(R.drawable.ic_arrow_downward_blue_24dp), + contentDescription = null, + // The drawable's own path fill is already the design's blue; tinting it would + // override that with an unrelated color. + tint = Color.Unspecified, + modifier = Modifier.size(12.dp) + ) } - - // To: destination coin + address, truncated from the center so both ends stay - // checkable on one line (the common way of displaying a crypto address). - MenuItem( - title = toCurrencyName, - subtitle = toAddress, - subtitleMaxLines = 1, - subtitleMiddleEllipsis = true, - customIcon = { CoinIcon(iconUrls = toIconUrls) } - ) } } From 6be2f68fe300a361a588108cbc9c1bcc08e690d2 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 07:58:45 -0700 Subject: [PATCH 18/24] fix: add Galaxy S22 @ 1.25x font-scale preview Reproduces the exact device configuration used to catch the earlier keypad/EnterAmount overlap investigation (360x780dp, measured via `adb shell wm size`/`wm density`) so it can be checked directly in the Compose preview pane. Uses plain widthDp/heightDp instead of the `device = "spec:...,dpi=..."` form, which some Studio versions fail to render; status/nav bar insets (27dp/48dp, from `dumpsys window displays`) are drawn manually instead of via showSystemUi for the same reason. Co-Authored-By: Claude Sonnet 5 --- .../maya/ui/MayaConvertCryptoScreen.kt | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt index 5bc2d09d6a..3776816d85 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.rememberScrollState @@ -61,6 +62,7 @@ import org.dash.wallet.common.ui.components.ToastImageResource import org.dash.wallet.common.ui.enter_amount.NumericKeyboardCompose import org.dash.wallet.common.util.GenericUtils import org.dash.wallet.integrations.maya.R +import org.dash.wallet.integrations.maya.payments.MayaBitcoinCryptoCurrency import java.text.DecimalFormatSymbols import java.util.Locale @@ -335,7 +337,7 @@ private fun CoinIcon(iconUrls: List) { // ── Previews ──────────────────────────────────────────────────────────────────── -@Preview(showBackground = true, widthDp = 393, heightDp = 850, fontScale = 1.25f) +@Preview(showBackground = true, widthDp = 393, heightDp = 850) @Composable private fun MayaConvertCryptoScreenPreview() { MayaConvertCryptoScreen( @@ -347,7 +349,7 @@ private fun MayaConvertCryptoScreenPreview() { dashBalance = "4.00", fiatBalance = "$140.00", toCurrencyName = "Bitcoin", - toAddress = "XbBzWvnvSyWFbYXFtjkWwuPApbfDD263uC", + toAddress = MayaBitcoinCryptoCurrency().exampleAddress, receiveAmount = "~ 0.0053 BTC", networkLabel = "using NEAR network", continueEnabled = true @@ -372,7 +374,7 @@ private fun MayaConvertCryptoScreenErrorPreview() { dashBalance = "0.05", fiatBalance = "1.20 US$", toCurrencyName = "Bitcoin", - toAddress = "XbBzWvnvSyWFbYXFtjkWwuPApbfDD263uC", + toAddress = MayaBitcoinCryptoCurrency().exampleAddress, errorMessage = "Max $1.20", continueEnabled = false ), @@ -383,3 +385,58 @@ private fun MayaConvertCryptoScreenErrorPreview() { onContinueClick = {} ) } + +// Mirrors a real Galaxy S22 (SM-S901U): 1080x2340px @ 480dpi (xxhdpi, scale 3.0) measures to +// exactly 360x780dp — confirmed via `adb shell wm size` / `wm density`. The status/nav bar +// placeholders below use the same device's measured insets (27dp / 48dp, from `dumpsys window +// displays`) instead of relying on `showSystemUi`, which some Studio versions fail to render. +@Preview( + name = "Galaxy S22 @ 1.25x font", + showBackground = true, + widthDp = 360, + heightDp = 780, + fontScale = 1.25f +) +@Composable +private fun MayaConvertCryptoScreenGalaxyS22Preview() { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(27.dp) + .background(Color.Black) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + MayaConvertCryptoScreen( + state = MayaConvertCryptoUIState( + title = "Convert", + displayAmount = "2", + currencyOptions = listOf("DASH", "USD", "BTC"), + selectedCurrencyIndex = 2, + dashBalance = "0.93999202", + fiatBalance = "$ 28.06", + toCurrencyName = "Bitcoin", + toAddress = "bc1qxhgnnp745xxxxxxxxxxxxxxxxxxxxgkkpkm35020js0", + receiveAmount = "~ 0.00095431 BTC", + networkLabel = "using Maya network", + continueEnabled = true + ), + onBackClick = {}, + onMaxClick = {}, + onCurrencySelected = {}, + onKeyInput = {}, + onContinueClick = {} + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(Color.Black) + ) + } +} From 30e834b63ab49ffae12648a30a4b383e17939cad Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 08:01:30 -0700 Subject: [PATCH 19/24] fix: align convert-crypto currency picker with Figma design Remove the SegmentedPicker's default pill background/shadow and shrink its corner radius for the vertical currency picker in EnterAmount, since the Figma design (node 38680:47341) shows plain stacked text labels with no background. Also fixes the rounded corners clipping into option text on the tightly-wrapped picker, and adds per-option padding so options have visible spacing between them instead of being packed flush. Co-Authored-By: Claude Sonnet 5 --- .../common/ui/components/EnterAmount.kt | 8 ++++++ .../ui/segmented_picker/SegmentedPicker.kt | 26 ++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) 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 d4a3d8d979..812cdfd1d9 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 @@ -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]) 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 7a6a561fd8..bad73700b0 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 @@ -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 @@ -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 @@ -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 ) } } @@ -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 ) } } @@ -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 @@ -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 { From 16b6003f5c8f90a718b906be5bee2a462c658a35 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 08:35:39 -0700 Subject: [PATCH 20/24] fix: middle-ellipsis the address rows on the Maya address-input screen The exchange deposit-address and clipboard-address MenuItems only set subtitleMaxLines = 1, which end-truncates via MenuItem's default TextOverflow.Ellipsis and cuts off the back half of the address. Enable subtitleMiddleEllipsis, matching the destination-address fix already applied on MayaConvertCryptoScreen. Co-Authored-By: Claude Sonnet 5 --- .../dash/wallet/integrations/maya/ui/MayaAddressInputScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputScreen.kt index 365d8fa2c8..ee10d43a19 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaAddressInputScreen.kt @@ -161,6 +161,7 @@ fun MayaAddressInputScreen( title = source.name, subtitle = source.address?.takeIf { it.isNotEmpty() }, subtitleMaxLines = 1, + subtitleMiddleEllipsis = true, icon = source.icon, trailingButtonText = if (connected) { null @@ -177,6 +178,7 @@ fun MayaAddressInputScreen( title = stringResource(R.string.maya_clipboard), subtitle = clipboardAddress, subtitleMaxLines = 1, + subtitleMiddleEllipsis = true, icon = R.drawable.ic_maya_clipboard, action = onClipboardClick ) From 1128c434a76ef0b607a0624164b1d1bcb02ae568 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 09:10:41 -0700 Subject: [PATCH 21/24] fix: use device spec for the Galaxy S22 preview's system UI frame Mixing widthDp/heightDp with showSystemUi left the rendered phone frame at Studio's default 411dp width while the composable itself stayed at 360dp, leaving blank margins on both sides. Driving both the frame and the content from the same device spec keeps them in sync. Also drops the manual status/nav-bar placeholder boxes, which are no longer needed now that showSystemUi renders correctly, and the now-unused height import. Co-Authored-By: Claude Sonnet 5 --- .../maya/ui/MayaConvertCryptoScreen.kt | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt index 3776816d85..d4badba104 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConvertCryptoScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.rememberScrollState @@ -387,56 +386,34 @@ private fun MayaConvertCryptoScreenErrorPreview() { } // Mirrors a real Galaxy S22 (SM-S901U): 1080x2340px @ 480dpi (xxhdpi, scale 3.0) measures to -// exactly 360x780dp — confirmed via `adb shell wm size` / `wm density`. The status/nav bar -// placeholders below use the same device's measured insets (27dp / 48dp, from `dumpsys window -// displays`) instead of relying on `showSystemUi`, which some Studio versions fail to render. +// exactly 360x780dp — confirmed via `adb shell wm size` / `wm density`. @Preview( name = "Galaxy S22 @ 1.25x font", showBackground = true, - widthDp = 360, - heightDp = 780, - fontScale = 1.25f + device = "spec:width=360dp,height=780dp,dpi=480", + fontScale = 1.25f, + showSystemUi = true ) @Composable private fun MayaConvertCryptoScreenGalaxyS22Preview() { - Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(27.dp) - .background(Color.Black) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - MayaConvertCryptoScreen( - state = MayaConvertCryptoUIState( - title = "Convert", - displayAmount = "2", - currencyOptions = listOf("DASH", "USD", "BTC"), - selectedCurrencyIndex = 2, - dashBalance = "0.93999202", - fiatBalance = "$ 28.06", - toCurrencyName = "Bitcoin", - toAddress = "bc1qxhgnnp745xxxxxxxxxxxxxxxxxxxxgkkpkm35020js0", - receiveAmount = "~ 0.00095431 BTC", - networkLabel = "using Maya network", - continueEnabled = true - ), - onBackClick = {}, - onMaxClick = {}, - onCurrencySelected = {}, - onKeyInput = {}, - onContinueClick = {} - ) - } - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background(Color.Black) - ) - } + MayaConvertCryptoScreen( + state = MayaConvertCryptoUIState( + title = "Convert", + displayAmount = "2", + currencyOptions = listOf("DASH", "USD", "BTC"), + selectedCurrencyIndex = 2, + dashBalance = "0.93999202", + fiatBalance = "$ 28.06", + toCurrencyName = "Bitcoin", + toAddress = "bc1qxhgnnp745xxxxxxxxxxxxxxxxxxxxgkkpkm35020js0", + receiveAmount = "~ 0.00095431 BTC", + networkLabel = "using Maya network", + continueEnabled = true + ), + onBackClick = {}, + onMaxClick = {}, + onCurrencySelected = {}, + onKeyInput = {}, + onContinueClick = {} + ) } From 38086cc9404d808480fc5a193b275b872fbc0e60 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 14 Aug 2026 15:20:26 -0700 Subject: [PATCH 22/24] fix: match DEXReceiveScreen to Figma layout, add Galaxy S22 preview Per Figma node 35042:51682: drop the subtitle under the heading, shrink the QR from 200dp to 160dp, keep the URI/memo values to one line, move the expiry warning inside the white card (restyled as a yellow system-message card) below the address, and add a new feature row below the card reusing the same copy that used to be the removed subtitle. Restructured the card so each section supplies its own padding instead of one blanket inset, matching Figma's per-section layout. Also adds a Galaxy S22 @ 1.25x-font preview alongside the existing ones, mirroring the treatment already used on MayaConvertCryptoScreen. Co-Authored-By: Claude Sonnet 5 --- .../integrations/maya/ui/DEXReceiveScreen.kt | 129 ++++++++++++++---- 1 file changed, 104 insertions(+), 25 deletions(-) diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXReceiveScreen.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXReceiveScreen.kt index 169895b7a1..e9e78ba18f 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXReceiveScreen.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/DEXReceiveScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator @@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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 androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -122,10 +124,9 @@ private fun DEXReceiveScreenContent( // height of the (overlaid) status + nav bar that NavBarBack already occupies, so // TopIntro's built-in padding (10dp `safe-area/top`, 20dp sides, 20dp below) covers // the remaining insets and the gap to the card (matches DEXRefundAddressScreen). - TopIntro( - heading = stringResource(R.string.dex_receive_heading, coinCode), - text = stringResource(R.string.dex_receive_description) - ) + // No subtitle here per Figma — the equivalent copy now lives in the feature row + // below the card instead (dex_receive_description). + TopIntro(heading = stringResource(R.string.dex_receive_heading, coinCode)) Column( modifier = Modifier @@ -133,7 +134,8 @@ private fun DEXReceiveScreenContent( .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // White card with QR + URI row. shadows/xs: #B8C1CC ~10% alpha, y=5, blur=20. + // White card: QR + URI row + expiry warning, each section supplying its own + // padding (matches Figma, which sets no gap on the card itself — see 35042:51782). Column( modifier = Modifier .fillMaxWidth() @@ -143,9 +145,7 @@ private fun DEXReceiveScreenContent( ambientColor = Color(0xFFB8C1CC), spotColor = Color(0xFFB8C1CC) ) - .background(LocalDashColors.current.backgroundSecondary, RoundedCornerShape(20.dp)) - .padding(top = 40.dp, bottom = 20.dp, start = 20.dp, end = 20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + .background(LocalDashColors.current.backgroundSecondary, RoundedCornerShape(20.dp)), horizontalAlignment = Alignment.CenterHorizontally ) { if (errorMessageRes != null) { @@ -155,7 +155,9 @@ private fun DEXReceiveScreenContent( style = MyTheme.Body2Regular, color = LocalDashColors.current.red, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 32.dp) ) } else { QrArea(content = qrContent, isLoading = isLoading || qrContent.isBlank()) @@ -179,14 +181,26 @@ private fun DEXReceiveScreenContent( text = stringResource(R.string.dex_receive_memo_warning), style = MyTheme.Body2Regular, color = LocalDashColors.current.red, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) ) } + + // Expiry warning, inside the card below the address (Figma 38669:7486). + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + ExpiryWarning(coinCode = coinCode) + } } } - // Expiry warning card (Figma 36265:22210): yellow triangle + title + refund note. - ExpiryWarning(coinCode = coinCode) + // Feature row explaining the post-deposit flow (Figma 38669:27847) — the same + // copy that used to sit under the heading as a plain subtitle. + DepositInfoRow(text = stringResource(R.string.dex_receive_description)) } } @@ -218,15 +232,15 @@ private fun DEXReceiveScreenContent( } } -/** Gray card warning that the deposit address expires; refund vs. convert note. */ +/** Yellow "system message" card warning that the deposit address expires (Figma 38669:7448). */ @Composable private fun ExpiryWarning(coinCode: String) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(20.dp)) - .background(LocalDashColors.current.gray.copy(alpha = 0.10f)) - .padding(16.dp), + .background(LocalDashColors.current.warningYellow) + .padding(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top ) { @@ -243,7 +257,7 @@ private fun ExpiryWarning(coinCode: String) { Column( modifier = Modifier .weight(1f) - .padding(top = 2.dp, end = 20.dp), + .padding(vertical = 5.dp), verticalArrangement = Arrangement.spacedBy(1.dp) ) { Text( @@ -260,18 +274,56 @@ private fun ExpiryWarning(coinCode: String) { } } -/** White, rounded box holding the 200dp QR, or a centered spinner while the address is loading. */ +/** + * Icon + text row explaining what happens after the deposit is confirmed (Figma 38669:27832), + * sitting below the card. The extra end padding (on top of the screen's 20dp side inset) matches + * Figma's wider right margin so the paragraph doesn't run the full screen width. + */ +@Composable +private fun DepositInfoRow(text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background(LocalDashColors.current.gray, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(CommonR.drawable.ic_arrow_downward_blue_24dp), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + Text( + text = text, + style = MyTheme.Typography.BodyMedium, + color = LocalDashColors.current.textPrimary, + modifier = Modifier + .weight(1f) + .padding(top = 10.dp) + ) + } +} + +/** 160dp QR centered in a 30dp vertical band (Figma "wrap.qr", 35042:51784), or a spinner while loading. */ @Composable private fun QrArea(content: String, isLoading: Boolean) { Box( modifier = Modifier + .fillMaxWidth() // Stays white in dark mode too: QR modules need a light background to scan reliably. - .background(Color.White, RoundedCornerShape(10.dp)) - .padding(10.dp), + .background(Color.White) + .padding(vertical = 30.dp), contentAlignment = Alignment.Center ) { Box( - modifier = Modifier.size(200.dp), + modifier = Modifier.size(160.dp), contentAlignment = Alignment.Center ) { val bitmap = remember(content, isLoading) { @@ -282,11 +334,11 @@ private fun QrArea(content: String, isLoading: Boolean) { Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, - // The QR bitmap is the raw module grid (tiny); scaling it to 200dp with the + // The QR bitmap is the raw module grid (tiny); scaling it to 160dp with the // default (smoothing) filter blurs the modules. None = nearest-neighbour, so the // squares stay crisp (mirrors Qr.themeAwareDrawable's isFilterBitmap = false). filterQuality = FilterQuality.None, - modifier = Modifier.size(200.dp) + modifier = Modifier.size(160.dp) ) } else { CircularProgressIndicator(color = LocalDashColors.current.dashBlue) @@ -295,13 +347,13 @@ private fun QrArea(content: String, isLoading: Boolean) { } } -/** Full-width row: label + value on the left, a tinted-gray copy button on the right. */ +/** Full-width row: label + single-line value on the left, a tinted-gray copy button on the right. */ @Composable private fun LabeledCopyRow(label: String, value: String, onCopyClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp), + .padding(horizontal = 20.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(40.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -319,7 +371,9 @@ private fun LabeledCopyRow(label: String, value: String, onCopyClick: () -> Unit Text( text = value, style = MyTheme.Typography.BodyMedium, - color = LocalDashColors.current.textPrimary + color = LocalDashColors.current.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } @@ -376,6 +430,31 @@ private fun DEXReceiveScreenLoadedPreview() { ) } +// Mirrors a real Galaxy S22 (SM-S901U): 1080x2340px @ 480dpi (xxhdpi, scale 3.0) measures to +// exactly 360x780dp — confirmed via `adb shell wm size` / `wm density`. +@Preview( + name = "Galaxy S22 @ 1.25x font", + showBackground = true, + device = "spec:width=360dp,height=780dp,dpi=480", + fontScale = 1.25f, + showSystemUi = true +) +@Composable +private fun DEXReceiveScreenGalaxyS22Preview() { + DEXReceiveScreenContent( + coinCode = "BTC", + address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + uri = "bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + memo = "", + isLoading = false, + errorMessageRes = null, + isOnline = true, + onBackClick = {}, + onBackHomeClick = {}, + onCopyClick = {} + ) +} + @Preview(showBackground = true, widthDp = 393, heightDp = 760) @Composable private fun DEXReceiveScreenMemoPreview() { From e5cd869c743811d5291716a4ff504ee396125cb5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 23 Aug 2026 16:38:13 -0700 Subject: [PATCH 23/24] fix: address review findings in MenuItem.kt - Move android.content.res.Configuration import above androidx imports to satisfy ktlint import ordering - Add density.fontScale to the MiddleEllipsisText remember key so the middle-ellipsis is recomputed when the font scale changes Co-Authored-By: Claude Fable 5 --- .../java/org/dash/wallet/common/ui/components/MenuItem.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ecf4d61a0b..b06ef9114d 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 @@ -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.border @@ -38,7 +39,6 @@ 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 @@ -304,7 +304,7 @@ private fun MiddleEllipsisText( val density = LocalDensity.current BoxWithConstraints(modifier = modifier) { val maxWidthPx = with(density) { maxWidth.toPx() } - val display = remember(text, maxWidthPx, style) { + val display = remember(text, maxWidthPx, style, density.fontScale) { middleEllipsizeToFit(text, maxWidthPx, style, measurer) } Text(text = display, style = style, color = color, maxLines = 1, overflow = TextOverflow.Clip) From 3dc25d4f6edd65f9218b534134026fa914509d99 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 23 Aug 2026 16:52:42 -0700 Subject: [PATCH 24/24] fix: test one-character candidates in middleEllipsizeToFit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop exited once only one retained character remained, so candidates like "a…" were never measured before falling back to the ellipsis-only string. Co-Authored-By: Claude Fable 5 --- .../main/java/org/dash/wallet/common/ui/components/MenuItem.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b06ef9114d..a06be666be 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 @@ -318,7 +318,7 @@ private fun middleEllipsizeToFit(text: String, maxWidthPx: Float, style: TextSty var head = (text.length + 1) / 2 var tail = text.length - head - while (head + tail > 1) { + while (head + tail > 0) { val candidate = "${text.take(head)}…${text.takeLast(tail)}" if (widthOf(candidate) <= maxWidthPx) return candidate if (head >= tail) head-- else tail--