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:
Android Team
2021-04-06 13:03:33 -03:00
committed by Alan Evans
parent c42023855b
commit fddba2906a
311 changed files with 18956 additions and 235 deletions

View File

@@ -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'

View File

@@ -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"

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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));

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()) {

View File

@@ -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() {

View File

@@ -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));
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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() {}
}
}

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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")

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)));
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)));
}
}
}

View File

@@ -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)));
}
}
}

View File

@@ -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)));
}
}
}

View File

@@ -42,6 +42,7 @@ public final class ProfileUploadJob extends BaseJob {
}
ProfileUtil.uploadProfile(context);
Log.i(TAG, "Profile uploaded.");
}
@Override

View File

@@ -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.");

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 +
'}';
}
}

View File

@@ -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();
}
}
}

View File

@@ -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];
}
};
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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), " "));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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();
}
});
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {
}
}
}

View File

@@ -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
}
}

View File

@@ -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())));
}
}
}

View File

@@ -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