package org.thoughtcrime.securesms.payments.confirm; import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.style.TextAppearanceSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.biometric.BiometricManager; import androidx.biometric.BiometricPrompt; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BiometricDeviceAuthentication; import org.thoughtcrime.securesms.BiometricDeviceLockContract; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog; import org.thoughtcrime.securesms.payments.FiatMoneyUtil; import org.thoughtcrime.securesms.payments.Payee; import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeFragmentDirections; import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.signal.core.util.StringUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import org.whispersystems.signalservice.api.payments.FormatterOptions; import java.util.concurrent.TimeUnit; import kotlin.Unit; public class ConfirmPaymentFragment extends BottomSheetDialogFragment { private static final String TAG = Log.tag(ConfirmPaymentFragment.class); private ConfirmPaymentViewModel viewModel; private ActivityResultLauncher activityResultLauncher; private BiometricDeviceAuthentication biometricAuth; private final Runnable dismiss = () -> { dismissAllowingStateLoss(); if (ConfirmPaymentFragmentArgs.fromBundle(requireArguments()).getFinishOnConfirm()) { requireActivity().setResult(Activity.RESULT_OK); requireActivity().finish(); } else { SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), PaymentsHomeFragmentDirections.actionDirectlyToPaymentsHome(!isPaymentLockEnabled(requireContext()))); } }; @Override public void show(@NonNull FragmentManager manager, @Nullable String tag) { BottomSheetUtil.show(manager, tag, this); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { setStyle(DialogFragment.STYLE_NORMAL, R.style.Signal_DayNight_BottomSheet_Rounded); super.onCreate(savedInstanceState); } @Override public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { BottomSheetDialog dialog = (BottomSheetDialog) super.onCreateDialog(savedInstanceState); dialog.getBehavior().setHideable(false); return dialog; } @Override public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.confirm_payment_fragment, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { ConfirmPaymentViewModel.Factory factory = new ConfirmPaymentViewModel.Factory(ConfirmPaymentFragmentArgs.fromBundle(requireArguments()).getCreatePaymentDetails()); viewModel = new ViewModelProvider(this, factory).get(ConfirmPaymentViewModel.class); RecyclerView list = view.findViewById(R.id.confirm_payment_fragment_list); ConfirmPaymentAdapter adapter = new ConfirmPaymentAdapter(new Callbacks()); list.setAdapter(adapter); activityResultLauncher = registerForActivityResult(new BiometricDeviceLockContract(), result -> { if (result == BiometricDeviceAuthentication.AUTHENTICATED) { viewModel.confirmPayment(); } }); viewModel.getState().observe(getViewLifecycleOwner(), state -> adapter.submitList(createList(state))); viewModel.isPaymentDone().observe(getViewLifecycleOwner(), isDone -> { if (isDone) { ThreadUtil.runOnMainDelayed(dismiss, TimeUnit.SECONDS.toMillis(2)); } }); viewModel.getErrorTypeEvents().observe(getViewLifecycleOwner(), error -> { switch (error) { case NO_PROFILE_KEY: CanNotSendPaymentDialog.show(requireContext()); break; case NO_ADDRESS: RecipientHasNotEnabledPaymentsDialog.show(requireContext()); break; case CAN_NOT_GET_FEE: new AlertDialog.Builder(requireContext()) .setMessage(R.string.ConfirmPaymentFragment__unable_to_request_a_network_fee) .setPositiveButton(android.R.string.ok, (dialog, which) -> { dialog.dismiss(); viewModel.refreshFee(); }) .setNegativeButton(android.R.string.cancel, (dialog, which) -> { dialog.dismiss(); dismiss(); }) .setCancelable(false) .show(); break; } }); BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo .Builder() .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS) .setTitle(requireContext().getString(R.string.ConfirmPaymentFragment__unlock_to_send_payment)) .setConfirmationRequired(false) .build(); biometricAuth = new BiometricDeviceAuthentication(BiometricManager.from(requireActivity()), new BiometricPrompt(requireActivity(), new BiometricAuthenticationListener()), promptInfo); } @Override public void onDismiss(@NonNull DialogInterface dialog) { super.onDismiss(dialog); ThreadUtil.cancelRunnableOnMain(dismiss); } @Override public void onPause() { super.onPause(); biometricAuth.cancelAuthentication(); } private @NonNull MappingModelList createList(@NonNull ConfirmPaymentState state) { MappingModelList list = new MappingModelList(); FormatterOptions options = FormatterOptions.defaults(); switch (state.getFeeStatus()) { case STILL_LOADING: case ERROR: list.add(new ConfirmPaymentAdapter.LoadingItem()); break; case NOT_SET: case SET: list.add(new ConfirmPaymentAdapter.LineItem(getToPayeeDescription(requireContext(), state), state.getAmount().toString(options))); if (state.getExchange() != null) { list.add(new ConfirmPaymentAdapter.LineItem(getString(R.string.ConfirmPayment__estimated_s, state.getExchange().getCurrency().getCurrencyCode()), FiatMoneyUtil.format(getResources(), state.getExchange(), FiatMoneyUtil.formatOptions().withDisplayTime(false)))); } list.add(new ConfirmPaymentAdapter.LineItem(getString(R.string.ConfirmPayment__network_fee), state.getFee().toString(options))); list.add(new ConfirmPaymentAdapter.Divider()); list.add(new ConfirmPaymentAdapter.TotalLineItem(getString(R.string.ConfirmPayment__total_amount), state.getTotal().toString(options))); } list.add(new ConfirmPaymentAdapter.ConfirmPaymentStatus(state.getStatus(), state.getFeeStatus(), state.getBalance())); return list; } private static CharSequence getToPayeeDescription(Context context, @NonNull ConfirmPaymentState state) { return new SpannableStringBuilder().append(context.getString(R.string.ConfirmPayment__to)) .append(' ') .append(getPayeeDescription(context, state.getPayee())); } private static CharSequence getPayeeDescription(Context context, @NonNull Payee payee) { return payee.hasRecipientId() ? Recipient.resolved(payee.requireRecipientId()).getDisplayName(context) : mono(context, StringUtil.abbreviateInMiddle(payee.requirePublicAddress().getPaymentAddressBase58(), 17)); } private static CharSequence mono(Context context, CharSequence address) { SpannableString spannable = new SpannableString(address); spannable.setSpan(new TextAppearanceSpan(context, R.style.TextAppearance_Signal_Mono), 0, address.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return spannable; } private boolean isPaymentLockEnabled(Context context) { return SignalStore.paymentsValues().getPaymentLock() && ServiceUtil.getKeyguardManager(context).isKeyguardSecure(); } private class Callbacks implements ConfirmPaymentAdapter.Callbacks { @Override public void onConfirmPayment() { setCancelable(false); if (isPaymentLockEnabled(requireContext())) { boolean success = biometricAuth.authenticate(requireContext(), true, this::showConfirmDeviceCredentialIntent); if (!success) { setCancelable(true); new MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.ConfirmPaymentFragment__failed_to_show_payment_lock) .setMessage(R.string.ConfirmPaymentFragment__you_enabled_payment_lock_in_the_settings) .setNeutralButton(android.R.string.ok, (d, i) -> d.dismiss()) .setNegativeButton(R.string.ConfirmPaymentFragment__go_to_settings, (d, i) -> { startActivity(AppSettingsActivity.privacy(requireContext())); d.dismiss(); }) .show(); } } else { viewModel.confirmPayment(); } } public Unit showConfirmDeviceCredentialIntent() { activityResultLauncher.launch(getString(R.string.ConfirmPaymentFragment__unlock_to_send_payment)); return Unit.INSTANCE; } } private class BiometricAuthenticationListener extends BiometricPrompt.AuthenticationCallback { @Override public void onAuthenticationError(int errorCode, @NonNull CharSequence errorString) { Log.w(TAG, "Authentication error: " + errorCode); switch (errorCode) { case BiometricPrompt.ERROR_CANCELED: case BiometricPrompt.ERROR_USER_CANCELED: setCancelable(true); break; default: onAuthenticationFailed(); break; } } @Override public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { Log.i(TAG, "onAuthenticationSucceeded"); viewModel.confirmPayment(); } @Override public void onAuthenticationFailed() { Log.w(TAG, "Unable to authenticate payment lock"); } } }