mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +01:00
Payments.
Co-authored-by: Alan Evans <alan@signal.org> Co-authored-by: Alex Hart <alex@signal.org> Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -38,6 +38,9 @@ repositories {
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
mavenLocal()
|
||||
maven {
|
||||
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
@@ -123,6 +126,8 @@ android {
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}"
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
@@ -248,6 +253,8 @@ android {
|
||||
dimension 'environment'
|
||||
|
||||
isDefault true
|
||||
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
|
||||
}
|
||||
|
||||
staging {
|
||||
@@ -268,6 +275,7 @@ android {
|
||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +370,11 @@ dependencies {
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.1.7'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
||||
|
||||
implementation('com.mobilecoin:android-sdk:1.0.0') {
|
||||
exclude group: 'com.google.protobuf'
|
||||
}
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:2.9.4'
|
||||
|
||||
@@ -497,6 +497,10 @@
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
|
||||
<activity android:name=".payments.preferences.PaymentsActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".lock.v2.CreateKbsPinActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -712,6 +716,8 @@
|
||||
|
||||
<receiver android:name=".service.TrimThreadsByDateManager$TrimThreadsByDateAlarm" />
|
||||
|
||||
<receiver android:name=".payments.backup.phrase.ClearClipboardAlarmReceiver" />
|
||||
|
||||
<provider android:name=".providers.PartProvider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false"
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.content.SharedPreferences;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -32,8 +33,11 @@ import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
|
||||
import org.thoughtcrime.securesms.help.HelpFragment;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
|
||||
import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment;
|
||||
import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment;
|
||||
import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment;
|
||||
@@ -44,9 +48,9 @@ import org.thoughtcrime.securesms.preferences.DataAndStoragePreferenceFragment;
|
||||
import org.thoughtcrime.securesms.preferences.EditProxyFragment;
|
||||
import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment;
|
||||
import org.thoughtcrime.securesms.preferences.SmsMmsPreferenceFragment;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.PaymentsPreference;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.ProfilePreference;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.UsernamePreference;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
@@ -87,6 +91,7 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
|
||||
private static final String PREFERENCE_CATEGORY_HELP = "preference_category_help";
|
||||
private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced";
|
||||
private static final String PREFERENCE_CATEGORY_DONATE = "preference_category_donate";
|
||||
private static final String PREFERENCE_CATEGORY_PAYMENTS = "preference_category_payments";
|
||||
|
||||
private static final String WAS_CONFIGURATION_UPDATED = "was_configuration_updated";
|
||||
|
||||
@@ -111,7 +116,10 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
|
||||
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_BACKUPS_FRAGMENT, false)) {
|
||||
initFragment(android.R.id.content, new BackupsPreferenceFragment());
|
||||
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_HELP_FRAGMENT, false)) {
|
||||
initFragment(android.R.id.content, new HelpFragment());
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt(HelpFragment.START_CATEGORY_INDEX, getIntent().getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0));
|
||||
|
||||
initFragment(android.R.id.content, new HelpFragment(), null, bundle);
|
||||
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_PROXY_FRAGMENT, false)) {
|
||||
initFragment(android.R.id.content, EditProxyFragment.newInstance());
|
||||
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_NOTIFICATIONS_FRAGMENT, false)) {
|
||||
@@ -191,6 +199,8 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
|
||||
|
||||
public static class ApplicationPreferenceFragment extends CorrectedPreferenceFragment {
|
||||
|
||||
private final UnreadPaymentsLiveData unreadPaymentsLiveData = new UnreadPaymentsLiveData();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle icicle) {
|
||||
super.onCreate(icicle);
|
||||
@@ -220,9 +230,29 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
|
||||
this.findPreference(PREFERENCE_CATEGORY_DONATE)
|
||||
.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DONATE));
|
||||
|
||||
Preference paymentsPreference = this.findPreference(PREFERENCE_CATEGORY_PAYMENTS);
|
||||
|
||||
if (SignalStore.paymentsValues().getPaymentsAvailability().showPaymentsMenu()) {
|
||||
paymentsPreference.setVisible(true);
|
||||
paymentsPreference.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_PAYMENTS));
|
||||
} else {
|
||||
paymentsPreference.setVisible(false);
|
||||
}
|
||||
|
||||
tintIcons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (SignalStore.paymentsValues().getPaymentsAvailability().showPaymentsMenu()) {
|
||||
PaymentsPreference paymentsPreference = (PaymentsPreference) this.findPreference(PREFERENCE_CATEGORY_PAYMENTS);
|
||||
|
||||
unreadPaymentsLiveData.observe(getViewLifecycleOwner(), unreadPayments -> paymentsPreference.setUnreadCount(unreadPayments.transform(UnreadPayments::getUnreadCount).or(-1)));
|
||||
}
|
||||
}
|
||||
|
||||
private void tintIcons() {
|
||||
if (Build.VERSION.SDK_INT >= 21) return;
|
||||
|
||||
@@ -336,6 +366,9 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
|
||||
case PREFERENCE_CATEGORY_DONATE:
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url));
|
||||
break;
|
||||
case PREFERENCE_CATEGORY_PAYMENTS:
|
||||
startActivity(new Intent(requireContext(), PaymentsActivity.class));
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
@@ -153,10 +153,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
listCallback = (ListCallback) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) context;
|
||||
}
|
||||
@@ -200,7 +208,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
|
||||
if (activity.getIntent().getBooleanExtra(RECENTS, false)) {
|
||||
if (safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false))) {
|
||||
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
|
||||
} else {
|
||||
initializeNoContactsPermission();
|
||||
@@ -234,14 +242,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
});
|
||||
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Bundle arguments = safeArguments();
|
||||
|
||||
swipeRefresh.setEnabled(intent.getBooleanExtra(REFRESHABLE, true));
|
||||
swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)));
|
||||
|
||||
hideCount = intent.getBooleanExtra(HIDE_COUNT, false);
|
||||
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti);
|
||||
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||
if (selectionLimit == null) {
|
||||
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
|
||||
}
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = arguments.getBoolean(CAN_SELECT_SELF, intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti));
|
||||
|
||||
if (!isMulti) {
|
||||
selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
@@ -254,6 +266,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return view;
|
||||
}
|
||||
|
||||
private @NonNull Bundle safeArguments() {
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
|
||||
private void updateGroupLimit(int chipCount) {
|
||||
int members = currentSelection.size() + chipCount;
|
||||
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
|
||||
@@ -283,7 +299,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
|
||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||
if (currentSelection == null) {
|
||||
currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
|
||||
}
|
||||
|
||||
return currentSelection == null ? Collections.emptySet()
|
||||
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
|
||||
@@ -398,8 +417,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
int displayMode = activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL);
|
||||
boolean displayRecents = activity.getIntent().getBooleanExtra(RECENTS, false);
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
||||
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
|
||||
|
||||
if (cursorFactoryProvider != null) {
|
||||
return cursorFactoryProvider.get().create();
|
||||
@@ -671,7 +690,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
if (!requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true)) {
|
||||
if (!safeArguments().getBoolean(DISPLAY_CHIPS, requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
@@ -17,7 +18,7 @@ public abstract class LoggingFragment extends Fragment {
|
||||
|
||||
public LoggingFragment() { }
|
||||
|
||||
public LoggingFragment(int contentLayoutId) {
|
||||
public LoggingFragment(@LayoutRes int contentLayoutId) {
|
||||
super(contentLayoutId);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.graphics.Paint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
@@ -119,11 +120,18 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
* Shows self as the actual profile picture.
|
||||
*/
|
||||
public void setRecipient(@NonNull Recipient recipient) {
|
||||
setRecipient(recipient, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows self as the actual profile picture.
|
||||
*/
|
||||
public void setRecipient(@NonNull Recipient recipient, boolean quickContactEnabled) {
|
||||
if (recipient.isSelf()) {
|
||||
setAvatar(GlideApp.with(this), null, false);
|
||||
setAvatar(GlideApp.with(this), null, quickContactEnabled);
|
||||
AvatarUtil.loadIconIntoImageView(recipient, this);
|
||||
} else {
|
||||
setAvatar(GlideApp.with(this), recipient, false);
|
||||
setAvatar(GlideApp.with(this), recipient, quickContactEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +213,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
super.setOnClickListener(listener);
|
||||
setClickable(listener != null);
|
||||
disableQuickContact();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +234,16 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
.into(this);
|
||||
}
|
||||
|
||||
public void setNonAvatarImageResource(@DrawableRes int imageResource) {
|
||||
recipientContactPhoto = null;
|
||||
setImageResource(imageResource);
|
||||
}
|
||||
|
||||
public void disableQuickContact() {
|
||||
super.setOnClickListener(listener);
|
||||
setClickable(listener != null);
|
||||
}
|
||||
|
||||
private static class RecipientContactPhoto {
|
||||
|
||||
private final @NonNull Recipient recipient;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class PaymentPillStrip extends ConstraintLayout {
|
||||
|
||||
private FrameLayout buttonStart;
|
||||
private FrameLayout buttonEnd;
|
||||
|
||||
public PaymentPillStrip(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public PaymentPillStrip(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public PaymentPillStrip(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public PaymentPillStrip(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
buttonStart = findViewById(R.id.button_start_frame);
|
||||
buttonEnd = findViewById(R.id.button_end_frame);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
|
||||
if (buttonStart.getMeasuredWidth() > buttonEnd.getMinimumWidth()) {
|
||||
buttonEnd.setMinimumWidth(buttonStart.getMeasuredWidth());
|
||||
}
|
||||
|
||||
if (buttonEnd.getMeasuredWidth() > buttonStart.getMinimumWidth()) {
|
||||
buttonStart.setMinimumWidth(buttonEnd.getMeasuredWidth());
|
||||
}
|
||||
|
||||
if (buttonStart.getMeasuredHeight() > buttonEnd.getMinimumHeight()) {
|
||||
buttonEnd.setMinimumHeight(buttonStart.getMeasuredHeight());
|
||||
}
|
||||
|
||||
if (buttonEnd.getMeasuredHeight() > buttonStart.getMinimumHeight()) {
|
||||
buttonStart.setMinimumHeight(buttonEnd.getMeasuredHeight());
|
||||
}
|
||||
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
|
||||
/**
|
||||
* Displays the data in a given UnreadPayments object in a banner.
|
||||
*/
|
||||
public class UnreadPaymentsView extends ConstraintLayout {
|
||||
|
||||
private TextView title;
|
||||
private AvatarImageView avatar;
|
||||
private Listener listener;
|
||||
|
||||
public UnreadPaymentsView(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
title = findViewById(R.id.payment_notification_title);
|
||||
avatar = findViewById(R.id.payment_notification_avatar);
|
||||
|
||||
View open = findViewById(R.id.payment_notification_touch_target);
|
||||
View close = findViewById(R.id.payment_notification_close_touch_target);
|
||||
|
||||
open.setOnClickListener(v -> {
|
||||
if (listener != null) listener.onOpenPaymentsNotificationClicked();
|
||||
});
|
||||
|
||||
close.setOnClickListener(v -> {
|
||||
if (listener != null) listener.onClosePaymentsNotificationClicked();
|
||||
});
|
||||
}
|
||||
|
||||
public void setListener(@NonNull Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setUnreadPayments(@NonNull UnreadPayments unreadPayments) {
|
||||
title.setText(unreadPayments.getDescription(getContext()));
|
||||
avatar.setAvatar(unreadPayments.getRecipient());
|
||||
avatar.setVisibility(unreadPayments.getRecipient() == null ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
void onOpenPaymentsNotificationClicked();
|
||||
|
||||
void onClosePaymentsNotificationClicked();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
* Reusable adapter for generic settings list.
|
||||
*/
|
||||
public class BaseSettingsAdapter extends MappingAdapter {
|
||||
|
||||
public BaseSettingsAdapter() {
|
||||
registerFactory(SettingHeader.Item.class, SettingHeader.ViewHolder::new, R.layout.base_settings_header_item);
|
||||
registerFactory(SettingProgress.Item.class, SettingProgress.ViewHolder::new, R.layout.base_settings_progress_item);
|
||||
}
|
||||
|
||||
public void configureSingleSelect(@NonNull SingleSelectSetting.SingleSelectSelectionChangedListener selectionChangedListener) {
|
||||
registerFactory(SingleSelectSetting.Item.class,
|
||||
new LayoutFactory<>(v -> new SingleSelectSetting.ViewHolder(v, selectionChangedListener), R.layout.single_select_item));
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -20,13 +18,11 @@ import java.util.Objects;
|
||||
public class CustomizableSingleSelectSetting {
|
||||
|
||||
public interface CustomizableSingleSelectionListener extends SingleSelectSetting.SingleSelectSelectionChangedListener {
|
||||
void onCustomizeClicked(@NonNull Item item);
|
||||
void onCustomizeClicked(@Nullable Item item);
|
||||
}
|
||||
|
||||
public static class ViewHolder extends MappingViewHolder<Item> {
|
||||
private final TextView summaryText;
|
||||
private final View customize;
|
||||
private final RadioButton radio;
|
||||
private final SingleSelectSetting.ViewHolder delegate;
|
||||
private final Group customizeGroup;
|
||||
private final CustomizableSingleSelectionListener selectionListener;
|
||||
@@ -35,50 +31,34 @@ public class CustomizableSingleSelectSetting {
|
||||
super(itemView);
|
||||
this.selectionListener = selectionListener;
|
||||
|
||||
radio = findViewById(R.id.customizable_single_select_radio);
|
||||
summaryText = findViewById(R.id.customizable_single_select_summary);
|
||||
customize = findViewById(R.id.customizable_single_select_customize);
|
||||
customizeGroup = findViewById(R.id.customizable_single_select_customize_group);
|
||||
|
||||
delegate = new SingleSelectSetting.ViewHolder(itemView, selectionListener) {
|
||||
@Override
|
||||
protected void setChecked(boolean checked) {
|
||||
radio.setChecked(checked);
|
||||
}
|
||||
};
|
||||
delegate = new SingleSelectSetting.ViewHolder(itemView, selectionListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull Item model) {
|
||||
delegate.bind(model.singleSelectItem);
|
||||
customizeGroup.setVisibility(radio.isChecked() ? View.VISIBLE : View.GONE);
|
||||
customizeGroup.setVisibility(model.singleSelectItem.isSelected() ? View.VISIBLE : View.GONE);
|
||||
customize.setOnClickListener(v -> selectionListener.onCustomizeClicked(model));
|
||||
if (model.getCustomValue() != null) {
|
||||
summaryText.setText(model.getSummaryText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Item implements MappingModel<Item> {
|
||||
private SingleSelectSetting.Item singleSelectItem;
|
||||
private Object customValue;
|
||||
private String summaryText;
|
||||
private final SingleSelectSetting.Item singleSelectItem;
|
||||
private final Object customValue;
|
||||
|
||||
public <T> Item(@NonNull T item, @Nullable String text, boolean isSelected, @Nullable Object customValue, @Nullable String summaryText) {
|
||||
this.customValue = customValue;
|
||||
this.summaryText = summaryText;
|
||||
|
||||
singleSelectItem = new SingleSelectSetting.Item(item, text, isSelected);
|
||||
singleSelectItem = new SingleSelectSetting.Item(item, text, summaryText, isSelected);
|
||||
}
|
||||
|
||||
public @Nullable Object getCustomValue() {
|
||||
return customValue;
|
||||
}
|
||||
|
||||
public @Nullable String getSummaryText() {
|
||||
return summaryText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull Item newItem) {
|
||||
return singleSelectItem.areItemsTheSame(newItem.singleSelectItem);
|
||||
@@ -86,7 +66,7 @@ public class CustomizableSingleSelectSetting {
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull Item newItem) {
|
||||
return singleSelectItem.areContentsTheSame(newItem.singleSelectItem) && Objects.equals(customValue, newItem.customValue) && Objects.equals(summaryText, newItem.summaryText);
|
||||
return singleSelectItem.areContentsTheSame(newItem.singleSelectItem) && Objects.equals(customValue, newItem.customValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.components.settings;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Provide a default header {@link MappingModel} and {@link MappingViewHolder} for settings screens.
|
||||
*/
|
||||
public final class SettingHeader {
|
||||
|
||||
public static final class ViewHolder extends MappingViewHolder<Item> {
|
||||
|
||||
private final TextView headerText;
|
||||
|
||||
public ViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.headerText = findViewById(R.id.base_settings_header_item_text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull Item model) {
|
||||
if (model.text != null) {
|
||||
headerText.setText(model.text);
|
||||
} else {
|
||||
headerText.setText(model.textRes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Item implements MappingModel<Item> {
|
||||
private final int textRes;
|
||||
private final String text;
|
||||
|
||||
public Item(String text) {
|
||||
this.text = text;
|
||||
this.textRes = 0;
|
||||
}
|
||||
|
||||
public Item(@StringRes int textRes) {
|
||||
this.text = null;
|
||||
this.textRes = textRes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull Item newItem) {
|
||||
return textRes == newItem.textRes && Objects.equals(text, newItem.text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull Item newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.components.settings;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
|
||||
/**
|
||||
* Simple progress indicator that can be used multiple times (if provided with different {@link Item#id}s).
|
||||
*/
|
||||
public final class SettingProgress {
|
||||
|
||||
public static final class ViewHolder extends MappingViewHolder<SettingProgress.Item> {
|
||||
|
||||
public ViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull SettingProgress.Item model) { }
|
||||
}
|
||||
|
||||
public static final class Item implements MappingModel<SettingProgress.Item> {
|
||||
private final int id;
|
||||
|
||||
public Item() {
|
||||
this(0);
|
||||
}
|
||||
|
||||
public Item(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull SettingProgress.Item newItem) {
|
||||
return id == newItem.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull SettingProgress.Item newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.CheckedTextView;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -23,46 +26,62 @@ public class SingleSelectSetting {
|
||||
|
||||
public static class ViewHolder extends MappingViewHolder<Item> {
|
||||
|
||||
private final RadioButton radio;
|
||||
protected final CheckedTextView text;
|
||||
private final TextView summaryText;
|
||||
protected final SingleSelectSelectionChangedListener selectionChangedListener;
|
||||
|
||||
public ViewHolder(@NonNull View itemView, @NonNull SingleSelectSelectionChangedListener selectionChangedListener) {
|
||||
super(itemView);
|
||||
this.selectionChangedListener = selectionChangedListener;
|
||||
this.radio = findViewById(R.id.single_select_item_radio);
|
||||
this.text = findViewById(R.id.single_select_item_text);
|
||||
this.summaryText = findViewById(R.id.single_select_item_summary);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull Item model) {
|
||||
radio.setChecked(model.isSelected);
|
||||
text.setText(model.text);
|
||||
setChecked(model.isSelected);
|
||||
if (!TextUtils.isEmpty(model.summaryText)) {
|
||||
summaryText.setText(model.summaryText);
|
||||
summaryText.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
summaryText.setVisibility(View.GONE);
|
||||
}
|
||||
itemView.setOnClickListener(v -> selectionChangedListener.onSelectionChanged(model.item));
|
||||
}
|
||||
|
||||
protected void setChecked(boolean checked) {
|
||||
text.setChecked(checked);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Item implements MappingModel<Item> {
|
||||
private final String text;
|
||||
private final Object item;
|
||||
private final String text;
|
||||
private final String summaryText;
|
||||
private final boolean isSelected;
|
||||
|
||||
public <T> Item(@NonNull T item, @Nullable String text, boolean isSelected) {
|
||||
this.item = item;
|
||||
this.text = text != null ? text : item.toString();
|
||||
this.isSelected = isSelected;
|
||||
}
|
||||
|
||||
public @NonNull String getText() {
|
||||
return text;
|
||||
public <T> Item(@NonNull T item, @Nullable String text, @Nullable String summaryText, boolean isSelected) {
|
||||
this.item = item;
|
||||
this.summaryText = summaryText;
|
||||
this.text = text != null ? text : item.toString();
|
||||
this.isSelected = isSelected;
|
||||
}
|
||||
|
||||
public @NonNull Object getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
public @Nullable String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public @Nullable String getSummaryText() {
|
||||
return summaryText;
|
||||
}
|
||||
|
||||
public boolean isSelected() {
|
||||
return isSelected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull Item newItem) {
|
||||
return item.equals(newItem.item);
|
||||
@@ -70,7 +89,9 @@ public class SingleSelectSetting {
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull Item newItem) {
|
||||
return Objects.equals(text, newItem.text) && isSelected == newItem.isSelected;
|
||||
return Objects.equals(text, newItem.text) &&
|
||||
Objects.equals(summaryText, newItem.summaryText) &&
|
||||
isSelected == newItem.isSelected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.annimon.stream.function.Predicate;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
@@ -24,6 +26,15 @@ import java.util.List;
|
||||
|
||||
public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.InputView {
|
||||
|
||||
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
|
||||
AttachmentKeyboardButton.GALLERY,
|
||||
AttachmentKeyboardButton.GIF,
|
||||
AttachmentKeyboardButton.FILE,
|
||||
AttachmentKeyboardButton.PAYMENT,
|
||||
AttachmentKeyboardButton.CONTACT,
|
||||
AttachmentKeyboardButton.LOCATION
|
||||
);
|
||||
|
||||
private View container;
|
||||
private AttachmentKeyboardMediaAdapter mediaAdapter;
|
||||
private AttachmentKeyboardButtonAdapter buttonAdapter;
|
||||
@@ -71,19 +82,21 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
|
||||
mediaList.setLayoutManager(new GridLayoutManager(context, 1, GridLayoutManager.HORIZONTAL, false));
|
||||
buttonList.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));
|
||||
|
||||
buttonAdapter.setButtons(Arrays.asList(
|
||||
AttachmentKeyboardButton.GALLERY,
|
||||
AttachmentKeyboardButton.GIF,
|
||||
AttachmentKeyboardButton.FILE,
|
||||
AttachmentKeyboardButton.CONTACT,
|
||||
AttachmentKeyboardButton.LOCATION
|
||||
));
|
||||
buttonAdapter.setButtons(DEFAULT_BUTTONS);
|
||||
}
|
||||
|
||||
public void setCallback(@NonNull Callback callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void filterAttachmentKeyboardButtons(@Nullable Predicate<AttachmentKeyboardButton> buttonPredicate) {
|
||||
if (buttonPredicate == null) {
|
||||
buttonAdapter.setButtons(DEFAULT_BUTTONS);
|
||||
} else {
|
||||
buttonAdapter.setButtons(Stream.of(DEFAULT_BUTTONS).filter(buttonPredicate).toList());
|
||||
}
|
||||
}
|
||||
|
||||
public void onMediaChanged(@NonNull List<Media> media) {
|
||||
if (StorageUtil.canReadFromMediaStore()) {
|
||||
mediaAdapter.setMedia(media);
|
||||
|
||||
@@ -10,6 +10,7 @@ public enum AttachmentKeyboardButton {
|
||||
GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.ic_photo_album_outline_32),
|
||||
GIF(R.string.AttachmentKeyboard_gif, R.drawable.ic_gif_outline_32),
|
||||
FILE(R.string.AttachmentKeyboard_file, R.drawable.ic_file_outline_32),
|
||||
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.ic_payments_32),
|
||||
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.ic_contact_circle_outline_32),
|
||||
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.ic_location_outline_32);
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
@@ -221,6 +222,7 @@ import org.thoughtcrime.securesms.mms.SlideFactory.MediaType;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView;
|
||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment;
|
||||
@@ -1102,6 +1104,14 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
case LOCATION:
|
||||
AttachmentManager.selectLocation(this, PICK_LOCATION);
|
||||
break;
|
||||
case PAYMENT:
|
||||
if (recipient.get().hasProfileKeyCredential()) {
|
||||
AttachmentManager.selectPayment(this, recipient.getId());
|
||||
} else {
|
||||
CanNotSendPaymentDialog.show(this);
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
container.hideCurrentInput(composeText);
|
||||
@@ -1428,6 +1438,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
viewModel.getRecentMedia().observe(this, media -> attachmentKeyboardStub.get().onMediaChanged(media));
|
||||
attachmentKeyboardStub.get().setCallback(this);
|
||||
attachmentKeyboardStub.get().setWallpaperEnabled(recipient.get().hasWallpaper());
|
||||
|
||||
updatePaymentsAvailable();
|
||||
|
||||
container.show(composeText, attachmentKeyboardStub.get());
|
||||
|
||||
viewModel.onAttachmentKeyboardOpen();
|
||||
@@ -1437,6 +1450,25 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePaymentsAvailable() {
|
||||
if (!attachmentKeyboardStub.resolved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PaymentsValues paymentsValues = SignalStore.paymentsValues();
|
||||
|
||||
if (paymentsValues.getPaymentsAvailability().isSendAllowed() &&
|
||||
!recipient.get().isSelf() &&
|
||||
!recipient.get().isGroup() &&
|
||||
recipient.get().isRegistered() &&
|
||||
!recipient.get().isForceSmsSelection())
|
||||
{
|
||||
attachmentKeyboardStub.get().filterAttachmentKeyboardButtons(null);
|
||||
} else {
|
||||
attachmentKeyboardStub.get().filterAttachmentKeyboardButtons(btn -> btn != AttachmentKeyboardButton.PAYMENT);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleManualMmsRequired() {
|
||||
Toast.makeText(this, R.string.MmsDownloader_error_reading_mms_settings, Toast.LENGTH_LONG).show();
|
||||
|
||||
@@ -2346,6 +2378,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
setBlockedUserState(recipient, isSecureText, isDefaultSms);
|
||||
updateReminders();
|
||||
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
|
||||
updatePaymentsAvailable();
|
||||
initializeSecurity(isSecureText, isDefaultSms);
|
||||
|
||||
if (searchViewItem == null || !searchViewItem.isActionViewExpanded()) {
|
||||
|
||||
@@ -53,15 +53,15 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.content.res.ResourcesCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
@@ -78,6 +78,7 @@ import org.thoughtcrime.securesms.NewConversationActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.RatingManager;
|
||||
import org.thoughtcrime.securesms.components.SearchToolbar;
|
||||
import org.thoughtcrime.securesms.components.UnreadPaymentsView;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator;
|
||||
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
|
||||
import org.thoughtcrime.securesms.components.reminder.DozeReminder;
|
||||
@@ -92,6 +93,7 @@ import org.thoughtcrime.securesms.conversation.ConversationFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
@@ -110,6 +112,9 @@ import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.net.PipeConnectivityListener;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
|
||||
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
@@ -139,6 +144,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
@@ -157,8 +163,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private static final int MAXIMUM_PINNED_CONVERSATIONS = 4;
|
||||
|
||||
private ActionMode actionMode;
|
||||
private ConstraintLayout constraintLayout;
|
||||
private RecyclerView list;
|
||||
private Stub<ReminderView> reminderView;
|
||||
private Stub<UnreadPaymentsView> paymentNotificationView;
|
||||
private Stub<ViewGroup> emptyState;
|
||||
private TextView searchEmptyState;
|
||||
private PulsingFloatingActionButton fab;
|
||||
@@ -167,6 +175,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private ImageView proxyStatus;
|
||||
private ImageView searchAction;
|
||||
private View toolbarShadow;
|
||||
private View unreadPaymentsDot;
|
||||
private ConversationListViewModel viewModel;
|
||||
private RecyclerView.Adapter activeAdapter;
|
||||
private ConversationListAdapter defaultAdapter;
|
||||
@@ -197,17 +206,20 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
list = view.findViewById(R.id.list);
|
||||
fab = view.findViewById(R.id.fab);
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
searchEmptyState = view.findViewById(R.id.search_no_results);
|
||||
searchAction = view.findViewById(R.id.search_action);
|
||||
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
|
||||
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status);
|
||||
reminderView = new Stub<>(view.findViewById(R.id.reminder));
|
||||
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
|
||||
searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar));
|
||||
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
|
||||
constraintLayout = view.findViewById(R.id.constraint_layout);
|
||||
list = view.findViewById(R.id.list);
|
||||
fab = view.findViewById(R.id.fab);
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
searchEmptyState = view.findViewById(R.id.search_no_results);
|
||||
searchAction = view.findViewById(R.id.search_action);
|
||||
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
|
||||
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status);
|
||||
unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator);
|
||||
reminderView = new Stub<>(view.findViewById(R.id.reminder));
|
||||
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
|
||||
searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar));
|
||||
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
|
||||
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
|
||||
|
||||
Toolbar toolbar = getToolbar(view);
|
||||
toolbar.setVisibility(View.VISIBLE);
|
||||
@@ -563,6 +575,43 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
@Override
|
||||
public void onBackground() { }
|
||||
};
|
||||
|
||||
viewModel.getUnreadPaymentsLiveData().observe(getViewLifecycleOwner(), this::onUnreadPaymentsChanged);
|
||||
}
|
||||
|
||||
private void onUnreadPaymentsChanged(@NonNull Optional<UnreadPayments> unreadPayments) {
|
||||
if (unreadPayments.isPresent()) {
|
||||
paymentNotificationView.get().setListener(new PaymentNotificationListener(unreadPayments.get()));
|
||||
paymentNotificationView.get().setUnreadPayments(unreadPayments.get());
|
||||
animatePaymentUnreadStatusIn();
|
||||
} else {
|
||||
animatePaymentUnreadStatusOut();
|
||||
}
|
||||
}
|
||||
|
||||
private void animatePaymentUnreadStatusIn() {
|
||||
animatePaymentUnreadStatus(ConstraintSet.VISIBLE);
|
||||
unreadPaymentsDot.animate().alpha(1);
|
||||
}
|
||||
|
||||
private void animatePaymentUnreadStatusOut() {
|
||||
if (paymentNotificationView.resolved()) {
|
||||
animatePaymentUnreadStatus(ConstraintSet.GONE);
|
||||
}
|
||||
|
||||
unreadPaymentsDot.animate().alpha(0);
|
||||
}
|
||||
|
||||
private void animatePaymentUnreadStatus(int constraintSetVisibility) {
|
||||
paymentNotificationView.get();
|
||||
|
||||
TransitionManager.beginDelayedTransition(constraintLayout);
|
||||
|
||||
ConstraintSet currentLayout = new ConstraintSet();
|
||||
currentLayout.clone(constraintLayout);
|
||||
|
||||
currentLayout.setVisibility(R.id.payments_notification, constraintSetVisibility);
|
||||
currentLayout.applyTo(constraintLayout);
|
||||
}
|
||||
|
||||
private void onSearchResultChanged(@Nullable SearchResult result) {
|
||||
@@ -1100,6 +1149,44 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId);
|
||||
}
|
||||
|
||||
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
|
||||
|
||||
private final UnreadPayments unreadPayments;
|
||||
|
||||
private PaymentNotificationListener(@NonNull UnreadPayments unreadPayments) {
|
||||
this.unreadPayments = unreadPayments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpenPaymentsNotificationClicked() {
|
||||
UUID paymentId = unreadPayments.getPaymentUuid();
|
||||
|
||||
if (paymentId == null) {
|
||||
goToPaymentsHome();
|
||||
} else {
|
||||
goToSinglePayment(paymentId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosePaymentsNotificationClicked() {
|
||||
viewModel.onUnreadPaymentsClosed();
|
||||
}
|
||||
|
||||
private void goToPaymentsHome() {
|
||||
startActivity(new Intent(requireContext(), PaymentsActivity.class));
|
||||
}
|
||||
|
||||
private void goToSinglePayment(@NonNull UUID paymentId) {
|
||||
Intent intent = new Intent(requireContext(), PaymentsActivity.class);
|
||||
|
||||
intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_paymentDetails);
|
||||
intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new PaymentDetailsFragmentArgs.Builder(PaymentDetailsParcelable.forUuid(paymentId)).build().toBundle());
|
||||
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
ArchiveListenerCallback() {
|
||||
|
||||
@@ -16,6 +16,8 @@ import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -23,11 +25,13 @@ import org.thoughtcrime.securesms.megaphone.Megaphone;
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.net.PipeConnectivityListener;
|
||||
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -37,34 +41,38 @@ class ConversationListViewModel extends ViewModel {
|
||||
|
||||
private static boolean coldStart = true;
|
||||
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final PagedData<Conversation> pagedData;
|
||||
private final LiveData<Boolean> hasNoConversations;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer searchDebouncer;
|
||||
private final ThrottledDebouncer updateDebouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final PagedData<Conversation> pagedData;
|
||||
private final LiveData<Boolean> hasNoConversations;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer searchDebouncer;
|
||||
private final ThrottledDebouncer updateDebouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
|
||||
private final UnreadPaymentsRepository unreadPaymentsRepository;
|
||||
|
||||
private String lastQuery;
|
||||
private int pinnedCount;
|
||||
|
||||
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
this.searchResult = new MutableLiveData<>();
|
||||
this.searchRepository = searchRepository;
|
||||
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
||||
this.searchDebouncer = new Debouncer(300);
|
||||
this.updateDebouncer = new ThrottledDebouncer(500);
|
||||
this.invalidator = new Invalidator();
|
||||
this.pagedData = PagedData.create(ConversationListDataSource.create(application, isArchived),
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
.setBufferPages(2)
|
||||
.build());
|
||||
this.observer = () -> {
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
this.searchResult = new MutableLiveData<>();
|
||||
this.searchRepository = searchRepository;
|
||||
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
||||
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
|
||||
this.searchDebouncer = new Debouncer(300);
|
||||
this.updateDebouncer = new ThrottledDebouncer(500);
|
||||
this.invalidator = new Invalidator();
|
||||
this.pagedData = PagedData.create(ConversationListDataSource.create(application, isArchived),
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
.setBufferPages(2)
|
||||
.build());
|
||||
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
|
||||
this.observer = () -> {
|
||||
updateDebouncer.publish(() -> {
|
||||
if (!TextUtils.isEmpty(getLastQuery())) {
|
||||
searchRepository.query(getLastQuery(), searchResult::postValue);
|
||||
@@ -110,6 +118,10 @@ class ConversationListViewModel extends ViewModel {
|
||||
return ApplicationDependencies.getPipeListener().getState();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<UnreadPayments>> getUnreadPaymentsLiveData() {
|
||||
return unreadPaymentsLiveData;
|
||||
}
|
||||
|
||||
public int getPinnedCount() {
|
||||
return pinnedCount;
|
||||
}
|
||||
@@ -138,6 +150,10 @@ class ConversationListViewModel extends ViewModel {
|
||||
megaphoneRepository.markVisible(visible.getEvent());
|
||||
}
|
||||
|
||||
void onUnreadPaymentsClosed() {
|
||||
unreadPaymentsRepository.markAllPaymentsSeen();
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
lastQuery = query;
|
||||
searchDebouncer.publish(() -> searchRepository.query(query, result -> {
|
||||
@@ -170,7 +186,7 @@ class ConversationListViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.conversationlist.model;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* UnreadPayments encapsulates information required by the view layer to render and interact with an UnreadPaymentsView.
|
||||
* This class is intentionally abstract with a private constructor to prevent other subclasses from being created.
|
||||
*/
|
||||
public abstract class UnreadPayments {
|
||||
|
||||
private UnreadPayments() {}
|
||||
|
||||
public abstract @NonNull String getDescription(@NonNull Context context);
|
||||
|
||||
public abstract @Nullable Recipient getRecipient();
|
||||
|
||||
public abstract @Nullable UUID getPaymentUuid();
|
||||
|
||||
public abstract int getUnreadCount();
|
||||
|
||||
public static @NonNull UnreadPayments forSingle(@Nullable Recipient recipient, @NonNull UUID paymentId, @NonNull Money amount) {
|
||||
return new SingleRecipient(recipient, paymentId, amount);
|
||||
}
|
||||
|
||||
public static @NonNull UnreadPayments forMultiple(int unreadCount) {
|
||||
return new MultipleRecipients(unreadCount);
|
||||
}
|
||||
|
||||
private static final class SingleRecipient extends UnreadPayments {
|
||||
|
||||
private final Recipient recipient;
|
||||
private final UUID paymentId;
|
||||
private final Money amount;
|
||||
|
||||
private SingleRecipient(@Nullable Recipient recipient, @NonNull UUID paymentId, @NonNull Money amount) {
|
||||
this.recipient = recipient;
|
||||
this.paymentId = paymentId;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getDescription(@NonNull Context context) {
|
||||
if (recipient != null) {
|
||||
return context.getString(R.string.UnreadPayments__s_sent_you_s,
|
||||
recipient.getShortDisplayName(context),
|
||||
amount.toString(FormatterOptions.defaults()));
|
||||
} else {
|
||||
return context.getString(R.string.UnreadPayments__d_new_payment_notifications, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Recipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable UUID getPaymentUuid() {
|
||||
return paymentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUnreadCount() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MultipleRecipients extends UnreadPayments {
|
||||
|
||||
private final int unreadCount;
|
||||
|
||||
private MultipleRecipients(int unreadCount) {
|
||||
this.unreadCount = unreadCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getDescription(@NonNull Context context) {
|
||||
return context.getString(R.string.UnreadPayments__d_new_payment_notifications, unreadCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Recipient getRecipient() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable UUID getPaymentUuid() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUnreadCount() {
|
||||
return unreadCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.conversationlist.model;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* LiveData encapsulating the logic to watch the Payments database for changes to payments and supply
|
||||
* a list of unread payments to listeners. If there are no unread payments, Optional.absent() will be passed
|
||||
* through instead.
|
||||
*/
|
||||
public final class UnreadPaymentsLiveData extends LiveData<Optional<UnreadPayments>> {
|
||||
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Executor executor;
|
||||
|
||||
public UnreadPaymentsLiveData() {
|
||||
this.paymentDatabase = DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication());
|
||||
this.observer = this::refreshUnreadPayments;
|
||||
this.executor = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActive() {
|
||||
refreshUnreadPayments();
|
||||
ApplicationDependencies.getDatabaseObserver().registerAllPaymentsObserver(observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onInactive() {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
|
||||
}
|
||||
|
||||
private void refreshUnreadPayments() {
|
||||
executor.execute(() -> postValue(Optional.fromNullable(getUnreadPayments())));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @Nullable UnreadPayments getUnreadPayments() {
|
||||
List<PaymentDatabase.PaymentTransaction> unseenPayments = paymentDatabase.getUnseenPayments();
|
||||
int unseenCount = unseenPayments.size();
|
||||
|
||||
switch (unseenCount) {
|
||||
case 0:
|
||||
return null;
|
||||
case 1:
|
||||
PaymentDatabase.PaymentTransaction transaction = unseenPayments.get(0);
|
||||
Recipient recipient = transaction.getPayee().hasRecipientId()
|
||||
? Recipient.resolved(transaction.getPayee().requireRecipientId())
|
||||
: null;
|
||||
|
||||
return UnreadPayments.forSingle(recipient, transaction.getUuid(), transaction.getAmount());
|
||||
default:
|
||||
return UnreadPayments.forMultiple(unseenCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,18 @@ public class IdentityKeyUtil {
|
||||
}
|
||||
|
||||
public static void generateIdentityKeys(Context context) {
|
||||
IdentityKeyPair identityKeyPair = generateIdentityKeyPair();
|
||||
|
||||
save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(identityKeyPair.getPublicKey().serialize()));
|
||||
save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(identityKeyPair.getPrivateKey().serialize()));
|
||||
}
|
||||
|
||||
public static IdentityKeyPair generateIdentityKeyPair() {
|
||||
ECKeyPair djbKeyPair = Curve.generateKeyPair();
|
||||
IdentityKey djbIdentityKey = new IdentityKey(djbKeyPair.getPublicKey());
|
||||
ECPrivateKey djbPrivateKey = djbKeyPair.getPrivateKey();
|
||||
|
||||
save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(djbIdentityKey.serialize()));
|
||||
save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(djbPrivateKey.serialize()));
|
||||
return new IdentityKeyPair(djbIdentityKey, djbPrivateKey);
|
||||
}
|
||||
|
||||
public static void migrateIdentityKeys(@NonNull Context context,
|
||||
|
||||
@@ -63,6 +63,7 @@ public class DatabaseFactory {
|
||||
private final StorageKeyDatabase storageKeyDatabase;
|
||||
private final RemappedRecordsDatabase remappedRecordsDatabase;
|
||||
private final MentionDatabase mentionDatabase;
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
|
||||
public static DatabaseFactory getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
@@ -165,6 +166,10 @@ public class DatabaseFactory {
|
||||
return getInstance(context).mentionDatabase;
|
||||
}
|
||||
|
||||
public static PaymentDatabase getPaymentDatabase(Context context) {
|
||||
return getInstance(context).paymentDatabase;
|
||||
}
|
||||
|
||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase();
|
||||
}
|
||||
@@ -217,6 +222,7 @@ public class DatabaseFactory {
|
||||
this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
|
||||
this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper);
|
||||
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
|
||||
this.paymentDatabase = new PaymentDatabase(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.app.Application;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
@@ -13,6 +11,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
@@ -28,6 +27,8 @@ public final class DatabaseObserver {
|
||||
private final Set<Observer> conversationListObservers;
|
||||
private final Map<Long, Set<Observer>> conversationObservers;
|
||||
private final Map<Long, Set<Observer>> verboseConversationObservers;
|
||||
private final Map<UUID, Set<Observer>> paymentObservers;
|
||||
private final Set<Observer> allPaymentsObservers;
|
||||
|
||||
public DatabaseObserver(Application application) {
|
||||
this.application = application;
|
||||
@@ -35,6 +36,8 @@ public final class DatabaseObserver {
|
||||
this.conversationListObservers = new HashSet<>();
|
||||
this.conversationObservers = new HashMap<>();
|
||||
this.verboseConversationObservers = new HashMap<>();
|
||||
this.paymentObservers = new HashMap<>();
|
||||
this.allPaymentsObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -55,11 +58,24 @@ public final class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void registerPaymentObserver(@NonNull UUID paymentId, @NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
registerMapped(paymentObservers, paymentId, listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void registerAllPaymentsObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
allPaymentsObservers.add(listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
unregisterMapped(conversationObservers, listener);
|
||||
unregisterMapped(verboseConversationObservers, listener);
|
||||
unregisterMapped(paymentObservers, listener);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,6 +121,18 @@ public final class DatabaseObserver {
|
||||
application.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
public void notifyPaymentListeners(@NonNull UUID paymentId) {
|
||||
executor.execute(() -> {
|
||||
notifyMapped(paymentObservers, paymentId);
|
||||
});
|
||||
}
|
||||
|
||||
public void notifyAllPaymentsListeners() {
|
||||
executor.execute(() -> {
|
||||
notifySet(allPaymentsObservers);
|
||||
});
|
||||
}
|
||||
|
||||
private <K> void registerMapped(@NonNull Map<K, Set<Observer>> map, @NonNull K key, @NonNull Observer listener) {
|
||||
Set<Observer> listeners = map.get(key);
|
||||
|
||||
@@ -132,6 +160,12 @@ public final class DatabaseObserver {
|
||||
}
|
||||
}
|
||||
|
||||
public static void notifySet(@NonNull Set<Observer> set) {
|
||||
for (final Observer observer : set) {
|
||||
observer.onChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Observer {
|
||||
/**
|
||||
* Called when the relevant data changes. Executed on a serial executor, so don't do any
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.payments.CryptoValueUtil;
|
||||
import org.thoughtcrime.securesms.payments.Direction;
|
||||
import org.thoughtcrime.securesms.payments.FailureReason;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.State;
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PaymentDatabase extends Database {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentDatabase.class);
|
||||
|
||||
public static final String TABLE_NAME = "payments";
|
||||
|
||||
private static final String ID = "_id";
|
||||
private static final String PAYMENT_UUID = "uuid";
|
||||
private static final String RECIPIENT_ID = "recipient";
|
||||
private static final String ADDRESS = "recipient_address";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String DIRECTION = "direction";
|
||||
private static final String STATE = "state";
|
||||
private static final String NOTE = "note";
|
||||
private static final String AMOUNT = "amount";
|
||||
private static final String FEE = "fee";
|
||||
private static final String TRANSACTION = "transaction_record";
|
||||
private static final String RECEIPT = "receipt";
|
||||
private static final String PUBLIC_KEY = "receipt_public_key";
|
||||
private static final String META_DATA = "payment_metadata";
|
||||
private static final String FAILURE = "failure_reason";
|
||||
private static final String BLOCK_INDEX = "block_index";
|
||||
private static final String BLOCK_TIME = "block_timestamp";
|
||||
private static final String SEEN = "seen";
|
||||
|
||||
public static final String CREATE_TABLE =
|
||||
"CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY, " +
|
||||
PAYMENT_UUID + " TEXT DEFAULT NULL, " +
|
||||
RECIPIENT_ID + " INTEGER DEFAULT 0, " +
|
||||
ADDRESS + " TEXT DEFAULT NULL, " +
|
||||
TIMESTAMP + " INTEGER, " +
|
||||
NOTE + " TEXT DEFAULT NULL, " +
|
||||
DIRECTION + " INTEGER, " +
|
||||
STATE + " INTEGER, " +
|
||||
FAILURE + " INTEGER, " +
|
||||
AMOUNT + " BLOB NOT NULL, " +
|
||||
FEE + " BLOB NOT NULL, " +
|
||||
TRANSACTION + " BLOB DEFAULT NULL, " +
|
||||
RECEIPT + " BLOB DEFAULT NULL, " +
|
||||
META_DATA + " BLOB DEFAULT NULL, " +
|
||||
PUBLIC_KEY + " TEXT DEFAULT NULL, " +
|
||||
BLOCK_INDEX + " INTEGER DEFAULT 0, " +
|
||||
BLOCK_TIME + " INTEGER DEFAULT 0, " +
|
||||
SEEN + " INTEGER, " +
|
||||
"UNIQUE(" + PAYMENT_UUID + ") ON CONFLICT ABORT)";
|
||||
|
||||
public static final String[] CREATE_INDEXES = {
|
||||
"CREATE INDEX IF NOT EXISTS timestamp_direction_index ON " + TABLE_NAME + " (" + TIMESTAMP + ", " + DIRECTION + ");",
|
||||
"CREATE INDEX IF NOT EXISTS timestamp_index ON " + TABLE_NAME + " (" + TIMESTAMP + ");",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS receipt_public_key_index ON " + TABLE_NAME + " (" + PUBLIC_KEY + ");"
|
||||
};
|
||||
|
||||
private final MutableLiveData<Object> changeSignal;
|
||||
|
||||
PaymentDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
|
||||
this.changeSignal = new MutableLiveData<>(new Object());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void createIncomingPayment(@NonNull UUID uuid,
|
||||
@Nullable RecipientId fromRecipient,
|
||||
long timestamp,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money fee,
|
||||
@NonNull byte[] receipt)
|
||||
throws PublicKeyConflictException
|
||||
{
|
||||
create(uuid, fromRecipient, null, timestamp, 0, note, Direction.RECEIVED, State.SUBMITTED, amount, fee, null, receipt, null, false);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void createOutgoingPayment(@NonNull UUID uuid,
|
||||
@Nullable RecipientId toRecipient,
|
||||
@NonNull MobileCoinPublicAddress publicAddress,
|
||||
long timestamp,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount)
|
||||
{
|
||||
try {
|
||||
create(uuid, toRecipient, publicAddress, timestamp, 0, note, Direction.SENT, State.INITIAL, amount, amount.toZero(), null, null, null, true);
|
||||
} catch (PublicKeyConflictException e) {
|
||||
Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a payment in its final successful state.
|
||||
* <p>
|
||||
* This is for when a linked device has told us about the payment only.
|
||||
*/
|
||||
@WorkerThread
|
||||
public void createSuccessfulPayment(@NonNull UUID uuid,
|
||||
@Nullable RecipientId toRecipient,
|
||||
@NonNull MobileCoinPublicAddress publicAddress,
|
||||
long timestamp,
|
||||
long blockIndex,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money fee,
|
||||
@NonNull byte[] receipt,
|
||||
@NonNull PaymentMetaData metaData)
|
||||
{
|
||||
try {
|
||||
create(uuid, toRecipient, publicAddress, timestamp, blockIndex, note, Direction.SENT, State.SUCCESSFUL, amount, fee, null, receipt, metaData, true);
|
||||
} catch (PublicKeyConflictException e) {
|
||||
Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void createDefrag(@NonNull UUID uuid,
|
||||
@Nullable RecipientId self,
|
||||
@NonNull MobileCoinPublicAddress selfPublicAddress,
|
||||
long timestamp,
|
||||
@NonNull Money fee,
|
||||
@NonNull byte[] transaction,
|
||||
@NonNull byte[] receipt)
|
||||
{
|
||||
try {
|
||||
create(uuid, self, selfPublicAddress, timestamp, 0, "", Direction.SENT, State.SUBMITTED, fee.toZero(), fee, transaction, receipt, null, true);
|
||||
} catch (PublicKeyConflictException e) {
|
||||
Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void create(@NonNull UUID uuid,
|
||||
@Nullable RecipientId recipientId,
|
||||
@Nullable MobileCoinPublicAddress publicAddress,
|
||||
long timestamp,
|
||||
long blockIndex,
|
||||
@NonNull String note,
|
||||
@NonNull Direction direction,
|
||||
@NonNull State state,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money fee,
|
||||
@Nullable byte[] transaction,
|
||||
@Nullable byte[] receipt,
|
||||
@Nullable PaymentMetaData metaData,
|
||||
boolean seen)
|
||||
throws PublicKeyConflictException
|
||||
{
|
||||
if (recipientId == null && publicAddress == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (amount.isNegative()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (fee.isNegative()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(15);
|
||||
|
||||
values.put(PAYMENT_UUID, uuid.toString());
|
||||
if (recipientId == null || recipientId.isUnknown()) {
|
||||
values.put(RECIPIENT_ID, 0);
|
||||
} else {
|
||||
values.put(RECIPIENT_ID, recipientId.serialize());
|
||||
}
|
||||
if (publicAddress == null) {
|
||||
values.putNull(ADDRESS);
|
||||
} else {
|
||||
values.put(ADDRESS, publicAddress.getPaymentAddressBase58());
|
||||
}
|
||||
values.put(TIMESTAMP, timestamp);
|
||||
values.put(BLOCK_INDEX, blockIndex);
|
||||
values.put(NOTE, note);
|
||||
values.put(DIRECTION, direction.serialize());
|
||||
values.put(STATE, state.serialize());
|
||||
values.put(AMOUNT, CryptoValueUtil.moneyToCryptoValue(amount).toByteArray());
|
||||
values.put(FEE, CryptoValueUtil.moneyToCryptoValue(fee).toByteArray());
|
||||
if (transaction != null) {
|
||||
values.put(TRANSACTION, transaction);
|
||||
} else {
|
||||
values.putNull(TRANSACTION);
|
||||
}
|
||||
if (receipt != null) {
|
||||
values.put(RECEIPT, receipt);
|
||||
values.put(PUBLIC_KEY, Base64.encodeBytes(PaymentMetaDataUtil.receiptPublic(PaymentMetaDataUtil.fromReceipt(receipt))));
|
||||
} else {
|
||||
values.putNull(RECEIPT);
|
||||
values.putNull(PUBLIC_KEY);
|
||||
}
|
||||
if (metaData != null) {
|
||||
values.put(META_DATA, metaData.toByteArray());
|
||||
} else {
|
||||
values.put(META_DATA, PaymentMetaDataUtil.fromReceiptAndTransaction(receipt, transaction).toByteArray());
|
||||
}
|
||||
values.put(SEEN, seen ? 1 : 0);
|
||||
|
||||
long inserted = database.insert(TABLE_NAME, null, values);
|
||||
|
||||
if (inserted == -1) {
|
||||
throw new PublicKeyConflictException();
|
||||
}
|
||||
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
|
||||
public void deleteAll() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.delete(TABLE_NAME, null, null);
|
||||
Log.i(TAG, "Deleted all records");
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public boolean delete(@NonNull UUID uuid) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String where = PAYMENT_UUID + " = ?";
|
||||
String[] args = {uuid.toString()};
|
||||
int deleted;
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
deleted = database.delete(TABLE_NAME, where, args);
|
||||
|
||||
if (deleted > 1) {
|
||||
Log.w(TAG, "More than one row matches criteria");
|
||||
throw new AssertionError();
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
if (deleted > 0) {
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull List<PaymentTransaction> getAll() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<PaymentTransaction> result = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, TIMESTAMP + " DESC")) {
|
||||
while (cursor.moveToNext()) {
|
||||
result.add(readPayment(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void markAllSeen() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(1);
|
||||
List<UUID> unseenIds = new LinkedList<>();
|
||||
String[] unseenProjection = SqlUtil.buildArgs(PAYMENT_UUID);
|
||||
String unseenWhile = SEEN + " != ?";
|
||||
String[] unseenArgs = SqlUtil.buildArgs("1");
|
||||
int updated = -1;
|
||||
|
||||
values.put(SEEN, 1);
|
||||
|
||||
try {
|
||||
database.beginTransaction();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, unseenProjection, unseenWhile, unseenArgs, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
unseenIds.add(UUID.fromString(CursorUtil.requireString(cursor, PAYMENT_UUID)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!unseenIds.isEmpty()) {
|
||||
updated = database.update(TABLE_NAME, values, null, null);
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
for (final UUID unseenId : unseenIds) {
|
||||
notifyUuidChanged(unseenId);
|
||||
}
|
||||
|
||||
notifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void markPaymentSeen(@NonNull UUID uuid) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(1);
|
||||
String where = PAYMENT_UUID + " = ?";
|
||||
String[] args = {uuid.toString()};
|
||||
|
||||
values.put(SEEN, 1);
|
||||
int updated = database.update(TABLE_NAME, values, where, args);
|
||||
|
||||
if (updated > 0) {
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull List<PaymentTransaction> getUnseenPayments() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = SEEN + " = 0 AND " + STATE + " = " + State.SUCCESSFUL.serialize();
|
||||
List<PaymentTransaction> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, query, null, null, null, null)) {
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(readPayment(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @Nullable PaymentTransaction getPayment(@NonNull UUID uuid) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String select = PAYMENT_UUID + " = ?";
|
||||
String[] args = {uuid.toString()};
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, select, args, null, null, null)) {
|
||||
if (cursor.moveToNext()) {
|
||||
PaymentTransaction payment = readPayment(cursor);
|
||||
|
||||
if (cursor.moveToNext()) {
|
||||
throw new AssertionError("Multiple records for one UUID");
|
||||
}
|
||||
|
||||
return payment;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull LiveData<List<PaymentTransaction>> getAllLive() {
|
||||
return LiveDataUtil.mapAsync(changeSignal, change -> getAll());
|
||||
}
|
||||
|
||||
public boolean markPaymentSubmitted(@NonNull UUID uuid,
|
||||
@NonNull byte[] transaction,
|
||||
@NonNull byte[] receipt,
|
||||
@NonNull Money fee)
|
||||
throws PublicKeyConflictException
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String where = PAYMENT_UUID + " = ?";
|
||||
String[] whereArgs = {uuid.toString()};
|
||||
int updated;
|
||||
ContentValues values = new ContentValues(6);
|
||||
|
||||
values.put(STATE, State.SUBMITTED.serialize());
|
||||
values.put(TRANSACTION, transaction);
|
||||
values.put(RECEIPT, receipt);
|
||||
values.put(PUBLIC_KEY, Base64.encodeBytes(PaymentMetaDataUtil.receiptPublic(PaymentMetaDataUtil.fromReceipt(receipt))));
|
||||
values.put(META_DATA, PaymentMetaDataUtil.fromReceiptAndTransaction(receipt, transaction).toByteArray());
|
||||
values.put(FEE, CryptoValueUtil.moneyToCryptoValue(fee).toByteArray());
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
updated = database.update(TABLE_NAME, values, where, whereArgs);
|
||||
|
||||
if (updated == -1) {
|
||||
throw new PublicKeyConflictException();
|
||||
}
|
||||
|
||||
if (updated > 1) {
|
||||
Log.w(TAG, "More than one row matches criteria");
|
||||
throw new AssertionError();
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
public boolean markPaymentSuccessful(@NonNull UUID uuid, long blockIndex) {
|
||||
return markPayment(uuid, State.SUCCESSFUL, null, null, blockIndex);
|
||||
}
|
||||
|
||||
public boolean markReceivedPaymentSuccessful(@NonNull UUID uuid, @NonNull Money amount, long blockIndex) {
|
||||
return markPayment(uuid, State.SUCCESSFUL, amount, null, blockIndex);
|
||||
}
|
||||
|
||||
public boolean markPaymentFailed(@NonNull UUID uuid, @NonNull FailureReason failureReason) {
|
||||
return markPayment(uuid, State.FAILED, null, failureReason, null);
|
||||
}
|
||||
|
||||
private boolean markPayment(@NonNull UUID uuid,
|
||||
@NonNull State state,
|
||||
@Nullable Money amount,
|
||||
@Nullable FailureReason failureReason,
|
||||
@Nullable Long blockIndex)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String where = PAYMENT_UUID + " = ?";
|
||||
String[] whereArgs = {uuid.toString()};
|
||||
int updated;
|
||||
ContentValues values = new ContentValues(3);
|
||||
|
||||
values.put(STATE, state.serialize());
|
||||
|
||||
if (amount != null) {
|
||||
values.put(AMOUNT, CryptoValueUtil.moneyToCryptoValue(amount).toByteArray());
|
||||
}
|
||||
|
||||
if (state == State.FAILED) {
|
||||
values.put(FAILURE, failureReason != null ? failureReason.serialize()
|
||||
: FailureReason.UNKNOWN.serialize());
|
||||
} else {
|
||||
if (failureReason != null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
values.putNull(FAILURE);
|
||||
}
|
||||
|
||||
if (blockIndex != null) {
|
||||
values.put(BLOCK_INDEX, blockIndex);
|
||||
}
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
updated = database.update(TABLE_NAME, values, where, whereArgs);
|
||||
|
||||
if (updated > 1) {
|
||||
Log.w(TAG, "More than one row matches criteria");
|
||||
throw new AssertionError();
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
public boolean updateBlockDetails(@NonNull UUID uuid,
|
||||
long blockIndex,
|
||||
long blockTimestamp)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String where = PAYMENT_UUID + " = ?";
|
||||
String[] whereArgs = {uuid.toString()};
|
||||
int updated;
|
||||
ContentValues values = new ContentValues(2);
|
||||
|
||||
values.put(BLOCK_INDEX, blockIndex);
|
||||
values.put(BLOCK_TIME, blockTimestamp);
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
updated = database.update(TABLE_NAME, values, where, whereArgs);
|
||||
|
||||
if (updated > 1) {
|
||||
Log.w(TAG, "More than one row matches criteria");
|
||||
throw new AssertionError();
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
notifyChanged(uuid);
|
||||
}
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
private static @NonNull PaymentTransaction readPayment(@NonNull Cursor cursor) {
|
||||
return new PaymentTransaction(UUID.fromString(CursorUtil.requireString(cursor, PAYMENT_UUID)),
|
||||
getRecipientId(cursor),
|
||||
MobileCoinPublicAddress.fromBase58NullableOrThrow(CursorUtil.requireString(cursor, ADDRESS)),
|
||||
CursorUtil.requireLong(cursor, TIMESTAMP),
|
||||
Direction.deserialize(CursorUtil.requireInt(cursor, DIRECTION)),
|
||||
State.deserialize(CursorUtil.requireInt(cursor, STATE)),
|
||||
FailureReason.deserialize(CursorUtil.requireInt(cursor, FAILURE)),
|
||||
CursorUtil.requireString(cursor, NOTE),
|
||||
getMoneyValue(CursorUtil.requireBlob(cursor, AMOUNT)),
|
||||
getMoneyValue(CursorUtil.requireBlob(cursor, FEE)),
|
||||
CursorUtil.requireBlob(cursor, TRANSACTION),
|
||||
CursorUtil.requireBlob(cursor, RECEIPT),
|
||||
PaymentMetaDataUtil.parseOrThrow(CursorUtil.requireBlob(cursor, META_DATA)),
|
||||
CursorUtil.requireLong(cursor, BLOCK_INDEX),
|
||||
CursorUtil.requireLong(cursor, BLOCK_TIME),
|
||||
CursorUtil.requireBoolean(cursor, SEEN));
|
||||
}
|
||||
|
||||
private static @Nullable RecipientId getRecipientId(@NonNull Cursor cursor) {
|
||||
long id = CursorUtil.requireLong(cursor, RECIPIENT_ID);
|
||||
if (id == 0) return null;
|
||||
return RecipientId.from(id);
|
||||
}
|
||||
|
||||
private static @NonNull Money getMoneyValue(@NonNull byte[] blob) {
|
||||
try {
|
||||
CryptoValue cryptoValue = CryptoValue.parseFrom(blob);
|
||||
return CryptoValueUtil.cryptoValueToMoney(cryptoValue);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* notifyChanged will alert the database observer for two events:
|
||||
*
|
||||
* 1. It will alert the global payments observer that something changed
|
||||
* 2. It will alert the uuid specific observer that something will change.
|
||||
*
|
||||
* You should not call this in a tight loop, opting to call notifyUuidChanged instead.
|
||||
*/
|
||||
private void notifyChanged(@Nullable UUID uuid) {
|
||||
notifyChanged();
|
||||
notifyUuidChanged(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the global payments observer that something changed.
|
||||
*/
|
||||
private void notifyChanged() {
|
||||
changeSignal.postValue(new Object());
|
||||
ApplicationDependencies.getDatabaseObserver().notifyAllPaymentsListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the database observer of a change for a specific uuid. Does not trigger
|
||||
* the global payments observer.
|
||||
*/
|
||||
private void notifyUuidChanged(@Nullable UUID uuid) {
|
||||
if (uuid != null) {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyPaymentListeners(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PaymentTransaction implements Payment {
|
||||
private final UUID uuid;
|
||||
private final Payee payee;
|
||||
private final long timestamp;
|
||||
private final Direction direction;
|
||||
private final State state;
|
||||
private final FailureReason failureReason;
|
||||
private final String note;
|
||||
private final Money amount;
|
||||
private final Money fee;
|
||||
private final byte[] transaction;
|
||||
private final byte[] receipt;
|
||||
private final PaymentMetaData paymentMetaData;
|
||||
private final Long blockIndex;
|
||||
private final long blockTimestamp;
|
||||
private final boolean seen;
|
||||
|
||||
PaymentTransaction(@NonNull UUID uuid,
|
||||
@Nullable RecipientId recipientId,
|
||||
@Nullable MobileCoinPublicAddress publicAddress,
|
||||
long timestamp,
|
||||
@NonNull Direction direction,
|
||||
@NonNull State state,
|
||||
@Nullable FailureReason failureReason,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money fee,
|
||||
@Nullable byte[] transaction,
|
||||
@Nullable byte[] receipt,
|
||||
@NonNull PaymentMetaData paymentMetaData,
|
||||
@Nullable Long blockIndex,
|
||||
long blockTimestamp,
|
||||
boolean seen)
|
||||
{
|
||||
this.uuid = uuid;
|
||||
this.paymentMetaData = paymentMetaData;
|
||||
this.payee = fromPaymentTransaction(recipientId, publicAddress);
|
||||
this.timestamp = timestamp;
|
||||
this.direction = direction;
|
||||
this.state = state;
|
||||
this.failureReason = failureReason;
|
||||
this.note = note;
|
||||
this.amount = amount;
|
||||
this.fee = fee;
|
||||
this.transaction = transaction;
|
||||
this.receipt = receipt;
|
||||
this.blockIndex = blockIndex;
|
||||
this.blockTimestamp = blockTimestamp;
|
||||
this.seen = seen;
|
||||
|
||||
if (amount.isNegative()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return failureReason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return paymentMetaData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return seen;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getReceipt() {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof PaymentTransaction)) return false;
|
||||
|
||||
final PaymentTransaction other = (PaymentTransaction) o;
|
||||
|
||||
return timestamp == other.timestamp &&
|
||||
uuid.equals(other.uuid) &&
|
||||
payee.equals(other.payee) &&
|
||||
direction == other.direction &&
|
||||
state == other.state &&
|
||||
note.equals(other.note) &&
|
||||
amount.equals(other.amount) &&
|
||||
Arrays.equals(transaction, other.transaction) &&
|
||||
Arrays.equals(receipt, other.receipt) &&
|
||||
paymentMetaData.equals(other.paymentMetaData);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = uuid.hashCode();
|
||||
result = 31 * result + payee.hashCode();
|
||||
result = 31 * result + (int) (timestamp ^ (timestamp >>> 32));
|
||||
result = 31 * result + direction.hashCode();
|
||||
result = 31 * result + state.hashCode();
|
||||
result = 31 * result + note.hashCode();
|
||||
result = 31 * result + amount.hashCode();
|
||||
result = 31 * result + Arrays.hashCode(transaction);
|
||||
result = 31 * result + Arrays.hashCode(receipt);
|
||||
result = 31 * result + paymentMetaData.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull Payee fromPaymentTransaction(@Nullable RecipientId recipientId, @Nullable MobileCoinPublicAddress publicAddress) {
|
||||
if (recipientId == null && publicAddress == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (recipientId != null) {
|
||||
return Payee.fromRecipientAndAddress(recipientId, publicAddress);
|
||||
} else {
|
||||
return new Payee(publicAddress);
|
||||
}
|
||||
}
|
||||
|
||||
public final class PublicKeyConflictException extends Exception {
|
||||
private PublicKeyConflictException() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.mobilecoin.lib.KeyImage;
|
||||
import com.mobilecoin.lib.Receipt;
|
||||
import com.mobilecoin.lib.RistrettoPublic;
|
||||
import com.mobilecoin.lib.Transaction;
|
||||
import com.mobilecoin.lib.exceptions.SerializationException;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class PaymentMetaDataUtil {
|
||||
|
||||
public static PaymentMetaData parseOrThrow(byte[] requireBlob) {
|
||||
try {
|
||||
return PaymentMetaData.parseFrom(requireBlob);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull PaymentMetaData fromReceipt(@Nullable byte[] receipt) {
|
||||
PaymentMetaData.MobileCoinTxoIdentification.Builder builder = PaymentMetaData.MobileCoinTxoIdentification.newBuilder();
|
||||
|
||||
if (receipt != null) {
|
||||
addReceiptData(receipt, builder);
|
||||
}
|
||||
|
||||
return PaymentMetaData.newBuilder().setMobileCoinTxoIdentification(builder).build();
|
||||
}
|
||||
|
||||
public static @NonNull PaymentMetaData fromKeysAndImages(@NonNull List<ByteString> publicKeys, @NonNull List<ByteString> keyImages) {
|
||||
PaymentMetaData.MobileCoinTxoIdentification.Builder builder = PaymentMetaData.MobileCoinTxoIdentification.newBuilder();
|
||||
|
||||
builder.addAllKeyImages(keyImages);
|
||||
builder.addAllPublicKey(publicKeys);
|
||||
|
||||
return PaymentMetaData.newBuilder().setMobileCoinTxoIdentification(builder).build();
|
||||
}
|
||||
|
||||
public static @NonNull PaymentMetaData fromReceiptAndTransaction(@Nullable byte[] receipt, @Nullable byte[] transaction) {
|
||||
PaymentMetaData.MobileCoinTxoIdentification.Builder builder = PaymentMetaData.MobileCoinTxoIdentification.newBuilder();
|
||||
|
||||
if (transaction != null) {
|
||||
addTransactionData(transaction, builder);
|
||||
} else if (receipt != null) {
|
||||
addReceiptData(receipt, builder);
|
||||
}
|
||||
|
||||
return PaymentMetaData.newBuilder().setMobileCoinTxoIdentification(builder).build();
|
||||
}
|
||||
|
||||
private static void addReceiptData(@NonNull byte[] receipt, PaymentMetaData.MobileCoinTxoIdentification.Builder builder) {
|
||||
try {
|
||||
RistrettoPublic publicKey = Receipt.fromBytes(receipt).getPublicKey();
|
||||
addPublicKey(builder, publicKey);
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addTransactionData(@NonNull byte[] transactionBytes, PaymentMetaData.MobileCoinTxoIdentification.Builder builder) {
|
||||
try {
|
||||
Transaction transaction = Transaction.fromBytes(transactionBytes);
|
||||
Set<KeyImage> keyImages = transaction.getKeyImages();
|
||||
for (KeyImage keyImage : keyImages) {
|
||||
builder.addKeyImages(ByteString.copyFrom(keyImage.getData()));
|
||||
}
|
||||
for (RistrettoPublic publicKey : transaction.getOutputPublicKeys()) {
|
||||
addPublicKey(builder, publicKey);
|
||||
}
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPublicKey(@NonNull PaymentMetaData.MobileCoinTxoIdentification.Builder builder, @NonNull RistrettoPublic publicKey) {
|
||||
builder.addPublicKey(ByteString.copyFrom(publicKey.getKeyBytes()));
|
||||
}
|
||||
|
||||
public static byte[] receiptPublic(@NonNull PaymentMetaData paymentMetaData) {
|
||||
return Stream.of(paymentMetaData.getMobileCoinTxoIdentification().getPublicKeyList()).single().toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import com.annimon.stream.Stream;
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteDatabaseHook;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -30,12 +29,10 @@ import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase;
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
|
||||
import org.thoughtcrime.securesms.database.MentionDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RemappedRecordsDatabase;
|
||||
@@ -172,8 +169,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
private static final int WALLPAPER = 88;
|
||||
private static final int ABOUT = 89;
|
||||
private static final int SPLIT_SYSTEM_NAMES = 90;
|
||||
private static final int PAYMENTS = 91;
|
||||
|
||||
private static final int DATABASE_VERSION = 90;
|
||||
private static final int DATABASE_VERSION = 91;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
@@ -204,6 +202,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
db.execSQL(StickerDatabase.CREATE_TABLE);
|
||||
db.execSQL(StorageKeyDatabase.CREATE_TABLE);
|
||||
db.execSQL(MentionDatabase.CREATE_TABLE);
|
||||
db.execSQL(PaymentDatabase.CREATE_TABLE);
|
||||
executeStatements(db, SearchDatabase.CREATE_TABLE);
|
||||
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE);
|
||||
|
||||
@@ -218,6 +217,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
executeStatements(db, StickerDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, StorageKeyDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, MentionDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, PaymentDatabase.CREATE_INDEXES);
|
||||
|
||||
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
|
||||
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
|
||||
@@ -1265,6 +1265,32 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
|
||||
db.execSQL("UPDATE recipient SET system_given_name = system_display_name");
|
||||
}
|
||||
|
||||
if (oldVersion < PAYMENTS) {
|
||||
db.execSQL("CREATE TABLE payments(_id INTEGER PRIMARY KEY, " +
|
||||
"uuid TEXT DEFAULT NULL, " +
|
||||
"recipient INTEGER DEFAULT 0, " +
|
||||
"recipient_address TEXT DEFAULT NULL, " +
|
||||
"timestamp INTEGER, " +
|
||||
"note TEXT DEFAULT NULL, " +
|
||||
"direction INTEGER, " +
|
||||
"state INTEGER, " +
|
||||
"failure_reason INTEGER, " +
|
||||
"amount BLOB NOT NULL, " +
|
||||
"fee BLOB NOT NULL, " +
|
||||
"transaction_record BLOB DEFAULT NULL, " +
|
||||
"receipt BLOB DEFAULT NULL, " +
|
||||
"payment_metadata BLOB DEFAULT NULL, " +
|
||||
"receipt_public_key TEXT DEFAULT NULL, " +
|
||||
"block_index INTEGER DEFAULT 0, " +
|
||||
"block_timestamp INTEGER DEFAULT 0, " +
|
||||
"seen INTEGER, " +
|
||||
"UNIQUE(uuid) ON CONFLICT ABORT)");
|
||||
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS timestamp_direction_index ON payments (timestamp, direction);");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS timestamp_index ON payments (timestamp);");
|
||||
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS receipt_public_key_index ON payments (receipt_public_key);");
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
||||
@@ -38,10 +38,12 @@ import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public class DeleteAccountFragment extends Fragment {
|
||||
|
||||
private ArrayAdapter<String> countrySpinnerAdapter;
|
||||
private TextView bullets;
|
||||
private LabeledEditText countryCode;
|
||||
private LabeledEditText number;
|
||||
private AsYouTypeFormatter countryFormatter;
|
||||
@@ -55,10 +57,10 @@ public class DeleteAccountFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
TextView bullets = view.findViewById(R.id.delete_account_fragment_bullets);
|
||||
Spinner countrySpinner = view.findViewById(R.id.delete_account_fragment_country_spinner);
|
||||
View confirm = view.findViewById(R.id.delete_account_fragment_delete);
|
||||
|
||||
bullets = view.findViewById(R.id.delete_account_fragment_bullets);
|
||||
countryCode = view.findViewById(R.id.delete_account_fragment_country_code);
|
||||
number = view.findViewById(R.id.delete_account_fragment_number);
|
||||
|
||||
@@ -67,6 +69,7 @@ public class DeleteAccountFragment extends Fragment {
|
||||
viewModel.getCountryDisplayName().observe(getViewLifecycleOwner(), this::setCountryDisplay);
|
||||
viewModel.getRegionCode().observe(getViewLifecycleOwner(), this::handleRegionUpdated);
|
||||
viewModel.getEvents().observe(getViewLifecycleOwner(), this::handleEvent);
|
||||
viewModel.getWalletBalance().observe(getViewLifecycleOwner(), this::updateBullets);
|
||||
|
||||
initializeNumberInput();
|
||||
|
||||
@@ -74,7 +77,6 @@ public class DeleteAccountFragment extends Fragment {
|
||||
countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
confirm.setOnClickListener(unused -> viewModel.submit());
|
||||
|
||||
bullets.setText(buildBulletsText());
|
||||
initializeSpinner(countrySpinner);
|
||||
}
|
||||
|
||||
@@ -84,10 +86,21 @@ public class DeleteAccountFragment extends Fragment {
|
||||
((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__delete_account);
|
||||
}
|
||||
|
||||
private @NonNull CharSequence buildBulletsText() {
|
||||
return new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)))
|
||||
.append("\n")
|
||||
.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages)));
|
||||
private void updateBullets(@NonNull Optional<String> formattedBalance) {
|
||||
bullets.setText(buildBulletsText(formattedBalance));
|
||||
}
|
||||
|
||||
private @NonNull CharSequence buildBulletsText(@NonNull Optional<String> formattedBalance) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)))
|
||||
.append("\n")
|
||||
.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages)));
|
||||
|
||||
if (formattedBalance.isPresent()) {
|
||||
builder.append("\n");
|
||||
builder.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, formattedBalance.get())));
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
|
||||
@@ -15,24 +15,28 @@ import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DeleteAccountViewModel extends ViewModel {
|
||||
|
||||
private final DeleteAccountRepository repository;
|
||||
private final List<Country> allCountries;
|
||||
private final LiveData<List<Country>> filteredCountries;
|
||||
private final MutableLiveData<String> regionCode;
|
||||
private final LiveData<String> countryDisplayName;
|
||||
private final MutableLiveData<Long> nationalNumber;
|
||||
private final MutableLiveData<String> query;
|
||||
private final SingleLiveEvent<EventType> events;
|
||||
private final DeleteAccountRepository repository;
|
||||
private final List<Country> allCountries;
|
||||
private final LiveData<List<Country>> filteredCountries;
|
||||
private final MutableLiveData<String> regionCode;
|
||||
private final LiveData<String> countryDisplayName;
|
||||
private final MutableLiveData<Long> nationalNumber;
|
||||
private final MutableLiveData<String> query;
|
||||
private final SingleLiveEvent<EventType> events;
|
||||
private final LiveData<Optional<String>> walletBalance;
|
||||
|
||||
public DeleteAccountViewModel(@NonNull DeleteAccountRepository repository) {
|
||||
this.repository = repository;
|
||||
@@ -43,6 +47,12 @@ public class DeleteAccountViewModel extends ViewModel {
|
||||
this.countryDisplayName = Transformations.map(regionCode, repository::getRegionDisplayName);
|
||||
this.filteredCountries = Transformations.map(query, q -> Stream.of(allCountries).filter(country -> isMatch(q, country)).toList());
|
||||
this.events = new SingleLiveEvent<>();
|
||||
this.walletBalance = Transformations.map(SignalStore.paymentsValues().liveMobileCoinBalance(),
|
||||
DeleteAccountViewModel::getFormattedWalletBalance);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<String>> getWalletBalance() {
|
||||
return walletBalance;
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Country>> getFilteredCountries() {
|
||||
@@ -128,6 +138,15 @@ public class DeleteAccountViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull Optional<String> getFormattedWalletBalance(@NonNull Balance balance) {
|
||||
Money amount = balance.getFullAmount();
|
||||
if (amount.isPositive()) {
|
||||
return Optional.of(amount.toString(FormatterOptions.defaults()));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isMatch(@NonNull String query, @NonNull Country country) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
return true;
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageProcessor;
|
||||
import org.thoughtcrime.securesms.net.PipeConnectivityListener;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.payments.Payments;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
|
||||
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
|
||||
@@ -74,6 +75,7 @@ public class ApplicationDependencies {
|
||||
private static volatile TypingStatusSender typingStatusSender;
|
||||
private static volatile DatabaseObserver databaseObserver;
|
||||
private static volatile TrimThreadsByDateManager trimThreadsByDateManager;
|
||||
private static volatile Payments payments;
|
||||
private static volatile ShakeToReport shakeToReport;
|
||||
private static volatile SignalCallManager signalCallManager;
|
||||
|
||||
@@ -373,6 +375,18 @@ public class ApplicationDependencies {
|
||||
return databaseObserver;
|
||||
}
|
||||
|
||||
public static @NonNull Payments getPayments() {
|
||||
if (payments == null) {
|
||||
synchronized (LOCK) {
|
||||
if (payments == null) {
|
||||
payments = provider.providePayments(getSignalServiceAccountManager());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return payments;
|
||||
}
|
||||
|
||||
public static @NonNull ShakeToReport getShakeToReport() {
|
||||
if (shakeToReport == null) {
|
||||
synchronized (LOCK) {
|
||||
@@ -422,6 +436,7 @@ public class ApplicationDependencies {
|
||||
@NonNull TypingStatusRepository provideTypingStatusRepository();
|
||||
@NonNull TypingStatusSender provideTypingStatusSender();
|
||||
@NonNull DatabaseObserver provideDatabaseObserver();
|
||||
@NonNull Payments providePayments(@NonNull SignalServiceAccountManager signalServiceAccountManager);
|
||||
@NonNull ShakeToReport provideShakeToReport();
|
||||
@NonNull AppForegroundObserver provideAppForegroundObserver();
|
||||
@NonNull SignalCallManager provideSignalCallManager();
|
||||
|
||||
@@ -37,6 +37,8 @@ import org.thoughtcrime.securesms.net.PipeConnectivityListener;
|
||||
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinConfig;
|
||||
import org.thoughtcrime.securesms.payments.Payments;
|
||||
import org.thoughtcrime.securesms.push.SecurityEventListener;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
|
||||
@@ -209,6 +211,18 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
|
||||
return new DatabaseObserver(context);
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public @NonNull Payments providePayments(@NonNull SignalServiceAccountManager signalServiceAccountManager) {
|
||||
MobileCoinConfig network;
|
||||
|
||||
if (BuildConfig.MOBILE_COIN_ENVIRONMENT.equals("mainnet")) network = MobileCoinConfig.getMainNet(signalServiceAccountManager);
|
||||
else if (BuildConfig.MOBILE_COIN_ENVIRONMENT.equals("testnet")) network = MobileCoinConfig.getTestNet(signalServiceAccountManager);
|
||||
else throw new AssertionError("Unknown network " + BuildConfig.MOBILE_COIN_ENVIRONMENT);
|
||||
|
||||
return new Payments(network);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ShakeToReport provideShakeToReport() {
|
||||
return new ShakeToReport(context);
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -36,6 +37,9 @@ import java.util.List;
|
||||
|
||||
public class HelpFragment extends LoggingFragment {
|
||||
|
||||
public static final String START_CATEGORY_INDEX = "start.category.index";
|
||||
public static final int PAYMENT_INDEX = 5;
|
||||
|
||||
private EditText problem;
|
||||
private CheckBox includeDebugLogs;
|
||||
private View debugLogInfo;
|
||||
@@ -93,6 +97,11 @@ public class HelpFragment extends LoggingFragment {
|
||||
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
categorySpinner.setAdapter(categoryAdapter);
|
||||
|
||||
Bundle args = getArguments();
|
||||
if (args != null) {
|
||||
categorySpinner.setSelection(Util.clamp(args.getInt(START_CATEGORY_INDEX, 0), 0, categorySpinner.getCount() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeListeners() {
|
||||
|
||||
@@ -12,6 +12,8 @@ import org.thoughtcrime.securesms.jobmanager.JobMigration;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint;
|
||||
@@ -19,8 +21,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.migrations.PushDecryptMessageJobEnvelopeMigration;
|
||||
import org.thoughtcrime.securesms.jobmanager.migrations.PushProcessMessageQueueJobMigration;
|
||||
import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration;
|
||||
@@ -94,6 +94,7 @@ public final class JobManagerFactories {
|
||||
put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory());
|
||||
put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory());
|
||||
put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory());
|
||||
put(MultiDeviceOutgoingPaymentSyncJob.KEY, new MultiDeviceOutgoingPaymentSyncJob.Factory());
|
||||
put(MultiDeviceProfileContentUpdateJob.KEY, new MultiDeviceProfileContentUpdateJob.Factory());
|
||||
put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory());
|
||||
put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory());
|
||||
@@ -144,6 +145,10 @@ public final class JobManagerFactories {
|
||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
||||
put(MarkerJob.KEY, new MarkerJob.Factory());
|
||||
put(PaymentLedgerUpdateJob.KEY, new PaymentLedgerUpdateJob.Factory());
|
||||
put(PaymentNotificationSendJob.KEY, new PaymentNotificationSendJob.Factory());
|
||||
put(PaymentSendJob.KEY, new PaymentSendJob.Factory());
|
||||
put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory());
|
||||
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
|
||||
|
||||
// Migrations
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tells a linked device about sent payments.
|
||||
*/
|
||||
public final class MultiDeviceOutgoingPaymentSyncJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(MultiDeviceOutgoingPaymentSyncJob.class);
|
||||
|
||||
public static final String KEY = "MultiDeviceOutgoingPaymentSyncJob";
|
||||
|
||||
private static final String KEY_UUID = "uuid";
|
||||
|
||||
private final UUID uuid;
|
||||
|
||||
public MultiDeviceOutgoingPaymentSyncJob(@NonNull UUID sentPaymentId) {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue("MultiDeviceOutgoingPaymentSyncJob")
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build(),
|
||||
sentPaymentId);
|
||||
}
|
||||
|
||||
private MultiDeviceOutgoingPaymentSyncJob(@NonNull Parameters parameters,
|
||||
@NonNull UUID sentPaymentId)
|
||||
{
|
||||
super(parameters);
|
||||
this.uuid = sentPaymentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_UUID, uuid.toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (!TextSecurePreferences.isMultiDevice(context)) {
|
||||
Log.i(TAG, "Not multi device, aborting...");
|
||||
return;
|
||||
}
|
||||
|
||||
PaymentDatabase.PaymentTransaction payment = DatabaseFactory.getPaymentDatabase(context).getPayment(uuid);
|
||||
|
||||
if (payment == null) {
|
||||
Log.w(TAG, "Payment not found " + uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
PaymentMetaData.MobileCoinTxoIdentification txoIdentification = payment.getPaymentMetaData().getMobileCoinTxoIdentification();
|
||||
|
||||
boolean defrag = payment.isDefrag();
|
||||
|
||||
Optional<UUID> uuid;
|
||||
if (!defrag && payment.getPayee().hasRecipientId()) {
|
||||
uuid = Optional.of(Recipient.resolved(payment.getPayee().requireRecipientId()).requireUuid());
|
||||
} else {
|
||||
uuid = Optional.absent();
|
||||
}
|
||||
|
||||
byte[] receipt = payment.getReceipt();
|
||||
|
||||
if (receipt == null) {
|
||||
throw new AssertionError("Trying to sync payment before sent?");
|
||||
}
|
||||
|
||||
OutgoingPaymentMessage outgoingPaymentMessage = new OutgoingPaymentMessage(uuid,
|
||||
payment.getAmount().requireMobileCoin(),
|
||||
payment.getFee().requireMobileCoin(),
|
||||
ByteString.copyFrom(receipt),
|
||||
payment.getBlockIndex(),
|
||||
payment.getTimestamp(),
|
||||
defrag ? Optional.absent() : Optional.of(payment.getPayee().requirePublicAddress().serialize()),
|
||||
defrag ? Optional.absent() : Optional.of(payment.getNote()),
|
||||
txoIdentification.getPublicKeyList(),
|
||||
txoIdentification.getKeyImagesList());
|
||||
|
||||
|
||||
ApplicationDependencies.getSignalServiceMessageSender()
|
||||
.sendMessage(SignalServiceSyncMessage.forOutgoingPayment(outgoingPaymentMessage),
|
||||
UnidentifiedAccessUtil.getAccessForSync(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, "Failed to sync sent payment!");
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<MultiDeviceOutgoingPaymentSyncJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull MultiDeviceOutgoingPaymentSyncJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new MultiDeviceOutgoingPaymentSyncJob(parameters,
|
||||
UUID.fromString(data.getString(KEY_UUID)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinLedgerWrapper;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Updates the local cache of the ledger, and so the balance.
|
||||
*/
|
||||
public final class PaymentLedgerUpdateJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentLedgerUpdateJob.class);
|
||||
|
||||
public static final String KEY = "PaymentLedgerUpdateJob";
|
||||
|
||||
private static final String KEY_PAYMENT_UUID = "payment_uuid";
|
||||
|
||||
private final UUID paymentUuid;
|
||||
|
||||
public static PaymentLedgerUpdateJob updateLedgerToReflectPayment(@NonNull UUID paymentUuid) {
|
||||
return new PaymentLedgerUpdateJob(paymentUuid);
|
||||
}
|
||||
|
||||
public static PaymentLedgerUpdateJob updateLedger() {
|
||||
return new PaymentLedgerUpdateJob(null);
|
||||
}
|
||||
|
||||
private PaymentLedgerUpdateJob(@Nullable UUID paymentUuid) {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue(PaymentSendJob.QUEUE)
|
||||
.setMaxAttempts(10)
|
||||
.setMaxInstancesForQueue(1)
|
||||
.build(),
|
||||
paymentUuid);
|
||||
}
|
||||
|
||||
private PaymentLedgerUpdateJob(@NonNull Parameters parameters,
|
||||
@Nullable UUID paymentUuid)
|
||||
{
|
||||
super(parameters);
|
||||
this.paymentUuid = paymentUuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws IOException, RetryLaterException {
|
||||
if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
|
||||
Log.w(TAG, "Payments are not enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Long minimumBlockIndex = null;
|
||||
if (paymentUuid != null) {
|
||||
PaymentDatabase.PaymentTransaction payment = DatabaseFactory.getPaymentDatabase(context)
|
||||
.getPayment(paymentUuid);
|
||||
|
||||
if (payment != null) {
|
||||
minimumBlockIndex = payment.getBlockIndex();
|
||||
Log.i(TAG, "Fetching for a payment " + paymentUuid + " " + (minimumBlockIndex > 0 ? "non-zero" : "zero"));
|
||||
} else {
|
||||
Log.w(TAG, "Payment not found " + paymentUuid);
|
||||
}
|
||||
}
|
||||
|
||||
MobileCoinLedgerWrapper ledger = ApplicationDependencies.getPayments()
|
||||
.getWallet()
|
||||
.tryGetFullLedger(minimumBlockIndex);
|
||||
|
||||
if (ledger == null) {
|
||||
Log.i(TAG, "Ledger not updated yet, waiting for a minimum block index");
|
||||
throw new RetryLaterException();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Ledger fetched successfully");
|
||||
|
||||
SignalStore.paymentsValues()
|
||||
.setMobileCoinFullLedger(ledger);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_PAYMENT_UUID, paymentUuid != null ? paymentUuid.toString() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
@NonNull @Override
|
||||
public String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, "Failed to get ledger");
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<PaymentLedgerUpdateJob> {
|
||||
@Override
|
||||
public @NonNull PaymentLedgerUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
String paymentUuid = data.getString(KEY_PAYMENT_UUID);
|
||||
return new PaymentLedgerUpdateJob(parameters,
|
||||
paymentUuid != null ? UUID.fromString(paymentUuid) : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.messages.SendMessageResult;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class PaymentNotificationSendJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "PaymentNotificationSendJob";
|
||||
|
||||
private static final String TAG = Log.tag(PaymentNotificationSendJob.class);
|
||||
|
||||
private static final String KEY_UUID = "uuid";
|
||||
private static final String KEY_RECIPIENT = "recipient";
|
||||
|
||||
private final RecipientId recipientId;
|
||||
private final UUID uuid;
|
||||
|
||||
PaymentNotificationSendJob(@NonNull RecipientId recipientId,
|
||||
@NonNull UUID uuid,
|
||||
@NonNull String queue)
|
||||
{
|
||||
this(new Parameters.Builder()
|
||||
.setQueue(queue)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
recipientId,
|
||||
uuid);
|
||||
}
|
||||
|
||||
private PaymentNotificationSendJob(@NonNull Parameters parameters,
|
||||
@NonNull RecipientId recipientId,
|
||||
@NonNull UUID uuid)
|
||||
{
|
||||
super(parameters);
|
||||
|
||||
this.recipientId = recipientId;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_RECIPIENT, recipientId.serialize())
|
||||
.putString(KEY_UUID, uuid.toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
PaymentDatabase paymentDatabase = DatabaseFactory.getPaymentDatabase(context);
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
|
||||
SignalServiceAddress addresses = RecipientUtil.toSignalServiceAddress(context, recipient);
|
||||
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient);
|
||||
|
||||
PaymentDatabase.PaymentTransaction payment = paymentDatabase.getPayment(uuid);
|
||||
|
||||
if (payment == null) {
|
||||
Log.w(TAG, "Could not find payment, cannot send notification " + uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.getReceipt() == null) {
|
||||
Log.w(TAG, "Could not find payment receipt, cannot send notification " + uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
SignalServiceDataMessage dataMessage = SignalServiceDataMessage.newBuilder()
|
||||
.withPayment(new SignalServiceDataMessage.Payment(new SignalServiceDataMessage.PaymentNotification(payment.getReceipt(), payment.getNote())))
|
||||
.build();
|
||||
|
||||
SendMessageResult sendMessageResult = messageSender.sendMessage(addresses, unidentifiedAccess, dataMessage);
|
||||
|
||||
if (sendMessageResult.getIdentityFailure() != null) {
|
||||
Log.w(TAG, "Identity failure for " + recipient.getId());
|
||||
} else if (sendMessageResult.isUnregisteredFailure()) {
|
||||
Log.w(TAG, "Unregistered failure for " + recipient.getId());
|
||||
} else if (sendMessageResult.getSuccess() == null) {
|
||||
throw new RetryLaterException();
|
||||
} else {
|
||||
Log.i(TAG, String.format("Payment notification sent to %s for %s", recipientId, uuid));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
if (e instanceof ServerRejectedException) return false;
|
||||
return e instanceof IOException ||
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, String.format("Failed to send payment notification to recipient %s for %s", recipientId, uuid));
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<PaymentNotificationSendJob> {
|
||||
@Override
|
||||
public @NonNull PaymentNotificationSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new PaymentNotificationSendJob(parameters,
|
||||
RecipientId.from(data.getString(KEY_RECIPIENT)),
|
||||
UUID.fromString(data.getString(KEY_UUID)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.FailureReason;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.PaymentSubmissionResult;
|
||||
import org.thoughtcrime.securesms.payments.PaymentTransactionId;
|
||||
import org.thoughtcrime.securesms.payments.TransactionSubmissionResult;
|
||||
import org.thoughtcrime.securesms.payments.Wallet;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Allows payment to a recipient or a public address.
|
||||
*/
|
||||
public final class PaymentSendJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentSendJob.class);
|
||||
|
||||
public static final String KEY = "PaymentSendJob";
|
||||
|
||||
static final String QUEUE = "Payments";
|
||||
|
||||
private static final String KEY_UUID = "uuid";
|
||||
private static final String KEY_TIMESTAMP = "timestamp";
|
||||
private static final String KEY_RECIPIENT = "recipient";
|
||||
private static final String KEY_ADDRESS = "public_address";
|
||||
private static final String KEY_NOTE = "note";
|
||||
private static final String KEY_AMOUNT = "amount";
|
||||
private static final String KEY_FEE = "fee";
|
||||
|
||||
private final UUID uuid;
|
||||
private final long timestamp;
|
||||
private final RecipientId recipientId;
|
||||
private final MobileCoinPublicAddress publicAddress;
|
||||
private final String note;
|
||||
private final Money amount;
|
||||
private final Money totalFee;
|
||||
|
||||
/**
|
||||
* @param totalFee Total quoted totalFee to the user. This is expected to cover all defrags and the actual transaction.
|
||||
*/
|
||||
@AnyThread
|
||||
public static @NonNull UUID enqueuePayment(@Nullable RecipientId recipientId,
|
||||
@NonNull MobileCoinPublicAddress publicAddress,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money totalFee)
|
||||
{
|
||||
UUID uuid = UUID.randomUUID();
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
Job sendJob = new PaymentSendJob(new Parameters.Builder()
|
||||
.setQueue(QUEUE)
|
||||
.setMaxAttempts(1)
|
||||
.build(),
|
||||
uuid,
|
||||
timestamp,
|
||||
recipientId,
|
||||
Objects.requireNonNull(publicAddress),
|
||||
note,
|
||||
amount,
|
||||
totalFee);
|
||||
|
||||
JobManager.Chain chain = ApplicationDependencies.getJobManager()
|
||||
.startChain(sendJob)
|
||||
.then(new PaymentTransactionCheckJob(uuid, QUEUE))
|
||||
.then(new MultiDeviceOutgoingPaymentSyncJob(uuid));
|
||||
|
||||
if (recipientId != null) {
|
||||
chain.then(new PaymentNotificationSendJob(recipientId, uuid, recipientId.toQueueKey(true)));
|
||||
}
|
||||
|
||||
chain.then(PaymentLedgerUpdateJob.updateLedgerToReflectPayment(uuid))
|
||||
.enqueue();
|
||||
|
||||
return uuid;
|
||||
}
|
||||
|
||||
private PaymentSendJob(@NonNull Parameters parameters,
|
||||
@NonNull UUID uuid,
|
||||
long timestamp,
|
||||
@Nullable RecipientId recipientId,
|
||||
@NonNull MobileCoinPublicAddress publicAddress,
|
||||
@NonNull String note,
|
||||
@NonNull Money amount,
|
||||
@NonNull Money totalFee)
|
||||
{
|
||||
super(parameters);
|
||||
this.uuid = uuid;
|
||||
this.timestamp = timestamp;
|
||||
this.recipientId = recipientId;
|
||||
this.publicAddress = publicAddress;
|
||||
this.note = note;
|
||||
this.amount = amount;
|
||||
this.totalFee = totalFee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to track the payment in the database.
|
||||
* <p>
|
||||
* Present in the database only after it has been submitted successfully.
|
||||
*/
|
||||
public @NonNull UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
|
||||
Log.w(TAG, "Payments are not enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Stopwatch stopwatch = new Stopwatch("Payment submission");
|
||||
|
||||
Wallet wallet = ApplicationDependencies.getPayments().getWallet();
|
||||
PaymentDatabase paymentDatabase = DatabaseFactory.getPaymentDatabase(context);
|
||||
|
||||
paymentDatabase.createOutgoingPayment(uuid,
|
||||
recipientId,
|
||||
publicAddress,
|
||||
timestamp,
|
||||
note,
|
||||
amount);
|
||||
Log.i(TAG, "Payment record created " + uuid);
|
||||
stopwatch.split("Record created");
|
||||
|
||||
try {
|
||||
PaymentSubmissionResult paymentSubmissionResult = wallet.sendPayment(publicAddress, amount.requireMobileCoin(), totalFee.requireMobileCoin());
|
||||
stopwatch.split("Payment submitted");
|
||||
|
||||
if (paymentSubmissionResult.containsDefrags()) {
|
||||
Log.i(TAG, "Payment contains " + paymentSubmissionResult.defrags().size() + " defrags, main payment" + uuid);
|
||||
RecipientId self = Recipient.self().getId();
|
||||
MobileCoinPublicAddress selfAddress = wallet.getMobileCoinPublicAddress();
|
||||
for (TransactionSubmissionResult defrag : paymentSubmissionResult.defrags()) {
|
||||
UUID defragUuid = UUID.randomUUID();
|
||||
PaymentTransactionId.MobileCoin mobileCoinTransaction = (PaymentTransactionId.MobileCoin) defrag.getTransactionId();
|
||||
paymentDatabase.createDefrag(defragUuid,
|
||||
self,
|
||||
selfAddress,
|
||||
timestamp - 1,
|
||||
mobileCoinTransaction.getFee(),
|
||||
mobileCoinTransaction.getTransaction(), mobileCoinTransaction.getReceipt());
|
||||
Log.i(TAG, "Defrag entered with id " + defragUuid);
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new PaymentTransactionCheckJob(defragUuid, QUEUE))
|
||||
.then(new MultiDeviceOutgoingPaymentSyncJob(defragUuid))
|
||||
.enqueue();
|
||||
}
|
||||
stopwatch.split("Defrag");
|
||||
}
|
||||
|
||||
TransactionSubmissionResult.ErrorCode errorCode = paymentSubmissionResult.getErrorCode();
|
||||
|
||||
switch (errorCode) {
|
||||
case INSUFFICIENT_FUNDS:
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.INSUFFICIENT_FUNDS);
|
||||
throw new PaymentException("Payment failed due to " + errorCode);
|
||||
case GENERIC_FAILURE:
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.UNKNOWN);
|
||||
throw new PaymentException("Payment failed due to " + errorCode);
|
||||
case NETWORK_FAILURE:
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.NETWORK);
|
||||
throw new PaymentException("Payment failed due to " + errorCode);
|
||||
case NONE:
|
||||
Log.i(TAG, "Payment submission complete");
|
||||
TransactionSubmissionResult transactionSubmissionResult = Objects.requireNonNull(paymentSubmissionResult.getNonDefrag());
|
||||
PaymentTransactionId.MobileCoin mobileCoinTransaction = (PaymentTransactionId.MobileCoin) transactionSubmissionResult.getTransactionId();
|
||||
paymentDatabase.markPaymentSubmitted(uuid,
|
||||
mobileCoinTransaction.getTransaction(),
|
||||
mobileCoinTransaction.getReceipt(),
|
||||
mobileCoinTransaction.getFee());
|
||||
Log.i(TAG, "Payment record updated " + uuid);
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Unknown payment failure", e);
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.UNKNOWN);
|
||||
throw e;
|
||||
}
|
||||
stopwatch.split("Update database record");
|
||||
|
||||
stopwatch.stop(TAG);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull @Override
|
||||
public Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_UUID, uuid.toString())
|
||||
.putLong(KEY_TIMESTAMP, timestamp)
|
||||
.putString(KEY_RECIPIENT, recipientId != null ? recipientId.serialize() : null)
|
||||
.putString(KEY_ADDRESS, publicAddress != null ? publicAddress.getPaymentAddressBase58() : null)
|
||||
.putString(KEY_NOTE, note)
|
||||
.putString(KEY_AMOUNT, amount.serialize())
|
||||
.putString(KEY_FEE, totalFee.serialize())
|
||||
.build();
|
||||
}
|
||||
|
||||
@NonNull @Override
|
||||
public String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, "Failed to make payment to " + recipientId);
|
||||
}
|
||||
|
||||
public static final class PaymentException extends Exception {
|
||||
PaymentException(@NonNull String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<PaymentSendJob> {
|
||||
@Override
|
||||
public @NonNull PaymentSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new PaymentSendJob(parameters,
|
||||
UUID.fromString(data.getString(KEY_UUID)),
|
||||
data.getLong(KEY_TIMESTAMP),
|
||||
RecipientId.fromNullable(data.getString(KEY_RECIPIENT)),
|
||||
MobileCoinPublicAddress.fromBase58NullableOrThrow(data.getString(KEY_ADDRESS)),
|
||||
data.getString(KEY_NOTE),
|
||||
Money.parseOrThrow(data.getString(KEY_AMOUNT)),
|
||||
Money.parseOrThrow(data.getString(KEY_FEE)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.payments.FailureReason;
|
||||
import org.thoughtcrime.securesms.payments.PaymentTransactionId;
|
||||
import org.thoughtcrime.securesms.payments.Payments;
|
||||
import org.thoughtcrime.securesms.payments.Wallet;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PaymentTransactionCheckJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentTransactionCheckJob.class);
|
||||
|
||||
public static final String KEY = "PaymentTransactionCheckJob";
|
||||
|
||||
private static final String KEY_UUID = "uuid";
|
||||
|
||||
private final UUID uuid;
|
||||
|
||||
public PaymentTransactionCheckJob(@NonNull UUID uuid) {
|
||||
this(uuid, PaymentSendJob.QUEUE);
|
||||
}
|
||||
|
||||
public PaymentTransactionCheckJob(@NonNull UUID uuid, @NonNull String queue) {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue(queue)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
uuid);
|
||||
}
|
||||
|
||||
private PaymentTransactionCheckJob(@NonNull Parameters parameters, @NonNull UUID uuid) {
|
||||
super(parameters);
|
||||
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
PaymentDatabase paymentDatabase = DatabaseFactory.getPaymentDatabase(context);
|
||||
|
||||
PaymentDatabase.PaymentTransaction payment = paymentDatabase.getPayment(uuid);
|
||||
|
||||
if (payment == null) {
|
||||
Log.w(TAG, "No payment found for UUID " + uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
Payments payments = ApplicationDependencies.getPayments();
|
||||
|
||||
switch (payment.getDirection()) {
|
||||
case SENT: {
|
||||
Log.i(TAG, "Checking sent status of " + uuid);
|
||||
PaymentTransactionId paymentTransactionId = new PaymentTransactionId.MobileCoin(Objects.requireNonNull(payment.getTransaction()), Objects.requireNonNull(payment.getReceipt()), payment.getFee().requireMobileCoin());
|
||||
Wallet.TransactionStatusResult status = payments.getWallet().getSentTransactionStatus(paymentTransactionId);
|
||||
|
||||
switch (status.getTransactionStatus()) {
|
||||
case COMPLETE:
|
||||
paymentDatabase.markPaymentSuccessful(uuid, status.getBlockIndex());
|
||||
Log.i(TAG, "Marked sent payment successful " + uuid);
|
||||
break;
|
||||
case FAILED:
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.UNKNOWN);
|
||||
Log.i(TAG, "Marked sent payment failed " + uuid);
|
||||
break;
|
||||
case IN_PROGRESS:
|
||||
Log.i(TAG, "Sent payment still in progress " + uuid);
|
||||
throw new IncompleteTransactionException();
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RECEIVED: {
|
||||
Log.i(TAG, "Checking received status of " + uuid);
|
||||
Wallet.ReceivedTransactionStatus transactionStatus = payments.getWallet().getReceivedTransactionStatus(Objects.requireNonNull(payment.getReceipt()));
|
||||
|
||||
switch (transactionStatus.getStatus()) {
|
||||
case COMPLETE:
|
||||
paymentDatabase.markReceivedPaymentSuccessful(uuid, transactionStatus.getAmount(), transactionStatus.getBlockIndex());
|
||||
Log.i(TAG, "Marked received payment successful " + uuid);
|
||||
break;
|
||||
case FAILED:
|
||||
paymentDatabase.markPaymentFailed(uuid, FailureReason.UNKNOWN);
|
||||
Log.i(TAG, "Marked received payment failed " + uuid);
|
||||
break;
|
||||
case IN_PROGRESS:
|
||||
Log.i(TAG, "Received payment still in progress " + uuid);
|
||||
throw new IncompleteTransactionException();
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
if (exception instanceof NonSuccessfulResponseCodeException) {
|
||||
if (((NonSuccessfulResponseCodeException) exception).is5xx()) {
|
||||
return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getServerErrorMaxBackoff());
|
||||
}
|
||||
}
|
||||
|
||||
if (exception instanceof IncompleteTransactionException && pastAttemptCount < 20) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof IncompleteTransactionException ||
|
||||
e instanceof IOException;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_UUID, uuid.toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
}
|
||||
|
||||
private static final class IncompleteTransactionException extends Exception {
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<PaymentTransactionCheckJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentTransactionCheckJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new PaymentTransactionCheckJob(parameters,
|
||||
UUID.fromString(data.getString(KEY_UUID)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public final class ProfileUploadJob extends BaseJob {
|
||||
}
|
||||
|
||||
ProfileUtil.uploadProfile(context);
|
||||
Log.i(TAG, "Profile uploaded.");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -110,7 +110,7 @@ public class RefreshOwnProfileJob extends BaseJob {
|
||||
private void setProfileName(@Nullable String encryptedName) {
|
||||
try {
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
String plaintextName = ProfileUtil.decryptName(profileKey, encryptedName);
|
||||
String plaintextName = ProfileUtil.decryptString(profileKey, encryptedName);
|
||||
ProfileName profileName = ProfileName.fromSerialized(plaintextName);
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName);
|
||||
@@ -122,8 +122,8 @@ public class RefreshOwnProfileJob extends BaseJob {
|
||||
private void setProfileAbout(@Nullable String encryptedAbout, @Nullable String encryptedEmoji) {
|
||||
try {
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
String plaintextAbout = ProfileUtil.decryptName(profileKey, encryptedAbout);
|
||||
String plaintextEmoji = ProfileUtil.decryptName(profileKey, encryptedEmoji);
|
||||
String plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout);
|
||||
String plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji);
|
||||
|
||||
Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextAbout) ? "non-" : "") + "empty about.");
|
||||
Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextEmoji) ? "non-" : "") + "empty emoji.");
|
||||
|
||||
@@ -419,7 +419,7 @@ public class RetrieveProfileJob extends BaseJob {
|
||||
ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
|
||||
if (profileKey == null) return;
|
||||
|
||||
String plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptName(profileKey, profileName));
|
||||
String plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptString(profileKey, profileName));
|
||||
|
||||
ProfileName remoteProfileName = ProfileName.fromSerialized(plaintextProfileName);
|
||||
ProfileName localProfileName = recipient.getProfileName();
|
||||
@@ -460,8 +460,8 @@ public class RetrieveProfileJob extends BaseJob {
|
||||
ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
|
||||
if (profileKey == null) return;
|
||||
|
||||
String plaintextAbout = ProfileUtil.decryptName(profileKey, encryptedAbout);
|
||||
String plaintextEmoji = ProfileUtil.decryptName(profileKey, encryptedEmoji);
|
||||
String plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout);
|
||||
String plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji);
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context).setAbout(recipient.getId(), plaintextAbout, plaintextEmoji);
|
||||
} catch (InvalidCiphertextException | IOException e) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.keyvalue;
|
||||
|
||||
public enum PaymentsAvailability {
|
||||
NOT_IN_REGION(false, false),
|
||||
DISABLED_REMOTELY(false, false),
|
||||
REGISTRATION_AVAILABLE(false, true),
|
||||
WITHDRAW_ONLY(true, true),
|
||||
WITHDRAW_AND_SEND(true, true);
|
||||
|
||||
private final boolean showPaymentsMenu;
|
||||
private final boolean isEnabled;
|
||||
|
||||
PaymentsAvailability(boolean isEnabled, boolean showPaymentsMenu) {
|
||||
this.showPaymentsMenu = showPaymentsMenu;
|
||||
this.isEnabled = isEnabled;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return isEnabled;
|
||||
}
|
||||
|
||||
public boolean showPaymentsMenu() {
|
||||
return showPaymentsMenu;
|
||||
}
|
||||
|
||||
public boolean isSendAllowed() {
|
||||
return this == WITHDRAW_AND_SEND;
|
||||
}
|
||||
|
||||
public boolean canRegister() {
|
||||
return this == REGISTRATION_AVAILABLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package org.thoughtcrime.securesms.keyvalue;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.mobilecoin.lib.Mnemonics;
|
||||
import com.mobilecoin.lib.exceptions.BadMnemonicException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.Entropy;
|
||||
import org.thoughtcrime.securesms.payments.GeographicalRestrictions;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinLedgerWrapper;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil;
|
||||
import org.thoughtcrime.securesms.payments.proto.MobileCoinLedger;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class PaymentsValues extends SignalStoreValues {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsValues.class);
|
||||
|
||||
private static final String PAYMENTS_ENTROPY = "payments_entropy";
|
||||
private static final String MOB_PAYMENTS_ENABLED = "mob_payments_enabled";
|
||||
private static final String MOB_LEDGER = "mob_ledger";
|
||||
private static final String PAYMENTS_CURRENT_CURRENCY = "payments_current_currency";
|
||||
private static final String DEFAULT_CURRENCY_CODE = "GBP";
|
||||
private static final String USER_CONFIRMED_MNEMONIC = "mob_payments_user_confirmed_mnemonic";
|
||||
|
||||
private static final String SHOW_ABOUT_MOBILE_COIN_INFO_CARD = "mob_payments_show_about_mobile_coin_info_card";
|
||||
private static final String SHOW_ADDING_TO_YOUR_WALLET_INFO_CARD = "mob_payments_show_adding_to_your_wallet_info_card";
|
||||
private static final String SHOW_CASHING_OUT_INFO_CARD = "mob_payments_show_cashing_out_info_card";
|
||||
private static final String SHOW_RECOVERY_PHRASE_INFO_CARD = "mob_payments_show_recovery_phrase_info_card";
|
||||
private static final String SHOW_UPDATE_PIN_INFO_CARD = "mob_payments_show_update_pin_info_card";
|
||||
|
||||
private static final Money.MobileCoin LARGE_BALANCE_THRESHOLD = Money.mobileCoin(BigDecimal.valueOf(500));
|
||||
|
||||
private final MutableLiveData<Currency> liveCurrentCurrency;
|
||||
private final MutableLiveData<MobileCoinLedgerWrapper> liveMobileCoinLedger;
|
||||
private final LiveData<Balance> liveMobileCoinBalance;
|
||||
|
||||
PaymentsValues(@NonNull KeyValueStore store) {
|
||||
super(store);
|
||||
this.liveCurrentCurrency = new MutableLiveData<>(currentCurrency());
|
||||
this.liveMobileCoinLedger = new MutableLiveData<>(mobileCoinLatestFullLedger());
|
||||
this.liveMobileCoinBalance = Transformations.map(liveMobileCoinLedger, MobileCoinLedgerWrapper::getBalance);
|
||||
}
|
||||
|
||||
@Override void onFirstEverAppLaunch() {
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull List<String> getKeysToIncludeInBackup() {
|
||||
return Arrays.asList(PAYMENTS_ENTROPY,
|
||||
MOB_PAYMENTS_ENABLED,
|
||||
MOB_LEDGER,
|
||||
PAYMENTS_CURRENT_CURRENCY,
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
USER_CONFIRMED_MNEMONIC,
|
||||
SHOW_ABOUT_MOBILE_COIN_INFO_CARD,
|
||||
SHOW_ADDING_TO_YOUR_WALLET_INFO_CARD,
|
||||
SHOW_CASHING_OUT_INFO_CARD,
|
||||
SHOW_RECOVERY_PHRASE_INFO_CARD,
|
||||
SHOW_UPDATE_PIN_INFO_CARD);
|
||||
}
|
||||
|
||||
public boolean userConfirmedMnemonic() {
|
||||
return getStore().getBoolean(USER_CONFIRMED_MNEMONIC, false);
|
||||
}
|
||||
|
||||
public void setUserConfirmedMnemonic(boolean userConfirmedMnemonic) {
|
||||
getStore().beginWrite().putBoolean(USER_CONFIRMED_MNEMONIC, userConfirmedMnemonic).commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Consider using {@link #getPaymentsAvailability} which includes feature flag and region status.
|
||||
*/
|
||||
public boolean mobileCoinPaymentsEnabled() {
|
||||
KeyValueReader reader = getStore().beginRead();
|
||||
|
||||
return reader.getBoolean(MOB_PAYMENTS_ENABLED, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies feature flags and region restrictions to return an enum which describes the available feature set for the user.
|
||||
*/
|
||||
public PaymentsAvailability getPaymentsAvailability() {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
|
||||
if (!TextSecurePreferences.isPushRegistered(context) ||
|
||||
!GeographicalRestrictions.e164Allowed(TextSecurePreferences.getLocalNumber(context)))
|
||||
{
|
||||
return PaymentsAvailability.NOT_IN_REGION;
|
||||
}
|
||||
|
||||
if (FeatureFlags.payments()) {
|
||||
if (mobileCoinPaymentsEnabled()) {
|
||||
return PaymentsAvailability.WITHDRAW_AND_SEND;
|
||||
} else {
|
||||
return PaymentsAvailability.REGISTRATION_AVAILABLE;
|
||||
}
|
||||
} else {
|
||||
if (mobileCoinPaymentsEnabled()) {
|
||||
return PaymentsAvailability.WITHDRAW_ONLY;
|
||||
} else {
|
||||
return PaymentsAvailability.DISABLED_REMOTELY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public void setMobileCoinPaymentsEnabled(boolean isMobileCoinPaymentsEnabled) {
|
||||
if (mobileCoinPaymentsEnabled() == isMobileCoinPaymentsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobileCoinPaymentsEnabled) {
|
||||
Entropy entropy = getPaymentsEntropy();
|
||||
if (entropy == null) {
|
||||
entropy = Entropy.generateNew();
|
||||
Log.i(TAG, "Generated new payments entropy");
|
||||
}
|
||||
|
||||
getStore().beginWrite()
|
||||
.putBlob(PAYMENTS_ENTROPY, entropy.getBytes())
|
||||
.putBoolean(MOB_PAYMENTS_ENABLED, true)
|
||||
.putString(PAYMENTS_CURRENT_CURRENCY, currentCurrency().getCurrencyCode())
|
||||
.commit();
|
||||
} else {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(MOB_PAYMENTS_ENABLED, false)
|
||||
.putBoolean(USER_CONFIRMED_MNEMONIC, false)
|
||||
.commit();
|
||||
}
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).markNeedsSync(Recipient.self().getId());
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
}
|
||||
|
||||
public @NonNull Mnemonic getPaymentsMnemonic() {
|
||||
Entropy paymentsEntropy = getPaymentsEntropy();
|
||||
if (paymentsEntropy == null) {
|
||||
throw new IllegalStateException("Entropy has not been set");
|
||||
}
|
||||
|
||||
return paymentsEntropy.asMnemonic();
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a local entropy is set, regardless of whether payments is currently enabled.
|
||||
*/
|
||||
public boolean hasPaymentsEntropy() {
|
||||
return getPaymentsEntropy() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local payments entropy, regardless of whether payments is currently enabled.
|
||||
* <p>
|
||||
* And null if has never been set.
|
||||
*/
|
||||
public @Nullable Entropy getPaymentsEntropy() {
|
||||
return Entropy.fromBytes(getStore().getBlob(PAYMENTS_ENTROPY, null));
|
||||
}
|
||||
|
||||
public @NonNull Balance mobileCoinLatestBalance() {
|
||||
return mobileCoinLatestFullLedger().getBalance();
|
||||
}
|
||||
|
||||
public @NonNull LiveData<MobileCoinLedgerWrapper> liveMobileCoinLedger() {
|
||||
return liveMobileCoinLedger;
|
||||
}
|
||||
|
||||
public @NonNull LiveData<Balance> liveMobileCoinBalance() {
|
||||
return liveMobileCoinBalance;
|
||||
}
|
||||
|
||||
public void setCurrentCurrency(@NonNull Currency currentCurrency) {
|
||||
getStore().beginWrite()
|
||||
.putString(PAYMENTS_CURRENT_CURRENCY, currentCurrency.getCurrencyCode())
|
||||
.commit();
|
||||
|
||||
liveCurrentCurrency.postValue(currentCurrency);
|
||||
}
|
||||
|
||||
public @NonNull Currency currentCurrency() {
|
||||
String currencyCode = getStore().getString(PAYMENTS_CURRENT_CURRENCY, null);
|
||||
return currencyCode == null ? determineCurrency()
|
||||
: Currency.getInstance(currencyCode);
|
||||
}
|
||||
|
||||
public @NonNull MutableLiveData<Currency> liveCurrentCurrency() {
|
||||
return liveCurrentCurrency;
|
||||
}
|
||||
|
||||
public boolean showAboutMobileCoinInfoCard() {
|
||||
return getStore().getBoolean(SHOW_ABOUT_MOBILE_COIN_INFO_CARD, true);
|
||||
}
|
||||
|
||||
public boolean showAddingToYourWalletInfoCard() {
|
||||
return getStore().getBoolean(SHOW_ADDING_TO_YOUR_WALLET_INFO_CARD, true);
|
||||
}
|
||||
|
||||
public boolean showCashingOutInfoCard() {
|
||||
return getStore().getBoolean(SHOW_CASHING_OUT_INFO_CARD, true);
|
||||
}
|
||||
|
||||
public boolean showRecoveryPhraseInfoCard() {
|
||||
if (userHasLargeBalance()) {
|
||||
return getStore().getBoolean(SHOW_CASHING_OUT_INFO_CARD, true);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean showUpdatePinInfoCard() {
|
||||
if (userHasLargeBalance() &&
|
||||
SignalStore.kbsValues().hasPin() &&
|
||||
!SignalStore.kbsValues().hasOptedOut() &&
|
||||
SignalStore.pinValues().getKeyboardType().equals(PinKeyboardType.NUMERIC)) {
|
||||
return getStore().getBoolean(SHOW_CASHING_OUT_INFO_CARD, true);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void dismissAboutMobileCoinInfoCard() {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(SHOW_ABOUT_MOBILE_COIN_INFO_CARD, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void dismissAddingToYourWalletInfoCard() {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(SHOW_ADDING_TO_YOUR_WALLET_INFO_CARD, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void dismissCashingOutInfoCard() {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(SHOW_CASHING_OUT_INFO_CARD, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void dismissRecoveryPhraseInfoCard() {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(SHOW_RECOVERY_PHRASE_INFO_CARD, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void dismissUpdatePinInfoCard() {
|
||||
getStore().beginWrite()
|
||||
.putBoolean(SHOW_UPDATE_PIN_INFO_CARD, false)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void setMobileCoinFullLedger(@NonNull MobileCoinLedgerWrapper ledger) {
|
||||
getStore().beginWrite()
|
||||
.putBlob(MOB_LEDGER, ledger.serialize())
|
||||
.commit();
|
||||
|
||||
liveMobileCoinLedger.postValue(ledger);
|
||||
}
|
||||
|
||||
public @NonNull MobileCoinLedgerWrapper mobileCoinLatestFullLedger() {
|
||||
byte[] blob = getStore().getBlob(MOB_LEDGER, null);
|
||||
|
||||
if (blob == null) {
|
||||
return new MobileCoinLedgerWrapper(MobileCoinLedger.getDefaultInstance());
|
||||
}
|
||||
|
||||
try {
|
||||
return new MobileCoinLedgerWrapper(MobileCoinLedger.parseFrom(blob));
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
Log.w(TAG, "Bad cached ledger, clearing", e);
|
||||
setMobileCoinFullLedger(new MobileCoinLedgerWrapper(MobileCoinLedger.getDefaultInstance()));
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Currency determineCurrency() {
|
||||
String localE164 = TextSecurePreferences.getLocalNumber(ApplicationDependencies.getApplication());
|
||||
if (localE164 == null) {
|
||||
localE164 = "";
|
||||
}
|
||||
return Util.firstNonNull(CurrencyUtil.getCurrencyByE164(localE164),
|
||||
CurrencyUtil.getCurrencyByLocale(Locale.getDefault()),
|
||||
Currency.getInstance(DEFAULT_CURRENCY_CODE));
|
||||
}
|
||||
|
||||
public void setEnabledAndEntropy(boolean enabled, @Nullable Entropy entropy) {
|
||||
KeyValueStore.Writer writer = getStore().beginWrite();
|
||||
|
||||
if (entropy != null) {
|
||||
writer.putBlob(PAYMENTS_ENTROPY, entropy.getBytes());
|
||||
}
|
||||
|
||||
writer.putBoolean(MOB_PAYMENTS_ENABLED, enabled)
|
||||
.commit();
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public WalletRestoreResult restoreWallet(@NonNull String mnemonic) {
|
||||
byte[] entropyFromMnemonic;
|
||||
try {
|
||||
entropyFromMnemonic = Mnemonics.bip39EntropyFromMnemonic(mnemonic);
|
||||
} catch (BadMnemonicException e) {
|
||||
return WalletRestoreResult.MNEMONIC_ERROR;
|
||||
}
|
||||
Entropy paymentsEntropy = getPaymentsEntropy();
|
||||
if (paymentsEntropy != null) {
|
||||
byte[] existingEntropy = paymentsEntropy.getBytes();
|
||||
if (Arrays.equals(existingEntropy, entropyFromMnemonic)) {
|
||||
setMobileCoinPaymentsEnabled(true);
|
||||
setUserConfirmedMnemonic(true);
|
||||
return WalletRestoreResult.ENTROPY_UNCHANGED;
|
||||
}
|
||||
}
|
||||
|
||||
getStore().beginWrite()
|
||||
.putBlob(PAYMENTS_ENTROPY, entropyFromMnemonic)
|
||||
.putBoolean(MOB_PAYMENTS_ENABLED, true)
|
||||
.remove(MOB_LEDGER)
|
||||
.putBoolean(USER_CONFIRMED_MNEMONIC, true)
|
||||
.commit();
|
||||
|
||||
liveMobileCoinLedger.postValue(new MobileCoinLedgerWrapper(MobileCoinLedger.getDefaultInstance()));
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
|
||||
return WalletRestoreResult.ENTROPY_CHANGED;
|
||||
}
|
||||
|
||||
public enum WalletRestoreResult {
|
||||
ENTROPY_CHANGED,
|
||||
ENTROPY_UNCHANGED,
|
||||
MNEMONIC_ERROR
|
||||
}
|
||||
|
||||
private boolean userHasLargeBalance() {
|
||||
return mobileCoinLatestBalance().getFullAmount().requireMobileCoin().greaterThan(LARGE_BALANCE_THRESHOLD);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public final class SignalStore {
|
||||
private final PhoneNumberPrivacyValues phoneNumberPrivacyValues;
|
||||
private final OnboardingValues onboardingValues;
|
||||
private final WallpaperValues wallpaperValues;
|
||||
private final PaymentsValues paymentsValues;
|
||||
private final ProxyValues proxyValues;
|
||||
|
||||
private SignalStore() {
|
||||
@@ -52,6 +53,7 @@ public final class SignalStore {
|
||||
this.phoneNumberPrivacyValues = new PhoneNumberPrivacyValues(store);
|
||||
this.onboardingValues = new OnboardingValues(store);
|
||||
this.wallpaperValues = new WallpaperValues(store);
|
||||
this.paymentsValues = new PaymentsValues(store);
|
||||
this.proxyValues = new ProxyValues(store);
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ public final class SignalStore {
|
||||
phoneNumberPrivacy().onFirstEverAppLaunch();
|
||||
onboarding().onFirstEverAppLaunch();
|
||||
wallpaper().onFirstEverAppLaunch();
|
||||
paymentsValues().onFirstEverAppLaunch();
|
||||
proxy().onFirstEverAppLaunch();
|
||||
}
|
||||
|
||||
@@ -91,6 +94,7 @@ public final class SignalStore {
|
||||
keys.addAll(phoneNumberPrivacy().getKeysToIncludeInBackup());
|
||||
keys.addAll(onboarding().getKeysToIncludeInBackup());
|
||||
keys.addAll(wallpaper().getKeysToIncludeInBackup());
|
||||
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
|
||||
keys.addAll(proxy().getKeysToIncludeInBackup());
|
||||
return keys;
|
||||
}
|
||||
@@ -164,6 +168,10 @@ public final class SignalStore {
|
||||
return INSTANCE.wallpaperValues;
|
||||
}
|
||||
|
||||
public static @NonNull PaymentsValues paymentsValues() {
|
||||
return INSTANCE.paymentsValues;
|
||||
}
|
||||
|
||||
public static @NonNull ProxyValues proxy() {
|
||||
return INSTANCE.proxyValues;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.database.PaymentMetaDataUtil;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
@@ -60,6 +62,9 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentTransactionCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupInfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
@@ -78,6 +83,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
|
||||
@@ -118,6 +124,7 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
|
||||
@@ -126,9 +133,11 @@ import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOper
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage;
|
||||
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -137,6 +146,7 @@ import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Takes data about a decrypted message, transforms it into user-presentable data, and writes that
|
||||
@@ -227,15 +237,16 @@ public final class MessageContentProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId);
|
||||
else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
|
||||
else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId, groupId.get().requireV1());
|
||||
else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId, groupId);
|
||||
else if (message.getReaction().isPresent()) handleReaction(content, message);
|
||||
else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message);
|
||||
else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId);
|
||||
else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId);
|
||||
else if (Build.VERSION.SDK_INT > 19 && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId);
|
||||
if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId);
|
||||
else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId);
|
||||
else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId, groupId.get().requireV1());
|
||||
else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId, groupId);
|
||||
else if (message.getReaction().isPresent()) handleReaction(content, message);
|
||||
else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message);
|
||||
else if (message.getPayment().isPresent()) handlePayment(content, message);
|
||||
else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId);
|
||||
else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId);
|
||||
else if (Build.VERSION.SDK_INT > 19 && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId);
|
||||
|
||||
if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) {
|
||||
handleUnknownGroupMessage(content, message.getGroupContext().get());
|
||||
@@ -263,6 +274,7 @@ public final class MessageContentProcessor {
|
||||
else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get());
|
||||
else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get());
|
||||
else if (syncMessage.getMessageRequestResponse().isPresent()) handleSynchronizeMessageRequestResponse(syncMessage.getMessageRequestResponse().get());
|
||||
else if (syncMessage.getOutgoingPaymentMessage().isPresent()) handleSynchronizeOutgoingPayment(syncMessage.getOutgoingPaymentMessage().get());
|
||||
else warn(String.valueOf(content.getTimestamp()), "Contains no known sync types...");
|
||||
} else if (content.getCallMessage().isPresent()) {
|
||||
log(String.valueOf(content.getTimestamp()), "Got call message...");
|
||||
@@ -302,6 +314,41 @@ public final class MessageContentProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePayment(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) {
|
||||
if (!message.getPayment().isPresent()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (!message.getPayment().get().getPaymentNotification().isPresent()) {
|
||||
Log.w(TAG, "Ignoring payment message without notification");
|
||||
return;
|
||||
}
|
||||
|
||||
SignalServiceDataMessage.PaymentNotification paymentNotification = message.getPayment().get().getPaymentNotification().get();
|
||||
PaymentDatabase paymentDatabase = DatabaseFactory.getPaymentDatabase(context);
|
||||
UUID uuid = UUID.randomUUID();
|
||||
Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender());
|
||||
String queue = "Payment_" + PushProcessMessageJob.getQueueName(recipient.getId());
|
||||
|
||||
try {
|
||||
paymentDatabase.createIncomingPayment(uuid,
|
||||
recipient.getId(),
|
||||
message.getTimestamp(),
|
||||
paymentNotification.getNote(),
|
||||
Money.mobileCoin(BigDecimal.ZERO),
|
||||
Money.mobileCoin(BigDecimal.ZERO),
|
||||
paymentNotification.getReceipt());
|
||||
} catch (PaymentDatabase.PublicKeyConflictException e) {
|
||||
Log.w(TAG, "Ignoring payment with public key already in database");
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new PaymentTransactionCheckJob(uuid, queue))
|
||||
.then(PaymentLedgerUpdateJob.updateLedger())
|
||||
.enqueue();
|
||||
}
|
||||
|
||||
private static @Nullable
|
||||
SignalServiceGroupContext getGroupContextIfPresent(@NonNull SignalServiceContent content) {
|
||||
if (content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupContext().isPresent()) {
|
||||
@@ -793,6 +840,38 @@ public final class MessageContentProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSynchronizeOutgoingPayment(@NonNull OutgoingPaymentMessage outgoingPaymentMessage) {
|
||||
RecipientId recipientId = outgoingPaymentMessage.getRecipient()
|
||||
.transform(uuid -> RecipientId.from(uuid, null))
|
||||
.orNull();
|
||||
long timestamp = outgoingPaymentMessage.getBlockTimestamp();
|
||||
if (timestamp == 0) {
|
||||
timestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
Optional<MobileCoinPublicAddress> address = outgoingPaymentMessage.getAddress().transform(MobileCoinPublicAddress::fromBytes);
|
||||
if (!address.isPresent() && recipientId == null) {
|
||||
Log.i(TAG, "Inserting defrag");
|
||||
address = Optional.of(ApplicationDependencies.getPayments().getWallet().getMobileCoinPublicAddress());
|
||||
recipientId = Recipient.self().getId();
|
||||
}
|
||||
|
||||
UUID uuid = UUID.randomUUID();
|
||||
DatabaseFactory.getPaymentDatabase(context)
|
||||
.createSuccessfulPayment(uuid,
|
||||
recipientId,
|
||||
address.get(),
|
||||
timestamp,
|
||||
outgoingPaymentMessage.getBlockIndex(),
|
||||
outgoingPaymentMessage.getNote().or(""),
|
||||
outgoingPaymentMessage.getAmount(),
|
||||
outgoingPaymentMessage.getFee(),
|
||||
outgoingPaymentMessage.getReceipt().toByteArray(),
|
||||
PaymentMetaDataUtil.fromKeysAndImages(outgoingPaymentMessage.getPublicKeys(), outgoingPaymentMessage.getKeyImages()));
|
||||
|
||||
log("Inserted synchronized payment " + uuid);
|
||||
}
|
||||
|
||||
private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content,
|
||||
@NonNull SentTranscriptMessage message)
|
||||
throws StorageFailedException, BadGroupIdException, IOException, GroupChangeBusyException
|
||||
|
||||
@@ -40,7 +40,7 @@ public class ApplicationMigrations {
|
||||
|
||||
private static final int LEGACY_CANONICAL_VERSION = 455;
|
||||
|
||||
public static final int CURRENT_VERSION = 28;
|
||||
public static final int CURRENT_VERSION = 30;
|
||||
|
||||
private static final class Version {
|
||||
static final int LEGACY = 1;
|
||||
|
||||
@@ -53,10 +53,14 @@ import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
|
||||
import org.thoughtcrime.securesms.maps.PlacePickerActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
import org.thoughtcrime.securesms.payments.create.CreatePaymentFragmentArgs;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -404,6 +408,13 @@ public class AttachmentManager {
|
||||
activity.startActivityForResult(intent, requestCode);
|
||||
}
|
||||
|
||||
public static void selectPayment(@NonNull Activity activity, @NonNull RecipientId recipientId) {
|
||||
Intent intent = new Intent(activity, PaymentsActivity.class);
|
||||
intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_createPayment);
|
||||
intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new CreatePaymentFragmentArgs.Builder(new PayeeParcelable(recipientId)).build().toBundle());
|
||||
activity.startActivity(intent);
|
||||
}
|
||||
|
||||
private @Nullable Uri getSlideUri() {
|
||||
return slide.isPresent() ? slide.get().getUri() : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public final class Balance {
|
||||
private final Money fullAmount;
|
||||
private final Money transferableAmount;
|
||||
private final long checkedAt;
|
||||
|
||||
public Balance(@NonNull Money fullAmount, @NonNull Money transferableAmount, long checkedAt) {
|
||||
this.fullAmount = fullAmount;
|
||||
this.transferableAmount = transferableAmount;
|
||||
this.checkedAt = checkedAt;
|
||||
}
|
||||
|
||||
public @NonNull Money getFullAmount() {
|
||||
return fullAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full amount minus estimated fees required to send all funds.
|
||||
*/
|
||||
public @NonNull Money getTransferableAmount() {
|
||||
return transferableAmount;
|
||||
}
|
||||
|
||||
public long getCheckedAt() {
|
||||
return checkedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Balance{" +
|
||||
"fullAmount=" + fullAmount +
|
||||
", transferableAmount=" + transferableAmount +
|
||||
", checkedAt=" + checkedAt +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Utility to display a dialog when the user tries to send a payment to someone they do not have
|
||||
* a profile key for.
|
||||
*/
|
||||
public final class CanNotSendPaymentDialog {
|
||||
|
||||
private CanNotSendPaymentDialog() {
|
||||
}
|
||||
|
||||
public static void show(@NonNull Context context) {
|
||||
show(context, null);
|
||||
}
|
||||
|
||||
public static void show(@NonNull Context context, @Nullable Runnable onSendAMessageClicked) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.CanNotSendPaymentDialog__cant_send_payment)
|
||||
.setMessage(R.string.CanNotSendPaymentDialog__to_send_a_payment_to_this_user);
|
||||
|
||||
if (onSendAMessageClicked != null) {
|
||||
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.setPositiveButton(R.string.CanNotSendPaymentDialog__send_a_message, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
onSendAMessageClicked.run();
|
||||
})
|
||||
.show();
|
||||
} else {
|
||||
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public class CreatePaymentDetails implements Parcelable {
|
||||
private final PayeeParcelable payee;
|
||||
private final Money amount;
|
||||
private final String note;
|
||||
|
||||
public CreatePaymentDetails(@NonNull PayeeParcelable payee,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note)
|
||||
{
|
||||
this.payee = payee;
|
||||
this.amount = amount;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
protected CreatePaymentDetails(@NonNull Parcel in) {
|
||||
this.payee = in.readParcelable(PayeeParcelable.class.getClassLoader());
|
||||
this.amount = Money.parseOrThrow(in.readString());
|
||||
this.note = in.readString();
|
||||
}
|
||||
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee.getPayee();
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public @Nullable String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeParcelable(payee, flags);
|
||||
dest.writeString(amount.serialize());
|
||||
dest.writeString(note);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<CreatePaymentDetails> CREATOR = new Creator<CreatePaymentDetails>() {
|
||||
@Override
|
||||
public @NonNull CreatePaymentDetails createFromParcel(@NonNull Parcel in) {
|
||||
return new CreatePaymentDetails(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CreatePaymentDetails[] newArray(int size) {
|
||||
return new CreatePaymentDetails[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Converts from database protobuf type {@link CryptoValue} to and from other types.
|
||||
*/
|
||||
public final class CryptoValueUtil {
|
||||
|
||||
private CryptoValueUtil() {
|
||||
}
|
||||
|
||||
public static @NonNull CryptoValue moneyToCryptoValue(@NonNull Money money) {
|
||||
CryptoValue.Builder builder = CryptoValue.newBuilder();
|
||||
|
||||
if (money instanceof Money.MobileCoin) {
|
||||
Money.MobileCoin mobileCoin = (Money.MobileCoin) money;
|
||||
builder.setMobileCoinValue(CryptoValue.MobileCoinValue
|
||||
.newBuilder()
|
||||
.setPicoMobileCoin(mobileCoin.serializeAmountString()));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static @NonNull Money cryptoValueToMoney(@NonNull CryptoValue amount) {
|
||||
CryptoValue.ValueCase valueCase = amount.getValueCase();
|
||||
|
||||
switch (valueCase) {
|
||||
case MOBILECOINVALUE:
|
||||
return Money.picoMobileCoin(new BigInteger(amount.getMobileCoinValue().getPicoMobileCoin()));
|
||||
case VALUE_NOT_SET:
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.reconciliation.LedgerReconcile;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DataExportUtil {
|
||||
|
||||
private DataExportUtil() {}
|
||||
|
||||
public static @NonNull String createTsv() {
|
||||
if (!FeatureFlags.internalUser()) {
|
||||
throw new AssertionError("This is intended for internal use only");
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
List<PaymentDatabase.PaymentTransaction> paymentTransactions = DatabaseFactory.getPaymentDatabase(context)
|
||||
.getAll();
|
||||
MobileCoinLedgerWrapper ledger = SignalStore.paymentsValues().liveMobileCoinLedger().getValue();
|
||||
List<Payment> reconciled = LedgerReconcile.reconcile(paymentTransactions, Objects.requireNonNull(ledger));
|
||||
|
||||
return createTsv(reconciled);
|
||||
}
|
||||
|
||||
@RequiresApi(api = 26)
|
||||
private static @NonNull String createTsv(@NonNull List<Payment> payments) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append(String.format(Locale.US, "%s\t%s\t%s\t%s\t%s%n", "Date Time", "From", "To", "Amount", "Fee"));
|
||||
|
||||
for (Payment payment : payments) {
|
||||
if (payment.getState() != State.SUCCESSFUL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String self = Recipient.self().getDisplayName(context);
|
||||
String otherParty = describePayee(context, payment.getPayee());
|
||||
String from;
|
||||
String to;
|
||||
switch (payment.getDirection()) {
|
||||
case SENT:
|
||||
from = self;
|
||||
to = otherParty;
|
||||
break;
|
||||
case RECEIVED:
|
||||
from = otherParty;
|
||||
to = self;
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
sb.append(String.format(Locale.US, "%s\t%s\t%s\t%s\t%s%n",
|
||||
DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(payment.getDisplayTimestamp())),
|
||||
from,
|
||||
to,
|
||||
payment.getAmountWithDirection().requireMobileCoin().toBigDecimal(),
|
||||
payment.getFee().requireMobileCoin().toBigDecimal()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String describePayee(Context context, Payee payee) {
|
||||
if (payee.hasRecipientId()) {
|
||||
return Recipient.resolved(payee.requireRecipientId()).getDisplayName(context);
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
return payee.requirePublicAddress().getPaymentAddressBase58();
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum Direction {
|
||||
// These are serialized into the database, do not change the values.
|
||||
SENT(0),
|
||||
RECEIVED(1);
|
||||
|
||||
private final int value;
|
||||
|
||||
Direction(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull Direction deserialize(int value) {
|
||||
if (value == Direction.SENT.value) return Direction.SENT;
|
||||
else if (value == Direction.RECEIVED.value) return Direction.RECEIVED;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
|
||||
public boolean isReceived() {
|
||||
return this == RECEIVED;
|
||||
}
|
||||
|
||||
public boolean isSent() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.Mnemonics;
|
||||
import com.mobilecoin.lib.exceptions.BadEntropyException;
|
||||
import com.mobilecoin.lib.exceptions.BadMnemonicException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class Entropy {
|
||||
private static final String TAG = Log.tag(Entropy.class);
|
||||
|
||||
private final byte[] bytes;
|
||||
|
||||
Entropy(@NonNull byte[] bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public static @NonNull Entropy generateNew() {
|
||||
return new Entropy(Util.getSecretBytes(PaymentsConstants.PAYMENTS_ENTROPY_LENGTH));
|
||||
}
|
||||
|
||||
public static Entropy fromBytes(@Nullable byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
if (bytes.length == PaymentsConstants.PAYMENTS_ENTROPY_LENGTH) {
|
||||
return new Entropy(bytes);
|
||||
} else {
|
||||
Log.w(TAG, String.format(Locale.US, "Entropy was supplied of length %d and ignored", bytes.length), new Throwable());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public Mnemonic asMnemonic() {
|
||||
try {
|
||||
String mnemonic = Mnemonics.bip39EntropyToMnemonic(bytes);
|
||||
byte[] check = Mnemonics.bip39EntropyFromMnemonic(mnemonic);
|
||||
if (!Arrays.equals(bytes, check)) {
|
||||
throw new AssertionError("Round trip mnemonic failure");
|
||||
}
|
||||
return new Mnemonic(mnemonic);
|
||||
} catch (BadEntropyException | BadMnemonicException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum FailureReason {
|
||||
// These are serialized into the database, do not change the values.
|
||||
UNKNOWN(0),
|
||||
INSUFFICIENT_FUNDS(1),
|
||||
NETWORK(2);
|
||||
|
||||
private final int value;
|
||||
|
||||
FailureReason(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull FailureReason deserialize(int value) {
|
||||
if (value == FailureReason.UNKNOWN.value) return FailureReason.UNKNOWN;
|
||||
else if (value == FailureReason.INSUFFICIENT_FUNDS.value) return FailureReason.INSUFFICIENT_FUNDS;
|
||||
else if (value == FailureReason.NETWORK.value) return FailureReason.NETWORK;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.res.Resources;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
public class FiatMoneyUtil {
|
||||
|
||||
private static final String TAG = Log.tag(FiatMoneyUtil.class);
|
||||
|
||||
public static @NonNull LiveData<Optional<FiatMoney>> getExchange(@NonNull LiveData<Money> amount) {
|
||||
return LiveDataUtil.mapAsync(amount, a -> {
|
||||
try {
|
||||
return ApplicationDependencies.getPayments()
|
||||
.getCurrencyExchange(false)
|
||||
.getExchangeRate(SignalStore.paymentsValues().currentCurrency())
|
||||
.exchange(a);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return Optional.absent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static @NonNull String format(@NonNull Resources resources, @NonNull FiatMoney amount) {
|
||||
return format(resources, amount, new FormatOptions());
|
||||
}
|
||||
|
||||
public static @NonNull String format(@NonNull Resources resources, @NonNull FiatMoney amount, @NonNull FormatOptions options) {
|
||||
final NumberFormat formatter;
|
||||
|
||||
if (options.withSymbol) {
|
||||
formatter = NumberFormat.getCurrencyInstance();
|
||||
formatter.setCurrency(amount.getCurrency());
|
||||
} else {
|
||||
formatter = NumberFormat.getNumberInstance();
|
||||
formatter.setMinimumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
|
||||
}
|
||||
|
||||
String formattedAmount = formatter.format(amount.getAmount());
|
||||
if (amount.getTimestamp() > 0 && options.displayTime) {
|
||||
return resources.getString(R.string.CurrencyAmountFormatter_s_at_s,
|
||||
formattedAmount,
|
||||
DateUtils.getTimeString(ApplicationDependencies.getApplication(), Locale.getDefault(), amount.getTimestamp()));
|
||||
}
|
||||
return formattedAmount;
|
||||
}
|
||||
|
||||
public static FormatOptions formatOptions() {
|
||||
return new FormatOptions();
|
||||
}
|
||||
|
||||
public static class FormatOptions {
|
||||
private boolean displayTime = true;
|
||||
private boolean withSymbol = true;
|
||||
|
||||
private FormatOptions() {
|
||||
}
|
||||
|
||||
public @NonNull FormatOptions withDisplayTime(boolean enabled) {
|
||||
this.displayTime = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull FormatOptions withoutSymbol() {
|
||||
this.withSymbol = false;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GeographicalRestrictions {
|
||||
|
||||
private static final String TAG = Log.tag(GeographicalRestrictions.class);
|
||||
|
||||
private GeographicalRestrictions() {}
|
||||
|
||||
private static final Set<Integer> REGION_CODE_SET;
|
||||
|
||||
static {
|
||||
Set<Integer> set = new HashSet<>(BuildConfig.MOBILE_COIN_REGIONS.length);
|
||||
|
||||
for (int i = 0; i < BuildConfig.MOBILE_COIN_REGIONS.length; i++) {
|
||||
set.add(BuildConfig.MOBILE_COIN_REGIONS[i]);
|
||||
}
|
||||
|
||||
REGION_CODE_SET = Collections.unmodifiableSet(set);
|
||||
}
|
||||
|
||||
public static boolean regionAllowed(int regionCode) {
|
||||
return REGION_CODE_SET.contains(regionCode);
|
||||
}
|
||||
|
||||
public static boolean e164Allowed(@Nullable String e164) {
|
||||
try {
|
||||
int countryCode = PhoneNumberUtil.getInstance()
|
||||
.parse(e164, null)
|
||||
.getCountryCode();
|
||||
|
||||
return GeographicalRestrictions.regionAllowed(countryCode);
|
||||
} catch (NumberParseException e) {
|
||||
Log.w(TAG, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.Mnemonics;
|
||||
import com.mobilecoin.lib.exceptions.BadMnemonicException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class Mnemonic {
|
||||
|
||||
public static final List<String> BIP39_WORDS_ENGLISH;
|
||||
|
||||
private final String mnemonic;
|
||||
private final String[] words;
|
||||
|
||||
static {
|
||||
try {
|
||||
BIP39_WORDS_ENGLISH = Collections.unmodifiableList(Arrays.asList(Mnemonics.wordsByPrefix("")));
|
||||
} catch (BadMnemonicException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public Mnemonic(@NonNull String mnemonic) {
|
||||
this.mnemonic = mnemonic;
|
||||
this.words = mnemonic.split(" ");
|
||||
}
|
||||
|
||||
public @NonNull List<String> getWords() {
|
||||
return Collections.unmodifiableList(Arrays.asList(words));
|
||||
}
|
||||
|
||||
public int getWordCount() {
|
||||
return words.length;
|
||||
}
|
||||
|
||||
public String getMnemonic() {
|
||||
return mnemonic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RawRes;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class MobileCoinConfig {
|
||||
|
||||
abstract @NonNull Uri getConsensusUri();
|
||||
|
||||
abstract @NonNull Uri getFogUri();
|
||||
|
||||
abstract @NonNull byte[] getFogAuthoritySpki();
|
||||
|
||||
abstract @NonNull AuthCredentials getAuth() throws IOException;
|
||||
|
||||
abstract @NonNull ClientConfig getConfig();
|
||||
|
||||
public static MobileCoinConfig getTestNet(SignalServiceAccountManager signalServiceAccountManager) {
|
||||
return new MobileCoinTestNetConfig(signalServiceAccountManager);
|
||||
}
|
||||
|
||||
public static MobileCoinConfig getMainNet(SignalServiceAccountManager signalServiceAccountManager) {
|
||||
return new MobileCoinMainNetConfig(signalServiceAccountManager);
|
||||
}
|
||||
|
||||
protected static Set<X509Certificate> getTrustRoots(@RawRes int pemResource) {
|
||||
try (InputStream inputStream = ApplicationDependencies.getApplication().getResources().openRawResource(pemResource)) {
|
||||
Collection<? extends Certificate> certificates = CertificateFactory.getInstance("X.509")
|
||||
.generateCertificates(inputStream);
|
||||
|
||||
HashSet<X509Certificate> x509Certificates = new HashSet<>(certificates.size());
|
||||
for (Certificate c : certificates) {
|
||||
x509Certificates.add((X509Certificate) c);
|
||||
}
|
||||
|
||||
return x509Certificates;
|
||||
} catch (IOException | CertificateException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.MobileCoinLedger;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public final class MobileCoinLedgerWrapper {
|
||||
|
||||
private final MobileCoinLedger ledger;
|
||||
private final Balance balance;
|
||||
|
||||
public MobileCoinLedgerWrapper(@NonNull MobileCoinLedger ledger) {
|
||||
Money.MobileCoin fullAmount = Money.picoMobileCoin(ledger.getBalance());
|
||||
Money.MobileCoin transferableAmount = Money.picoMobileCoin(ledger.getTransferableBalance());
|
||||
|
||||
this.ledger = ledger;
|
||||
this.balance = new Balance(fullAmount, transferableAmount, ledger.getAsOfTimeStamp());
|
||||
}
|
||||
|
||||
public @NonNull Balance getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return ledger.toByteArray();
|
||||
}
|
||||
|
||||
public @NonNull List<OwnedTxo> getAllTxos() {
|
||||
List<OwnedTxo> txoList = new ArrayList<>(ledger.getSpentTxosCount() + ledger.getUnspentTxosCount());
|
||||
addAllMapped(txoList, ledger.getSpentTxosList());
|
||||
addAllMapped(txoList, ledger.getUnspentTxosList());
|
||||
return txoList;
|
||||
}
|
||||
|
||||
private static void addAllMapped(@NonNull List<OwnedTxo> output, @NonNull List<MobileCoinLedger.OwnedTXO> txosList) {
|
||||
for (MobileCoinLedger.OwnedTXO ownedTxo : txosList) {
|
||||
output.add(new OwnedTxo(ownedTxo));
|
||||
}
|
||||
}
|
||||
|
||||
public static class OwnedTxo {
|
||||
private final MobileCoinLedger.OwnedTXO ownedTXO;
|
||||
|
||||
OwnedTxo(MobileCoinLedger.OwnedTXO ownedTXO) {
|
||||
this.ownedTXO = ownedTXO;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getValue() {
|
||||
return Money.picoMobileCoin(ownedTXO.getAmount());
|
||||
}
|
||||
|
||||
public @NonNull ByteString getKeyImage() {
|
||||
return ownedTXO.getKeyImage();
|
||||
}
|
||||
|
||||
public @NonNull ByteString getPublicKey() {
|
||||
return ownedTXO.getPublicKey();
|
||||
}
|
||||
|
||||
public long getReceivedInBlock() {
|
||||
return ownedTXO.getReceivedInBlock().getBlockNumber();
|
||||
}
|
||||
|
||||
public @Nullable Long getSpentInBlock() {
|
||||
return nullIfZero(ownedTXO.getSpentInBlock().getBlockNumber());
|
||||
}
|
||||
|
||||
public boolean isSpent() {
|
||||
return ownedTXO.getSpentInBlock().getBlockNumber() != 0;
|
||||
}
|
||||
|
||||
public @Nullable Long getReceivedInBlockTimestamp() {
|
||||
return nullIfZero(ownedTXO.getReceivedInBlock().getTimestamp());
|
||||
}
|
||||
|
||||
public @Nullable Long getSpentInBlockTimestamp() {
|
||||
return nullIfZero(ownedTXO.getSpentInBlock().getTimestamp());
|
||||
}
|
||||
|
||||
private @Nullable Long nullIfZero(long value) {
|
||||
return value == 0 ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.log.LogAdapter;
|
||||
import com.mobilecoin.lib.log.Logger;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
final class MobileCoinLogAdapter implements LogAdapter {
|
||||
|
||||
@Override
|
||||
public boolean isLoggable(@NonNull Logger.Level level, @NonNull String s) {
|
||||
return level.ordinal() >= Logger.Level.WARNING.ordinal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param metadata May contain PII, do not log.
|
||||
*/
|
||||
@Override
|
||||
public void log(@NonNull Logger.Level level,
|
||||
@NonNull String tag,
|
||||
@NonNull String message,
|
||||
@Nullable Throwable throwable,
|
||||
@NonNull Object... metadata)
|
||||
{
|
||||
switch (level) {
|
||||
case INFO:
|
||||
Log.i(tag, message, throwable);
|
||||
break;
|
||||
case VERBOSE:
|
||||
Log.v(tag, message, throwable);
|
||||
break;
|
||||
case DEBUG:
|
||||
Log.d(tag, message, throwable);
|
||||
break;
|
||||
case WARNING:
|
||||
Log.w(tag, message, throwable);
|
||||
break;
|
||||
case ERROR:
|
||||
Log.e(tag, message, throwable);
|
||||
break;
|
||||
case WTF:
|
||||
Log.wtf(tag, message, throwable);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
import com.mobilecoin.lib.Verifier;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.util.Hex;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Set;
|
||||
|
||||
final class MobileCoinMainNetConfig extends MobileCoinConfig {
|
||||
|
||||
private final SignalServiceAccountManager signalServiceAccountManager;
|
||||
|
||||
public MobileCoinMainNetConfig(@NonNull SignalServiceAccountManager signalServiceAccountManager) {
|
||||
this.signalServiceAccountManager = signalServiceAccountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getConsensusUri() {
|
||||
return Uri.parse("mc://node1.prod.mobilecoinww.com");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getFogUri() {
|
||||
return Uri.parse("fog://service.fog.mob.production.namda.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull byte[] getFogAuthoritySpki() {
|
||||
return Base64.decodeOrThrow("MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxaNIOgcoQtq0S64dFVha\n"
|
||||
+ "6rn0hDv/ec+W0cKRdFKygiyp5xuWdW3YKVAkK1PPgSDD2dwmMN/1xcGWrPMqezx1\n"
|
||||
+ "h1xCzbr7HL7XvLyFyoiMB2JYd7aoIuGIbHpCOlpm8ulVnkOX7BNuo0Hi2F0AAHyT\n"
|
||||
+ "PwmtVMt6RZmae1Z/Pl2I06+GgWN6vufV7jcjiLT3yQPsn1kVSj+DYCf3zq+1sCkn\n"
|
||||
+ "KIvoRPMdQh9Vi3I/fqNXz00DSB7lt3v5/FQ6sPbjljqdGD/qUl4xKRW+EoDLlAUf\n"
|
||||
+ "zahomQOLXVAlxcws3Ua5cZUhaJi6U5jVfw5Ng2N7FwX/D5oX82r9o3xcFqhWpGnf\n"
|
||||
+ "SxSrAudv1X7WskXomKhUzMl/0exWpcJbdrQWB/qshzi9Et7HEDNY+xEDiwGiikj5\n"
|
||||
+ "f0Lb+QA4mBMlAhY/cmWec8NKi1gf3Dmubh6c3sNteb9OpZ/irA3AfE8jI37K1rve\n"
|
||||
+ "zDI8kbNtmYgvyhfz0lZzRT2WAfffiTe565rJglvKa8rh8eszKk2HC9DyxUb/TcyL\n"
|
||||
+ "/OjGhe2fDYO2t6brAXCqjPZAEkVJq3I30NmnPdE19SQeP7wuaUIb3U7MGxoZC/Nu\n"
|
||||
+ "JoxZh8svvZ8cyqVjG+dOQ6/UfrFY0jiswT8AsrfqBis/ZV5EFukZr+zbPtg2MH0H\n"
|
||||
+ "3tSJ14BCLduvc7FY6lAZmOcCAwEAAQ==");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull AuthCredentials getAuth() throws IOException {
|
||||
return signalServiceAccountManager.getPaymentsAuthorization();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull ClientConfig getConfig() {
|
||||
try {
|
||||
byte[] mrEnclaveConsensus = Hex.toByteArray("e66db38b8a43a33f6c1610d335a361963bb2b31e056af0dc0a895ac6c857cab9");
|
||||
byte[] mrEnclaveReport = Hex.toByteArray("709ab90621e3a8d9eb26ed9e2830e091beceebd55fb01c5d7c31d27e83b9b0d1");
|
||||
byte[] mrEnclaveLedger = Hex.toByteArray("511eab36de691ded50eb08b173304194da8b9d86bfdd7102001fe6bb279c3666");
|
||||
byte[] mrEnclaveView = Hex.toByteArray("ddd59da874fdf3239d5edb1ef251df07a8728c9ef63057dd0b50ade5a9ddb041");
|
||||
Set<X509Certificate> trustRoots = getTrustRoots(R.raw.signal_mobilecoin_authority);
|
||||
ClientConfig config = new ClientConfig();
|
||||
String[] hardeningAdvisories = {"INTEL-SA-00334"};
|
||||
|
||||
config.logAdapter = new MobileCoinLogAdapter();
|
||||
config.fogView = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveView, null, hardeningAdvisories));
|
||||
config.fogLedger = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveLedger, null, hardeningAdvisories));
|
||||
config.consensus = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveConsensus, null, hardeningAdvisories));
|
||||
config.report = new ClientConfig.Service().withVerifier(new Verifier().withMrEnclave(mrEnclaveReport, null, hardeningAdvisories));
|
||||
return config;
|
||||
} catch (AttestationException ex) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.PrintableWrapper;
|
||||
import com.mobilecoin.lib.PublicAddress;
|
||||
import com.mobilecoin.lib.exceptions.InvalidUriException;
|
||||
import com.mobilecoin.lib.exceptions.SerializationException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
public final class MobileCoinPublicAddress {
|
||||
|
||||
private static final String TAG = Log.tag(MobileCoinPublicAddress.class);
|
||||
|
||||
private final com.mobilecoin.lib.PublicAddress publicAddress;
|
||||
private final String base58;
|
||||
private final Uri uri;
|
||||
|
||||
static @NonNull MobileCoinPublicAddress fromPublicAddress(@Nullable PublicAddress publicAddress) throws AddressException {
|
||||
if (publicAddress == null) {
|
||||
throw new AddressException("Does not contain a public address");
|
||||
}
|
||||
return new MobileCoinPublicAddress(publicAddress);
|
||||
}
|
||||
|
||||
MobileCoinPublicAddress(@NonNull PublicAddress publicAddress) {
|
||||
this.publicAddress = publicAddress;
|
||||
try {
|
||||
PrintableWrapper printableWrapper = PrintableWrapper.fromPublicAddress(publicAddress);
|
||||
this.base58 = printableWrapper.toB58String();
|
||||
this.uri = printableWrapper.toUri();
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable MobileCoinPublicAddress fromBytes(@Nullable byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new MobileCoinPublicAddress(PublicAddress.fromBytes(bytes));
|
||||
} catch (SerializationException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static MobileCoinPublicAddress fromBase58NullableOrThrow(@Nullable String base58String) {
|
||||
return base58String != null ? fromBase58OrThrow(base58String) : null;
|
||||
}
|
||||
|
||||
public static @NonNull MobileCoinPublicAddress fromBase58OrThrow(@NonNull String base58String) {
|
||||
try {
|
||||
return fromBase58(base58String);
|
||||
} catch (AddressException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static MobileCoinPublicAddress fromBase58(@NonNull String base58String) throws AddressException {
|
||||
try {
|
||||
PublicAddress publicAddress = PrintableWrapper.fromB58String(base58String).getPublicAddress();
|
||||
|
||||
return MobileCoinPublicAddress.fromPublicAddress(publicAddress);
|
||||
} catch (SerializationException e) {
|
||||
throw new AddressException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull MobileCoinPublicAddress fromQr(@NonNull String data) throws AddressException {
|
||||
try {
|
||||
PrintableWrapper printableWrapper = PrintableWrapper.fromUri(Uri.parse(data));
|
||||
return MobileCoinPublicAddress.fromPublicAddress(printableWrapper.getPublicAddress());
|
||||
} catch (SerializationException | InvalidUriException e) {
|
||||
return fromBase58(data);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull String getPaymentAddressBase58() {
|
||||
return base58;
|
||||
}
|
||||
|
||||
public @NonNull Uri getPaymentAddressUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public @NonNull byte[] serialize() {
|
||||
return publicAddress.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof MobileCoinPublicAddress)) return false;
|
||||
|
||||
return base58.equals(((MobileCoinPublicAddress) o).base58);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return base58.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return base58;
|
||||
}
|
||||
|
||||
PublicAddress getAddress() {
|
||||
return publicAddress;
|
||||
}
|
||||
|
||||
public static final class AddressException extends Exception {
|
||||
|
||||
private AddressException(Throwable e) {
|
||||
super(e);
|
||||
}
|
||||
|
||||
private AddressException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
public final class MobileCoinPublicAddressProfileUtil {
|
||||
|
||||
private MobileCoinPublicAddressProfileUtil() {}
|
||||
|
||||
/**
|
||||
* Signs the supplied address bytes with the {@link IdentityKeyPair}'s private key and returns a proto that includes it and it's signature.
|
||||
*/
|
||||
public static @NonNull SignalServiceProtos.PaymentAddress signPaymentsAddress(@NonNull byte[] publicAddressBytes,
|
||||
@NonNull IdentityKeyPair identityKeyPair)
|
||||
{
|
||||
byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(publicAddressBytes);
|
||||
|
||||
return SignalServiceProtos.PaymentAddress.newBuilder()
|
||||
.setMobileCoinAddress(SignalServiceProtos.PaymentAddress.MobileCoinAddress.newBuilder()
|
||||
.setAddress(ByteString.copyFrom(publicAddressBytes))
|
||||
.setSignature(ByteString.copyFrom(signature)))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the payments address is signed with the supplied {@link IdentityKey}.
|
||||
* <p>
|
||||
* Returns the validated bytes if so, otherwise throws.
|
||||
*/
|
||||
public static @NonNull byte[] verifyPaymentsAddress(@NonNull SignalServiceProtos.PaymentAddress paymentAddress,
|
||||
@NonNull IdentityKey identityKey)
|
||||
throws PaymentsAddressException
|
||||
{
|
||||
if (!paymentAddress.hasMobileCoinAddress()) {
|
||||
throw new PaymentsAddressException(PaymentsAddressException.Code.NO_ADDRESS);
|
||||
}
|
||||
|
||||
byte[] bytes = paymentAddress.getMobileCoinAddress().getAddress().toByteArray();
|
||||
byte[] signature = paymentAddress.getMobileCoinAddress().getSignature().toByteArray();
|
||||
|
||||
if (signature.length != 64 || !identityKey.getPublicKey().verifySignature(bytes, signature)) {
|
||||
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS_SIGNATURE);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
import com.mobilecoin.lib.Verifier;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.util.Hex;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Set;
|
||||
|
||||
final class MobileCoinTestNetConfig extends MobileCoinConfig {
|
||||
|
||||
private static final short SECURITY_VERSION = 1;
|
||||
private static final short CONSENSUS_PRODUCT_ID = 1;
|
||||
private static final short FOG_LEDGER_PRODUCT_ID = 2;
|
||||
private static final short FOG_VIEW_PRODUCT_ID = 3;
|
||||
private static final short FOG_REPORT_PRODUCT_ID = 4;
|
||||
|
||||
private final SignalServiceAccountManager signalServiceAccountManager;
|
||||
|
||||
public MobileCoinTestNetConfig(@NonNull SignalServiceAccountManager signalServiceAccountManager) {
|
||||
this.signalServiceAccountManager = signalServiceAccountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getConsensusUri() {
|
||||
return Uri.parse("mc://node1.test.mobilecoin.com");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getFogUri() {
|
||||
return Uri.parse("fog://service.fog.mob.staging.namda.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull byte[] getFogAuthoritySpki() {
|
||||
return Base64.decodeOrThrow("MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAoCMq8nnjTq5EEQ4EI7yr\n"
|
||||
+ "ABL9P4y4h1P/h0DepWgXx+w/fywcfRSZINxbaMpvcV3uSJayExrpV1KmaS2wfASe\n"
|
||||
+ "YhSj+rEzAm0XUOw3Q94NOx5A/dOQag/d1SS6/QpF3PQYZTULnRFetmM4yzEnXsXc\n"
|
||||
+ "WtzEu0hh02wYJbLeAq4CCcPTPe2qckrbUP9sD18/KOzzNeypF4p5dQ2m/ezfxtga\n"
|
||||
+ "LvdUMVDVIAs2v9a5iu6ce4bIcwTIUXgX0w3+UKRx8zqowc3HIqo9yeaGn4ZOwQHv\n"
|
||||
+ "AJZecPmb2pH1nK+BtDUvHpvf+Y3/NJxwh+IPp6Ef8aoUxs2g5oIBZ3Q31fjS2Bh2\n"
|
||||
+ "gmwoVooyytEysPAHvRPVBxXxLi36WpKfk1Vq8K7cgYh3IraOkH2/l2Pyi8EYYFkW\n"
|
||||
+ "sLYofYogaiPzVoq2ZdcizfoJWIYei5mgq+8m0ZKZYLebK1i2GdseBJNIbSt3wCNX\n"
|
||||
+ "ZxyN6uqFHOCB29gmA5cbKvs/j9mDz64PJe9LCanqcDQV1U5l9dt9UdmUt7Ab1PjB\n"
|
||||
+ "toIFaP+u473Z0hmZdCgAivuiBMMYMqt2V2EIw4IXLASE3roLOYp0p7h0IQHb+lVI\n"
|
||||
+ "uEl0ZmwAI30ZmzgcWc7RBeWD1/zNt55zzhfPRLx/DfDY5Kdp6oFHWMvI2r1/oZkd\n"
|
||||
+ "hjFp7pV6qrl7vOyR5QqmuRkCAwEAAQ==");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull AuthCredentials getAuth() throws IOException {
|
||||
return signalServiceAccountManager.getPaymentsAuthorization();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull ClientConfig getConfig() {
|
||||
try {
|
||||
byte[] mrEnclaveConsensus = Hex.toByteArray("9268c3220a5260e51e4b586f00e4677fed2b80380f1eeaf775af60f8e880fde8");
|
||||
byte[] mrEnclaveReport = Hex.toByteArray("185875464ccd67a879d58181055383505a719b364b12d56d9bef90a40bed07ca");
|
||||
byte[] mrEnclaveLedger = Hex.toByteArray("7330c9987f21b91313b39dcdeaa7da8da5ca101c929f5740c207742c762e6dcd");
|
||||
byte[] mrEnclaveView = Hex.toByteArray("4e598799faa4bb08a3bd55c0bcda7e1d22e41151d0c591f6c2a48b3562b0881e");
|
||||
byte[] mrSigner = Hex.toByteArray("bf7fa957a6a94acb588851bc8767e0ca57706c79f4fc2aa6bcb993012c3c386c");
|
||||
Set<X509Certificate> trustRoots = getTrustRoots(R.raw.signal_mobilecoin_authority);
|
||||
ClientConfig config = new ClientConfig();
|
||||
String[] hardeningAdvisories = {"INTEL-SA-00334"};
|
||||
|
||||
config.logAdapter = new MobileCoinLogAdapter();
|
||||
config.fogView = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveView, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_VIEW_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.fogLedger = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveLedger, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_LEDGER_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.consensus = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveConsensus, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, CONSENSUS_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.report = new ClientConfig.Service().withVerifier(new Verifier().withMrEnclave(mrEnclaveReport, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_REPORT_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
return config;
|
||||
} catch (AttestationException ex) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.whispersystems.signalservice.api.payments.Currency;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class MoneyView extends AppCompatTextView {
|
||||
private FormatterOptions formatterOptions;
|
||||
|
||||
public MoneyView(@NonNull Context context) {
|
||||
super(context);
|
||||
|
||||
init(context, null);
|
||||
}
|
||||
|
||||
public MoneyView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
public MoneyView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
public void init(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
FormatterOptions.Builder builder = FormatterOptions.builder(Locale.getDefault());
|
||||
|
||||
TypedArray styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.MoneyView, 0, 0);
|
||||
|
||||
if (styledAttributes.getBoolean(R.styleable.MoneyView_always_show_sign, false)) {
|
||||
builder.alwaysPrefixWithSign();
|
||||
}
|
||||
|
||||
formatterOptions = builder.withoutSpaceBeforeUnit().build();
|
||||
|
||||
String value = styledAttributes.getString(R.styleable.MoneyView_money);
|
||||
if (value != null) {
|
||||
try {
|
||||
setMoney(Money.parse(value));
|
||||
} catch (Money.ParseException e) {
|
||||
throw new AssertionError("Invalid money format", e);
|
||||
}
|
||||
}
|
||||
|
||||
styledAttributes.recycle();
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull String amount, @NonNull Currency currency) {
|
||||
SpannableString balanceSpan = new SpannableString(amount + currency.getCurrencyCode());
|
||||
int currencyIndex = balanceSpan.length() - currency.getCurrencyCode().length();
|
||||
balanceSpan.setSpan(new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.payment_currency_code_foreground_color)), currencyIndex, currencyIndex + currency.getCurrencyCode().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
setText(balanceSpan);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money) {
|
||||
setMoney(money, true, 0L);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money, boolean highlightCurrency) {
|
||||
setMoney(money, highlightCurrency, 0L);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money, boolean highlightCurrency, long timestamp) {
|
||||
String balance = money.toString(formatterOptions);
|
||||
int currencyIndex = balance.indexOf(money.getCurrency().getCurrencyCode());
|
||||
|
||||
final SpannableString balanceSpan;
|
||||
|
||||
if (timestamp > 0L) {
|
||||
balanceSpan = new SpannableString(getResources().getString(R.string.CurrencyAmountFormatter_s_at_s,
|
||||
balance,
|
||||
DateUtils.getTimeString(ApplicationDependencies.getApplication(), Locale.getDefault(), timestamp)));
|
||||
} else {
|
||||
balanceSpan = new SpannableString(balance);
|
||||
}
|
||||
|
||||
if (highlightCurrency) {
|
||||
balanceSpan.setSpan(new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.payment_currency_code_foreground_color)), currencyIndex, currencyIndex + money.getCurrency().getCurrencyCode().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
setText(balanceSpan);
|
||||
}
|
||||
|
||||
private static @NonNull NumberFormat getMoneyFormat(int decimalPrecision) {
|
||||
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.getDefault());
|
||||
|
||||
numberFormat.setGroupingUsed(true);
|
||||
numberFormat.setMaximumFractionDigits(decimalPrecision);
|
||||
|
||||
return numberFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Payee {
|
||||
private final RecipientId recipientId;
|
||||
private final MobileCoinPublicAddress publicAddress;
|
||||
|
||||
/**
|
||||
* Used for reconstructed payments from the ledger where we do not know who it was from or to.
|
||||
*/
|
||||
public static final Payee UNKNOWN = new Payee(null, null);
|
||||
|
||||
public static Payee fromRecipientAndAddress(@NonNull RecipientId recipientId, @NonNull MobileCoinPublicAddress publicAddress) {
|
||||
return new Payee(Objects.requireNonNull(recipientId), publicAddress);
|
||||
}
|
||||
|
||||
public Payee(@NonNull RecipientId recipientId) {
|
||||
this(Objects.requireNonNull(recipientId), null);
|
||||
}
|
||||
|
||||
public Payee(@NonNull MobileCoinPublicAddress publicAddress) {
|
||||
this(null, Objects.requireNonNull(publicAddress));
|
||||
}
|
||||
|
||||
private Payee(@Nullable RecipientId recipientId, @Nullable MobileCoinPublicAddress publicAddress) {
|
||||
this.recipientId = recipientId;
|
||||
this.publicAddress = publicAddress;
|
||||
}
|
||||
|
||||
public boolean hasRecipientId() {
|
||||
return recipientId != null && !recipientId.isUnknown();
|
||||
}
|
||||
|
||||
public @NonNull RecipientId requireRecipientId() {
|
||||
return Objects.requireNonNull(recipientId);
|
||||
}
|
||||
|
||||
public boolean hasPublicAddress() {
|
||||
return publicAddress != null;
|
||||
}
|
||||
|
||||
public @NonNull MobileCoinPublicAddress requirePublicAddress() {
|
||||
return Objects.requireNonNull(publicAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final Payee payee = (Payee) o;
|
||||
return Objects.equals(recipientId, payee.recipientId) &&
|
||||
Objects.equals(publicAddress, payee.publicAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(recipientId, publicAddress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.ComparatorCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Represents one payment as displayed to the user.
|
||||
* <p>
|
||||
* It could be from a sent or received Signal payment message or reconstructed.
|
||||
*/
|
||||
public interface Payment {
|
||||
Comparator<Payment> UNKNOWN_BLOCK_INDEX_FIRST = (a, b) -> Boolean.compare(b.getBlockIndex() == 0, a.getBlockIndex() == 0);
|
||||
Comparator<Payment> ASCENDING_BLOCK_INDEX = (a, b) -> Long.compare(a.getBlockIndex(), b.getBlockIndex());
|
||||
Comparator<Payment> DESCENDING_BLOCK_INDEX = ComparatorCompat.reversed(ASCENDING_BLOCK_INDEX);
|
||||
Comparator<Payment> DESCENDING_BLOCK_INDEX_UNKNOWN_FIRST = ComparatorCompat.chain(UNKNOWN_BLOCK_INDEX_FIRST)
|
||||
.thenComparing(DESCENDING_BLOCK_INDEX);
|
||||
|
||||
@NonNull UUID getUuid();
|
||||
|
||||
@NonNull Payee getPayee();
|
||||
|
||||
long getBlockIndex();
|
||||
|
||||
long getBlockTimestamp();
|
||||
|
||||
long getTimestamp();
|
||||
|
||||
default long getDisplayTimestamp() {
|
||||
long blockTimestamp = getBlockTimestamp();
|
||||
if (blockTimestamp > 0) {
|
||||
return blockTimestamp;
|
||||
} else {
|
||||
return getTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull Direction getDirection();
|
||||
|
||||
@NonNull State getState();
|
||||
|
||||
@Nullable FailureReason getFailureReason();
|
||||
|
||||
@NonNull String getNote();
|
||||
|
||||
/**
|
||||
* Always >= 0, does not include fee
|
||||
*/
|
||||
@NonNull Money getAmount();
|
||||
|
||||
/**
|
||||
* Always >= 0
|
||||
*/
|
||||
@NonNull Money getFee();
|
||||
|
||||
@NonNull PaymentMetaData getPaymentMetaData();
|
||||
|
||||
boolean isSeen();
|
||||
|
||||
/**
|
||||
* Negative if sent, positive if received.
|
||||
*/
|
||||
default @NonNull Money getAmountWithDirection() {
|
||||
switch (getDirection()) {
|
||||
case SENT : return getAmount().negate();
|
||||
case RECEIVED: return getAmount();
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative if sent including fee, positive if received.
|
||||
*/
|
||||
default @NonNull Money getAmountPlusFeeWithDirection() {
|
||||
switch (getDirection()) {
|
||||
case SENT : return getAmount().add(getFee()).negate();
|
||||
case RECEIVED: return getAmount();
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
default boolean isDefrag() {
|
||||
return getDirection() == Direction.SENT &&
|
||||
getPayee().hasRecipientId() &&
|
||||
getPayee().requireRecipientId().equals(Recipient.self().getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class PaymentDecorator implements Payment {
|
||||
|
||||
private final Payment inner;
|
||||
|
||||
public PaymentDecorator(@NonNull Payment inner) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
public @NonNull Payment getInner() {
|
||||
return inner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UUID getUuid() {
|
||||
return inner.getUuid();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return inner.getPayee();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return inner.getBlockIndex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return inner.getBlockTimestamp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return inner.getTimestamp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return inner.getDirection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return inner.getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return inner.getFailureReason();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return inner.getNote();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return inner.getAmount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return inner.getFee();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return inner.getPaymentMetaData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return inner.isSeen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Wraps a Payment and enables it to be parcelized.
|
||||
*/
|
||||
public class PaymentParcelable implements Parcelable {
|
||||
|
||||
private final Payment payment;
|
||||
|
||||
public PaymentParcelable(@NonNull Payment payment) {
|
||||
this.payment = payment;
|
||||
}
|
||||
|
||||
protected PaymentParcelable(Parcel in) {
|
||||
this.payment = new ParcelPayment(in);
|
||||
}
|
||||
|
||||
public @NonNull Payment getPayment() {
|
||||
return payment;
|
||||
}
|
||||
|
||||
public static final Creator<PaymentParcelable> CREATOR = new Creator<PaymentParcelable>() {
|
||||
@Override
|
||||
public PaymentParcelable createFromParcel(Parcel in) {
|
||||
return new PaymentParcelable(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentParcelable[] newArray(int size) {
|
||||
return new PaymentParcelable[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(payment.getUuid().toString());
|
||||
dest.writeParcelable(new PayeeParcelable(payment.getPayee()), flags);
|
||||
dest.writeLong(payment.getBlockIndex());
|
||||
dest.writeLong(payment.getBlockTimestamp());
|
||||
dest.writeLong(payment.getTimestamp());
|
||||
dest.writeLong(payment.getDisplayTimestamp());
|
||||
dest.writeInt(payment.getDirection().serialize());
|
||||
dest.writeInt(payment.getState().serialize());
|
||||
|
||||
if (payment.getFailureReason() == null) {
|
||||
dest.writeInt(-1);
|
||||
} else {
|
||||
dest.writeInt(payment.getFailureReason().serialize());
|
||||
}
|
||||
|
||||
dest.writeString(payment.getNote());
|
||||
dest.writeString(payment.getAmount().serialize());
|
||||
dest.writeString(payment.getFee().serialize());
|
||||
dest.writeByteArray(payment.getPaymentMetaData().toByteArray());
|
||||
dest.writeByte(payment.isSeen() ? (byte) 1 : 0);
|
||||
dest.writeString(payment.getAmountWithDirection().serialize());
|
||||
dest.writeString(payment.getAmountPlusFeeWithDirection().serialize());
|
||||
dest.writeByte(payment.isDefrag() ? (byte) 1 : 0);
|
||||
}
|
||||
|
||||
private static final class ParcelPayment implements Payment {
|
||||
|
||||
private final UUID uuid;
|
||||
private final Payee payee;
|
||||
private final long blockIndex;
|
||||
private final long blockTimestamp;
|
||||
private final long timestamp;
|
||||
private final long displayTimestamp;
|
||||
private final Direction direction;
|
||||
private final State state;
|
||||
private final FailureReason failureReason;
|
||||
private final String note;
|
||||
private final Money amount;
|
||||
private final Money fee;
|
||||
private final PaymentMetaData paymentMetaData;
|
||||
private final boolean isSeen;
|
||||
private final Money amountWithDirection;
|
||||
private final Money amountPlusFeeWithDirection;
|
||||
private final boolean isDefrag;
|
||||
|
||||
private ParcelPayment(Parcel in) {
|
||||
try {
|
||||
uuid = UUID.fromString(in.readString());
|
||||
|
||||
PayeeParcelable payeeParcelable = in.readParcelable(PayeeParcelable.class.getClassLoader());
|
||||
payee = payeeParcelable.getPayee();
|
||||
|
||||
blockIndex = in.readLong();
|
||||
blockTimestamp = in.readLong();
|
||||
timestamp = in.readLong();
|
||||
displayTimestamp = in.readLong();
|
||||
direction = Direction.deserialize(in.readInt());
|
||||
state = State.deserialize(in.readInt());
|
||||
|
||||
int failureReasonSerialized = in.readInt();
|
||||
if (failureReasonSerialized == -1) {
|
||||
failureReason = null;
|
||||
} else {
|
||||
failureReason = FailureReason.deserialize(failureReasonSerialized);
|
||||
}
|
||||
|
||||
note = in.readString();
|
||||
amount = Money.parse(in.readString());
|
||||
fee = Money.parse(in.readString());
|
||||
paymentMetaData = PaymentMetaData.parseFrom(in.createByteArray());
|
||||
isSeen = in.readByte() == 1;
|
||||
amountWithDirection = Money.parse(in.readString());
|
||||
amountPlusFeeWithDirection = Money.parse(in.readString());
|
||||
isDefrag = in.readByte() == 1;
|
||||
} catch (Money.ParseException | InvalidProtocolBufferException e) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayTimestamp() {
|
||||
return displayTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return failureReason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return paymentMetaData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return isSeen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmountWithDirection() {
|
||||
return amountWithDirection;
|
||||
}
|
||||
|
||||
@NonNull @Override public Money getAmountPlusFeeWithDirection() {
|
||||
return amountPlusFeeWithDirection;
|
||||
}
|
||||
|
||||
@Override public boolean isDefrag() {
|
||||
return isDefrag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A payment may be comprised of zero or more defrag transactions and the payment transaction.
|
||||
* <p>
|
||||
* Or a number of successful transactions and a failed transaction.
|
||||
*/
|
||||
public final class PaymentSubmissionResult {
|
||||
|
||||
private final List<TransactionSubmissionResult> defrags;
|
||||
private final TransactionSubmissionResult nonDefrag;
|
||||
private final TransactionSubmissionResult erroredTransaction;
|
||||
|
||||
PaymentSubmissionResult(@NonNull List<TransactionSubmissionResult> transactions) {
|
||||
if (transactions.isEmpty()) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
this.defrags = Stream.of(transactions)
|
||||
.filter(TransactionSubmissionResult::isDefrag)
|
||||
.toList();
|
||||
this.nonDefrag = Stream.of(transactions)
|
||||
.filterNot(TransactionSubmissionResult::isDefrag)
|
||||
.findSingle()
|
||||
.orElse(null);
|
||||
this.erroredTransaction = Stream.of(transactions)
|
||||
.filter(t -> t.getErrorCode() != TransactionSubmissionResult.ErrorCode.NONE)
|
||||
.findSingle()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<TransactionSubmissionResult> defrags() {
|
||||
return defrags;
|
||||
}
|
||||
|
||||
public boolean containsDefrags() {
|
||||
return defrags.size() > 0;
|
||||
}
|
||||
|
||||
public @Nullable TransactionSubmissionResult getNonDefrag() {
|
||||
return nonDefrag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could return the error that happened during a defrag or the main transaction.
|
||||
*/
|
||||
public TransactionSubmissionResult.ErrorCode getErrorCode() {
|
||||
return erroredTransaction != null ? erroredTransaction.getErrorCode()
|
||||
: TransactionSubmissionResult.ErrorCode.NONE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public abstract class PaymentTransactionId {
|
||||
|
||||
private PaymentTransactionId() {}
|
||||
|
||||
public static final class MobileCoin extends PaymentTransactionId {
|
||||
|
||||
private final byte[] transaction;
|
||||
private final byte[] receipt;
|
||||
private final Money.MobileCoin fee;
|
||||
|
||||
public MobileCoin(@NonNull byte[] transaction,
|
||||
@NonNull byte[] receipt,
|
||||
@NonNull Money.MobileCoin fee)
|
||||
{
|
||||
this.transaction = transaction;
|
||||
this.receipt = receipt;
|
||||
this.fee = fee;
|
||||
|
||||
if (transaction.length == 0 || receipt.length == 0) {
|
||||
throw new AssertionError("Both transaction and receipt must be specified");
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull byte[] getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public @NonNull byte[] getReceipt() {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getFee() {
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public final class PaymentTransactionLiveData extends LiveData<PaymentDatabase.PaymentTransaction> {
|
||||
|
||||
private final UUID paymentId;
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Executor executor;
|
||||
|
||||
public PaymentTransactionLiveData(@NonNull UUID paymentId) {
|
||||
this.paymentId = paymentId;
|
||||
this.paymentDatabase = DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication());
|
||||
this.observer = this::getPaymentTransaction;
|
||||
this.executor = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActive() {
|
||||
getPaymentTransaction();
|
||||
ApplicationDependencies.getDatabaseObserver().registerPaymentObserver(paymentId, observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onInactive() {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
|
||||
}
|
||||
|
||||
private void getPaymentTransaction() {
|
||||
executor.execute(() -> postValue(paymentDatabase.getPayment(paymentId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.whispersystems.signalservice.api.payments.CurrencyConversion;
|
||||
import org.whispersystems.signalservice.api.payments.CurrencyConversions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class Payments {
|
||||
|
||||
private static final String TAG = Log.tag(Payments.class);
|
||||
|
||||
private static final long MINIMUM_ELAPSED_TIME_BETWEEN_REFRESH = TimeUnit.MINUTES.toMillis(1);
|
||||
|
||||
private final MobileCoinConfig mobileCoinConfig;
|
||||
|
||||
private Wallet wallet;
|
||||
private CurrencyConversions currencyConversions;
|
||||
|
||||
public Payments(@NonNull MobileCoinConfig mobileCoinConfig) {
|
||||
this.mobileCoinConfig = mobileCoinConfig;
|
||||
}
|
||||
|
||||
public synchronized Wallet getWallet() {
|
||||
if (wallet != null) {
|
||||
return wallet;
|
||||
}
|
||||
Entropy paymentsEntropy = SignalStore.paymentsValues().getPaymentsEntropy();
|
||||
wallet = new Wallet(mobileCoinConfig, Objects.requireNonNull(paymentsEntropy));
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public synchronized void closeWallet() {
|
||||
wallet = null;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public synchronized @NonNull CurrencyExchange getCurrencyExchange(boolean refreshIfAble) throws IOException {
|
||||
if (currencyConversions == null || shouldRefresh(refreshIfAble, currencyConversions.getTimestamp())) {
|
||||
Log.i(TAG, "Currency conversion data is unavailable or a refresh was requested and available");
|
||||
CurrencyConversions newCurrencyConversions = ApplicationDependencies.getSignalServiceAccountManager().getCurrencyConversions();
|
||||
if (currencyConversions == null || (newCurrencyConversions != null && newCurrencyConversions.getTimestamp() > currencyConversions.getTimestamp())) {
|
||||
currencyConversions = newCurrencyConversions;
|
||||
}
|
||||
}
|
||||
|
||||
if (currencyConversions != null) {
|
||||
for (CurrencyConversion currencyConversion : currencyConversions.getCurrencies()) {
|
||||
if ("MOB".equals(currencyConversion.getBase())) {
|
||||
return new CurrencyExchange(currencyConversion.getConversions(), currencyConversions.getTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("Unable to retrieve currency conversions");
|
||||
}
|
||||
|
||||
private boolean shouldRefresh(boolean refreshIfAble, long lastRefreshTime) {
|
||||
return refreshIfAble && System.currentTimeMillis() - lastRefreshTime >= MINIMUM_ELAPSED_TIME_BETWEEN_REFRESH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class PaymentsAddressException extends Exception {
|
||||
|
||||
private final Code code;
|
||||
|
||||
public PaymentsAddressException(@NonNull Code code) {
|
||||
super(code.message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public @NonNull Code getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
NO_PROFILE_KEY("No profile key available"),
|
||||
NOT_ENABLED("Payments not enabled"),
|
||||
COULD_NOT_DECRYPT("Payment address could not be decrypted"),
|
||||
INVALID_ADDRESS("Invalid MobileCoin address on payments address proto"),
|
||||
INVALID_ADDRESS_SIGNATURE("Invalid MobileCoin address signature on payments address proto"),
|
||||
NO_ADDRESS("No MobileCoin address on payments address proto");
|
||||
|
||||
private final String message;
|
||||
|
||||
Code(@NonNull String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ReconstructedPayment implements Payment {
|
||||
|
||||
private final long blockIndex;
|
||||
private final long blockTimestamp;
|
||||
private final Direction direction;
|
||||
private final Money amount;
|
||||
|
||||
public ReconstructedPayment(long blockIndex,
|
||||
long blockTimestamp,
|
||||
@NonNull Direction direction,
|
||||
@NonNull Money amount)
|
||||
{
|
||||
this.blockIndex = blockIndex;
|
||||
this.blockTimestamp = blockTimestamp;
|
||||
this.direction = direction;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public @Override UUID getUuid() {
|
||||
return UuidUtil.UNKNOWN_UUID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return Payee.UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return State.SUCCESSFUL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return amount.toZero();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return PaymentMetaData.getDefaultInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum State {
|
||||
// These are serialized into the database, do not change the values.
|
||||
INITIAL(0),
|
||||
SUBMITTED(1),
|
||||
SUCCESSFUL(2),
|
||||
FAILED(3);
|
||||
|
||||
private final int value;
|
||||
|
||||
State(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull State deserialize(int value) {
|
||||
if (value == State.INITIAL.value) return State.INITIAL;
|
||||
else if (value == State.SUBMITTED.value) return State.SUBMITTED;
|
||||
else if (value == State.SUCCESSFUL.value) return State.SUCCESSFUL;
|
||||
else if (value == State.FAILED.value) return State.FAILED;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
|
||||
public boolean isInProgress() {
|
||||
return this == INITIAL || this == SUBMITTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class TransactionSubmissionResult {
|
||||
|
||||
private final PaymentTransactionId transaction;
|
||||
private final ErrorCode code;
|
||||
private final boolean defrag;
|
||||
|
||||
private TransactionSubmissionResult(@Nullable PaymentTransactionId transaction, @NonNull ErrorCode code, boolean defrag) {
|
||||
this.transaction = transaction;
|
||||
this.code = code;
|
||||
this.defrag = defrag;
|
||||
}
|
||||
|
||||
static TransactionSubmissionResult successfullySubmittedDefrag(@NonNull PaymentTransactionId transaction) {
|
||||
return new TransactionSubmissionResult(transaction, ErrorCode.NONE, true);
|
||||
}
|
||||
|
||||
static @NonNull TransactionSubmissionResult successfullySubmitted(@NonNull PaymentTransactionId transaction) {
|
||||
return new TransactionSubmissionResult(transaction, ErrorCode.NONE, false);
|
||||
}
|
||||
|
||||
static @NonNull TransactionSubmissionResult failure(@NonNull ErrorCode code, boolean defrag) {
|
||||
return new TransactionSubmissionResult(null, code, defrag);
|
||||
}
|
||||
|
||||
public @NonNull PaymentTransactionId getTransactionId() {
|
||||
if (transaction == null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public @NonNull ErrorCode getErrorCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public boolean isDefrag() {
|
||||
return defrag;
|
||||
}
|
||||
|
||||
public enum ErrorCode {
|
||||
INSUFFICIENT_FUNDS,
|
||||
GENERIC_FAILURE,
|
||||
NETWORK_FAILURE,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public class UnreadPaymentsRepository {
|
||||
|
||||
private static final Executor EXECUTOR = SignalExecutors.BOUNDED;
|
||||
|
||||
public void markAllPaymentsSeen() {
|
||||
EXECUTOR.execute(this::markAllPaymentsSeenInternal);
|
||||
}
|
||||
|
||||
public void markPaymentSeen(@NonNull UUID paymentId) {
|
||||
EXECUTOR.execute(() -> markPaymentSeenInternal(paymentId));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void markAllPaymentsSeenInternal() {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
DatabaseFactory.getPaymentDatabase(context).markAllSeen();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void markPaymentSeenInternal(@NonNull UUID paymentId) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
DatabaseFactory.getPaymentDatabase(context).markPaymentSeen(paymentId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.mobilecoin.lib.AccountKey;
|
||||
import com.mobilecoin.lib.AccountSnapshot;
|
||||
import com.mobilecoin.lib.DefragmentationDelegate;
|
||||
import com.mobilecoin.lib.MobileCoinClient;
|
||||
import com.mobilecoin.lib.OwnedTxOut;
|
||||
import com.mobilecoin.lib.PendingTransaction;
|
||||
import com.mobilecoin.lib.Receipt;
|
||||
import com.mobilecoin.lib.Transaction;
|
||||
import com.mobilecoin.lib.UnsignedLong;
|
||||
import com.mobilecoin.lib.exceptions.AmountDecoderException;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.exceptions.BadEntropyException;
|
||||
import com.mobilecoin.lib.exceptions.FeeRejectedException;
|
||||
import com.mobilecoin.lib.exceptions.FogReportException;
|
||||
import com.mobilecoin.lib.exceptions.FragmentedAccountException;
|
||||
import com.mobilecoin.lib.exceptions.InsufficientFundsException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidFogResponse;
|
||||
import com.mobilecoin.lib.exceptions.InvalidReceiptException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidTransactionException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidUriException;
|
||||
import com.mobilecoin.lib.exceptions.NetworkException;
|
||||
import com.mobilecoin.lib.exceptions.SerializationException;
|
||||
import com.mobilecoin.lib.exceptions.TransactionBuilderException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.proto.MobileCoinLedger;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.util.Uint64RangeException;
|
||||
import org.whispersystems.signalservice.api.util.Uint64Util;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class Wallet {
|
||||
|
||||
private static final String TAG = Log.tag(Wallet.class);
|
||||
|
||||
private final MobileCoinConfig mobileCoinConfig;
|
||||
private final MobileCoinClient mobileCoinClient;
|
||||
private final AccountKey account;
|
||||
private final MobileCoinPublicAddress publicAddress;
|
||||
|
||||
public Wallet(@NonNull MobileCoinConfig mobileCoinConfig, @NonNull Entropy paymentsEntropy) {
|
||||
this.mobileCoinConfig = mobileCoinConfig;
|
||||
try {
|
||||
this.account = AccountKey.fromBip39Entropy(paymentsEntropy.getBytes(), 0, mobileCoinConfig.getFogUri(), "", mobileCoinConfig.getFogAuthoritySpki());
|
||||
this.publicAddress = new MobileCoinPublicAddress(account.getPublicAddress());
|
||||
|
||||
this.mobileCoinClient = new MobileCoinClient(account,
|
||||
mobileCoinConfig.getFogUri(),
|
||||
mobileCoinConfig.getConsensusUri(),
|
||||
mobileCoinConfig.getConfig());
|
||||
} catch (InvalidUriException | BadEntropyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
try {
|
||||
reauthorizeClient();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to authorize client", e);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull MobileCoinPublicAddress getMobileCoinPublicAddress() {
|
||||
return publicAddress;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull Balance getCachedBalance() {
|
||||
return SignalStore.paymentsValues().mobileCoinLatestBalance();
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull MobileCoinLedgerWrapper getCachedLedger() {
|
||||
return SignalStore.paymentsValues().mobileCoinLatestFullLedger();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull MobileCoinLedgerWrapper getFullLedger() {
|
||||
return getFullLedger(true);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull MobileCoinLedgerWrapper getFullLedger(boolean retryOnAuthFailure) {
|
||||
PaymentsValues paymentsValues = SignalStore.paymentsValues();
|
||||
try {
|
||||
MobileCoinLedgerWrapper ledger = tryGetFullLedger(null);
|
||||
|
||||
paymentsValues.setMobileCoinFullLedger(Objects.requireNonNull(ledger));
|
||||
} catch (IOException e) {
|
||||
if ((retryOnAuthFailure && e.getCause() instanceof NetworkException) &&
|
||||
(((NetworkException) e.getCause()).statusCode == 401)) {
|
||||
Log.w(TAG, "Failed to get up to date ledger, due to temp auth failure, retrying", e);
|
||||
return getFullLedger(false);
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get up to date ledger", e);
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedLedger();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @Nullable MobileCoinLedgerWrapper tryGetFullLedger(@Nullable Long minimumBlockIndex) throws IOException {
|
||||
try {
|
||||
MobileCoinLedger.Builder builder = MobileCoinLedger.newBuilder();
|
||||
BigInteger totalUnspent = BigInteger.ZERO;
|
||||
long highestBlockTimeStamp = 0;
|
||||
UnsignedLong highestBlockIndex = UnsignedLong.ZERO;
|
||||
final long asOfTimestamp = System.currentTimeMillis();
|
||||
AccountSnapshot accountSnapshot = mobileCoinClient.getAccountSnapshot();
|
||||
|
||||
if (minimumBlockIndex != null) {
|
||||
long snapshotBlockIndex = accountSnapshot.getBlockIndex().longValue();
|
||||
if (snapshotBlockIndex < minimumBlockIndex) {
|
||||
Log.d(TAG, "Waiting for block index");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (OwnedTxOut txOut : accountSnapshot.getAccountActivity().getAllTxOuts()) {
|
||||
MobileCoinLedger.OwnedTXO.Builder txoBuilder = MobileCoinLedger.OwnedTXO.newBuilder()
|
||||
.setAmount(Uint64Util.bigIntegerToUInt64(txOut.getValue()))
|
||||
.setReceivedInBlock(getBlock(txOut.getReceivedBlockIndex(), txOut.getReceivedBlockTimestamp()))
|
||||
.setKeyImage(ByteString.copyFrom(txOut.getKeyImage().getData()))
|
||||
.setPublicKey(ByteString.copyFrom(txOut.getPublicKey().getKeyBytes()));
|
||||
if (txOut.getSpentBlockIndex() != null &&
|
||||
(minimumBlockIndex == null ||
|
||||
txOut.isSpent(UnsignedLong.valueOf(minimumBlockIndex))))
|
||||
{
|
||||
txoBuilder.setSpentInBlock(getBlock(txOut.getSpentBlockIndex(), txOut.getSpentBlockTimestamp()));
|
||||
builder.addSpentTxos(txoBuilder);
|
||||
} else {
|
||||
totalUnspent = totalUnspent.add(txOut.getValue());
|
||||
builder.addUnspentTxos(txoBuilder);
|
||||
}
|
||||
|
||||
if (txOut.getSpentBlockIndex() != null && txOut.getSpentBlockIndex().compareTo(highestBlockIndex) > 0) {
|
||||
highestBlockIndex = txOut.getSpentBlockIndex();
|
||||
}
|
||||
|
||||
if (txOut.getReceivedBlockIndex().compareTo(highestBlockIndex) > 0) {
|
||||
highestBlockIndex = txOut.getReceivedBlockIndex();
|
||||
}
|
||||
|
||||
if (txOut.getSpentBlockTimestamp() != null && txOut.getSpentBlockTimestamp().getTime() > highestBlockTimeStamp) {
|
||||
highestBlockTimeStamp = txOut.getSpentBlockTimestamp().getTime();
|
||||
}
|
||||
|
||||
if (txOut.getReceivedBlockTimestamp() != null && txOut.getReceivedBlockTimestamp().getTime() > highestBlockTimeStamp) {
|
||||
highestBlockTimeStamp = txOut.getReceivedBlockTimestamp().getTime();
|
||||
}
|
||||
}
|
||||
builder.setBalance(Uint64Util.bigIntegerToUInt64(totalUnspent))
|
||||
.setTransferableBalance(Uint64Util.bigIntegerToUInt64(accountSnapshot.getTransferableAmount()))
|
||||
.setAsOfTimeStamp(asOfTimestamp)
|
||||
.setHighestBlock(MobileCoinLedger.Block.newBuilder()
|
||||
.setBlockNumber(highestBlockIndex.longValue())
|
||||
.setTimestamp(highestBlockTimeStamp));
|
||||
|
||||
return new MobileCoinLedgerWrapper(builder.build());
|
||||
} catch (InvalidFogResponse e) {
|
||||
Log.w(TAG, "Problem getting ledger", e);
|
||||
throw new IOException(e);
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem getting ledger", e);
|
||||
if (e.statusCode == 401) {
|
||||
Log.d(TAG, "Reauthorizing client");
|
||||
reauthorizeClient();
|
||||
}
|
||||
throw new IOException(e);
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem getting ledger", e);
|
||||
throw new IOException(e);
|
||||
} catch (Uint64RangeException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable MobileCoinLedger.Block getBlock(@NonNull UnsignedLong blockIndex, @Nullable Date timeStamp) throws Uint64RangeException {
|
||||
MobileCoinLedger.Block.Builder builder = MobileCoinLedger.Block.newBuilder();
|
||||
builder.setBlockNumber(Uint64Util.bigIntegerToUInt64(blockIndex.toBigInteger()));
|
||||
if (timeStamp != null) {
|
||||
builder.setTimestamp(timeStamp.getTime());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull Money.MobileCoin getFee(@NonNull Money.MobileCoin amount) throws IOException {
|
||||
try {
|
||||
BigInteger picoMob = amount.requireMobileCoin().toPicoMobBigInteger();
|
||||
return Money.picoMobileCoin(mobileCoinClient.estimateTotalFee(picoMob));
|
||||
} catch (InvalidFogResponse | AttestationException | InsufficientFundsException e) {
|
||||
Log.w(TAG, "Failed to get fee", e);
|
||||
return Money.MobileCoin.ZERO;
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Failed to get fee", e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull PaymentSubmissionResult sendPayment(@NonNull MobileCoinPublicAddress to,
|
||||
@NonNull Money.MobileCoin amount,
|
||||
@NonNull Money.MobileCoin totalFee)
|
||||
{
|
||||
List<TransactionSubmissionResult> transactionSubmissionResults = new LinkedList<>();
|
||||
sendPayment(to, amount, totalFee, false, transactionSubmissionResults);
|
||||
return new PaymentSubmissionResult(transactionSubmissionResults);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull TransactionStatusResult getSentTransactionStatus(@NonNull PaymentTransactionId transactionId) throws IOException {
|
||||
try {
|
||||
PaymentTransactionId.MobileCoin mobcoinTransaction = (PaymentTransactionId.MobileCoin) transactionId;
|
||||
Transaction transaction = Transaction.fromBytes(mobcoinTransaction.getTransaction());
|
||||
Transaction.Status status = mobileCoinClient.getAccountSnapshot()
|
||||
.getTransactionStatus(transaction);
|
||||
|
||||
switch (status) {
|
||||
case UNKNOWN:
|
||||
Log.w(TAG, "Unknown sent Transaction Status");
|
||||
return TransactionStatusResult.inProgress();
|
||||
case FAILED:
|
||||
return TransactionStatusResult.failed();
|
||||
case ACCEPTED:
|
||||
return TransactionStatusResult.complete(status.getBlockIndex().longValue());
|
||||
default:
|
||||
throw new IllegalStateException("Unknown Transaction Status: " + status);
|
||||
}
|
||||
} catch (SerializationException | InvalidFogResponse e) {
|
||||
Log.w(TAG, e);
|
||||
return TransactionStatusResult.failed();
|
||||
} catch (NetworkException | AttestationException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull ReceivedTransactionStatus getReceivedTransactionStatus(@NonNull byte[] receiptBytes) throws IOException {
|
||||
try {
|
||||
Receipt receipt = Receipt.fromBytes(receiptBytes);
|
||||
Receipt.Status status = mobileCoinClient.getReceiptStatus(receipt);
|
||||
switch (status) {
|
||||
case UNKNOWN:
|
||||
Log.w(TAG, "Unknown received Transaction Status");
|
||||
return ReceivedTransactionStatus.inProgress();
|
||||
case FAILED:
|
||||
return ReceivedTransactionStatus.failed();
|
||||
case RECEIVED:
|
||||
BigInteger amount = receipt.getAmount(account);
|
||||
return ReceivedTransactionStatus.complete(Money.picoMobileCoin(amount), status.getBlockIndex().longValue());
|
||||
default:
|
||||
throw new IllegalStateException("Unknown Transaction Status: " + status);
|
||||
}
|
||||
} catch (SerializationException | InvalidFogResponse | InvalidReceiptException e) {
|
||||
Log.w(TAG, e);
|
||||
return ReceivedTransactionStatus.failed();
|
||||
} catch (NetworkException | AttestationException e) {
|
||||
throw new IOException(e);
|
||||
} catch (AmountDecoderException e) {
|
||||
Log.w(TAG, "Failed to decode amount", e);
|
||||
return ReceivedTransactionStatus.failed();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void sendPayment(@NonNull MobileCoinPublicAddress to,
|
||||
@NonNull Money.MobileCoin amount,
|
||||
@NonNull Money.MobileCoin totalFee,
|
||||
boolean defragmentFirst,
|
||||
@NonNull List<TransactionSubmissionResult> results)
|
||||
{
|
||||
Money.MobileCoin defragmentFees = Money.MobileCoin.ZERO;
|
||||
if (defragmentFirst) {
|
||||
try {
|
||||
defragmentFees = defragment(amount, results);
|
||||
} catch (InsufficientFundsException e) {
|
||||
Log.w(TAG, "Insufficient funds", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.INSUFFICIENT_FUNDS, true));
|
||||
return;
|
||||
} catch (TimeoutException | InvalidTransactionException | InvalidFogResponse | AttestationException | TransactionBuilderException | NetworkException | FogReportException e) {
|
||||
Log.w(TAG, "Defragment failed", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, true));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Money.MobileCoin feeMobileCoin = totalFee.subtract(defragmentFees).requireMobileCoin();
|
||||
BigInteger picoMob = amount.requireMobileCoin().toPicoMobBigInteger();
|
||||
PendingTransaction pendingTransaction = null;
|
||||
|
||||
Log.i(TAG, String.format("Total fee advised: %s\nDefrag fees: %s\nTransaction fee: %s", totalFee, defragmentFees, feeMobileCoin));
|
||||
|
||||
if (!feeMobileCoin.isPositive()) {
|
||||
Log.i(TAG, "No fee left after defrag");
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pendingTransaction = mobileCoinClient.prepareTransaction(to.getAddress(),
|
||||
picoMob,
|
||||
feeMobileCoin.toPicoMobBigInteger());
|
||||
} catch (InsufficientFundsException e) {
|
||||
Log.w(TAG, "Insufficient funds", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.INSUFFICIENT_FUNDS, false));
|
||||
} catch (FeeRejectedException e) {
|
||||
Log.w(TAG, "Fee rejected " + totalFee, e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (InvalidFogResponse | FogReportException e) {
|
||||
Log.w(TAG, "Invalid fog response", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (FragmentedAccountException e) {
|
||||
if (defragmentFirst) {
|
||||
Log.w(TAG, "Account is fragmented, but already tried to defragment", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} else {
|
||||
Log.i(TAG, "Account is fragmented, defragmenting and retrying");
|
||||
sendPayment(to, amount, totalFee, true, results);
|
||||
}
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (TransactionBuilderException e) {
|
||||
Log.w(TAG, "Builder problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
}
|
||||
|
||||
if (pendingTransaction == null) {
|
||||
Log.w(TAG, "Failed to create pending transaction");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Submitting transaction");
|
||||
mobileCoinClient.submitTransaction(pendingTransaction.getTransaction());
|
||||
Log.i(TAG, "Transaction submitted");
|
||||
results.add(TransactionSubmissionResult.successfullySubmitted(new PaymentTransactionId.MobileCoin(pendingTransaction.getTransaction().toByteArray(), pendingTransaction.getReceipt().toByteArray(), feeMobileCoin)));
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.NETWORK_FAILURE, false));
|
||||
} catch (InvalidTransactionException e) {
|
||||
Log.w(TAG, "Invalid transaction", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (SerializationException e) {
|
||||
Log.w(TAG, "Serialization problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to defragment the account. It will at most merge 16 UTXOs to 1.
|
||||
* Therefore it may need to be called more than once before a certain payment is possible.
|
||||
*/
|
||||
@WorkerThread
|
||||
private @NonNull Money.MobileCoin defragment(@NonNull Money.MobileCoin amount, @NonNull List<TransactionSubmissionResult> results)
|
||||
throws TransactionBuilderException, NetworkException, InvalidTransactionException, AttestationException, FogReportException, InvalidFogResponse, TimeoutException, InsufficientFundsException
|
||||
{
|
||||
Log.i(TAG, "Defragmenting account");
|
||||
DefragDelegate defragDelegate = new DefragDelegate(mobileCoinClient, results);
|
||||
mobileCoinClient.defragmentAccount(amount.toPicoMobBigInteger(), defragDelegate);
|
||||
Log.i(TAG, "Account defragmented at a cost of " + defragDelegate.totalFeesSpent);
|
||||
return defragDelegate.totalFeesSpent;
|
||||
}
|
||||
|
||||
private void reauthorizeClient() throws IOException {
|
||||
AuthCredentials authorization = mobileCoinConfig.getAuth();
|
||||
mobileCoinClient.setFogBasicAuthorization(authorization.username(), authorization.password());
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
getFullLedger();
|
||||
}
|
||||
|
||||
public enum TransactionStatus {
|
||||
COMPLETE,
|
||||
IN_PROGRESS,
|
||||
FAILED;
|
||||
}
|
||||
|
||||
public static final class TransactionStatusResult {
|
||||
private final TransactionStatus transactionStatus;
|
||||
private final long blockIndex;
|
||||
|
||||
public TransactionStatusResult(@NonNull TransactionStatus transactionStatus,
|
||||
long blockIndex)
|
||||
{
|
||||
this.transactionStatus = transactionStatus;
|
||||
this.blockIndex = blockIndex;
|
||||
}
|
||||
|
||||
static TransactionStatusResult inProgress() {
|
||||
return new TransactionStatusResult(TransactionStatus.IN_PROGRESS, 0);
|
||||
}
|
||||
|
||||
static TransactionStatusResult failed() {
|
||||
return new TransactionStatusResult(TransactionStatus.FAILED, 0);
|
||||
}
|
||||
|
||||
static TransactionStatusResult complete(long blockIndex) {
|
||||
return new TransactionStatusResult(TransactionStatus.COMPLETE, blockIndex);
|
||||
}
|
||||
|
||||
public @NonNull TransactionStatus getTransactionStatus() {
|
||||
return transactionStatus;
|
||||
}
|
||||
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ReceivedTransactionStatus {
|
||||
|
||||
private final TransactionStatus status;
|
||||
private final Money amount;
|
||||
private final long blockIndex;
|
||||
|
||||
public static ReceivedTransactionStatus failed() {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.FAILED, null, 0);
|
||||
}
|
||||
|
||||
public static ReceivedTransactionStatus inProgress() {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.IN_PROGRESS, null, 0);
|
||||
}
|
||||
|
||||
public static ReceivedTransactionStatus complete(@NonNull Money amount, long blockIndex) {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.COMPLETE, amount, blockIndex);
|
||||
}
|
||||
|
||||
private ReceivedTransactionStatus(@NonNull TransactionStatus status, @Nullable Money amount, long blockIndex) {
|
||||
this.status = status;
|
||||
this.amount = amount;
|
||||
this.blockIndex = blockIndex;
|
||||
}
|
||||
|
||||
public @NonNull TransactionStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
if (status != TransactionStatus.COMPLETE || amount == null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DefragDelegate implements DefragmentationDelegate {
|
||||
private final MobileCoinClient mobileCoinClient;
|
||||
private final List<TransactionSubmissionResult> results;
|
||||
private Money.MobileCoin totalFeesSpent = Money.MobileCoin.ZERO;
|
||||
|
||||
DefragDelegate(@NonNull MobileCoinClient mobileCoinClient, @NonNull List<TransactionSubmissionResult> results) {
|
||||
this.mobileCoinClient = mobileCoinClient;
|
||||
this.results = results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
Log.i(TAG, "Defragmenting start");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onStepReady(@NonNull PendingTransaction pendingTransaction, @NonNull BigInteger fee)
|
||||
throws NetworkException, InvalidTransactionException, AttestationException
|
||||
{
|
||||
Log.i(TAG, "Submitting defrag transaction");
|
||||
mobileCoinClient.submitTransaction(pendingTransaction.getTransaction());
|
||||
Log.i(TAG, "Defrag transaction submitted");
|
||||
try {
|
||||
Money.MobileCoin defragFee = Money.picoMobileCoin(fee);
|
||||
results.add(TransactionSubmissionResult.successfullySubmittedDefrag(new PaymentTransactionId.MobileCoin(pendingTransaction.getTransaction().toByteArray(), pendingTransaction.getReceipt().toByteArray(), defragFee)));
|
||||
totalFeesSpent = totalFeesSpent.add(defragFee).requireMobileCoin();
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
Log.i(TAG, "Defragmenting complete");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel() {
|
||||
Log.w(TAG, "Defragmenting cancel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryPasteFragment extends Fragment {
|
||||
|
||||
public PaymentsRecoveryPasteFragment() {
|
||||
super(R.layout.payments_recovery_paste_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_paste_fragment_toolbar);
|
||||
EditText input = view.findViewById(R.id.payments_recovery_paste_fragment_phrase);
|
||||
View next = view.findViewById(R.id.payments_recovery_paste_fragment_next);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
next.setEnabled(false);
|
||||
}
|
||||
|
||||
input.addTextChangedListener(new AfterTextChanged(e -> {
|
||||
next.setEnabled(!e.toString().isEmpty());
|
||||
next.setAlpha(!e.toString().isEmpty() ? 1f : 0.5f);
|
||||
}));
|
||||
|
||||
next.setOnClickListener(v -> {
|
||||
String mnemonic = input.getText().toString();
|
||||
String[] words = mnemonic.split(" ");
|
||||
|
||||
if (words.length != PaymentsConstants.MNEMONIC_LENGTH) {
|
||||
showErrorDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.findNavController(v).navigate(PaymentsRecoveryPasteFragmentDirections.actionPaymentsRecoveryEntryToPaymentsRecoveryPhrase(false).setWords(words));
|
||||
});
|
||||
}
|
||||
|
||||
private void showErrorDialog() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPasteFragment__invalid_recovery_phrase)
|
||||
.setMessage(getString(R.string.PaymentsRecoveryPasteFragment__make_sure, PaymentsConstants.MNEMONIC_LENGTH))
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
|
||||
public final class PaymentsRecoveryRepository {
|
||||
public @NonNull Mnemonic getMnemonic() {
|
||||
return SignalStore.paymentsValues().getPaymentsMnemonic();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryStartFragment extends Fragment {
|
||||
|
||||
private final OnBackPressed onBackPressed = new OnBackPressed();
|
||||
|
||||
public PaymentsRecoveryStartFragment() {
|
||||
super(R.layout.payments_recovery_start_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_start_fragment_toolbar);
|
||||
TextView title = view.findViewById(R.id.payments_recovery_start_fragment_title);
|
||||
LearnMoreTextView message = view.findViewById(R.id.payments_recovery_start_fragment_message);
|
||||
TextView startButton = view.findViewById(R.id.payments_recovery_start_fragment_start);
|
||||
TextView pasteButton = view.findViewById(R.id.payments_recovery_start_fragment_paste);
|
||||
|
||||
PaymentsRecoveryStartFragmentArgs args = PaymentsRecoveryStartFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
if (args.getIsRestore()) {
|
||||
title.setText(R.string.PaymentsRecoveryStartFragment__enter_recovery_phrase);
|
||||
message.setText(getString(R.string.PaymentsRecoveryStartFragment__your_recovery_phrase_is_a, PaymentsConstants.MNEMONIC_LENGTH));
|
||||
message.setLink(getString(R.string.PaymentsRecoveryStartFragment__learn_more__restore));
|
||||
startButton.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryEntry()));
|
||||
startButton.setText(R.string.PaymentsRecoveryStartFragment__enter_manually);
|
||||
pasteButton.setVisibility(View.VISIBLE);
|
||||
pasteButton.setOnClickListener(v -> Navigation.findNavController(v).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryPaste()));
|
||||
} else {
|
||||
title.setText(R.string.PaymentsRecoveryStartFragment__view_recovery_phrase);
|
||||
message.setText(getString(R.string.PaymentsRecoveryStartFragment__your_balance_will_automatically_restore, PaymentsConstants.MNEMONIC_LENGTH));
|
||||
message.setLink(getString(R.string.PaymentsRecoveryStartFragment__learn_more__view));
|
||||
startButton.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryPhrase(args.getFinishOnConfirm())));
|
||||
startButton.setText(R.string.PaymentsRecoveryStartFragment__start);
|
||||
pasteButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
Navigation.findNavController(requireView()).popBackStack();
|
||||
}
|
||||
});
|
||||
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed);
|
||||
}
|
||||
|
||||
message.setLearnMoreVisible(true);
|
||||
}
|
||||
|
||||
private class OnBackPressed extends OnBackPressedCallback {
|
||||
|
||||
public OnBackPressed() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
requireActivity().finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
||||
public class PaymentsRecoveryPhraseConfirmFragment extends Fragment {
|
||||
|
||||
/**
|
||||
* The minimum number of characters required to show an error mark.
|
||||
*/
|
||||
private static final int ERROR_THRESHOLD = 1;
|
||||
|
||||
private Drawable validWordCheckMark;
|
||||
private Drawable invalidWordX;
|
||||
|
||||
public PaymentsRecoveryPhraseConfirmFragment() {
|
||||
super(R.layout.payments_recovery_phrase_confirm_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_toolbar);
|
||||
EditText word1 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word_1);
|
||||
EditText word2 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word_2);
|
||||
View seePhraseAgain = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_see_again);
|
||||
View done = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_done);
|
||||
TextInputLayout wordWrapper1 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word1_wrapper);
|
||||
TextInputLayout wordWrapper2 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word2_wrapper);
|
||||
|
||||
PaymentsRecoveryPhraseConfirmFragmentArgs args = PaymentsRecoveryPhraseConfirmFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
validWordCheckMark = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_check_circle_24);
|
||||
invalidWordX = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_circle_x_24);
|
||||
|
||||
DrawableCompat.setTint(validWordCheckMark, ContextCompat.getColor(requireContext(), R.color.signal_accent_green));
|
||||
DrawableCompat.setTint(invalidWordX, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary));
|
||||
|
||||
PaymentsRecoveryPhraseConfirmViewModel viewModel = ViewModelProviders.of(requireActivity()).get(PaymentsRecoveryPhraseConfirmViewModel.class);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack());
|
||||
|
||||
word1.addTextChangedListener(new AfterTextChanged(e -> viewModel.validateWord1(e.toString())));
|
||||
word2.addTextChangedListener(new AfterTextChanged(e -> viewModel.validateWord2(e.toString())));
|
||||
seePhraseAgain.setOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack());
|
||||
done.setOnClickListener(v -> {
|
||||
SignalStore.paymentsValues().setUserConfirmedMnemonic(true);
|
||||
ViewUtil.hideKeyboard(requireContext(), view);
|
||||
Toast.makeText(requireContext(), R.string.PaymentRecoveryPhraseConfirmFragment__recovery_phrase_confirmed, Toast.LENGTH_SHORT).show();
|
||||
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().setResult(Activity.RESULT_OK);
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
Navigation.findNavController(view).popBackStack(R.id.paymentsHome, true);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getViewState().observe(getViewLifecycleOwner(), viewState -> {
|
||||
updateValidity(word1, viewState.isWord1Valid());
|
||||
updateValidity(word2, viewState.isWord2Valid());
|
||||
done.setEnabled(viewState.areAllWordsValid());
|
||||
|
||||
String hint1 = getString(R.string.PaymentRecoveryPhraseConfirmFragment__word_d, viewState.getWord1Index() + 1);
|
||||
String hint2 = getString(R.string.PaymentRecoveryPhraseConfirmFragment__word_d, viewState.getWord2Index() + 1);
|
||||
|
||||
wordWrapper1.setHint(hint1);
|
||||
wordWrapper2.setHint(hint2);
|
||||
});
|
||||
|
||||
viewModel.updateRandomIndices();
|
||||
}
|
||||
|
||||
private void updateValidity(TextView word, boolean isValid) {
|
||||
if (isValid) {
|
||||
setEndDrawable(word, validWordCheckMark);
|
||||
} else if (word.getText().length() >= ERROR_THRESHOLD) {
|
||||
setEndDrawable(word, invalidWordX);
|
||||
} else {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void setEndDrawable(@NonNull TextView word, @NonNull Drawable invalidWordX) {
|
||||
if (word.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR) {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(null, null, invalidWordX, null);
|
||||
} else {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(invalidWordX, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.payments.backup.PaymentsRecoveryRepository;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Random;
|
||||
|
||||
public class PaymentsRecoveryPhraseConfirmViewModel extends ViewModel {
|
||||
|
||||
private final Random random;
|
||||
private final Mnemonic mnemonic;
|
||||
private final Store<PaymentsRecoveryPhraseConfirmViewState> viewState;
|
||||
|
||||
public PaymentsRecoveryPhraseConfirmViewModel() {
|
||||
PaymentsRecoveryRepository repository = new PaymentsRecoveryRepository();
|
||||
|
||||
random = new SecureRandom();
|
||||
mnemonic = repository.getMnemonic();
|
||||
|
||||
this.viewState = new Store<>(PaymentsRecoveryPhraseConfirmViewState.init(-1, -1));
|
||||
}
|
||||
|
||||
public void updateRandomIndices() {
|
||||
int[] indices = getRandomIndices();
|
||||
|
||||
this.viewState.update(unused -> PaymentsRecoveryPhraseConfirmViewState.init(indices[0], indices[1]));
|
||||
}
|
||||
|
||||
private int[] getRandomIndices() {
|
||||
int firstIndex = random.nextInt(mnemonic.getWordCount());
|
||||
int secondIndex = random.nextInt(mnemonic.getWordCount());
|
||||
|
||||
while (firstIndex == secondIndex) {
|
||||
secondIndex = random.nextInt(mnemonic.getWordCount());
|
||||
}
|
||||
|
||||
return new int[]{firstIndex, secondIndex};
|
||||
}
|
||||
|
||||
@NonNull LiveData<PaymentsRecoveryPhraseConfirmViewState> getViewState() {
|
||||
return viewState.getStateLiveData();
|
||||
}
|
||||
|
||||
void validateWord1(@NonNull String entry) {
|
||||
viewState.update(s -> s.buildUpon().withValidWord1(mnemonic.getWords().get(s.getWord1Index()).equals(entry.toLowerCase())).build());
|
||||
}
|
||||
|
||||
void validateWord2(@NonNull String entry) {
|
||||
viewState.update(s -> s.buildUpon().withValidWord2(mnemonic.getWords().get(s.getWord2Index()).equals(entry.toLowerCase())).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
final class PaymentsRecoveryPhraseConfirmViewState {
|
||||
|
||||
private final int word1Index;
|
||||
private final int word2Index;
|
||||
private final boolean isWord1Valid;
|
||||
private final boolean isWord2Valid;
|
||||
|
||||
private PaymentsRecoveryPhraseConfirmViewState(@NonNull Builder builder) {
|
||||
this.word1Index = builder.word1Index;
|
||||
this.word2Index = builder.word2Index;
|
||||
this.isWord1Valid = builder.isWord1Valid;
|
||||
this.isWord2Valid = builder.isWord2Valid;
|
||||
}
|
||||
|
||||
int getWord1Index() {
|
||||
return word1Index;
|
||||
}
|
||||
|
||||
int getWord2Index() {
|
||||
return word2Index;
|
||||
}
|
||||
|
||||
boolean isWord1Valid() {
|
||||
return isWord1Valid;
|
||||
}
|
||||
|
||||
boolean isWord2Valid() {
|
||||
return isWord2Valid;
|
||||
}
|
||||
|
||||
boolean areAllWordsValid() {
|
||||
return isWord1Valid() && isWord2Valid();
|
||||
}
|
||||
|
||||
@NonNull Builder buildUpon() {
|
||||
return new Builder(word1Index, word2Index).withValidWord1(isWord1Valid())
|
||||
.withValidWord2(isWord2Valid());
|
||||
}
|
||||
|
||||
static @NonNull PaymentsRecoveryPhraseConfirmViewState init(int word1Index, int word2Index) {
|
||||
return new Builder(word1Index, word2Index).build();
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private final int word1Index;
|
||||
private final int word2Index;
|
||||
|
||||
private boolean isWord1Valid;
|
||||
private boolean isWord2Valid;
|
||||
|
||||
private Builder(int word1Index, int word2Index) {
|
||||
this.word1Index = word1Index;
|
||||
this.word2Index = word2Index;
|
||||
}
|
||||
|
||||
@NonNull Builder withValidWord1(boolean isValid) {
|
||||
this.isWord1Valid = isValid;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withValidWord2(boolean isValid) {
|
||||
this.isWord2Valid = isValid;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull PaymentsRecoveryPhraseConfirmViewState build() {
|
||||
return new PaymentsRecoveryPhraseConfirmViewState(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView;
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
||||
public class PaymentsRecoveryEntryFragment extends Fragment {
|
||||
|
||||
public PaymentsRecoveryEntryFragment() {
|
||||
super(R.layout.payments_recovery_entry_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_entry_fragment_toolbar);
|
||||
TextView message = view.findViewById(R.id.payments_recovery_entry_fragment_message);
|
||||
TextInputLayout wrapper = view.findViewById(R.id.payments_recovery_entry_fragment_word_wrapper);
|
||||
MaterialAutoCompleteTextView word = view.findViewById(R.id.payments_recovery_entry_fragment_word);
|
||||
View next = view.findViewById(R.id.payments_recovery_entry_fragment_next);
|
||||
PaymentsRecoveryEntryViewModel viewModel = ViewModelProviders.of(this).get(PaymentsRecoveryEntryViewModel.class);
|
||||
|
||||
toolbar.setNavigationOnClickListener(t -> Navigation.findNavController(view).popBackStack(R.id.paymentsHome, false));
|
||||
|
||||
viewModel.getState().observe(getViewLifecycleOwner(), state -> {
|
||||
message.setText(getString(R.string.PaymentsRecoveryEntryFragment__enter_word_d, state.getWordIndex() + 1));
|
||||
word.setHint(getString(R.string.PaymentsRecoveryEntryFragment__word_d, state.getWordIndex() + 1));
|
||||
wrapper.setError(state.canMoveToNext() || TextUtils.isEmpty(state.getCurrentEntry()) ? null : getString(R.string.PaymentsRecoveryEntryFragment__invalid_word));
|
||||
next.setEnabled(state.canMoveToNext());
|
||||
|
||||
String inTextView = word.getText().toString();
|
||||
String inState = Util.firstNonNull(state.getCurrentEntry(), "");
|
||||
|
||||
if (!inTextView.equals(inState)) {
|
||||
word.setText(inState);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getEvents().observe(getViewLifecycleOwner(), event -> {
|
||||
if (event == PaymentsRecoveryEntryViewModel.Events.GO_TO_CONFIRM) {
|
||||
Navigation.findNavController(view).navigate(PaymentsRecoveryEntryFragmentDirections.actionPaymentsRecoveryEntryToPaymentsRecoveryPhrase(false)
|
||||
.setWords(viewModel.getWords()));
|
||||
}
|
||||
});
|
||||
|
||||
ArrayAdapter<String> wordAdapter = new ArrayAdapter<>(requireContext(), R.layout.support_simple_spinner_dropdown_item, Mnemonic.BIP39_WORDS_ENGLISH);
|
||||
|
||||
word.setAdapter(wordAdapter);
|
||||
word.addTextChangedListener(new AfterTextChanged(e -> viewModel.onWordChanged(e.toString())));
|
||||
next.setOnClickListener(v -> viewModel.onNextClicked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryEntryViewModel extends ViewModel {
|
||||
|
||||
private Store<PaymentsRecoveryEntryViewState> state = new Store<>(new PaymentsRecoveryEntryViewState());
|
||||
private SingleLiveEvent<Events> events = new SingleLiveEvent<>();
|
||||
private String[] words = new String[PaymentsConstants.MNEMONIC_LENGTH];
|
||||
|
||||
@NonNull LiveData<PaymentsRecoveryEntryViewState> getState() {
|
||||
return state.getStateLiveData();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Events> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
@NonNull String[] getWords() {
|
||||
return words;
|
||||
}
|
||||
|
||||
void onWordChanged(@NonNull String entry) {
|
||||
state.update(s -> new PaymentsRecoveryEntryViewState(s.getWordIndex(), isValid(entry), entry));
|
||||
}
|
||||
|
||||
void onNextClicked() {
|
||||
state.update(s -> {
|
||||
words[s.getWordIndex()] = s.getCurrentEntry();
|
||||
|
||||
if (s.getWordIndex() == PaymentsConstants.MNEMONIC_LENGTH - 1) {
|
||||
events.postValue(Events.GO_TO_CONFIRM);
|
||||
return new PaymentsRecoveryEntryViewState(0, isValid(words[0]), words[0]);
|
||||
} else {
|
||||
int newIndex = s.getWordIndex() + 1;
|
||||
return new PaymentsRecoveryEntryViewState(newIndex, isValid(words[newIndex]), words[newIndex]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isValid(@Nullable String string) {
|
||||
if (string == null) return false;
|
||||
else return Mnemonic.BIP39_WORDS_ENGLISH.contains(string.toLowerCase());
|
||||
}
|
||||
|
||||
enum Events {
|
||||
GO_TO_CONFIRM
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
class PaymentsRecoveryEntryViewState {
|
||||
|
||||
private final int wordIndex;
|
||||
private final boolean canMoveToNext;
|
||||
private final String currentEntry;
|
||||
|
||||
PaymentsRecoveryEntryViewState() {
|
||||
this.wordIndex = 0;
|
||||
this.canMoveToNext = false;
|
||||
this.currentEntry = null;
|
||||
}
|
||||
|
||||
PaymentsRecoveryEntryViewState(int wordIndex, boolean canMoveToNext, String currentEntry) {
|
||||
this.wordIndex = wordIndex;
|
||||
this.canMoveToNext = canMoveToNext;
|
||||
this.currentEntry = currentEntry;
|
||||
}
|
||||
|
||||
int getWordIndex() {
|
||||
return wordIndex;
|
||||
}
|
||||
|
||||
boolean canMoveToNext() {
|
||||
return canMoveToNext;
|
||||
}
|
||||
|
||||
@Nullable String getCurrentEntry() {
|
||||
return currentEntry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
public class ClearClipboardAlarmReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = Log.tag(ClearClipboardAlarmReceiver.class);
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "onReceive: clearing clipboard");
|
||||
|
||||
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(context);
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), " "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
final class MnemonicPart {
|
||||
|
||||
private final String word;
|
||||
private final int index;
|
||||
|
||||
MnemonicPart(int index, @NonNull String word) {
|
||||
this.word = word;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public String getWord() {
|
||||
return word;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
|
||||
|
||||
final class MnemonicPartAdapter extends ListAdapter<MnemonicPart, MnemonicPartAdapter.ViewHolder> {
|
||||
|
||||
protected MnemonicPartAdapter() {
|
||||
super(new AlwaysChangedDiffUtil<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
return new ViewHolder((TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.mnemonic_part_adapter_item, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
holder.bind(getItem(position));
|
||||
}
|
||||
|
||||
final static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final TextView view;
|
||||
|
||||
ViewHolder(@NonNull TextView itemView) {
|
||||
super(itemView);
|
||||
|
||||
this.view = itemView;
|
||||
}
|
||||
|
||||
void bind(@NonNull MnemonicPart mnemonicPart) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
|
||||
builder.append(SpanUtil.color(ContextCompat.getColor(view.getContext(), R.color.payment_currency_code_foreground_color),
|
||||
String.valueOf(mnemonicPart.getIndex() + 1)))
|
||||
.append(" ")
|
||||
.append(SpanUtil.bold(mnemonicPart.getWord()));
|
||||
|
||||
view.setText(builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class PaymentsRecoveryPhraseFragment extends Fragment {
|
||||
|
||||
private static final int SPAN_COUNT = 2;
|
||||
|
||||
public PaymentsRecoveryPhraseFragment() {
|
||||
super(R.layout.payments_recovery_phrase_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_phrase_fragment_toolbar);
|
||||
RecyclerView recyclerView = view.findViewById(R.id.payments_recovery_phrase_fragment_recycler);
|
||||
TextView message = view.findViewById(R.id.payments_recovery_phrase_fragment_message);
|
||||
View next = view.findViewById(R.id.payments_recovery_phrase_fragment_next);
|
||||
View edit = view.findViewById(R.id.payments_recovery_phrase_fragment_edit);
|
||||
View copy = view.findViewById(R.id.payments_recovery_phrase_fragment_copy);
|
||||
GridLayoutManager gridLayoutManager = new GridLayoutManager(requireContext(), SPAN_COUNT);
|
||||
PaymentsRecoveryPhraseFragmentArgs args = PaymentsRecoveryPhraseFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
final List<String> words;
|
||||
|
||||
if (args.getWords() != null) {
|
||||
words = Arrays.asList(args.getWords());
|
||||
|
||||
setUpForConfirmation(message, next, edit, copy, words);
|
||||
} else {
|
||||
Mnemonic mnemonic = SignalStore.paymentsValues().getPaymentsMnemonic();
|
||||
|
||||
words = mnemonic.getWords();
|
||||
|
||||
setUpForDisplay(message, next, edit, copy, words, args);
|
||||
}
|
||||
|
||||
List<MnemonicPart> parts = Stream.of(words)
|
||||
.mapIndexed(MnemonicPart::new)
|
||||
.sorted(new MnemonicPartComparator(words.size(), SPAN_COUNT))
|
||||
.toList();
|
||||
|
||||
MnemonicPartAdapter adapter = new MnemonicPartAdapter();
|
||||
|
||||
recyclerView.setLayoutManager(gridLayoutManager);
|
||||
recyclerView.setAdapter(adapter);
|
||||
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
toolbar.setNavigationOnClickListener(t -> Navigation.findNavController(view).popBackStack(R.id.paymentsHome, false));
|
||||
}
|
||||
});
|
||||
|
||||
adapter.submitList(parts);
|
||||
}
|
||||
|
||||
private void copyWordsToClipboard(List<String> words) {
|
||||
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(requireContext());
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(getString(R.string.app_name), Util.join(words, " ")));
|
||||
|
||||
AlarmManager alarmManager = ServiceUtil.getAlarmManager(requireContext());
|
||||
Intent alarmIntent = new Intent(requireContext(), ClearClipboardAlarmReceiver.class);
|
||||
PendingIntent pendingAlarmIntent = PendingIntent.getBroadcast(requireContext(), 0, alarmIntent, 0);
|
||||
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30), pendingAlarmIntent);
|
||||
}
|
||||
|
||||
private void setUpForConfirmation(@NonNull TextView message,
|
||||
@NonNull View next,
|
||||
@NonNull View edit,
|
||||
@NonNull View copy,
|
||||
@NonNull List<String> words)
|
||||
{
|
||||
message.setText(R.string.PaymentsRecoveryPhraseFragment__make_sure_youve_entered);
|
||||
edit.setVisibility(View.VISIBLE);
|
||||
copy.setVisibility(View.GONE);
|
||||
|
||||
PaymentsRecoveryPhraseViewModel viewModel = ViewModelProviders.of(this).get(PaymentsRecoveryPhraseViewModel.class);
|
||||
|
||||
next.setOnClickListener(v -> viewModel.onSubmit(words));
|
||||
edit.setOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
viewModel.getSubmitResult().observe(getViewLifecycleOwner(), this::onSubmitResult);
|
||||
}
|
||||
|
||||
private void setUpForDisplay(@NonNull TextView message,
|
||||
@NonNull View next,
|
||||
@NonNull View edit,
|
||||
@NonNull View copy,
|
||||
@NonNull List<String> words,
|
||||
@NonNull PaymentsRecoveryPhraseFragmentArgs args)
|
||||
{
|
||||
message.setText(getString(R.string.PaymentsRecoveryPhraseFragment__write_down_the_following_d_words, words.size()));
|
||||
next.setOnClickListener(v -> Navigation.findNavController(v).navigate(PaymentsRecoveryPhraseFragmentDirections.actionPaymentsRecoveryPhraseToPaymentsRecoveryPhraseConfirm(args.getFinishOnConfirm())));
|
||||
edit.setVisibility(View.GONE);
|
||||
copy.setVisibility(View.VISIBLE);
|
||||
copy.setOnClickListener(v -> confirmCopy(words));
|
||||
}
|
||||
|
||||
private void confirmCopy(@NonNull List<String> words) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPhraseFragment__copy_to_clipboard)
|
||||
.setMessage(R.string.PaymentsRecoveryPhraseFragment__if_you_choose_to_store)
|
||||
.setPositiveButton(R.string.PaymentsRecoveryPhraseFragment__copy, (dialog, which) -> copyWordsToClipboard(words))
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
|
||||
private void onSubmitResult(@NonNull PaymentsRecoveryPhraseViewModel.SubmitResult submitResult) {
|
||||
switch (submitResult) {
|
||||
case SUCCESS:
|
||||
Toast.makeText(requireContext(), R.string.PaymentsRecoveryPhraseFragment__payments_account_restored, Toast.LENGTH_LONG).show();
|
||||
Navigation.findNavController(requireView()).popBackStack(R.id.paymentsHome, false);
|
||||
break;
|
||||
case ERROR:
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPhraseFragment__invalid_recovery_phrase)
|
||||
.setMessage(R.string.PaymentsRecoveryPhraseFragment__make_sure_youve_entered_your_phrase_correctly_and_try_again)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zips together a list of MnemonicParts with itself, based off the part count and desired span count.
|
||||
*
|
||||
* For example, for two spans, 1..12 becomes 1, 7, 2, 8, 3, 9...12
|
||||
*/
|
||||
private static class MnemonicPartComparator implements Comparator<MnemonicPart> {
|
||||
|
||||
private final int partsPerSpan;
|
||||
|
||||
private MnemonicPartComparator(int partCount, int spanCount) {
|
||||
this.partsPerSpan = partCount / spanCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(MnemonicPart o1, MnemonicPart o2) {
|
||||
int span1 = o1.getIndex() % partsPerSpan;
|
||||
int span2 = o2.getIndex() % partsPerSpan;
|
||||
|
||||
if (span1 != span2) {
|
||||
return Integer.compare(span1, span2);
|
||||
}
|
||||
|
||||
return Integer.compare(o1.getIndex(), o2.getIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
class PaymentsRecoveryPhraseRepository {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsRecoveryPhraseRepository.class);
|
||||
|
||||
void restoreMnemonic(@NonNull List<String> words,
|
||||
@NonNull Consumer<PaymentsValues.WalletRestoreResult> resultConsumer)
|
||||
{
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
String mnemonic = Util.join(words, " ");
|
||||
PaymentsValues.WalletRestoreResult result = SignalStore.paymentsValues().restoreWallet(mnemonic);
|
||||
|
||||
switch (result) {
|
||||
case ENTROPY_CHANGED:
|
||||
Log.i(TAG, "restoreMnemonic: mnemonic resulted in entropy mismatch, flushing cached values");
|
||||
DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication()).deleteAll();
|
||||
ApplicationDependencies.getPayments().closeWallet();
|
||||
updateProfileAndFetchLedger();
|
||||
break;
|
||||
case ENTROPY_UNCHANGED:
|
||||
Log.i(TAG, "restoreMnemonic: mnemonic resulted in entropy match, no flush needed.");
|
||||
updateProfileAndFetchLedger();
|
||||
break;
|
||||
case MNEMONIC_ERROR:
|
||||
Log.w(TAG, "restoreMnemonic: failed to restore wallet from given mnemonic.");
|
||||
break;
|
||||
}
|
||||
|
||||
resultConsumer.accept(result);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateProfileAndFetchLedger() {
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new ProfileUploadJob())
|
||||
.then(PaymentLedgerUpdateJob.updateLedger())
|
||||
.enqueue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PaymentsRecoveryPhraseViewModel extends ViewModel {
|
||||
|
||||
private final SingleLiveEvent<SubmitResult> submitResult = new SingleLiveEvent<>();
|
||||
private final PaymentsRecoveryPhraseRepository repository = new PaymentsRecoveryPhraseRepository();
|
||||
|
||||
public LiveData<SubmitResult> getSubmitResult() {
|
||||
return submitResult;
|
||||
}
|
||||
|
||||
void onSubmit(List<String> words) {
|
||||
repository.restoreMnemonic(words, result -> {
|
||||
switch (result) {
|
||||
case ENTROPY_CHANGED:
|
||||
case ENTROPY_UNCHANGED:
|
||||
submitResult.postValue(SubmitResult.SUCCESS);
|
||||
break;
|
||||
case MNEMONIC_ERROR:
|
||||
submitResult.postValue(SubmitResult.ERROR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum SubmitResult {
|
||||
SUCCESS,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.confirm.ConfirmPaymentState.Status;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public class ConfirmPaymentAdapter extends MappingAdapter {
|
||||
public ConfirmPaymentAdapter(@NonNull Callbacks callbacks) {
|
||||
registerFactory(LoadingItem.class, LoadingItemViewHolder::new, R.layout.confirm_payment_loading_item);
|
||||
registerFactory(LineItem.class, LineItemViewHolder::new, R.layout.confirm_payment_line_item);
|
||||
registerFactory(Divider.class, MappingViewHolder.SimpleViewHolder::new, R.layout.confirm_payment_divider);
|
||||
registerFactory(TotalLineItem.class, TotalLineItemViewHolder::new, R.layout.confirm_payment_total_line_item);
|
||||
registerFactory(ConfirmPaymentStatus.class, p -> new ConfirmPaymentViewHolder(p, callbacks), R.layout.confirm_payment_status);
|
||||
}
|
||||
|
||||
public interface Callbacks {
|
||||
void onConfirmPayment();
|
||||
}
|
||||
|
||||
public static class LoadingItem implements MappingModel<LoadingItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull LoadingItem newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull LoadingItem newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LineItem implements MappingModel<LineItem> {
|
||||
|
||||
private final CharSequence description;
|
||||
private final String value;
|
||||
|
||||
public LineItem(@NonNull CharSequence description, @NonNull String value) {
|
||||
this.description = description;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public @NonNull CharSequence getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public @NonNull String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull LineItem newItem) {
|
||||
return description.toString().equals(newItem.description.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull LineItem newItem) {
|
||||
return value.equals(newItem.value);
|
||||
}
|
||||
}
|
||||
|
||||
public static class TotalLineItem implements MappingModel<TotalLineItem> {
|
||||
private final LineItem lineItem;
|
||||
|
||||
public TotalLineItem(@NonNull String description, @NonNull String value) {
|
||||
this.lineItem = new LineItem(description, value);
|
||||
}
|
||||
|
||||
public @NonNull LineItem getLineItem() {
|
||||
return lineItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull TotalLineItem newItem) {
|
||||
return lineItem.areItemsTheSame(newItem.lineItem);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull TotalLineItem newItem) {
|
||||
return lineItem.areContentsTheSame(newItem.lineItem);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConfirmPaymentStatus implements MappingModel<ConfirmPaymentStatus> {
|
||||
private final Status status;
|
||||
private final ConfirmPaymentState.FeeStatus feeStatus;
|
||||
private final Money balance;
|
||||
|
||||
public ConfirmPaymentStatus(@NonNull Status status, @NonNull ConfirmPaymentState.FeeStatus feeStatus, @NonNull Money balance) {
|
||||
this.status = status;
|
||||
this.feeStatus = feeStatus;
|
||||
this.balance = balance;
|
||||
}
|
||||
|
||||
public int getConfirmPaymentVisibility() {
|
||||
return status == Status.CONFIRM ? View.VISIBLE : View.INVISIBLE;
|
||||
}
|
||||
|
||||
public boolean getConfirmPaymentEnabled() {
|
||||
return status == Status.CONFIRM &&
|
||||
feeStatus == ConfirmPaymentState.FeeStatus.SET;
|
||||
}
|
||||
|
||||
public @NonNull Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull CharSequence getInfoText(@NonNull Context context) {
|
||||
switch (status) {
|
||||
case CONFIRM: return context.getString(R.string.ConfirmPayment__balance_s, balance.toString(FormatterOptions.defaults()));
|
||||
case SUBMITTING: return context.getString(R.string.ConfirmPayment__submitting_payment);
|
||||
case PROCESSING: return context.getString(R.string.ConfirmPayment__processing_payment);
|
||||
case DONE: return context.getString(R.string.ConfirmPayment__payment_complete);
|
||||
case ERROR: return SpanUtil.color(ContextCompat.getColor(context, R.color.signal_alert_primary), context.getString(R.string.ConfirmPayment__payment_failed));
|
||||
case TIMEOUT: return context.getString(R.string.ConfirmPayment__payment_will_continue_processing);
|
||||
}
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull ConfirmPaymentStatus newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull ConfirmPaymentStatus newItem) {
|
||||
return status == newItem.status &&
|
||||
feeStatus == newItem.feeStatus &&
|
||||
balance.equals(newItem.balance);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Divider implements MappingModel<Divider> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull Divider newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull Divider newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class LoadingItemViewHolder extends MappingViewHolder<LoadingItem> {
|
||||
|
||||
public LoadingItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@Override public void bind(@NonNull LoadingItem model) {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class LineItemViewHolder extends MappingViewHolder<LineItem> {
|
||||
private final TextView description;
|
||||
private final TextView value;
|
||||
|
||||
public LineItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.description = findViewById(R.id.confirm_payment_line_item_description);
|
||||
this.value = findViewById(R.id.confirm_payment_line_item_value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull LineItem model) {
|
||||
description.setText(model.getDescription());
|
||||
value.setText(model.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static class TotalLineItemViewHolder extends MappingViewHolder<TotalLineItem> {
|
||||
private final LineItemViewHolder delegate;
|
||||
|
||||
public TotalLineItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.delegate = new LineItemViewHolder(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull TotalLineItem model) {
|
||||
delegate.bind(model.getLineItem());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ConfirmPaymentViewHolder extends MappingViewHolder<ConfirmPaymentStatus> {
|
||||
|
||||
private final View confirmPayment;
|
||||
private final LottieAnimationView inProgress;
|
||||
private final LottieAnimationView completed;
|
||||
private final LottieAnimationView failed;
|
||||
private final LottieAnimationView timeout;
|
||||
private final TextView infoText;
|
||||
private final Callbacks callbacks;
|
||||
|
||||
public ConfirmPaymentViewHolder(@NonNull View itemView, @NonNull Callbacks callbacks) {
|
||||
super(itemView);
|
||||
this.callbacks = callbacks;
|
||||
this.confirmPayment = findViewById(R.id.confirm_payment_status_confirm);
|
||||
this.inProgress = findViewById(R.id.confirm_payment_spinner_lottie);
|
||||
this.completed = findViewById(R.id.confirm_payment_success_lottie);
|
||||
this.failed = findViewById(R.id.confirm_payment_error_lottie);
|
||||
this.timeout = findViewById(R.id.confirm_payment_timeout_lottie);
|
||||
this.infoText = findViewById(R.id.confirm_payment_status_info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull ConfirmPaymentStatus model) {
|
||||
confirmPayment.setOnClickListener(v -> callbacks.onConfirmPayment());
|
||||
confirmPayment.setVisibility(model.getConfirmPaymentVisibility());
|
||||
confirmPayment.setEnabled(model.getConfirmPaymentEnabled());
|
||||
infoText.setText(model.getInfoText(getContext()));
|
||||
|
||||
switch (model.getStatus()) {
|
||||
case CONFIRM:
|
||||
break;
|
||||
case SUBMITTING:
|
||||
case PROCESSING:
|
||||
playNextAnimation(inProgress, completed, failed, timeout);
|
||||
break;
|
||||
case DONE:
|
||||
playNextAnimation(completed, inProgress, failed, timeout);
|
||||
break;
|
||||
case ERROR:
|
||||
playNextAnimation(failed, inProgress, completed, timeout);
|
||||
break;
|
||||
case TIMEOUT:
|
||||
playNextAnimation(timeout, inProgress, completed, failed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void playNextAnimation(@NonNull LottieAnimationView next,
|
||||
@NonNull LottieAnimationView ... hide)
|
||||
{
|
||||
for (LottieAnimationView lottieAnimationView : hide) {
|
||||
lottieAnimationView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
next.setVisibility(View.VISIBLE);
|
||||
next.post(() -> {
|
||||
if (!next.isAnimating()) {
|
||||
next.playAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
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 org.signal.core.util.ThreadUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ConfirmPaymentFragment extends BottomSheetDialogFragment {
|
||||
|
||||
private ConfirmPaymentViewModel viewModel;
|
||||
private final Runnable dismiss = () -> {
|
||||
dismissAllowingStateLoss();
|
||||
|
||||
if (ConfirmPaymentFragmentArgs.fromBundle(requireArguments()).getFinishOnConfirm()) {
|
||||
requireActivity().setResult(Activity.RESULT_OK);
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
NavHostFragment.findNavController(this).navigate(R.id.action_directly_to_paymentsHome);
|
||||
}
|
||||
};
|
||||
|
||||
@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 = ViewModelProviders.of(this, factory).get(ConfirmPaymentViewModel.class);
|
||||
|
||||
RecyclerView list = view.findViewById(R.id.confirm_payment_fragment_list);
|
||||
ConfirmPaymentAdapter adapter = new ConfirmPaymentAdapter(new Callbacks());
|
||||
list.setAdapter(adapter);
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
ThreadUtil.cancelRunnableOnMain(dismiss);
|
||||
}
|
||||
|
||||
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)));
|
||||
list.add(new ConfirmPaymentAdapter.LineItem(getString(R.string.ConfirmPayment__network_fee), state.getFee().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.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 class Callbacks implements ConfirmPaymentAdapter.Callbacks {
|
||||
@Override
|
||||
public void onConfirmPayment() {
|
||||
setCancelable(false);
|
||||
viewModel.confirmPayment();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentSendJob;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
|
||||
import org.thoughtcrime.securesms.payments.Wallet;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
final class ConfirmPaymentRepository {
|
||||
|
||||
private static final String TAG = Log.tag(ConfirmPaymentRepository.class);
|
||||
|
||||
private final Wallet wallet;
|
||||
|
||||
ConfirmPaymentRepository(@NonNull Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
void confirmPayment(@NonNull ConfirmPaymentState state, @NonNull Consumer<ConfirmPaymentResult> consumer) {
|
||||
Log.i(TAG, "confirmPayment");
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
Balance balance = wallet.getCachedBalance();
|
||||
|
||||
if (state.getTotal().requireMobileCoin().greaterThan(balance.getFullAmount().requireMobileCoin())) {
|
||||
Log.w(TAG, "The total was greater than the wallet's balance");
|
||||
consumer.accept(new ConfirmPaymentResult.Error());
|
||||
return;
|
||||
}
|
||||
|
||||
Payee payee = state.getPayee();
|
||||
RecipientId recipientId;
|
||||
MobileCoinPublicAddress mobileCoinPublicAddress;
|
||||
|
||||
if (payee.hasRecipientId()) {
|
||||
recipientId = payee.requireRecipientId();
|
||||
try {
|
||||
mobileCoinPublicAddress = ProfileUtil.getAddressForRecipient(Recipient.resolved(recipientId));
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
Log.w(TAG, "Failed to get address for recipient " + recipientId);
|
||||
consumer.accept(new ConfirmPaymentResult.Error());
|
||||
return;
|
||||
} catch (PaymentsAddressException e) {
|
||||
Log.w(TAG, "Failed to get address for recipient " + recipientId);
|
||||
consumer.accept(new ConfirmPaymentResult.Error(e.getCode()));
|
||||
return;
|
||||
}
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
recipientId = null;
|
||||
mobileCoinPublicAddress = payee.requirePublicAddress();
|
||||
} else throw new AssertionError();
|
||||
|
||||
UUID paymentUuid = PaymentSendJob.enqueuePayment(recipientId,
|
||||
mobileCoinPublicAddress,
|
||||
Util.emptyIfNull(state.getNote()),
|
||||
state.getAmount().requireMobileCoin(),
|
||||
state.getFee().requireMobileCoin());
|
||||
|
||||
Log.i(TAG, "confirmPayment: PaymentSendJob enqueued");
|
||||
consumer.accept(new ConfirmPaymentResult.Success(paymentUuid));
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GetFeeResult getFee(@NonNull Money amount) {
|
||||
try {
|
||||
return new GetFeeResult.Success(wallet.getFee(amount.requireMobileCoin()));
|
||||
} catch (IOException e) {
|
||||
return new GetFeeResult.Error();
|
||||
}
|
||||
}
|
||||
|
||||
static class ConfirmPaymentResult {
|
||||
|
||||
static class Success extends ConfirmPaymentResult {
|
||||
private final UUID paymentId;
|
||||
|
||||
Success(@NonNull UUID paymentId) {
|
||||
this.paymentId = paymentId;
|
||||
}
|
||||
|
||||
@NonNull UUID getPaymentId() {
|
||||
return paymentId;
|
||||
}
|
||||
}
|
||||
|
||||
static class Error extends ConfirmPaymentResult {
|
||||
private final PaymentsAddressException.Code code;
|
||||
|
||||
Error() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
Error(@Nullable PaymentsAddressException.Code code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public @Nullable PaymentsAddressException.Code getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class GetFeeResult {
|
||||
|
||||
static class Success extends GetFeeResult {
|
||||
|
||||
private final Money fee;
|
||||
|
||||
Success(@NonNull Money fee) {
|
||||
this.fee = fee;
|
||||
}
|
||||
|
||||
@NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
|
||||
static class Error extends GetFeeResult {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class ConfirmPaymentState {
|
||||
private final Payee payee;
|
||||
private final Money balance;
|
||||
private final Money amount;
|
||||
private final String note;
|
||||
private final Money fee;
|
||||
private final FeeStatus feeStatus;
|
||||
private final FiatMoney exchange;
|
||||
private final Status status;
|
||||
private final Money total;
|
||||
private final UUID paymentId;
|
||||
|
||||
public ConfirmPaymentState(@NonNull Payee payee,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note)
|
||||
{
|
||||
this(payee,
|
||||
amount.toZero(),
|
||||
amount,
|
||||
note,
|
||||
amount.toZero(),
|
||||
FeeStatus.NOT_SET,
|
||||
null,
|
||||
Status.CONFIRM,
|
||||
null);
|
||||
}
|
||||
|
||||
private ConfirmPaymentState(@NonNull Payee payee,
|
||||
@NonNull Money balance,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note,
|
||||
@NonNull Money fee,
|
||||
@NonNull FeeStatus feeStatus,
|
||||
@NonNull FiatMoney exchange,
|
||||
@NonNull Status status,
|
||||
@Nullable UUID paymentId)
|
||||
{
|
||||
this.payee = payee;
|
||||
this.balance = balance;
|
||||
this.amount = amount;
|
||||
this.note = note;
|
||||
this.fee = fee;
|
||||
this.feeStatus = feeStatus;
|
||||
this.exchange = exchange;
|
||||
this.status = status;
|
||||
this.paymentId = paymentId;
|
||||
this.total = amount.add(fee);
|
||||
}
|
||||
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
public @NonNull Money getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public @Nullable String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public @NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
|
||||
public @NonNull FeeStatus getFeeStatus() {
|
||||
return feeStatus;
|
||||
}
|
||||
|
||||
public @Nullable FiatMoney getExchange() {
|
||||
return exchange;
|
||||
}
|
||||
|
||||
public @NonNull Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull Money getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public @Nullable UUID getPaymentId() {
|
||||
return paymentId;
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateStatus(@NonNull Status status) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateBalance(@NonNull Money balance) {
|
||||
return new ConfirmPaymentState(this.payee, balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFee(@NonNull Money fee) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, fee, FeeStatus.SET, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFeeStillLoading() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.amount.toZero(), FeeStatus.STILL_LOADING, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFeeError() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.amount.toZero(), FeeStatus.ERROR, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updatePaymentId(@Nullable UUID paymentId) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, this.status, paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateExchange(@Nullable FiatMoney exchange) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState timeout() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, Status.TIMEOUT, this.paymentId);
|
||||
}
|
||||
|
||||
enum Status {
|
||||
CONFIRM,
|
||||
SUBMITTING,
|
||||
PROCESSING,
|
||||
DONE,
|
||||
ERROR,
|
||||
TIMEOUT;
|
||||
|
||||
boolean isTerminalStatus() {
|
||||
return this == DONE || this == ERROR || this == TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
enum FeeStatus {
|
||||
NOT_SET,
|
||||
STILL_LOADING,
|
||||
SET,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase.PaymentTransaction;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.CreatePaymentDetails;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.PaymentTransactionLiveData;
|
||||
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
|
||||
import org.thoughtcrime.securesms.payments.confirm.ConfirmPaymentRepository.ConfirmPaymentResult;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class ConfirmPaymentViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(ConfirmPaymentViewModel.class);
|
||||
|
||||
private final Store<ConfirmPaymentState> store;
|
||||
private final ConfirmPaymentRepository confirmPaymentRepository;
|
||||
private final LiveData<Boolean> paymentDone;
|
||||
private final SingleLiveEvent<ErrorType> errorEvents;
|
||||
private final MutableLiveData<Boolean> feeRetry;
|
||||
|
||||
ConfirmPaymentViewModel(@NonNull ConfirmPaymentState confirmPaymentState,
|
||||
@NonNull ConfirmPaymentRepository confirmPaymentRepository)
|
||||
{
|
||||
this.store = new Store<>(confirmPaymentState);
|
||||
this.confirmPaymentRepository = confirmPaymentRepository;
|
||||
this.errorEvents = new SingleLiveEvent<>();
|
||||
this.feeRetry = new DefaultValueLiveData<>(true);
|
||||
|
||||
this.store.update(SignalStore.paymentsValues().liveMobileCoinBalance(), (balance, state) -> state.updateBalance(balance.getFullAmount()));
|
||||
|
||||
LiveData<Boolean> longLoadTime = LiveDataUtil.delay(1000, true);
|
||||
this.store.update(longLoadTime, (l, s) -> {
|
||||
if (s.getFeeStatus() == ConfirmPaymentState.FeeStatus.NOT_SET) return s.updateFeeStillLoading();
|
||||
else return s;
|
||||
});
|
||||
|
||||
LiveData<Money> amount = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getAmount));
|
||||
this.store.update(LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(amount, feeRetry, (a, f) -> a), this::getFee), (feeResult, state) -> {
|
||||
if (feeResult instanceof ConfirmPaymentRepository.GetFeeResult.Success) return state.updateFee(((ConfirmPaymentRepository.GetFeeResult.Success) feeResult).getFee());
|
||||
else if (feeResult instanceof ConfirmPaymentRepository.GetFeeResult.Error) return state.updateFeeError();
|
||||
else throw new AssertionError();
|
||||
});
|
||||
|
||||
LiveData<UUID> paymentId = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getPaymentId));
|
||||
LiveData<PaymentTransaction> transactionLiveData = Transformations.switchMap(paymentId, id -> (id != null) ? new PaymentTransactionLiveData(id) : new MutableLiveData<>());
|
||||
this.store.update(transactionLiveData, this::handlePaymentTransactionChanged);
|
||||
|
||||
this.paymentDone = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), state -> state.getStatus().isTerminalStatus()));
|
||||
|
||||
LiveData<Optional<FiatMoney>> exchange = FiatMoneyUtil.getExchange(amount);
|
||||
this.store.update(exchange, (exchange1, confirmPaymentState1) -> confirmPaymentState1.updateExchange(exchange1.orNull()));
|
||||
|
||||
LiveData<ConfirmPaymentState.Status> statusLiveData = Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getStatus);
|
||||
LiveData<ConfirmPaymentState.Status> timeoutSignal = Transformations.switchMap(statusLiveData,
|
||||
s -> {
|
||||
if (s == ConfirmPaymentState.Status.PROCESSING) {
|
||||
Log.i(TAG, "Beginning timeout timer");
|
||||
return LiveDataUtil.delay(TimeUnit.SECONDS.toMillis(20), s);
|
||||
} else {
|
||||
return LiveDataUtil.never();
|
||||
}
|
||||
});
|
||||
|
||||
this.store.update(timeoutSignal, this::handleTimeout);
|
||||
}
|
||||
|
||||
@NonNull LiveData<ConfirmPaymentState> getState() {
|
||||
return store.getStateLiveData();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> isPaymentDone() {
|
||||
return paymentDone;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ErrorType> getErrorTypeEvents() {
|
||||
return errorEvents;
|
||||
}
|
||||
|
||||
void confirmPayment() {
|
||||
store.update(state -> state.updateStatus(ConfirmPaymentState.Status.SUBMITTING));
|
||||
confirmPaymentRepository.confirmPayment(store.getState(), this::handleConfirmPaymentResult);
|
||||
}
|
||||
|
||||
void refreshFee() {
|
||||
feeRetry.setValue(true);
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentRepository.GetFeeResult getFee(@NonNull Money amount) {
|
||||
ConfirmPaymentRepository.GetFeeResult result = confirmPaymentRepository.getFee(amount);
|
||||
|
||||
if (result instanceof ConfirmPaymentRepository.GetFeeResult.Error) {
|
||||
errorEvents.postValue(ErrorType.CAN_NOT_GET_FEE);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handleConfirmPaymentResult(@NonNull ConfirmPaymentResult result) {
|
||||
if (result instanceof ConfirmPaymentResult.Success) {
|
||||
ConfirmPaymentResult.Success success = (ConfirmPaymentResult.Success) result;
|
||||
store.update(state -> state.updatePaymentId(success.getPaymentId()));
|
||||
} else if (result instanceof ConfirmPaymentResult.Error) {
|
||||
ConfirmPaymentResult.Error error = (ConfirmPaymentResult.Error) result;
|
||||
PaymentsAddressException.Code code = error.getCode();
|
||||
|
||||
store.update(state -> state.updateStatus(ConfirmPaymentState.Status.ERROR));
|
||||
if (code != null) {
|
||||
errorEvents.postValue(getErrorType(code));
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull ErrorType getErrorType(@NonNull PaymentsAddressException.Code code) {
|
||||
switch (code) {
|
||||
case NO_PROFILE_KEY:
|
||||
return ErrorType.NO_PROFILE_KEY;
|
||||
case COULD_NOT_DECRYPT:
|
||||
case NOT_ENABLED:
|
||||
case INVALID_ADDRESS:
|
||||
case INVALID_ADDRESS_SIGNATURE:
|
||||
case NO_ADDRESS:
|
||||
return ErrorType.NO_ADDRESS;
|
||||
}
|
||||
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentState handlePaymentTransactionChanged(@Nullable PaymentTransaction paymentTransaction, @NonNull ConfirmPaymentState state) {
|
||||
if (paymentTransaction == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.getStatus().isTerminalStatus()) {
|
||||
Log.w(TAG, "Payment already in a final state on transaction change");
|
||||
return state;
|
||||
}
|
||||
|
||||
switch (paymentTransaction.getState()) {
|
||||
case INITIAL: return state.updateStatus(ConfirmPaymentState.Status.SUBMITTING);
|
||||
case SUBMITTED: return state.updateStatus(ConfirmPaymentState.Status.PROCESSING);
|
||||
case SUCCESSFUL: return state.updateStatus(ConfirmPaymentState.Status.DONE);
|
||||
case FAILED: return state.updateStatus(ConfirmPaymentState.Status.ERROR);
|
||||
default: throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentState handleTimeout(@NonNull ConfirmPaymentState.Status status, @NonNull ConfirmPaymentState state) {
|
||||
if (state.getStatus().isTerminalStatus()) {
|
||||
Log.w(TAG, "Payment already in a final state on timeout");
|
||||
return state;
|
||||
}
|
||||
|
||||
Log.w(TAG, "Timed out while in " + status);
|
||||
return state.timeout();
|
||||
}
|
||||
|
||||
enum ErrorType {
|
||||
NO_PROFILE_KEY,
|
||||
NO_ADDRESS,
|
||||
CAN_NOT_GET_FEE
|
||||
}
|
||||
|
||||
static final class Factory implements ViewModelProvider.Factory {
|
||||
private final CreatePaymentDetails createPaymentDetails;
|
||||
|
||||
public Factory(@NonNull CreatePaymentDetails createPaymentDetails) {
|
||||
this.createPaymentDetails = createPaymentDetails;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ConfirmPaymentViewModel(new ConfirmPaymentState(createPaymentDetails.getPayee(),
|
||||
createPaymentDetails.getAmount(),
|
||||
createPaymentDetails.getNote()),
|
||||
new ConfirmPaymentRepository(ApplicationDependencies.getPayments().getWallet())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
enum AmountKeyboardGlyph {
|
||||
NONE(-1),
|
||||
ZERO(R.string.CreatePaymentFragment__0),
|
||||
ONE(R.string.CreatePaymentFragment__1),
|
||||
TWO(R.string.CreatePaymentFragment__2),
|
||||
THREE(R.string.CreatePaymentFragment__3),
|
||||
FOUR(R.string.CreatePaymentFragment__4),
|
||||
FIVE(R.string.CreatePaymentFragment__5),
|
||||
SIX(R.string.CreatePaymentFragment__6),
|
||||
SEVEN(R.string.CreatePaymentFragment__7),
|
||||
EIGHT(R.string.CreatePaymentFragment__8),
|
||||
NINE(R.string.CreatePaymentFragment__9),
|
||||
DECIMAL(R.string.CreatePaymentFragment__decimal),
|
||||
BACK(R.string.CreatePaymentFragment__lt);
|
||||
|
||||
private final @StringRes int glyphRes;
|
||||
|
||||
AmountKeyboardGlyph(int glyphRes) {
|
||||
this.glyphRes = glyphRes;
|
||||
}
|
||||
|
||||
public int getGlyphRes() {
|
||||
return glyphRes;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user