From 2a14c5944db1533c52f68f905214420ed21e500b Mon Sep 17 00:00:00 2001 From: blakehartin Date: Tue, 12 May 2026 17:40:31 -0700 Subject: [PATCH 1/2] view tablet fixes --- .../quantumcoin/app/utils/GlobalMethods.java | 65 +++++++ .../app/view/activities/HomeActivity.java | 160 ++++++++++++++---- .../fragment/AccountTransactionsFragment.java | 156 +++++++++++------ .../app/view/fragment/HomeMainFragment.java | 27 ++- .../app/view/fragment/HomeStartFragment.java | 10 +- .../app/view/fragment/HomeWalletFragment.java | 7 +- .../app/view/fragment/SendFragment.java | 60 +++++-- .../app/view/fragment/WalletsFragment.java | 21 ++- .../layout/account_transactions_fragment.xml | 36 ++-- app/src/main/res/layout/home_activity.xml | 17 +- .../main/res/layout/home_wallet_fragment.xml | 16 +- app/src/main/res/layout/send_fragment.xml | 13 +- 12 files changed, 453 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/quantumcoin/app/utils/GlobalMethods.java b/app/src/main/java/com/quantumcoin/app/utils/GlobalMethods.java index 8eea7c1..c06f8ca 100644 --- a/app/src/main/java/com/quantumcoin/app/utils/GlobalMethods.java +++ b/app/src/main/java/com/quantumcoin/app/utils/GlobalMethods.java @@ -448,6 +448,71 @@ public static void OfflineOrExceptionError(Context context, LinearLayout layout, } } + /** + * Surfaces a "service unavailable" / "no connection" failure as a + * non-blocking toast instead of the full-body retry overlay + * (R.layout.retry_layout). The overlay used to swallow the entire + * body region of the home / send / transactions screens whenever a + * single REST call failed (including on a transient 5xx during a + * background poll), which (a) hid wallet info that was already on + * screen behind a "Service unavailable" placeholder and (b) looked + * particularly bad on tablets where the placeholder left a huge + * empty body region under the wallet header. + * + *

Replacement contract: + *

+ * + *

The legacy {@link #OfflineOrExceptionError} helper is kept + * around so external callers don't break, but every internal + * surface has been migrated to this method. + */ + public static void NotifyServiceUnavailable(Context context, + boolean isNetworkAvailable, + boolean userInitiated) { + if (!userInitiated) return; + if (context == null) return; + final String message; + if (!isNetworkAvailable) { + message = context.getString(R.string.general_connect_internet); + } else { + message = context.getString(R.string.offline_exception_error_title) + + ". " + + context.getString(R.string.offline_exception_error_subtitle); + } + // NOTE: we deliberately do NOT route through ShowToast here. + // The legacy ShowToast helper wraps the system Toast in a + // CountDownTimer that calls Toast.cancel() at 600ms, which + // forces the toast off-screen long before LENGTH_LONG would + // otherwise (~3.5s on most OEMs) -- on a tablet that 600ms + // flash is essentially invisible because the user's eye is + // still on the refresh button when it disappears, especially + // when the toast renders near the bottom-edge of a large + // screen. We dispatch a plain LENGTH_LONG toast on the main + // thread so the message stays up long enough to read. + final Context appCtx = context.getApplicationContext(); + Runnable show = new Runnable() { + @Override + public void run() { + Toast.makeText(appCtx, message, Toast.LENGTH_LONG).show(); + } + }; + if (Looper.myLooper() == Looper.getMainLooper()) { + show.run(); + } else { + new android.os.Handler(Looper.getMainLooper()).post(show); + } + } + public static void ShowToast(final Context context, final String message) { if (context == null) { diff --git a/app/src/main/java/com/quantumcoin/app/view/activities/HomeActivity.java b/app/src/main/java/com/quantumcoin/app/view/activities/HomeActivity.java index 2b50c03..4e239e8 100644 --- a/app/src/main/java/com/quantumcoin/app/view/activities/HomeActivity.java +++ b/app/src/main/java/com/quantumcoin/app/view/activities/HomeActivity.java @@ -31,6 +31,10 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentTransaction; @@ -132,6 +136,7 @@ private void resetIdleLockTimer() { private TextView walletAddressTextView; private TextView balanceValueTextView; private ProgressBar progressBar; + private ImageButton refreshImageButton; private BottomNavigationView bottomNavigationView; private LinearLayout linerLayoutOffline; @@ -198,6 +203,40 @@ public void onCreate(Bundle savedInstanceState) { TextView titleTextView = (TextView) findViewById(R.id.textView_home_tile); TextView loadSeedTextView = (TextView) findViewById(R.id.textView_home_load_seed); + // Edge-to-edge (default on Android 15 / targetSdk 35) lets the + // status bar and any display cutout draw on top of our top-aligned + // header. Push the logo + titles below them via a status-bar + + // cutout inset, while leaving the gradient background pinned to + // the top edge so it still extends under the cutout. The same + // top-inset is applied to the network chip (also alignParentTop) + // so it doesn't get clipped on the right edge of a wide cutout. + ViewCompat.setOnApplyWindowInsetsListener(topLinearLayout, + new OnApplyWindowInsetsListener() { + @Override + public WindowInsetsCompat onApplyWindowInsets( + View v, WindowInsetsCompat insets) { + Insets bars = insets.getInsets( + WindowInsetsCompat.Type.statusBars() + | WindowInsetsCompat.Type.displayCutout()); + v.setPadding(v.getPaddingLeft(), bars.top, + v.getPaddingRight(), v.getPaddingBottom()); + return insets; + } + }); + ViewCompat.setOnApplyWindowInsetsListener(blockChainNetworkTextView, + new OnApplyWindowInsetsListener() { + @Override + public WindowInsetsCompat onApplyWindowInsets( + View v, WindowInsetsCompat insets) { + Insets bars = insets.getInsets( + WindowInsetsCompat.Type.statusBars() + | WindowInsetsCompat.Type.displayCutout()); + v.setPadding(v.getPaddingLeft(), bars.top, + v.getPaddingRight(), v.getPaddingBottom()); + return insets; + } + }); + //loadSeedsThread(loadSeedTextView); //Center Relative layout & Image Button @@ -210,7 +249,7 @@ public void onCreate(Bundle savedInstanceState) { TextView balanceCoinSymbolTextView = (TextView) findViewById(R.id.textView_home_coin_symbol); balanceValueTextView = (TextView) findViewById(R.id.textView_home_balance_value); - ImageButton refreshImageButton = (ImageButton) findViewById(R.id.imageButton_home_refresh); + refreshImageButton = (ImageButton) findViewById(R.id.imageButton_home_refresh); progressBar = (ProgressBar) findViewById(R.id.progress_home_loader); TextView sendTitleTextView = (TextView) findViewById(R.id.textView_home_send_title); @@ -238,7 +277,12 @@ public void onCreate(Bundle savedInstanceState) { transactionsTitleTextView.setText(jsonViewModel.getTransactionsByLangValues()); walletAddressTextView.setText(""); - balanceValueTextView.setText(""); + // "-" placeholder (NOT "0") so the user is never shown an + // authoritative-looking zero balance before the first + // balance fetch completes. Matches the XML default in + // home_activity.xml (textView_home_balance_value) and the + // matching default on the Send / Seed-Confirmation screens. + balanceValueTextView.setText("-"); screenViewType(-1); //Notification permission @@ -467,7 +511,9 @@ public void onBackPressed() { @Override public void onHomeMainComplete() { try { - getBalanceByAccount(walletAddress, balanceValueTextView, progressBar); + // Initial load on fragment-attach is NOT user-initiated, so + // a transient failure here stays silent (no toast). + getBalanceByAccount(walletAddress, balanceValueTextView, progressBar, false); } catch (Exception e) { GlobalMethods.ExceptionError(getApplicationContext(), TAG, e); } @@ -498,13 +544,19 @@ public void onHomeMainComplete() { */ public void performHomeRefresh() { try { - // A balance refresh should also invalidate the cached token - // list so the user sees fresh balances and any newly arrived - // tokens. Mirrors iOS where the refresh action re-pulls coin - // balance + tokens together. - getBalanceByAccount(walletAddress, balanceValueTextView, progressBar); - GlobalMethods.CURRENT_WALLET_TOKEN_LIST = new java.util.ArrayList<>(); - GlobalMethods.CURRENT_WALLET_TOKEN_LIST_ADDRESS = null; + // userInitiated=true: the user explicitly tapped refresh + // (toolbar button or pull-to-refresh) so a failure here + // surfaces a toast. + getBalanceByAccount(walletAddress, balanceValueTextView, progressBar, true); + // Re-pull the token list, but DO NOT pre-clear the cache or + // the in-fragment list before the fetch. Previously the + // cache was nulled and the network-change broadcast caused + // HomeMainFragment to reset the token adapter to empty + // before re-fetching, which meant a transient REST failure + // wiped the user's visible token table for no reason. The + // cache is now repopulated only by a successful response; + // a failed re-fetch leaves the previously displayed list + // intact (matches iOS UIRefreshControl semantics). com.quantumcoin.app.events.NetworkChangeBroadcaster .broadcastActiveNetworkChanged(getApplicationContext()); } catch (Exception e) { @@ -616,8 +668,11 @@ private void refreshActiveNetworkChrome(boolean reloadBalance) { if (reloadBalance && walletAddress != null && !walletAddress.isEmpty() && balanceValueTextView != null) { - balanceValueTextView.setText(""); - getBalanceByAccount(walletAddress, balanceValueTextView, progressBar); + // Network switch is a user-initiated refresh: surface + // failures via toast. Don't clear the existing balance + // first so a transient failure leaves the previous + // value visible until the next successful fetch. + getBalanceByAccount(walletAddress, balanceValueTextView, progressBar, true); } } catch (Exception e) { timber.log.Timber.w(e, "refresh active network chrome"); @@ -1314,20 +1369,60 @@ private boolean shouldShowHomeOfflineOverlay() { } } + /** + * Swap the toolbar refresh icon with the in-place spinner. The + * ImageButton and the ProgressBar share the same horizontal slot + * in home_activity.xml; both are sized 40dp and only one is + * visible at a time so the row's layout never shifts. Helper is + * null-safe so the broadcast-driven re-fetch path can call into + * getBalanceByAccount even if the toolbar views haven't been + * resolved yet. + */ + private void setRefreshLoading(boolean loading) { + if (refreshImageButton != null) { + refreshImageButton.setVisibility(loading ? View.GONE : View.VISIBLE); + } + if (progressBar != null) { + progressBar.setVisibility(loading ? View.VISIBLE : View.GONE); + } + } + //Get balance task - private void getBalanceByAccount(String address, TextView balanceValueTextView, ProgressBar progressBar) { + /** + * Fetches the native QC balance and updates the on-screen value. + * + *

{@code userInitiated} controls how failures surface: + *

+ * + *

The full-body retry overlay ({@code linerLayout_home_offline}) + * is no longer surfaced from this path. The previous behavior + * replaced the entire body region of the home screen with a + * "Service unavailable" placeholder whenever a single REST call + * failed, which (a) hid wallet info that was already on screen and + * (b) looked particularly broken on tablets where the placeholder + * left a giant empty area below the wallet header card. The + * existing balance value (the user's last successful read) is also + * intentionally preserved instead of being reset to "0" before the + * fetch, so a transient failure does not visually wipe the balance. + */ + private void getBalanceByAccount(String address, TextView balanceValueTextView, + ProgressBar progressBar, boolean userInitiated) { try { - linerLayoutOffline.setVisibility(View.GONE); - //Internet connection check if (GlobalMethods.IsNetworkAvailable(getApplicationContext())) { - progressBar.setVisibility(View.VISIBLE); - - balanceValueTextView.setText("0"); + setRefreshLoading(true); String[] taskParams = {address}; + final boolean userInitiatedFinal = userInitiated; AccountBalanceRestTask task = new AccountBalanceRestTask( getApplicationContext(), new AccountBalanceRestTask.TaskListener() { @Override @@ -1338,31 +1433,34 @@ public void onFinished(BalanceResponse balanceResponse) throws ServiceException balanceValueTextView.setText(quantity); } - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); } @Override public void onFailure(com.quantumcoin.app.api.read.ApiException e) { - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); int code = e.getCode(); boolean check = GlobalMethods.ApiExceptionSourceCodeBoolean(code); if (check == true) { - GlobalMethods.ApiExceptionSourceCodeRoute(getApplicationContext(), code, - getString(R.string.apierror), - TAG + " : AccountBalanceRestTask : " + e.toString()); - } else if (shouldShowHomeOfflineOverlay()) { - GlobalMethods.OfflineOrExceptionError(getApplicationContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, true); + // Specific HTTP status (401/404/406/409/417): + // route to the per-code toast only when the + // user explicitly asked for this fetch. + if (userInitiatedFinal) { + GlobalMethods.ApiExceptionSourceCodeRoute(getApplicationContext(), code, + getString(R.string.apierror), + TAG + " : AccountBalanceRestTask : " + e.toString()); + } + } else { + GlobalMethods.NotifyServiceUnavailable( + getApplicationContext(), true, userInitiatedFinal); } } }); task.execute(taskParams); - } else if (shouldShowHomeOfflineOverlay()) { - GlobalMethods.OfflineOrExceptionError(getApplicationContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, false); + } else { + GlobalMethods.NotifyServiceUnavailable( + getApplicationContext(), false, userInitiated); } } catch (Exception e) { GlobalMethods.ExceptionError(getApplicationContext(), TAG, e); diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/AccountTransactionsFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/AccountTransactionsFragment.java index ab67539..73a6af9 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/AccountTransactionsFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/AccountTransactionsFragment.java @@ -71,6 +71,9 @@ public class AccountTransactionsFragment extends Fragment { private TextView textViewEmpty; private JsonViewModel jsonViewModel; + private ImageButton refreshImageButton; + private ProgressBar refreshProgressBar; + private OnAccountTransactionsCompleteListener mAccountTransactionsListener; private int transactionStatus = 0; @@ -118,8 +121,10 @@ public void onViewCreated(View view, Bundle savedInstanceState) { ImageButton backArrowImageButton = (ImageButton) getView().findViewById(R.id.imageButton_account_transactions_back_arrow); ImageButton accountTransactionRefreshImageButton = (ImageButton) getView().findViewById(R.id.imageButton_account_transactions_refresh); + this.refreshImageButton = accountTransactionRefreshImageButton; - ProgressBar progressBar = (ProgressBar) getView().findViewById(R.id.progress_loader_account_transactions); + ProgressBar progressBar = (ProgressBar) getView().findViewById(R.id.progress_account_transactions_loader); + this.refreshProgressBar = progressBar; ToggleButton accountTransactionCompletedToggleButton = view.findViewById(R.id.toggleButton_account_transactions_langValues_completed); ToggleButton accountTransactionPendingToggleButton = view.findViewById(R.id.toggleButton_account_transactions_langValues_pending);; @@ -174,18 +179,20 @@ public void onViewCreated(View view, Bundle savedInstanceState) { accountPendingTransactionSummaries, walletAddress); this.recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + // Initial load on screen open is NOT user-initiated, so a + // transient failure here stays silent. + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, false); accountTransactionRefreshImageButton.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { switch(transactionStatus) { case 0: recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; case 1: recycler.setAdapter(accountPendingTransactionAdapter); - ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; } } @@ -221,7 +228,7 @@ public void onClick(View v) { } recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); } }); @@ -249,7 +256,7 @@ public void onClick(View v) { } recycler.setAdapter(accountPendingTransactionAdapter); - ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); } }); @@ -274,11 +281,11 @@ public void onClick(View v) { switch(transactionStatus) { case 0: recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; case 1: recycler.setAdapter(accountPendingTransactionAdapter); - ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; } } @@ -300,11 +307,11 @@ public void onClick(View v) { switch(transactionStatus) { case 0: recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, requested); + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, requested, true); break; case 1: recycler.setAdapter(accountPendingTransactionAdapter); - ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, requested); + ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, requested, true); break; } } @@ -315,11 +322,11 @@ public void onClick(View v) { switch(transactionStatus) { case 0: recycler.setAdapter(accountTransactionAdapter); - ListAccountTransactionByAccount(getContext(), walletAddress, progressBar,pageIndex); + ListAccountTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; case 1: recycler.setAdapter(accountPendingTransactionAdapter); - ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex); + ListAccountPendingTransactionByAccount(getContext(), walletAddress, progressBar, pageIndex, true); break; } } @@ -381,9 +388,49 @@ public void onAttach(Context context) { } - private void ListAccountTransactionByAccount(Context context, String address, ProgressBar progressBar, int pageIndex) { + /** + * Swap the toolbar refresh icon with the in-place spinner. The + * ImageButton and the ProgressBar share the same 40dp slot in + * account_transactions_fragment.xml; only one is visible at a + * time so the toolbar never shifts. This is the single loading + * indicator on this screen now (refresh, initial load, and + * pagination all funnel through the List* helpers below). + */ + private void setRefreshLoading(boolean loading) { + if (refreshImageButton != null) { + refreshImageButton.setVisibility(loading ? View.GONE : View.VISIBLE); + } + if (refreshProgressBar != null) { + refreshProgressBar.setVisibility(loading ? View.VISIBLE : View.GONE); + } + } + + /** + * Fetches a page of completed transactions and pushes the result + * into the recycler. + * + *

{@code userInitiated} controls how failures surface: + *

+ * + *

The full-body retry overlay + * ({@code linerLayout_account_transactions_offline}) is no longer + * surfaced from this path, and the existing transactions table is + * NOT cleared before the fetch. A transient failure therefore + * leaves the previously-displayed rows in place; the recycler is + * only re-populated on a successful response (or explicitly emptied + * on a 404/204 "no transactions" reply). + */ + private void ListAccountTransactionByAccount(Context context, String address, + ProgressBar progressBar, int pageIndex, + boolean userInitiated) { try{ - linerLayoutOffline.setVisibility(View.GONE); if (progressBar.getVisibility() == View.VISIBLE) { String message = getResources().getString(R.string.transaction_message_exits); GlobalMethods.ShowToast(context, message); @@ -395,11 +442,9 @@ private void ListAccountTransactionByAccount(Context context, String address, Pr String[] taskParams = {address, String.valueOf(pageIndex)}; - progressBar.setVisibility(View.VISIBLE); - recycler.removeAllViewsInLayout(); - accountTransactionSummaries.clear(); + setRefreshLoading(true); - hideEmptyState(); + final boolean userInitiatedFinal = userInitiated; AccountTxnRestTask task = new AccountTxnRestTask( context, new AccountTxnRestTask.TaskListener() { @Override @@ -422,22 +467,33 @@ public void onFinished(AccountTransactionSummaryResponse accountTransactionSumma } else { AccountTransactionsFragment.this.pageIndex = pageIndex; } + // Replace the visible rows atomically: clear + // and refill only on a successful response + // so a transient failure cannot wipe the + // previously displayed page. + accountTransactionSummaries.clear(); accountTransactionSummaries.addAll(accountTransactionSummaryResponse.getResult()); accountTransactionAdapter.notifyDataSetChanged(); hideEmptyState(); } else { AccountTransactionsFragment.this.pageCount = 0; AccountTransactionsFragment.this.pageIndex = 0; + accountTransactionSummaries.clear(); + accountTransactionAdapter.notifyDataSetChanged(); showEmptyState(); } - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); } @Override public void onFailure(com.quantumcoin.app.api.read.ApiException e) { - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); int code = e.getCode(); if (code == 404 || code == 204) { + // Authoritative "this account has no + // transactions on this page" response from + // the server. Clear the visible rows and + // show the empty placeholder. accountTransactionSummaries.clear(); accountTransactionAdapter.notifyDataSetChanged(); showEmptyState(); @@ -445,13 +501,14 @@ public void onFailure(com.quantumcoin.app.api.read.ApiException e) { } boolean check = GlobalMethods.ApiExceptionSourceCodeBoolean(code); if(check == true) { - GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, - getString(R.string.apierror), - TAG + " : AccountTxnRestTask : " + e.toString()); + if (userInitiatedFinal) { + GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, + getString(R.string.apierror), + TAG + " : AccountTxnRestTask : " + e.toString()); + } } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, true); + GlobalMethods.NotifyServiceUnavailable( + getContext(), true, userInitiatedFinal); } } }); @@ -459,24 +516,24 @@ public void onFailure(com.quantumcoin.app.api.read.ApiException e) { try { task.execute(taskParams); } catch (Exception ex) { - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); GlobalMethods.ExceptionError(getContext(), TAG, ex); } } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, false); + GlobalMethods.NotifyServiceUnavailable( + getContext(), false, userInitiated); } } catch (Exception e) { GlobalMethods.ExceptionError(getContext(), TAG, e); } } - private void ListAccountPendingTransactionByAccount(Context context, String address, ProgressBar progressBar, int pageIndex) { + /** See {@link #ListAccountTransactionByAccount}; same contract for the pending-transactions list. */ + private void ListAccountPendingTransactionByAccount(Context context, String address, + ProgressBar progressBar, int pageIndex, + boolean userInitiated) { try{ - linerLayoutOffline.setVisibility(View.GONE); - if (progressBar.getVisibility() == View.VISIBLE) { String message = getResources().getString(R.string.transaction_message_exits); GlobalMethods.ShowToast(getContext(), message); @@ -488,11 +545,9 @@ private void ListAccountPendingTransactionByAccount(Context context, String addr String[] taskParams = { address, String.valueOf(pageIndex) }; - progressBar.setVisibility(View.VISIBLE); - recycler.removeAllViewsInLayout(); - accountPendingTransactionSummaries.clear(); + setRefreshLoading(true); - hideEmptyState(); + final boolean userInitiatedFinal = userInitiated; AccountPendingTxnRestTask task = new AccountPendingTxnRestTask( context, new AccountPendingTxnRestTask.TaskListener() { @Override @@ -513,20 +568,23 @@ public void onFinished(AccountPendingTransactionSummaryResponse accountPendingTr } else { AccountTransactionsFragment.this.pageIndex = pageIndex; } + accountPendingTransactionSummaries.clear(); accountPendingTransactionSummaries.addAll(accountPendingTransactionSummaryResponse.getResult()); accountPendingTransactionAdapter.notifyDataSetChanged(); hideEmptyState(); } else { AccountTransactionsFragment.this.pageCount = 0; AccountTransactionsFragment.this.pageIndex = 0; + accountPendingTransactionSummaries.clear(); + accountPendingTransactionAdapter.notifyDataSetChanged(); showEmptyState(); } - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); } @Override public void onFailure(com.quantumcoin.app.api.read.ApiException e) { - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); int code = e.getCode(); if (code == 404 || code == 204) { accountPendingTransactionSummaries.clear(); @@ -536,13 +594,14 @@ public void onFailure(com.quantumcoin.app.api.read.ApiException e) { } boolean check = GlobalMethods.ApiExceptionSourceCodeBoolean(code); if(check == true) { - GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, - getString(R.string.apierror), - TAG + " : AccountPendingTxnRestTask : " + e.toString()); + if (userInitiatedFinal) { + GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, + getString(R.string.apierror), + TAG + " : AccountPendingTxnRestTask : " + e.toString()); + } } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, true); + GlobalMethods.NotifyServiceUnavailable( + getContext(), true, userInitiatedFinal); } } }); @@ -550,13 +609,12 @@ public void onFailure(com.quantumcoin.app.api.read.ApiException e) { try { task.execute(taskParams); } catch (Exception ex) { - progressBar.setVisibility(View.GONE); + setRefreshLoading(false); GlobalMethods.ExceptionError(getContext(), TAG, ex); } } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, false); + GlobalMethods.NotifyServiceUnavailable( + getContext(), false, userInitiated); } } catch (Exception e) { GlobalMethods.ExceptionError(getContext(), TAG, e); diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeMainFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeMainFragment.java index c0028f0..5c88690 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeMainFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeMainFragment.java @@ -406,6 +406,13 @@ public boolean canChildScrollUp(SwipeRefreshLayout parent, View child) { swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { @Override public void onRefresh() { + // Hide SwipeRefreshLayout's built-in circular indicator + // immediately. The toolbar refresh icon now renders the + // spinner in-place via HomeActivity.performHomeRefresh -> + // getBalanceByAccount -> setRefreshLoading, so the user + // sees a single, consistent loading affordance rather than + // two competing spinners. + swipeRefreshLayout.setRefreshing(false); if (mHomeMainListener != null) { mHomeMainListener.onHomeMainRefreshRequested(); } @@ -434,15 +441,17 @@ public void onResume() { @Override public void onReceive(android.content.Context context, android.content.Intent intent) { // Token list and balances are network-scoped, so any - // network-state change forces a fresh fetch. Cached - // tokens are tied to (address, network) elsewhere; - // we simply re-issue the listing. - GlobalMethods.CURRENT_WALLET_TOKEN_LIST = null; - GlobalMethods.CURRENT_WALLET_TOKEN_LIST_ADDRESS = null; - if (tokenAdapter != null) { - tokenAdapter.setTokens(new java.util.ArrayList()); - renderEmptyState(true); - } + // network-state change re-issues the listing. + //

Intentionally do NOT pre-clear the cache or the + // adapter before the fetch: a transient REST failure + // would otherwise wipe the user's visible token table + // and replace it with the empty-state placeholder + // even though we still have a perfectly good cached + // listing on screen. The success path + // (refreshTokenList -> applyFilteredItems) replaces + // the in-memory list atomically; the failure path + // (refreshTokenList onFailure) is silent and leaves + // the previously displayed entries in place. refreshTokenList(walletAddress); } }; diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeStartFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeStartFragment.java index 5820d80..555406c 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeStartFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeStartFragment.java @@ -94,7 +94,7 @@ public void onCheckedChanged(RadioGroup group, int checkedId) { public void onClick(View v) { try { //Next - if(jsonIndex +1 >= jsonViewModel.getInfoLength()-1){ + if(jsonIndex +1 >= jsonViewModel.getInfoLength()){ jsonIndex = 0; homeStartLinearLayout.setVisibility(View.GONE); homeSaftyQuizLinearLayout.setVisibility(View.VISIBLE); @@ -149,13 +149,13 @@ public void onClick(View v) { public void onClick(View v) { dialog.dismiss(); - if(jsonIndex +1 >= jsonViewModel.getQuizLength()-1) { + if(jsonIndex +1 >= jsonViewModel.getQuizLength()) { mHomeStartListener.onHomeStartComplete(); return; } QuizView(jsonViewModel, homeSaftyQuizQuizStepTextView,homeSaftyQuizQuizStepQuizeTitleTextView,homeSaftyQuizQuizStepQuizQuestionTextView, - homeStartLangValuesNextButton, + homeSaftyQuizLangValuesNextButton, homeSafetyQuizQuizStepQuizChoicesRadioButton_0,homeSafetyQuizQuizStepQuizChoicesRadioButton_1,homeSafetyQuizQuizStepQuizChoicesRadioButton_2,homeSafetyQuizQuizStepQuizChoicesRadioButton_3, jsonViewModel.getQuizStep(), jsonIndex + 1, jsonViewModel.getQuizLength()); @@ -211,7 +211,7 @@ public void onAttach(Context context) { private void InfoView(JsonViewModel jsonViewModel, TextView infoStepTextView,TextView infoStepInfoTileTextView,TextView infoStepInfoDescTextView, Button langValuesNextButton, String infoStep, int index, int length){ infoStepTextView.setText(infoStep.replace(GlobalMethods.step, String.valueOf(index +1)) - .replace(GlobalMethods.totalSteps,String.valueOf(length-1))); + .replace(GlobalMethods.totalSteps,String.valueOf(length))); infoStepInfoTileTextView.setText(jsonViewModel.getTitleByInfo(index)); infoStepInfoDescTextView.setText(jsonViewModel.getDescByInfo(index)); @@ -224,7 +224,7 @@ private void QuizView(JsonViewModel jsonViewModel, TextView quizStepTextView, Te RadioButton quizStepQuizChoicesRadioButton_3, String quizStep, int index, int length){ quizStepTextView.setText(quizStep.toString().replace(GlobalMethods.step, String.valueOf(index +1)) - .replace(GlobalMethods.totalSteps,String.valueOf(length-1))); + .replace(GlobalMethods.totalSteps,String.valueOf(length))); quizStepQuizeTitleTextView.setText(jsonViewModel.getTitleByQuiz(index)); quizStepQuizQuestionTextView.setText(jsonViewModel.getQuestionByQuiz(index)); diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeWalletFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeWalletFragment.java index 7762eda..1a48d8d 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/HomeWalletFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/HomeWalletFragment.java @@ -1533,7 +1533,12 @@ public void onClick(View v) { homeConfirmWalletAddressValueTextView.setText(""); } if (homeConfirmWalletBalanceValueTextView != null) { - homeConfirmWalletBalanceValueTextView.setText(""); + // "-" placeholder (NOT "" or "0") so the row is visibly + // populated with the same "no value yet" marker used by the + // main and send screens, including in the brief window + // before populateConfirmWalletAddressAndBalance reasserts it + // and kicks off the balance fetch. + homeConfirmWalletBalanceValueTextView.setText("-"); } if (backButton != null) { diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/SendFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/SendFragment.java index e4efee0..667b880 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/SendFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/SendFragment.java @@ -390,7 +390,9 @@ public void onClick(View v) { public void onClick(View v) { switch (retryStatus) { case 0: - getBalanceByAccount(walletAddress, balanceTextView, progressBar); + // Retry tap is an explicit user-initiated + // refresh: surface failures via toast. + getBalanceByAccount(walletAddress, balanceTextView, progressBar, true); break; case 1: String message = getResources().getString(R.string.send_transaction_message_description); @@ -1092,12 +1094,34 @@ public void onClick(View v) { } } - private void getBalanceByAccount(String address, TextView balanceTextView, ProgressBar progressBar) { + /** + * Fetches the native QC balance for the Send screen and updates the + * displayed amount. + * + *

{@code userInitiated} controls how failures surface: + *

+ * + *

The full-body retry overlay ({@code linerLayout_send_offline}) + * is no longer surfaced from this path. Replacing the entire send + * form with a "Service unavailable" placeholder on a transient + * balance-fetch failure was disproportionate (especially on + * tablets where the overlay leaves a huge empty body region) and + * blocked the user from continuing to compose a transaction with + * the previous balance value. The previously-displayed balance is + * left in place. + */ + private void getBalanceByAccount(String address, TextView balanceTextView, + ProgressBar progressBar, boolean userInitiated) { try{ retryStatus = 0; - linerLayoutOffline.setVisibility(View.GONE); - //Internet connection check if (GlobalMethods.IsNetworkAvailable(getContext())) { @@ -1105,6 +1129,7 @@ private void getBalanceByAccount(String address, TextView balanceTextView, Progr String[] taskParams = { address }; + final boolean userInitiatedFinal = userInitiated; AccountBalanceRestTask task = new AccountBalanceRestTask( getContext(), new AccountBalanceRestTask.TaskListener() { @Override @@ -1122,22 +1147,22 @@ public void onFailure(com.quantumcoin.app.api.read.ApiException e) { int code = e.getCode(); boolean check = GlobalMethods.ApiExceptionSourceCodeBoolean(code); if(check==true) { - GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, - getString(R.string.apierror), - TAG + " : AccountBalanceRestTask : " + e.toString()); + if (userInitiatedFinal) { + GlobalMethods.ApiExceptionSourceCodeRoute(getContext(), code, + getString(R.string.apierror), + TAG + " : AccountBalanceRestTask : " + e.toString()); + } } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, true); + GlobalMethods.NotifyServiceUnavailable( + getContext(), true, userInitiatedFinal); } } }); task.execute(taskParams); } else { - GlobalMethods.OfflineOrExceptionError(getContext(), - linerLayoutOffline, imageViewRetry, textViewTitleRetry, - textViewSubTitleRetry, false); + GlobalMethods.NotifyServiceUnavailable( + getContext(), false, userInitiated); } } catch (Exception e) { GlobalMethods.ExceptionError(getContext(), TAG, e); @@ -1358,7 +1383,14 @@ public void onItemSelected(AdapterView parent, View view, int position, long selectedDecimals = 18; selectedTokenBalanceWei = null; assetSelectedTextView.setText("QuantumCoin"); - getBalanceByAccount(walletAddress, balanceValueTextView, balanceProgress); + // Asset spinner re-selecting QC is the user picking + // an asset; treat it as user-initiated so a failure + // surfaces via toast. The first fetch on screen open + // also flows through here (ArrayAdapter.set -> + // OnItemSelected position=0); that initial firing + // also benefits from a toast on a 5xx since the user + // is actively using the screen. + getBalanceByAccount(walletAddress, balanceValueTextView, balanceProgress, true); return; } int tokenIndex = position - 1; diff --git a/app/src/main/java/com/quantumcoin/app/view/fragment/WalletsFragment.java b/app/src/main/java/com/quantumcoin/app/view/fragment/WalletsFragment.java index 8db24eb..68feea7 100644 --- a/app/src/main/java/com/quantumcoin/app/view/fragment/WalletsFragment.java +++ b/app/src/main/java/com/quantumcoin/app/view/fragment/WalletsFragment.java @@ -19,9 +19,7 @@ import com.quantumcoin.app.R; import com.quantumcoin.app.keystorage.SecureStorage; import com.quantumcoin.app.utils.GlobalMethods; -import com.quantumcoin.app.utils.GridAutoFitLayoutManager; import com.quantumcoin.app.utils.PrefConnect; -import com.quantumcoin.app.utils.Utility; import com.quantumcoin.app.view.adapter.WalletAdapter; import com.quantumcoin.app.viewmodel.JsonViewModel; import com.quantumcoin.app.viewmodel.KeyViewModel; @@ -88,10 +86,21 @@ public void onViewCreated(View view, Bundle savedInstanceState) { this.recycler.removeAllViewsInLayout(); - int mNoOfColumns = Utility.calculateNoOfColumns(getContext(), R.id.recycler_wallets); - - GridAutoFitLayoutManager mLayoutManager = new GridAutoFitLayoutManager(getContext(), - mNoOfColumns, 1, false); + // Each wallet is a single full-width row whose four cells line up + // with the four columns of wallet_header (Address / Block Explorer + // / Backup / Reveal Seed). The previous GridAutoFitLayoutManager + // call passed R.id.recycler_wallets (a resource id like + // ~2_131_297_012) as the column-width-in-dp argument, which made + // calculateNoOfColumns return 0 and the layout manager fall back + // to its 200dp default. On phones that still produced one column + // (~360dp wide), but on tablets (~700-900dp wide) it produced + // 3-4 columns, so each wallet item shrank to a fraction of the + // recycler width and its inner weight=1 cells no longer aligned + // with the header columns above. A plain vertical + // LinearLayoutManager guarantees one wallet per row at every + // form factor. + androidx.recyclerview.widget.LinearLayoutManager mLayoutManager = + new androidx.recyclerview.widget.LinearLayoutManager(getContext()); this.recycler.setLayoutManager(mLayoutManager); diff --git a/app/src/main/res/layout/account_transactions_fragment.xml b/app/src/main/res/layout/account_transactions_fragment.xml index 6acd849..bc979a5 100644 --- a/app/src/main/res/layout/account_transactions_fragment.xml +++ b/app/src/main/res/layout/account_transactions_fragment.xml @@ -68,6 +68,28 @@ android:background="@drawable/image_selector" android:src="@drawable/retry" app:tint="@color/colorCommon6" /> + + + - - diff --git a/app/src/main/res/layout/home_activity.xml b/app/src/main/res/layout/home_activity.xml index 00f97e0..12da615 100644 --- a/app/src/main/res/layout/home_activity.xml +++ b/app/src/main/res/layout/home_activity.xml @@ -203,8 +203,8 @@ + diff --git a/app/src/main/res/layout/home_wallet_fragment.xml b/app/src/main/res/layout/home_wallet_fragment.xml index bd603f5..3ac220f 100644 --- a/app/src/main/res/layout/home_wallet_fragment.xml +++ b/app/src/main/res/layout/home_wallet_fragment.xml @@ -104,7 +104,7 @@ app:layout_constraintStart_toStartOf="parent" app:hintAnimationEnabled="true" app:endIconMode="password_toggle" - app:endIconTint="@color/colorEye" + app:endIconTint="@color/colorCommon6" app:endIconDrawable="@drawable/show_password_selector" app:endIconContentDescription="@string/password_toggle_content_description"> @@ -140,7 +140,7 @@ app:layout_constraintStart_toStartOf="parent" app:hintAnimationEnabled="true" app:endIconMode="password_toggle" - app:endIconTint="@color/colorEye" + app:endIconTint="@color/colorCommon6" app:endIconDrawable="@drawable/show_password_selector" app:endIconContentDescription="@string/password_toggle_content_description"> @@ -3866,11 +3866,23 @@ android:gravity="center_vertical" android:orientation="horizontal"> + + From 5b0fe42a6ff547c60dd4c9c2c12feb2b41410905 Mon Sep 17 00:00:00 2001 From: a Date: Tue, 12 May 2026 18:22:21 -0700 Subject: [PATCH 2/2] verification --- README.md | 19 ++-- app/src/main/res/xml/backup_rules.xml | 21 ++-- .../main/res/xml/data_extraction_rules.xml | 19 ++-- gradle/verification-metadata.xml | 98 +++++++++++++++++++ 4 files changed, 138 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d7247f3..92b5087 100644 --- a/README.md +++ b/README.md @@ -700,12 +700,19 @@ are never persisted. Both [`backup_rules.xml`](app/src/main/res/xml/backup_rules.xml) and [`data_extraction_rules.xml`](app/src/main/res/xml/data_extraction_rules.xml) -include the slot directory and the `DP_QUANTUM_COIN_WALLET_APP_PREF.xml` -prefs file, and explicitly **exclude** the -`qc_strongbox_counter_v1.xml` pref file (its HMAC tag is signed -by a non-extractable AndroidKeystore key that does not survive a -device migration; on the new device the counter is heal-forwarded -from the restored slot's `generation` field). +whitelist exactly the slot directory and the +`DP_QUANTUM_COIN_WALLET_APP_PREF.xml` prefs file via +path-specific `` rules. Because the sharedpref include +names a single file, the +`qc_strongbox_counter_v1.xml` pref file is **excluded by +omission** — no explicit `` is used (lint's +`FullBackupContent` check rejects `` paths that are +not under any ``, and an explicit exclude here would +be a redundant no-op). The counter pref must not travel because +its HMAC tag is signed by a non-extractable AndroidKeystore key +that does not survive a device migration; on the new device the +counter is heal-forwarded from the restored slot's `generation` +field. The runtime gate in [`backup/WalletBackupAgent.java`](app/src/main/java/com/quantumcoin/app/backup/WalletBackupAgent.java) diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 9e62ffa..93596e6 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -84,15 +84,22 @@ Exclusions: The AndroidKeystore-bound generation-counter pref file - (qc_strongbox_counter_v1.xml) is explicitly excluded — its - HMAC tag is signed by a non-extractable device-bound key - that does not survive a device migration, so backing it up - would mean the new device's keystore tag check rejects the - counter on every unlock. The counter is re-initialised from - the slot file's generation field on the new device. + (qc_strongbox_counter_v1.xml) is implicitly excluded by the + path-specific sharedpref above: only + DP_QUANTUM_COIN_WALLET_APP_PREF.xml is whitelisted, so every + other sharedpref file (the counter pref included) stays + local to the device. We deliberately do NOT add an explicit + for the counter pref, because lint's + [FullBackupContent] check rejects paths that are + not under any path (the exclude would be a + redundant no-op that only adds noise). The counter pref + must not travel because its HMAC tag is signed by a + non-extractable device-bound AndroidKeystore key that does + not survive a device migration; on the new device the + counter is re-initialised from the slot file's generation + field. --> - diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index d353b28..edef577 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -17,21 +17,28 @@ the OS backup agent can read it pre-unlock without the wallet password. - Excluded explicitly: + Excluded implicitly: * qc_strongbox_counter_v1.xml — AndroidKeystore-bound - generation-counter HMAC tag. Device-bound key does not - migrate; counter is re-initialised on the new device - from the slot file's generation field. + generation-counter HMAC tag. The path-specific + + whitelists exactly one sharedpref file, so every other + sharedpref (the counter pref included) is excluded by + omission. We deliberately do NOT add an explicit + for it: lint's [FullBackupContent] check + rejects paths that are not under any + path, and the exclude would be a redundant no-op anyway. + The counter pref must not travel because the + AndroidKeystore-bound HMAC key is not extractable and + does not migrate; on the new device the counter is + re-initialised from the slot file's generation field. --> - - diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c04a6f8..e95710b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -885,6 +885,14 @@ + + + + + + + + @@ -1099,6 +1107,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1107,6 +1139,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2036,6 +2100,14 @@ + + + + + + + + @@ -2543,6 +2615,11 @@ + + + + + @@ -2591,6 +2668,11 @@ + + + + + @@ -2620,6 +2702,14 @@ + + + + + + + + @@ -2714,6 +2804,14 @@ + + + + + + + +