From 372f939a67254f8a5f78b4c07ecd00dad31708d8 Mon Sep 17 00:00:00 2001 From: Varsha <102332078+varsha888@users.noreply.github.com> Date: Wed, 24 Aug 2022 11:26:07 -0700 Subject: [PATCH] Add support for biometric auth for payments. --- .../BiometricDeviceAuthentication.kt | 80 ++++++++++++++++++ .../securesms/PassphrasePromptActivity.java | 63 ++++++--------- .../app/privacy/PrivacySettingsFragment.kt | 35 ++++++++ .../app/privacy/PrivacySettingsState.kt | 1 + .../app/privacy/PrivacySettingsViewModel.kt | 6 ++ .../securesms/keyvalue/PaymentsValues.kt | 15 ++++ .../confirm/ConfirmPaymentFragment.java | 81 ++++++++++++++++++- .../payments/confirm/ConfirmPaymentState.java | 2 +- .../preferences/PaymentsHomeFragment.java | 32 ++++++++ app/src/main/res/navigation/app_settings.xml | 57 +------------ .../res/navigation/payments_preferences.xml | 29 ++++++- .../main/res/navigation/privacy_settings.xml | 66 +++++++++++++++ app/src/main/res/values/strings.xml | 23 ++++++ 13 files changed, 390 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/BiometricDeviceAuthentication.kt create mode 100644 app/src/main/res/navigation/privacy_settings.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/BiometricDeviceAuthentication.kt b/app/src/main/java/org/thoughtcrime/securesms/BiometricDeviceAuthentication.kt new file mode 100644 index 000000000..856881592 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BiometricDeviceAuthentication.kt @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.PromptInfo +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.ServiceUtil + +/** + * Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase). + */ +class BiometricDeviceAuthentication( + private val biometricManager: BiometricManager, + private val biometricPrompt: BiometricPrompt, + private val biometricPromptInfo: PromptInfo +) { + companion object { + const val AUTHENTICATED = 1 + const val NOT_AUTHENTICATED = -1 + const val TAG: String = "BiometricDeviceAuth" + const val BIOMETRIC_AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK + const val ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS or BiometricManager.Authenticators.DEVICE_CREDENTIAL + } + + fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean { + val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure + + if (!isKeyGuardSecure) { + Log.w(TAG, "Keyguard not secure...") + return false + } + + return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) { + if (force) { + Log.i(TAG, "Listening for biometric authentication...") + biometricPrompt.authenticate(biometricPromptInfo) + } else { + Log.i(TAG, "Skipping show system biometric or device lock dialog unless forced") + } + true + } else if (force) { + if (force) { + Log.i(TAG, "firing intent...") + showConfirmDeviceCredentialIntent() + } else { + Log.i(TAG, "Skipping firing intent unless forced") + } + true + } else { + Log.w(TAG, "Not compatible...") + false + } + } + + fun cancelAuthentication() { + biometricPrompt.cancelAuthentication() + } +} + +class BiometricDeviceLockContract : ActivityResultContract() { + + @RequiresApi(api = 21) + override fun createIntent(context: Context, input: String): Intent { + val keyguardManager = ServiceUtil.getKeyguardManager(context) + return keyguardManager.createConfirmDeviceCredentialIntent(input, "") + } + + override fun parseResult(resultCode: Int, intent: Intent?) = + if (resultCode != Activity.RESULT_OK) { + BiometricDeviceAuthentication.NOT_AUTHENTICATED + } else { + BiometricDeviceAuthentication.AUTHENTICATED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index ab27c327f..e71d6e64e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -47,7 +47,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.widget.Toolbar; import androidx.biometric.BiometricManager; -import androidx.biometric.BiometricManager.Authenticators; import androidx.biometric.BiometricPrompt; import org.signal.core.util.ThreadUtil; @@ -64,6 +63,8 @@ import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.SupportEmailUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import kotlin.Unit; + /** * Activity that prompts for a user's passphrase. * @@ -72,8 +73,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; public class PassphrasePromptActivity extends PassphraseActivity { private static final String TAG = Log.tag(PassphrasePromptActivity.class); - private static final int BIOMETRIC_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG | Authenticators.BIOMETRIC_WEAK; - private static final int ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS | Authenticators.DEVICE_CREDENTIAL; private static final short AUTHENTICATE_REQUEST_CODE = 1007; private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown"; public static final String FROM_FOREGROUND = "from_foreground"; @@ -90,9 +89,9 @@ public class PassphrasePromptActivity extends PassphraseActivity { private ImageButton hideButton; private AnimatingToggle visibilityToggle; - private BiometricManager biometricManager; - private BiometricPrompt biometricPrompt; - private BiometricPrompt.PromptInfo biometricPromptInfo; + private BiometricManager biometricManager; + private BiometricPrompt biometricPrompt; + private BiometricDeviceAuthentication biometricAuth; private boolean authenticated; private boolean hadFailure; @@ -249,12 +248,12 @@ public class PassphrasePromptActivity extends PassphraseActivity { lockScreenButton = findViewById(R.id.lock_screen_auth_container); biometricManager = BiometricManager.from(this); biometricPrompt = new BiometricPrompt(this, new BiometricAuthenticationListener()); - biometricPromptInfo = new BiometricPrompt.PromptInfo - .Builder() - .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS) - .setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal)) - .build(); - + BiometricPrompt.PromptInfo biometricPromptInfo = new BiometricPrompt.PromptInfo + .Builder() + .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS) + .setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal)) + .build(); + biometricAuth = new BiometricDeviceAuthentication(biometricManager, biometricPrompt, biometricPromptInfo); setSupportActionBar(toolbar); getSupportActionBar().setTitle(""); @@ -279,7 +278,7 @@ public class PassphrasePromptActivity extends PassphraseActivity { private void setLockTypeVisibility() { if (TextSecurePreferences.isScreenLockEnabled(this)) { passphraseAuthContainer.setVisibility(View.GONE); - fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE + fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BiometricDeviceAuthentication.BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE : View.GONE); lockScreenButton.setVisibility(View.VISIBLE); } else { @@ -290,33 +289,7 @@ public class PassphrasePromptActivity extends PassphraseActivity { } private void resumeScreenLock(boolean force) { - KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); - - assert keyguardManager != null; - - if (!keyguardManager.isKeyguardSecure()) { - Log.w(TAG ,"Keyguard not secure..."); - handleAuthenticated(); - return; - } - - if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) { - if (force) { - Log.i(TAG, "Listening for biometric authentication..."); - biometricPrompt.authenticate(biometricPromptInfo); - } else { - Log.i(TAG, "Skipping show system biometric dialog unless forced"); - } - } else if (Build.VERSION.SDK_INT >= 21) { - if (force) { - Log.i(TAG, "firing intent..."); - Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), ""); - startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE); - } else { - Log.i(TAG, "Skipping firing intent unless forced"); - } - } else { - Log.w(TAG, "Not compatible..."); + if (!biometricAuth.authenticate(getApplicationContext(), force, this::showConfirmDeviceCredentialIntent)) { handleAuthenticated(); } } @@ -332,6 +305,16 @@ public class PassphrasePromptActivity extends PassphraseActivity { body); } + public Unit showConfirmDeviceCredentialIntent() { + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + Intent intent = null; + if (Build.VERSION.SDK_INT >= 21) { + intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), ""); + } + startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE); + return Unit.INSTANCE; + } + private class PassphraseActionListener implements TextView.OnEditorActionListener { @Override public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt index 206975a63..e30fe9789 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.privacy import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.provider.Settings import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.TextAppearanceSpan @@ -16,6 +17,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.Navigation import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import mobi.upod.timedurationpicker.TimeDurationPicker @@ -78,9 +80,15 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac val repository = PrivacySettingsRepository() val factory = PrivacySettingsViewModel.Factory(sharedPreferences, repository) viewModel = ViewModelProvider(this, factory)[PrivacySettingsViewModel::class.java] + val args: PrivacySettingsFragmentArgs by navArgs() + var showPaymentLock = true viewModel.state.observe(viewLifecycleOwner) { state -> adapter.submitList(getConfiguration(state).toMappingModelList()) + if (args.showPaymentLock && showPaymentLock) { + showPaymentLock = false + recyclerView?.scrollToPosition(adapter.itemCount - 1) + } } } @@ -304,6 +312,23 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac dividerPref() + sectionHeaderPref(R.string.preferences_app_protection__payments) + + switchPref( + title = DSLSettingsText.from(R.string.preferences__payment_lock), + summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__payment_lock_require_lock), + isChecked = state.paymentLock && ServiceUtil.getKeyguardManager(requireContext()).isKeyguardSecure, + onClick = { + if (!ServiceUtil.getKeyguardManager(requireContext()).isKeyguardSecure) { + showGoToPhoneSettings() + } else { + viewModel.togglePaymentLock() + } + } + ) + + dividerPref() + clickPref( title = DSLSettingsText.from(R.string.preferences__advanced), summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__signal_message_and_calls), @@ -314,6 +339,16 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac } } + private fun showGoToPhoneSettings() { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(getString(R.string.PrivacySettingsFragment__cant_enable_title)) + setMessage(getString(R.string.PrivacySettingsFragment__cant_enable_description)) + setPositiveButton(R.string.PaymentsHomeFragment__enable) { _, _ -> startActivity(Intent(Settings.ACTION_BIOMETRIC_ENROLL)) } + setNegativeButton(R.string.PaymentsHomeFragment__not_now) { _, _ -> } + show() + } + } + private fun getScreenLockInactivityTimeoutSummary(timeoutSeconds: Long): String { val hours = TimeUnit.SECONDS.toHours(timeoutSeconds) val minutes = TimeUnit.SECONDS.toMinutes(timeoutSeconds) - hours * 60 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt index 14f6e5591..f2c0ecf3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt @@ -12,6 +12,7 @@ data class PrivacySettingsState( val screenLockActivityTimeout: Long, val screenSecurity: Boolean, val incognitoKeyboard: Boolean, + val paymentLock: Boolean, val isObsoletePasswordEnabled: Boolean, val isObsoletePasswordTimeoutEnabled: Boolean, val obsoletePasswordTimeout: Int, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt index a9e055c75..6d2a2e7c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt @@ -74,6 +74,11 @@ class PrivacySettingsViewModel( refresh() } + fun togglePaymentLock() { + SignalStore.paymentsValues().paymentLock = state.value?.let { !it.paymentLock } ?: false + refresh() + } + fun setObsoletePasswordTimeoutEnabled(enabled: Boolean) { sharedPreferences.edit().putBoolean(TextSecurePreferences.PASSPHRASE_TIMEOUT_PREF, enabled).apply() refresh() @@ -97,6 +102,7 @@ class PrivacySettingsViewModel( screenLockActivityTimeout = TextSecurePreferences.getScreenLockTimeout(ApplicationDependencies.getApplication()), screenSecurity = TextSecurePreferences.isScreenSecurityEnabled(ApplicationDependencies.getApplication()), incognitoKeyboard = TextSecurePreferences.isIncognitoKeyboardEnabled(ApplicationDependencies.getApplication()), + paymentLock = SignalStore.paymentsValues().paymentLock, seeMyPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode, findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode, isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(ApplicationDependencies.getApplication()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt index fecd3fd36..8af205db1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt @@ -43,6 +43,9 @@ internal class PaymentsValues internal constructor(store: KeyValueStore) : Signa private const val SHOW_CASHING_OUT_INFO_CARD = "mob_payments_show_cashing_out_info_card" private const val SHOW_RECOVERY_PHRASE_INFO_CARD = "mob_payments_show_recovery_phrase_info_card" private const val SHOW_UPDATE_PIN_INFO_CARD = "mob_payments_show_update_pin_info_card" + private const val PAYMENT_LOCK_ENABLED = "mob_payments_payment_lock_enabled" + private const val PAYMENT_LOCK_TIMESTAMP = "mob_payments_payment_lock_timestamp" + private const val PAYMENT_LOCK_SKIP_COUNT = "mob_payments_payment_lock_skip_count" private val LARGE_BALANCE_THRESHOLD = Money.mobileCoin(BigDecimal.valueOf(500)) @@ -50,6 +53,18 @@ internal class PaymentsValues internal constructor(store: KeyValueStore) : Signa const val MOB_PAYMENTS_ENABLED = "mob_payments_enabled" } + var paymentLock + get() = getBoolean(PAYMENT_LOCK_ENABLED, false) + set(enabled) = putBoolean(PAYMENT_LOCK_ENABLED, enabled) + + var paymentLockTimestamp + get() = getLong(PAYMENT_LOCK_TIMESTAMP, 0) + set(timestamp) = putLong(PAYMENT_LOCK_TIMESTAMP, timestamp) + + var paymentLockSkipCount + get() = getInteger(PAYMENT_LOCK_SKIP_COUNT, 0) + set(count) = putInteger(PAYMENT_LOCK_SKIP_COUNT, count) + private val liveCurrentCurrency: MutableLiveData by lazy { MutableLiveData(currentCurrency()) } private val liveMobileCoinLedger: MutableLiveData by lazy { MutableLiveData(mobileCoinLatestFullLedger()) } private val liveMobileCoinBalance: LiveData by lazy { Transformations.map(liveMobileCoinLedger) { obj: MobileCoinLedgerWrapper -> obj.balance } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java index abaee435c..6d9d2745b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java @@ -14,8 +14,11 @@ 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.ViewModelProviders; @@ -26,31 +29,42 @@ import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; 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.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; -public class ConfirmPaymentFragment extends BottomSheetDialogFragment { +import kotlin.Unit; - private ConfirmPaymentViewModel viewModel; - private final Runnable dismiss = () -> { +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), R.id.action_directly_to_paymentsHome); + SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), PaymentsHomeFragmentDirections.actionDirectlyToPaymentsHome(!isPaymentLockEnabled(requireContext()))); } }; @@ -86,6 +100,12 @@ public class ConfirmPaymentFragment extends BottomSheetDialogFragment { 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) { @@ -117,6 +137,16 @@ public class ConfirmPaymentFragment extends BottomSheetDialogFragment { 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 @@ -125,6 +155,12 @@ public class ConfirmPaymentFragment extends BottomSheetDialogFragment { 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(); @@ -170,11 +206,48 @@ public class ConfirmPaymentFragment extends BottomSheetDialogFragment { 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())) { + biometricAuth.authenticate(requireContext(), true, this::showConfirmDeviceCredentialIntent); + } 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); + if (errorCode != BiometricPrompt.ERROR_CANCELED && errorCode != BiometricPrompt.ERROR_USER_CANCELED) { + onAuthenticationFailed(); + } + } + + @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"); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java index b9850a70d..a8ec43700 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java @@ -30,7 +30,7 @@ public class ConfirmPaymentState { amount, note, amount.toZero(), - FeeStatus.NOT_SET, + FeeStatus.STILL_LOADING, null, Status.CONFIRM, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java index aa364cfc7..63d23a945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java @@ -38,7 +38,11 @@ import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; +import java.util.concurrent.TimeUnit; + public class PaymentsHomeFragment extends LoggingFragment { + private static final int DAYS_UNTIL_REPROMPT_PAYMENT_LOCK = 30; + private static final int MAX_PAYMENT_LOCK_SKIP_COUNT = 2; private static final String TAG = Log.tag(PaymentsHomeFragment.class); @@ -50,6 +54,34 @@ public class PaymentsHomeFragment extends LoggingFragment { super(R.layout.payments_home_fragment); } + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + long paymentLockTimestamp = SignalStore.paymentsValues().getPaymentLockTimestamp(); + boolean enablePaymentLock = PaymentsHomeFragmentArgs.fromBundle(getArguments()).getEnablePaymentLock(); + boolean showPaymentLock = SignalStore.paymentsValues().getPaymentLockSkipCount() < MAX_PAYMENT_LOCK_SKIP_COUNT && + (System.currentTimeMillis() >= paymentLockTimestamp); + + if (enablePaymentLock && showPaymentLock) { + long waitUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(DAYS_UNTIL_REPROMPT_PAYMENT_LOCK); + + SignalStore.paymentsValues().setPaymentLockTimestamp(waitUntil); + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.PaymentsHomeFragment__turn_on)) + .setMessage(getString(R.string.PaymentsHomeFragment__add_an_additional_layer)) + .setPositiveButton(R.string.PaymentsHomeFragment__enable, (dialog, which) -> + SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), PaymentsHomeFragmentDirections.actionPaymentsHomeToPrivacySettings(true))) + .setNegativeButton(R.string.PaymentsHomeFragment__not_now, (dialog, which) -> setSkipCount()) + .setCancelable(false) + .show(); + } + } + + private void setSkipCount() { + int skipCount = SignalStore.paymentsValues().getPaymentLockSkipCount(); + SignalStore.paymentsValues().setPaymentLockSkipCount(++skipCount); + } + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { Toolbar toolbar = view.findViewById(R.id.payments_home_fragment_toolbar); diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index bf436498f..56e397179 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -54,7 +54,7 @@ app:popExitAnim="@anim/fragment_close_exit" /> - - - - - - - - - - - - - - - - + @@ -525,7 +474,7 @@ + + + + + + + + + app:popUpToInclusive="false"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/privacy_settings.xml b/app/src/main/res/navigation/privacy_settings.xml new file mode 100644 index 000000000..999418364 --- /dev/null +++ b/app/src/main/res/navigation/privacy_settings.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cbf9b8b2..bd3ab86d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2611,6 +2611,8 @@ Data and storage Storage Payments + + Payment lock Payments (Beta) Conversation length limit Keep messages @@ -2677,6 +2679,8 @@ Who can… App access Communication + + Payments Chats Manage storage Calls @@ -2887,6 +2891,14 @@ Payments in Signal is no longer available. You can still transfer funds to an exchange but you can no longer send and receive payments or add funds. https://www.mobilecoin.com/terms-of-use.html + + Turn on Payment Lock for future sends? + + Add an additional layer of security and require Android screen lock or fingerprint to transfer funds. + + Turn On + + Not Now Add funds @@ -2978,6 +2990,8 @@ Payment failed Payment will continue processing Invalid recipient + + Unlock to Send Payment This person has not activated payments Unable to request a network fee. To continue this payment tap okay to try again. @@ -3926,6 +3940,15 @@ Set a default disappearing message timer for all new chats started by you. Manage your stories and who can view them + Require Android screen lock or fingerprint to transfer funds + + Can\’t enable payment lock + + To use Payment Lock, you must first enable a screen lock or fingerprint ID in your phone’s settings. + + Go to settings + + Cancel https://signal.org/blog/sealed-sender