diff --git a/app/build.gradle b/app/build.gradle index a3d102ec6f..697b4632a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f7755f4d4..677e019ff5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -497,6 +497,10 @@ android:theme="@style/TextSecure.LightTheme" android:windowSoftInputMode="stateVisible|adjustResize" /> + + + + 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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 974efa9759..25595adc7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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 getCurrentSelection() { - List currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION); + List 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 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java index 012d15c032..f6c7fd3fdc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 0a3dfc4380..e8a44c2aae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PaymentPillStrip.java b/app/src/main/java/org/thoughtcrime/securesms/components/PaymentPillStrip.java new file mode 100644 index 0000000000..0e268c44dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/PaymentPillStrip.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/UnreadPaymentsView.java b/app/src/main/java/org/thoughtcrime/securesms/components/UnreadPaymentsView.java new file mode 100644 index 0000000000..2d793e4eb3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/UnreadPaymentsView.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java index a9769f39fd..ed4ce3e4ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java @@ -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)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java index 70c227edcf..74f1615bea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java @@ -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 { - 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 { - private SingleSelectSetting.Item singleSelectItem; - private Object customValue; - private String summaryText; + private final SingleSelectSetting.Item singleSelectItem; + private final Object customValue; public 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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingHeader.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingHeader.java new file mode 100644 index 0000000000..c04a263b0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingHeader.java @@ -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 { + + 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 { + 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingProgress.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingProgress.java new file mode 100644 index 0000000000..cf4346f202 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SettingProgress.java @@ -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 { + + public ViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + public void bind(@NonNull SettingProgress.Item model) { } + } + + public static final class Item implements MappingModel { + 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java index 7300bcd8bc..b5ef2f2ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/SingleSelectSetting.java @@ -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 { + 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 { - private final String text; private final Object item; + private final String text; + private final String summaryText; private final boolean isSelected; - public 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 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; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java index 59909d8414..0da7de992e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java @@ -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 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 buttonPredicate) { + if (buttonPredicate == null) { + buttonAdapter.setButtons(DEFAULT_BUTTONS); + } else { + buttonAdapter.setButtons(Stream.of(DEFAULT_BUTTONS).filter(buttonPredicate).toList()); + } + } + public void onMediaChanged(@NonNull List media) { if (StorageUtil.canReadFromMediaStore()) { mediaAdapter.setMedia(media); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java index cc3a366065..db3bcd1252 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index cb5215eabe..74f45ab7ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 74d55777ec..cd5c3ad733 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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; + private Stub paymentNotificationView; private Stub 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) { + 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index ffd7489f50..e74d9177b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -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; - private final MutableLiveData searchResult; - private final PagedData pagedData; - private final LiveData 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; + private final MutableLiveData searchResult; + private final PagedData pagedData; + private final LiveData 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> 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 create(@NonNull Class modelClass) { + public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(), isArchived)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPayments.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPayments.java new file mode 100644 index 0000000000..af587d5e78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPayments.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPaymentsLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPaymentsLiveData.java new file mode 100644 index 0000000000..1e4bb9dd2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/UnreadPaymentsLiveData.java @@ -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> { + + 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 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java index b15467c608..14028776ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 4d49278d90..f2264742ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index ac6228735c..8908cb87b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -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 conversationListObservers; private final Map> conversationObservers; private final Map> verboseConversationObservers; + private final Map> paymentObservers; + private final Set 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 void registerMapped(@NonNull Map> map, @NonNull K key, @NonNull Observer listener) { Set listeners = map.get(key); @@ -132,6 +160,12 @@ public final class DatabaseObserver { } } + public static void notifySet(@NonNull Set 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentDatabase.java new file mode 100644 index 0000000000..9de077c43b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentDatabase.java @@ -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 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. + *

+ * 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 getAll() { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + List 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 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 getUnseenPayments() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = SEEN + " = 0 AND " + STATE + " = " + State.SUCCESSFUL.serialize(); + List 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> 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() {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentMetaDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentMetaDataUtil.java new file mode 100644 index 0000000000..c73fd2887d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentMetaDataUtil.java @@ -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 publicKeys, @NonNull List 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 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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 0705804995..18c277c33d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java index 0691c3a704..82c4ee792c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java @@ -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 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 formattedBalance) { + bullets.setText(buildBulletsText(formattedBalance)); + } + + private @NonNull CharSequence buildBulletsText(@NonNull Optional 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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java index e59ea09fc9..214e2639e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java @@ -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 allCountries; - private final LiveData> filteredCountries; - private final MutableLiveData regionCode; - private final LiveData countryDisplayName; - private final MutableLiveData nationalNumber; - private final MutableLiveData query; - private final SingleLiveEvent events; + private final DeleteAccountRepository repository; + private final List allCountries; + private final LiveData> filteredCountries; + private final MutableLiveData regionCode; + private final LiveData countryDisplayName; + private final MutableLiveData nationalNumber; + private final MutableLiveData query; + private final SingleLiveEvent events; + private final LiveData> 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> getWalletBalance() { + return walletBalance; } @NonNull LiveData> getFilteredCountries() { @@ -128,6 +138,15 @@ public class DeleteAccountViewModel extends ViewModel { } } + private static @NonNull Optional 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index fc15f63709..9e563e7061 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 411c1d0086..fd2ac8df41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java index 6efae8a673..3a378b1d32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index c2e1e151c1..2b15d526a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceOutgoingPaymentSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceOutgoingPaymentSyncJob.java new file mode 100644 index 0000000000..41dfc3fd99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceOutgoingPaymentSyncJob.java @@ -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; + 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 { + + @Override + public @NonNull MultiDeviceOutgoingPaymentSyncJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new MultiDeviceOutgoingPaymentSyncJob(parameters, + UUID.fromString(data.getString(KEY_UUID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentLedgerUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentLedgerUpdateJob.java new file mode 100644 index 0000000000..05b958d4d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentLedgerUpdateJob.java @@ -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 { + @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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java new file mode 100644 index 0000000000..034f52f477 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentNotificationSendJob.java @@ -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 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 { + @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))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentSendJob.java new file mode 100644 index 0000000000..cb92fcf700 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentSendJob.java @@ -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. + *

+ * 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 { + @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))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentTransactionCheckJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentTransactionCheckJob.java new file mode 100644 index 0000000000..e7a587864a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PaymentTransactionCheckJob.java @@ -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 { + + @Override + public @NonNull PaymentTransactionCheckJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PaymentTransactionCheckJob(parameters, + UUID.fromString(data.getString(KEY_UUID))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java index 670527e026..58fd4e9f01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java @@ -42,6 +42,7 @@ public final class ProfileUploadJob extends BaseJob { } ProfileUtil.uploadProfile(context); + Log.i(TAG, "Profile uploaded."); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 0504c4a9ec..0f56ddb640 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -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."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java index edc667d1b1..7729c1569c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsAvailability.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsAvailability.java new file mode 100644 index 0000000000..25be69b5b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsAvailability.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.java new file mode 100644 index 0000000000..c4e70570d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.java @@ -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 liveCurrentCurrency; + private final MutableLiveData liveMobileCoinLedger; + private final LiveData 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 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. + *

+ * 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 liveMobileCoinLedger() { + return liveMobileCoinLedger; + } + + public @NonNull LiveData 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 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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 8239174ad4..85c6d01fe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index f0fb3ec618..8fd2842675 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -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 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 2daaf6ce54..7c4ac63e15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 37571c57b0..bd0b3c3e52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Balance.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Balance.java new file mode 100644 index 0000000000..d336023a65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Balance.java @@ -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 + + '}'; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/CanNotSendPaymentDialog.java b/app/src/main/java/org/thoughtcrime/securesms/payments/CanNotSendPaymentDialog.java new file mode 100644 index 0000000000..b79c1a5c4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/CanNotSendPaymentDialog.java @@ -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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/CreatePaymentDetails.java b/app/src/main/java/org/thoughtcrime/securesms/payments/CreatePaymentDetails.java new file mode 100644 index 0000000000..895422b812 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/CreatePaymentDetails.java @@ -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 CREATOR = new Creator() { + @Override + public @NonNull CreatePaymentDetails createFromParcel(@NonNull Parcel in) { + return new CreatePaymentDetails(in); + } + + @Override + public @NonNull CreatePaymentDetails[] newArray(int size) { + return new CreatePaymentDetails[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/CryptoValueUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/CryptoValueUtil.java new file mode 100644 index 0000000000..7d0e2d3009 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/CryptoValueUtil.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/DataExportUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/DataExportUtil.java new file mode 100644 index 0000000000..badce8fb40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/DataExportUtil.java @@ -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 paymentTransactions = DatabaseFactory.getPaymentDatabase(context) + .getAll(); + MobileCoinLedgerWrapper ledger = SignalStore.paymentsValues().liveMobileCoinLedger().getValue(); + List reconciled = LedgerReconcile.reconcile(paymentTransactions, Objects.requireNonNull(ledger)); + + return createTsv(reconciled); + } + + @RequiresApi(api = 26) + private static @NonNull String createTsv(@NonNull List 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"; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Direction.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Direction.java new file mode 100644 index 0000000000..3369185a1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Direction.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Entropy.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Entropy.java new file mode 100644 index 0000000000..6a84875b27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Entropy.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/FailureReason.java b/app/src/main/java/org/thoughtcrime/securesms/payments/FailureReason.java new file mode 100644 index 0000000000..320426e8c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/FailureReason.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java new file mode 100644 index 0000000000..1eccc315a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java @@ -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> getExchange(@NonNull LiveData 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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/GeographicalRestrictions.java b/app/src/main/java/org/thoughtcrime/securesms/payments/GeographicalRestrictions.java new file mode 100644 index 0000000000..ee5f5c1baa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/GeographicalRestrictions.java @@ -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 REGION_CODE_SET; + + static { + Set 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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Mnemonic.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Mnemonic.java new file mode 100644 index 0000000000..725b9d2284 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Mnemonic.java @@ -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 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 getWords() { + return Collections.unmodifiableList(Arrays.asList(words)); + } + + public int getWordCount() { + return words.length; + } + + public String getMnemonic() { + return mnemonic; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinConfig.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinConfig.java new file mode 100644 index 0000000000..d783db7621 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinConfig.java @@ -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 getTrustRoots(@RawRes int pemResource) { + try (InputStream inputStream = ApplicationDependencies.getApplication().getResources().openRawResource(pemResource)) { + Collection certificates = CertificateFactory.getInstance("X.509") + .generateCertificates(inputStream); + + HashSet x509Certificates = new HashSet<>(certificates.size()); + for (Certificate c : certificates) { + x509Certificates.add((X509Certificate) c); + } + + return x509Certificates; + } catch (IOException | CertificateException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLedgerWrapper.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLedgerWrapper.java new file mode 100644 index 0000000000..2d4ff4649c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLedgerWrapper.java @@ -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 getAllTxos() { + List txoList = new ArrayList<>(ledger.getSpentTxosCount() + ledger.getUnspentTxosCount()); + addAllMapped(txoList, ledger.getSpentTxosList()); + addAllMapped(txoList, ledger.getUnspentTxosList()); + return txoList; + } + + private static void addAllMapped(@NonNull List output, @NonNull List 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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLogAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLogAdapter.java new file mode 100644 index 0000000000..e140269cc6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinLogAdapter.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinMainNetConfig.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinMainNetConfig.java new file mode 100644 index 0000000000..8767b688b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinMainNetConfig.java @@ -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 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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddress.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddress.java new file mode 100644 index 0000000000..a672736b6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddress.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddressProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddressProfileUtil.java new file mode 100644 index 0000000000..22f04accd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinPublicAddressProfileUtil.java @@ -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}. + *

+ * 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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinTestNetConfig.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinTestNetConfig.java new file mode 100644 index 0000000000..24ac563c76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MobileCoinTestNetConfig.java @@ -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 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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/MoneyView.java b/app/src/main/java/org/thoughtcrime/securesms/payments/MoneyView.java new file mode 100644 index 0000000000..bd9eefa7f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/MoneyView.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Payee.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Payee.java new file mode 100644 index 0000000000..c2da354f96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Payee.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Payment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Payment.java new file mode 100644 index 0000000000..9539320959 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Payment.java @@ -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. + *

+ * It could be from a sent or received Signal payment message or reconstructed. + */ +public interface Payment { + Comparator UNKNOWN_BLOCK_INDEX_FIRST = (a, b) -> Boolean.compare(b.getBlockIndex() == 0, a.getBlockIndex() == 0); + Comparator ASCENDING_BLOCK_INDEX = (a, b) -> Long.compare(a.getBlockIndex(), b.getBlockIndex()); + Comparator DESCENDING_BLOCK_INDEX = ComparatorCompat.reversed(ASCENDING_BLOCK_INDEX); + Comparator 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()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentDecorator.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentDecorator.java new file mode 100644 index 0000000000..85209ed576 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentDecorator.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentParcelable.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentParcelable.java new file mode 100644 index 0000000000..2d031a0013 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentParcelable.java @@ -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 CREATOR = new Creator() { + @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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentSubmissionResult.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentSubmissionResult.java new file mode 100644 index 0000000000..33f7afe240 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentSubmissionResult.java @@ -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. + *

+ * Or a number of successful transactions and a failed transaction. + */ +public final class PaymentSubmissionResult { + + private final List defrags; + private final TransactionSubmissionResult nonDefrag; + private final TransactionSubmissionResult erroredTransaction; + + PaymentSubmissionResult(@NonNull List 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 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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionId.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionId.java new file mode 100644 index 0000000000..04adb2d646 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionId.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionLiveData.java new file mode 100644 index 0000000000..6262f29f9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentTransactionLiveData.java @@ -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 { + + 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))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Payments.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Payments.java new file mode 100644 index 0000000000..7c09b5554c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Payments.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentsAddressException.java b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentsAddressException.java new file mode 100644 index 0000000000..8803d15faa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/PaymentsAddressException.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/ReconstructedPayment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/ReconstructedPayment.java new file mode 100644 index 0000000000..8c1be58115 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/ReconstructedPayment.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/State.java b/app/src/main/java/org/thoughtcrime/securesms/payments/State.java new file mode 100644 index 0000000000..c9d12b6f41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/State.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/TransactionSubmissionResult.java b/app/src/main/java/org/thoughtcrime/securesms/payments/TransactionSubmissionResult.java new file mode 100644 index 0000000000..2917cbb239 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/TransactionSubmissionResult.java @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/UnreadPaymentsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/UnreadPaymentsRepository.java new file mode 100644 index 0000000000..1373acb4b5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/UnreadPaymentsRepository.java @@ -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); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/Wallet.java b/app/src/main/java/org/thoughtcrime/securesms/payments/Wallet.java new file mode 100644 index 0000000000..3df1470c51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/Wallet.java @@ -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 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 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 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 results; + private Money.MobileCoin totalFeesSpent = Money.MobileCoin.ZERO; + + DefragDelegate(@NonNull MobileCoinClient mobileCoinClient, @NonNull List 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"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryPasteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryPasteFragment.java new file mode 100644 index 0000000000..bd1cf16dbe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryPasteFragment.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryRepository.java new file mode 100644 index 0000000000..80c0630a70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryRepository.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryStartFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryStartFragment.java new file mode 100644 index 0000000000..2793c10395 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/PaymentsRecoveryStartFragment.java @@ -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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmFragment.java new file mode 100644 index 0000000000..3a30a45e9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmFragment.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewModel.java new file mode 100644 index 0000000000..72660a0454 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewModel.java @@ -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 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 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()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewState.java new file mode 100644 index 0000000000..9c0946fab3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/confirm/PaymentsRecoveryPhraseConfirmViewState.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryFragment.java new file mode 100644 index 0000000000..f89f54f789 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryFragment.java @@ -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 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()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewModel.java new file mode 100644 index 0000000000..343b3aa2e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewModel.java @@ -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 state = new Store<>(new PaymentsRecoveryEntryViewState()); + private SingleLiveEvent events = new SingleLiveEvent<>(); + private String[] words = new String[PaymentsConstants.MNEMONIC_LENGTH]; + + @NonNull LiveData getState() { + return state.getStateLiveData(); + } + + @NonNull LiveData 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewState.java new file mode 100644 index 0000000000..375e9b9785 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/entry/PaymentsRecoveryEntryViewState.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/ClearClipboardAlarmReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/ClearClipboardAlarmReceiver.java new file mode 100644 index 0000000000..d79c36d27d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/ClearClipboardAlarmReceiver.java @@ -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), " ")); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPart.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPart.java new file mode 100644 index 0000000000..e2afda069b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPart.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPartAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPartAdapter.java new file mode 100644 index 0000000000..0a818e69a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/MnemonicPartAdapter.java @@ -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 { + + 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java new file mode 100644 index 0000000000..08b0eafe6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java @@ -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 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 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 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 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 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 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 { + + 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()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseRepository.java new file mode 100644 index 0000000000..183f49b2c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseRepository.java @@ -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 words, + @NonNull Consumer 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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseViewModel.java new file mode 100644 index 0000000000..30440f0b2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseViewModel.java @@ -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 = new SingleLiveEvent<>(); + private final PaymentsRecoveryPhraseRepository repository = new PaymentsRecoveryPhraseRepository(); + + public LiveData getSubmitResult() { + return submitResult; + } + + void onSubmit(List 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentAdapter.java new file mode 100644 index 0000000000..80acc4235f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentAdapter.java @@ -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 { + @Override + public boolean areItemsTheSame(@NonNull LoadingItem newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull LoadingItem newItem) { + return true; + } + } + + public static class LineItem implements MappingModel { + + 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 { + 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 { + 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 { + @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 { + + public LoadingItemViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override public void bind(@NonNull LoadingItem model) { + } + } + + public static final class LineItemViewHolder extends MappingViewHolder { + 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 { + 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 { + + 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(); + } + }); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java new file mode 100644 index 0000000000..68b95c67d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentFragment.java @@ -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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentRepository.java new file mode 100644 index 0000000000..a3f0e23eec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentRepository.java @@ -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 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 { + + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java new file mode 100644 index 0000000000..dbcf498a7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentState.java @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentViewModel.java new file mode 100644 index 0000000000..d703b5dbd3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/confirm/ConfirmPaymentViewModel.java @@ -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 store; + private final ConfirmPaymentRepository confirmPaymentRepository; + private final LiveData paymentDone; + private final SingleLiveEvent errorEvents; + private final MutableLiveData 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 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 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 paymentId = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getPaymentId)); + LiveData 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> exchange = FiatMoneyUtil.getExchange(amount); + this.store.update(exchange, (exchange1, confirmPaymentState1) -> confirmPaymentState1.updateExchange(exchange1.orNull())); + + LiveData statusLiveData = Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getStatus); + LiveData 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 getState() { + return store.getStateLiveData(); + } + + @NonNull LiveData isPaymentDone() { + return paymentDone; + } + + @NonNull LiveData 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 create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ConfirmPaymentViewModel(new ConfirmPaymentState(createPaymentDetails.getPayee(), + createPaymentDetails.getAmount(), + createPaymentDetails.getNote()), + new ConfirmPaymentRepository(ApplicationDependencies.getPayments().getWallet()))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/AmountKeyboardGlyph.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/AmountKeyboardGlyph.java new file mode 100644 index 0000000000..7b625ce9af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/AmountKeyboardGlyph.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentFragment.java new file mode 100644 index 0000000000..65078cae17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentFragment.java @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.payments.create; + +import android.app.AlertDialog; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.transition.TransitionManager; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.payments.FiatMoneyUtil; +import org.thoughtcrime.securesms.payments.MoneyView; +import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.whispersystems.signalservice.api.payments.FormatterOptions; +import org.whispersystems.signalservice.api.payments.Money; + +import java.util.Currency; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class CreatePaymentFragment extends LoggingFragment { + + private static final Map ID_TO_GLYPH = new HashMap() {{ + put(R.id.create_payment_fragment_keyboard_decimal, AmountKeyboardGlyph.DECIMAL); + put(R.id.create_payment_fragment_keyboard_lt, AmountKeyboardGlyph.BACK); + put(R.id.create_payment_fragment_keyboard_0, AmountKeyboardGlyph.ZERO); + put(R.id.create_payment_fragment_keyboard_1, AmountKeyboardGlyph.ONE); + put(R.id.create_payment_fragment_keyboard_2, AmountKeyboardGlyph.TWO); + put(R.id.create_payment_fragment_keyboard_3, AmountKeyboardGlyph.THREE); + put(R.id.create_payment_fragment_keyboard_4, AmountKeyboardGlyph.FOUR); + put(R.id.create_payment_fragment_keyboard_5, AmountKeyboardGlyph.FIVE); + put(R.id.create_payment_fragment_keyboard_6, AmountKeyboardGlyph.SIX); + put(R.id.create_payment_fragment_keyboard_7, AmountKeyboardGlyph.SEVEN); + put(R.id.create_payment_fragment_keyboard_8, AmountKeyboardGlyph.EIGHT); + put(R.id.create_payment_fragment_keyboard_9, AmountKeyboardGlyph.NINE); + }}; + + private ConstraintLayout constraintLayout; + private TextView balance; + private MoneyView amount; + private TextView exchange; + private View pay; + private View request; + private EmojiTextView note; + private View addNote; + private View toggle; + private Drawable infoIcon; + + private ConstraintSet cryptoConstraintSet; + private ConstraintSet fiatConstraintSet; + + public CreatePaymentFragment() { + super(R.layout.create_payment_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.create_payment_fragment_toolbar); + + toolbar.setNavigationOnClickListener(this::goBack); + + CreatePaymentFragmentArgs arguments = CreatePaymentFragmentArgs.fromBundle(requireArguments()); + CreatePaymentViewModel.Factory factory = new CreatePaymentViewModel.Factory(arguments.getPayee(), arguments.getNote()); + CreatePaymentViewModel viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_create), factory).get(CreatePaymentViewModel.class); + + constraintLayout = view.findViewById(R.id.create_payment_fragment_amount_header); + request = view.findViewById(R.id.create_payment_fragment_request); + amount = view.findViewById(R.id.create_payment_fragment_amount); + exchange = view.findViewById(R.id.create_payment_fragment_exchange); + pay = view.findViewById(R.id.create_payment_fragment_pay); + balance = view.findViewById(R.id.create_payment_fragment_balance); + note = view.findViewById(R.id.create_payment_fragment_note); + addNote = view.findViewById(R.id.create_payment_fragment_add_note); + toggle = view.findViewById(R.id.create_payment_fragment_toggle); + + View infoTapTarget = view.findViewById(R.id.create_payment_fragment_info_tap_region); + + //noinspection CodeBlock2Expr + infoTapTarget.setOnClickListener(v -> { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.CreatePaymentFragment__conversions_are_just_estimates) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .setNegativeButton(R.string.LearnMoreTextView_learn_more, (dialog, which) -> { + dialog.dismiss(); + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.CreatePaymentFragment__learn_more__conversions)); + }) + .show(); + }); + + initializeInfoIcon(); + + note.setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_createPaymentFragment_to_editPaymentNoteFragment)); + addNote.setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_createPaymentFragment_to_editPaymentNoteFragment)); + + pay.setOnClickListener(v -> { + NavDirections directions = CreatePaymentFragmentDirections.actionCreatePaymentFragmentToConfirmPaymentFragment(viewModel.getCreatePaymentDetails()) + .setFinishOnConfirm(arguments.getFinishOnConfirm()); + Navigation.findNavController(v).navigate(directions); + }); + + toggle.setOnClickListener(v -> viewModel.toggleMoneyInputTarget(requireContext())); + + initializeConstraintSets(); + initializeKeyboardButtons(view, viewModel); + + viewModel.getInputState().observe(getViewLifecycleOwner(), inputState -> { + updateAmount(inputState); + updateExchange(inputState); + updateMoneyInputTarget(inputState.getInputTarget()); + }); + + viewModel.getIsPaymentsSupportedByPayee().observe(getViewLifecycleOwner(), isSupported -> { + if (!isSupported) RecipientHasNotEnabledPaymentsDialog.show(requireContext(), () -> goBack(requireView())); + }); + + viewModel.isValidAmount().observe(getViewLifecycleOwner(), this::updateRequestAmountButtons); + viewModel.getNote().observe(getViewLifecycleOwner(), this::updateNote); + viewModel.getSpendableBalance().observe(getViewLifecycleOwner(), this::updateBalance); + viewModel.getCanSendPayment().observe(getViewLifecycleOwner(), this::updatePayAmountButtons); + } + + private void goBack(View v) { + if (!Navigation.findNavController(v).popBackStack()) { + requireActivity().finish(); + } + } + + private void initializeInfoIcon() { + Drawable pad = Objects.requireNonNull(AppCompatResources.getDrawable(requireContext(), R.drawable.payment_info_pad)); + Drawable info = Objects.requireNonNull(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_update_info_16)); + + DrawableCompat.setTint(info, exchange.getCurrentTextColor()); + + pad.setBounds(0, 0, ViewUtil.dpToPx(29), ViewUtil.dpToPx(16)); + info.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); + + infoIcon = new LayerDrawable(new Drawable[]{pad, info}); + infoIcon.setBounds(0, 0, ViewUtil.dpToPx(29), ViewUtil.dpToPx(16)); + } + + private void updateNote(@Nullable CharSequence note) { + boolean hasNote = !TextUtils.isEmpty(note); + addNote.setVisibility(hasNote ? View.GONE : View.VISIBLE); + this.note.setVisibility(hasNote ? View.VISIBLE : View.GONE); + this.note.setText(note); + } + + private void initializeKeyboardButtons(@NonNull View view, @NonNull CreatePaymentViewModel viewModel) { + for (Map.Entry entry : ID_TO_GLYPH.entrySet()) { + view.findViewById(entry.getKey()).setOnClickListener(v -> viewModel.updateAmount(requireContext(), entry.getValue())); + } + + view.findViewById(R.id.create_payment_fragment_keyboard_lt).setOnLongClickListener(v -> { + viewModel.clearAmount(); + return true; + }); + } + + private void updateAmount(@NonNull InputState inputState) { + switch (inputState.getInputTarget()) { + case MONEY: + amount.setMoney(inputState.getMoneyAmount(), inputState.getMoney().getCurrency()); + break; + case FIAT_MONEY: + amount.setMoney(inputState.getMoney(), false, inputState.getExchangeRate().get().getTimestamp()); + amount.append(SpanUtil.buildImageSpan(infoIcon)); + break; + } + } + + private void updateExchange(@NonNull InputState inputState) { + switch (inputState.getInputTarget()) { + case MONEY: + if (inputState.getFiatMoney().isPresent()) { + exchange.setVisibility(View.VISIBLE); + exchange.setText(FiatMoneyUtil.format(getResources(), inputState.getFiatMoney().get(), FiatMoneyUtil.formatOptions().withDisplayTime(true))); + exchange.append(SpanUtil.buildImageSpan(infoIcon)); + toggle.setVisibility(View.VISIBLE); + toggle.setEnabled(true); + } else { + exchange.setVisibility(View.INVISIBLE); + toggle.setVisibility(View.INVISIBLE); + toggle.setEnabled(false); + } + break; + case FIAT_MONEY: + Currency currency = inputState.getFiatMoney().get().getCurrency(); + exchange.setText(new SpannableStringBuilder().append(currency.getSymbol()) + .append(inputState.getFiatAmount()) + .append(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.payment_currency_code_foreground_color), + currency.getCurrencyCode()))); + break; + } + } + + private void updateRequestAmountButtons(boolean isValidAmount) { + request.setEnabled(isValidAmount); + } + + private void updatePayAmountButtons(boolean isValidAmount) { + pay.setEnabled(isValidAmount); + } + + private void updateBalance(@NonNull Money balance) { + this.balance.setText(getString(R.string.CreatePaymentFragment__available_balance_s, balance.toString(FormatterOptions.defaults()))); + } + + private void initializeConstraintSets() { + cryptoConstraintSet = new ConstraintSet(); + cryptoConstraintSet.clone(constraintLayout); + + fiatConstraintSet = new ConstraintSet(); + fiatConstraintSet.clone(getContext(), R.layout.create_payment_fragment_amount_toggle); + } + + private void updateMoneyInputTarget(@NonNull InputTarget target) { + TransitionManager.endTransitions(constraintLayout); + TransitionManager.beginDelayedTransition(constraintLayout); + + switch (target) { + case FIAT_MONEY: + fiatConstraintSet.applyTo(constraintLayout); + amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_secondary)); + exchange.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary)); + break; + case MONEY: + cryptoConstraintSet.applyTo(constraintLayout); + exchange.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_secondary)); + amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary)); + break; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentViewModel.java new file mode 100644 index 0000000000..01e0e1b042 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/CreatePaymentViewModel.java @@ -0,0 +1,273 @@ +package org.thoughtcrime.securesms.payments.create; + +import android.content.Context; + +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.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.Balance; +import org.thoughtcrime.securesms.payments.CreatePaymentDetails; +import org.thoughtcrime.securesms.payments.FiatMoneyUtil; +import org.thoughtcrime.securesms.payments.currency.CurrencyExchange; +import org.thoughtcrime.securesms.payments.currency.FiatMoney; +import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.ProfileUtil; +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.FormatterOptions; +import org.whispersystems.signalservice.api.payments.Money; +import org.whispersystems.signalservice.api.util.OptionalUtil; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Objects; + +import static org.thoughtcrime.securesms.payments.create.InputTarget.FIAT_MONEY; + +public class CreatePaymentViewModel extends ViewModel { + + private static final String TAG = Log.tag(CreatePaymentViewModel.class); + + private static final Money.MobileCoin AMOUNT_LOWER_BOUND_EXCLUSIVE = Money.mobileCoin(BigDecimal.ZERO); + + private final LiveData spendableBalance; + private final LiveData isValidAmount; + private final Store inputState; + private final LiveData isPaymentsSupportedByPayee; + + private final PayeeParcelable payee; + private final MutableLiveData note; + + private CreatePaymentViewModel(@NonNull PayeeParcelable payee, @Nullable CharSequence note) { + this.payee = payee; + this.spendableBalance = Transformations.map(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getTransferableAmount); + this.note = new MutableLiveData<>(note); + this.inputState = new Store<>(new InputState()); + this.isValidAmount = LiveDataUtil.combineLatest(spendableBalance, inputState.getStateLiveData(), (b, s) -> validateAmount(b, s.getMoney())); + + if (payee.getPayee().hasRecipientId()) { + isPaymentsSupportedByPayee = LiveDataUtil.mapAsync(new DefaultValueLiveData<>(payee.getPayee().requireRecipientId()), r -> { + try { + ProfileUtil.getAddressForRecipient(Recipient.resolved(r)); + return true; + } catch (Exception e) { + Log.w(TAG, "Could not get address for recipient: ", e); + return false; + } + }); + } else { + isPaymentsSupportedByPayee = new DefaultValueLiveData<>(true); + } + + LiveData> liveExchangeRate = LiveDataUtil.mapAsync(SignalStore.paymentsValues().liveCurrentCurrency(), + currency -> { + try { + return Optional.fromNullable(ApplicationDependencies.getPayments() + .getCurrencyExchange(true) + .getExchangeRate(currency)); + } catch (IOException e) { + return Optional.absent(); + } + }); + + inputState.update(liveExchangeRate, (rate, state) -> updateAmount(ApplicationDependencies.getApplication(), state.updateExchangeRate(rate), AmountKeyboardGlyph.NONE)); + } + + @NonNull LiveData getInputState() { + return inputState.getStateLiveData(); + } + + @NonNull LiveData getIsPaymentsSupportedByPayee() { + return isPaymentsSupportedByPayee; + } + + @NonNull LiveData getNote() { + return Transformations.distinctUntilChanged(note); + } + + @NonNull LiveData isValidAmount() { + return isValidAmount; + } + + @NonNull LiveData getCanSendPayment() { + return isValidAmount; + } + + @NonNull LiveData getSpendableBalance() { + return spendableBalance; + } + + void clearAmount() { + inputState.update(s -> { + final Money money = Money.MobileCoin.ZERO; + final Optional fiat = OptionalUtil.flatMap(s.getExchangeRate(), r -> r.exchange(money)); + + return s.updateAmount("0", "0", Money.MobileCoin.ZERO, fiat); + }); + } + + void toggleMoneyInputTarget(@NonNull Context context) { + inputState.update(s -> trimFiatAfterToggle(updateAmount(context, s.updateInputTarget(s.getInputTarget().next()), AmountKeyboardGlyph.NONE))); + } + + void setNote(@Nullable CharSequence note) { + this.note.setValue(note); + } + + void updateAmount(@NonNull Context context, @NonNull AmountKeyboardGlyph glyph) { + inputState.update(s -> updateAmount(context, s, glyph)); + } + + private @NonNull InputState updateAmount(@NonNull Context context, @NonNull InputState inputState, @NonNull AmountKeyboardGlyph glyph) { + switch (inputState.getInputTarget()) { + case FIAT_MONEY: + return updateFiatAmount(context, inputState, glyph, SignalStore.paymentsValues().currentCurrency()); + case MONEY: + return updateMoneyAmount(context, inputState, glyph); + default: + throw new IllegalStateException("Unexpected input target " + inputState.getInputTarget().name()); + } + } + + private @NonNull InputState trimFiatAfterToggle(@NonNull InputState inputState) { + if (inputState.getInputTarget() == FIAT_MONEY) { + String fiatAmount = inputState.getFiatAmount(); + FiatMoney fiatMoney = inputState.getFiatMoney().get(); + + if (fiatMoney.getAmount().equals(BigDecimal.ZERO)) { + return inputState.updateFiatAmount("0"); + } else if (fiatAmount.contains(ApplicationDependencies.getApplication().getString(AmountKeyboardGlyph.DECIMAL.getGlyphRes()))) { + return inputState.updateFiatAmount(inputState.getFiatAmount().replaceFirst("\\.*0+$", "")); + } else { + return inputState; + } + } else { + return inputState; + } + } + + private @NonNull InputState updateFiatAmount(@NonNull Context context, + @NonNull InputState inputState, + @NonNull AmountKeyboardGlyph glyph, + @NonNull Currency currency) + { + String newFiatAmount = updateAmountString(context, inputState.getFiatAmount(), glyph, currency.getDefaultFractionDigits()); + FiatMoney newFiat = stringToFiatValueOrZero(newFiatAmount, currency); + Money newMoney = OptionalUtil.flatMap(inputState.getExchangeRate(), e -> e.exchange(newFiat)).get(); + String newMoneyAmount = newMoney.toString(FormatterOptions.builder().withoutUnit().build()); + + return inputState.updateAmount(newMoneyAmount, newFiatAmount, newMoney, Optional.of(newFiat)); + } + + private @NonNull InputState updateMoneyAmount(@NonNull Context context, + @NonNull InputState inputState, + @NonNull AmountKeyboardGlyph glyph) + { + String newMoneyAmount = updateAmountString(context, inputState.getMoneyAmount(), glyph, inputState.getMoney().getCurrency().getDecimalPrecision()); + Money newMoney = stringToMobileCoinValueOrZero(newMoneyAmount); + Optional newFiat = OptionalUtil.flatMap(inputState.getExchangeRate(), e -> e.exchange(newMoney)); + String newFiatAmount = newFiat.transform(f -> FiatMoneyUtil.format(context.getResources(), f, FiatMoneyUtil.formatOptions().withDisplayTime(false).withoutSymbol())).or("0"); + + return inputState.updateAmount(newMoneyAmount, newFiatAmount, newMoney, newFiat); + } + + private boolean validateAmount(@NonNull Money spendableBalance, @NonNull Money amount) { + try { + return amount.requireMobileCoin().greaterThan(AMOUNT_LOWER_BOUND_EXCLUSIVE) && + !amount.requireMobileCoin().greaterThan(spendableBalance.requireMobileCoin()); + } catch (NumberFormatException exception) { + return false; + } + } + + private @NonNull FiatMoney stringToFiatValueOrZero(@Nullable String string, @NonNull Currency currency) { + try { + if (string != null) return new FiatMoney(new BigDecimal(string), currency); + } catch (NumberFormatException ignored) { } + + return new FiatMoney(BigDecimal.ZERO, currency); + } + + private @NonNull Money stringToMobileCoinValueOrZero(@Nullable String string) { + try { + if (string != null) return Money.mobileCoin(new BigDecimal(string)); + } catch (NumberFormatException ignored) { } + + return Money.mobileCoin(BigDecimal.ZERO); + } + + public @NonNull CreatePaymentDetails getCreatePaymentDetails() { + CharSequence noteLocal = this.note.getValue(); + String note = noteLocal != null ? noteLocal.toString() : null; + return new CreatePaymentDetails(payee, Objects.requireNonNull(inputState.getState().getMoney()), note); + } + + private static @NonNull String updateAmountString(@NonNull Context context, @NonNull String oldAmount, @NonNull AmountKeyboardGlyph glyph, int maxPrecision) { + if (glyph == AmountKeyboardGlyph.NONE) { + return oldAmount; + } + + if (glyph == AmountKeyboardGlyph.BACK) { + if (!oldAmount.isEmpty()) { + String newAmount = oldAmount.substring(0, oldAmount.length() - 1); + if (newAmount.isEmpty()) { + return context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes()); + } else { + return newAmount; + } + } + + return oldAmount; + } + + boolean oldAmountIsZero = context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes()).equals(oldAmount); + int decimalIndex = oldAmount.indexOf(context.getString(AmountKeyboardGlyph.DECIMAL.getGlyphRes())); + + if (glyph == AmountKeyboardGlyph.DECIMAL) { + if (oldAmountIsZero) { + return context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes()) + context.getString(glyph.getGlyphRes()); + } else if (decimalIndex > -1) { + return oldAmount; + } + } + + if (decimalIndex > -1 && oldAmount.length() - 1 - decimalIndex >= maxPrecision) { + return oldAmount; + } + + if (oldAmountIsZero) { + return context.getString(glyph.getGlyphRes()); + } else { + return oldAmount + context.getString(glyph.getGlyphRes()); + } + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final PayeeParcelable payee; + private final CharSequence note; + + public Factory(@NonNull PayeeParcelable payee, @Nullable CharSequence note) { + this.payee = payee; + this.note = note; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new CreatePaymentViewModel(payee, note); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/EditNoteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/EditNoteFragment.java new file mode 100644 index 0000000000..12a59a245f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/EditNoteFragment.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.payments.create; + +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.view.inputmethod.EditorInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class EditNoteFragment extends LoggingFragment { + + private CreatePaymentViewModel viewModel; + private EmojiEditText noteEditText; + + public EditNoteFragment() { + super(R.layout.edit_note_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_create)).get(CreatePaymentViewModel.class); + + Toolbar toolbar = view.findViewById(R.id.edit_note_fragment_toolbar); + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + noteEditText = view.findViewById(R.id.edit_note_fragment_edit_text); + viewModel.getNote().observe(getViewLifecycleOwner(), note -> { + noteEditText.setText(note); + if (!TextUtils.isEmpty(note)) { + noteEditText.setSelection(note.length()); + } + }); + + noteEditText.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + saveNote(); + return true; + } + return false; + }); + + View fab = view.findViewById(R.id.edit_note_fragment_fab); + fab.setOnClickListener(v -> saveNote()); + + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(noteEditText); + } + + private void saveNote() { + ViewUtil.hideKeyboard(requireView().getContext(), requireView()); + viewModel.setNote(noteEditText.getText() != null ? noteEditText.getText().toString() : null); + Navigation.findNavController(requireView()).popBackStack(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputState.java new file mode 100644 index 0000000000..f6e75125e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputState.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.payments.create; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.payments.currency.CurrencyExchange; +import org.thoughtcrime.securesms.payments.currency.FiatMoney; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.payments.Money; + +import java.math.BigDecimal; + +class InputState { + private final InputTarget inputTarget; + private final String moneyAmount; + private final String fiatAmount; + private final Optional fiatMoney; + private final Money money; + private final Optional exchangeRate; + + InputState() { + this(InputTarget.MONEY, "0", "0", Money.mobileCoin(BigDecimal.ZERO), Optional.absent(), Optional.absent()); + } + + private InputState(@NonNull InputTarget inputTarget, + @NonNull String moneyAmount, + @NonNull String fiatAmount, + @NonNull Money money, + @NonNull Optional fiatMoney, + @NonNull Optional exchangeRate) + { + this.inputTarget = inputTarget; + this.moneyAmount = moneyAmount; + this.fiatAmount = fiatAmount; + this.money = money; + this.fiatMoney = fiatMoney; + this.exchangeRate = exchangeRate; + } + + @NonNull String getFiatAmount() { + return fiatAmount; + } + + @NonNull Optional getFiatMoney() { + return fiatMoney; + } + + @NonNull String getMoneyAmount() { + return moneyAmount; + } + + @NonNull Money getMoney() { + return money; + } + + @NonNull InputTarget getInputTarget() { + return inputTarget; + } + + @NonNull Optional getExchangeRate() { + return exchangeRate; + } + + @NonNull InputState updateInputTarget(@NonNull InputTarget inputTarget) { + return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate); + } + + @NonNull InputState updateFiatAmount(@NonNull String fiatAmount) { + return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate); + } + + @NonNull InputState updateAmount(@NonNull String moneyAmount, @NonNull String fiatAmount, @NonNull Money money, @NonNull Optional fiatMoney) { + return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate); + } + + @NonNull InputState updateExchangeRate(@NonNull Optional exchangeRate) { + return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputTarget.java b/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputTarget.java new file mode 100644 index 0000000000..265f9fd7dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/create/InputTarget.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.payments.create; + +enum InputTarget { + MONEY() { + @Override + InputTarget next() { + return FIAT_MONEY; + } + }, + FIAT_MONEY { + @Override + InputTarget next() { + return MONEY; + } + }; + + abstract InputTarget next(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchange.java b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchange.java new file mode 100644 index 0000000000..ce5df39b53 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchange.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.payments.currency; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.payments.Money; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Currency; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class CurrencyExchange { + private final Map conversions; + private final List supportedCurrencies; + private final long timestamp; + + public CurrencyExchange(@NonNull Map conversions, long timestamp) { + this.conversions = new HashMap<>(conversions.size()); + this.supportedCurrencies = new ArrayList<>(conversions.size()); + this.timestamp = timestamp; + + for (Map.Entry entry : conversions.entrySet()) { + if (entry.getValue() != null) { + this.conversions.put(entry.getKey(), BigDecimal.valueOf(entry.getValue())); + + Currency currency = CurrencyUtil.getCurrencyByCurrencyCode(entry.getKey()); + if (currency != null && SupportedCurrencies.ALL.contains(currency.getCurrencyCode())) { + supportedCurrencies.add(currency); + } + } + } + } + + public @NonNull ExchangeRate getExchangeRate(@NonNull Currency currency) { + return new ExchangeRate(currency, conversions.get(currency.getCurrencyCode()), timestamp); + } + + public @NonNull List getSupportedCurrencies() { + return supportedCurrencies; + } + + public static final class ExchangeRate { + + private final Currency currency; + private final BigDecimal rate; + private final long timestamp; + + @VisibleForTesting ExchangeRate(@NonNull Currency currency, @Nullable BigDecimal rate, long timestamp) { + this.currency = currency; + this.rate = rate; + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public @NonNull Optional exchange(@NonNull Money money) { + BigDecimal amount = money.requireMobileCoin().toBigDecimal(); + + if (rate != null) { + return Optional.of(new FiatMoney(amount.multiply(rate).setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN), currency, timestamp)); + } + return Optional.absent(); + } + + public @NonNull Optional exchange(@NonNull FiatMoney fiatMoney) { + if (rate != null) { + return Optional.of(Money.mobileCoin(fiatMoney.getAmount().setScale(12, RoundingMode.HALF_EVEN).divide(rate, RoundingMode.HALF_EVEN))); + } else { + return Optional.absent(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchangeRepository.java new file mode 100644 index 0000000000..f7e70e6715 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyExchangeRepository.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.payments.currency; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.payments.Payments; +import org.thoughtcrime.securesms.util.AsynchronousCallback; + +import java.io.IOException; + +public final class CurrencyExchangeRepository { + + private static final String TAG = Log.tag(CurrencyExchangeRepository.class); + + private final Payments payments; + + public CurrencyExchangeRepository(@NonNull Payments payments) { + this.payments = payments; + } + + @AnyThread + public void getCurrencyExchange(@NonNull AsynchronousCallback.WorkerThread callback, boolean refreshIfAble) { + SignalExecutors.BOUNDED.execute(() -> { + try { + callback.onComplete(payments.getCurrencyExchange(refreshIfAble)); + } catch (IOException e) { + Log.w(TAG, e); + callback.onError(e); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyUtil.java new file mode 100644 index 0000000000..c640de5f32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/CurrencyUtil.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.payments.currency; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import java.util.Currency; +import java.util.Locale; + +/** + * Utility class for interacting with currencies. + */ +public final class CurrencyUtil { + + public static @Nullable Currency getCurrencyByCurrencyCode(@NonNull String currencyCode) { + try { + return Currency.getInstance(currencyCode); + } catch (IllegalArgumentException e) { + return null; + } + } + + public static @Nullable Currency getCurrencyByE164(@NonNull String e164) { + PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + Phonenumber.PhoneNumber number; + + try { + number = PhoneNumberUtil.getInstance().parse(e164, ""); + } catch (NumberParseException e) { + return null; + } + + String regionCodeForNumber = phoneNumberUtil.getRegionCodeForNumber(number); + + for (Locale l : Locale.getAvailableLocales()) { + if (l.getCountry().equals(regionCodeForNumber)) { + return getCurrencyByLocale(l); + } + } + + return null; + } + + public static @Nullable Currency getCurrencyByLocale(@Nullable Locale locale) { + if (locale == null) { + return null; + } + + try { + return Currency.getInstance(locale); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/currency/FiatMoney.java b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/FiatMoney.java new file mode 100644 index 0000000000..2d9b6b4860 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/FiatMoney.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.payments.currency; + +import androidx.annotation.NonNull; + +import java.math.BigDecimal; +import java.util.Currency; + +public class FiatMoney { + private final BigDecimal amount; + private final Currency currency; + private final long timestamp; + + public FiatMoney(@NonNull BigDecimal amount, @NonNull Currency currency) { + this(amount, currency, 0); + } + + public FiatMoney(@NonNull BigDecimal amount, @NonNull Currency currency, long timestamp) { + this.amount = amount; + this.currency = currency; + this.timestamp = timestamp; + } + + public @NonNull BigDecimal getAmount() { + return amount; + } + + public @NonNull Currency getCurrency() { + return currency; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/currency/SupportedCurrencies.java b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/SupportedCurrencies.java new file mode 100644 index 0000000000..f8dd9f5668 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/currency/SupportedCurrencies.java @@ -0,0 +1,309 @@ +package org.thoughtcrime.securesms.payments.currency; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +final class SupportedCurrencies { + private SupportedCurrencies() {} + + static final Set ALL = new HashSet<>(Arrays.asList( + "ADP", + "AED", + "AFA", + "AFN", + "ALK", + "ALL", + "AMD", + "ANG", + "AOA", + "AOK", + "AON", + "AOR", + "ARA", + "ARL", + "ARM", + "ARP", + "ARS", + "ATS", + "AUD", + "AWG", + "AZM", + "AZN", + "BAD", + "BAM", + "BAN", + "BBD", + "BDT", + "BEC", + "BEF", + "BEL", + "BGL", + "BGM", + "BGN", + "BGO", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOL", + "BOP", + "BOV", + "BRB", + "BRC", + "BRE", + "BRL", + "BRN", + "BRR", + "BRZ", + "BSD", + "BTN", + "BUK", + "BWP", + "BYB", + "BYN", + "BYR", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLE", + "CLF", + "CLP", + "CNH", + "CNX", + "CNY", + "COP", + "COU", + "CRC", + "CSD", + "CSK", + "CVE", + "CYP", + "CZK", + "DDM", + "DEM", + "DJF", + "DKK", + "DOP", + "DZD", + "ECS", + "ECV", + "EEK", + "EGP", + "EQE", + "ERN", + "ESA", + "ESB", + "ESP", + "ETB", + "EUR", + "FIM", + "FJD", + "FKP", + "FRF", + "GBP", + "GEK", + "GEL", + "GHC", + "GHS", + "GIP", + "GMD", + "GNF", + "GNS", + "GQE", + "GRD", + "GTQ", + "GWE", + "GWP", + "GYD", + "HKD", + "HNL", + "HRD", + "HRK", + "HTG", + "HUF", + "IDR", + "IEP", + "ILP", + "ILR", + "ILS", + "INR", + "IQD", + "ISJ", + "ISK", + "ITL", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KRH", + "KRO", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LSM", + "LTL", + "LTT", + "LUC", + "LUF", + "LUL", + "LVL", + "LVR", + "LYD", + "MAD", + "MAF", + "MCF", + "MDC", + "MDL", + "MGA", + "MGF", + "MKD", + "MKN", + "MLF", + "MMK", + "MNT", + "MOP", + "MRO", + "MRU", + "MTL", + "MTP", + "MUR", + "MVP", + "MVR", + "MWK", + "MXN", + "MXP", + "MXV", + "MYR", + "MZE", + "MZM", + "MZN", + "NAD", + "NGN", + "NIC", + "NIO", + "NLG", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEI", + "PEN", + "PES", + "PGK", + "PHP", + "PKR", + "PLN", + "PLZ", + "PTE", + "PYG", + "QAR", + "RHD", + "ROL", + "RON", + "RSD", + "RUB", + "RUR", + "RWF", + "SAR", + "SBD", + "SCR", + "SDD", + "SDG", + "SDP", + "SEK", + "SGD", + "SHP", + "SIT", + "SKK", + "SLL", + "SOS", + "SRD", + "SRG", + "SSP", + "STD", + "STN", + "SUR", + "SVC", + "SZL", + "THB", + "TJR", + "TJS", + "TMM", + "TMT", + "TND", + "TOP", + "TPE", + "TRL", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UAK", + "UGS", + "UGX", + "USD", + "USN", + "USS", + "UYI", + "UYP", + "UYU", + "UZS", + "VEB", + "VEF", + "VND", + "VNN", + "VUV", + "WST", + "XAF", + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XCD", + "XDR", + "XEU", + "XFO", + "XFU", + "XOF", + "XPD", + "XPF", + "XPT", + "XRE", + "XSU", + "XTS", + "XUA", + "XXX", + "YDD", + "YER", + "YUD", + "YUM", + "YUN", + "YUR", + "ZAL", + "ZAR", + "ZMK", + "ZMW", + "ZRN", + "ZRZ", + "ZWD", + "ZWL", + "ZWR")); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletFragment.java new file mode 100644 index 0000000000..78b761fd34 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletFragment.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.payments.deactivate; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.MoneyView; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +public class DeactivateWalletFragment extends Fragment { + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.deactivate_wallet_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.deactivate_wallet_fragment_toolbar); + MoneyView balance = view.findViewById(R.id.deactivate_wallet_fragment_balance); + View transferRemainingBalance = view.findViewById(R.id.deactivate_wallet_fragment_transfer); + View deactivateWithoutTransfer = view.findViewById(R.id.deactivate_wallet_fragment_deactivate); + LearnMoreTextView notice = view.findViewById(R.id.deactivate_wallet_fragment_notice); + + notice.setLearnMoreVisible(true); + notice.setLink(getString(R.string.DeactivateWalletFragment__learn_more__we_recommend_transferring_your_funds)); + + DeactivateWalletViewModel viewModel = ViewModelProviders.of(this).get(DeactivateWalletViewModel.class); + + viewModel.getBalance().observe(getViewLifecycleOwner(), balance::setMoney); + viewModel.getDeactivationResults().observe(getViewLifecycleOwner(), r -> { + if (r == DeactivateWalletViewModel.Result.SUCCESS) { + Navigation.findNavController(requireView()).popBackStack(); + } else { + Toast.makeText(requireContext(), R.string.DeactivateWalletFragment__error_deactivating_wallet, Toast.LENGTH_SHORT).show(); + } + }); + + transferRemainingBalance.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(R.id.action_deactivateWallet_to_paymentsTransfer)); + + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack()); + + //noinspection CodeBlock2Expr + deactivateWithoutTransfer.setOnClickListener(v -> { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.DeactivateWalletFragment__deactivate_without_transferring_question) + .setMessage(R.string.DeactivateWalletFragment__your_balance_will_remain) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary), + getString(R.string.DeactivateWalletFragment__deactivate)), + (dialog, which) -> { + viewModel.deactivateWallet(); + dialog.dismiss(); + }) + .show(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletViewModel.java new file mode 100644 index 0000000000..457fd2c38b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/deactivate/DeactivateWalletViewModel.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.payments.deactivate; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.Balance; +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeRepository; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.whispersystems.signalservice.api.payments.Money; + +public class DeactivateWalletViewModel extends ViewModel { + + private final LiveData balance; + private final PaymentsHomeRepository paymentsHomeRepository = new PaymentsHomeRepository(); + private final SingleLiveEvent deactivatePaymentResults = new SingleLiveEvent<>(); + + public DeactivateWalletViewModel() { + balance = Transformations.map(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getFullAmount); + } + + void deactivateWallet() { + paymentsHomeRepository.deactivatePayments(isDisabled -> deactivatePaymentResults.postValue(isDisabled ? Result.SUCCESS : Result.FAILED)); + } + + LiveData getDeactivationResults() { + return deactivatePaymentResults; + } + + LiveData getBalance() { + return balance; + } + + enum Result { + SUCCESS, + FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/history/TransactionReconstruction.java b/app/src/main/java/org/thoughtcrime/securesms/payments/history/TransactionReconstruction.java new file mode 100644 index 0000000000..2b10cd0c08 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/history/TransactionReconstruction.java @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.payments.history; + +import androidx.annotation.NonNull; + +import com.annimon.stream.ComparatorCompat; + +import org.thoughtcrime.securesms.payments.Direction; +import org.whispersystems.signalservice.api.payments.Money; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public final class TransactionReconstruction { + private final List received; + @NonNull private final List allTransactions; + private final List sent; + + /** + * Given some unaccounted for TXO values within the same block separated into {@param spent} and {@param unspent}, estimates a sensible grouping for display + * to the user. + */ + public static TransactionReconstruction estimateBlockLevelActivity(@NonNull List spent, + @NonNull List unspent) + { + Money.MobileCoin totalSpent = Money.MobileCoin.sum(spent); + + List unspentDescending = new ArrayList<>(unspent); + Collections.sort(unspentDescending, Money.MobileCoin.DESCENDING); + + List received = new ArrayList<>(unspent.size()); + + for (Money.MobileCoin unspentValue : unspentDescending) { + if (unspentValue.lessThan(totalSpent)) { + totalSpent = totalSpent.subtract(unspentValue).requireMobileCoin(); + } else if (unspentValue.isPositive()) { + received.add(new Transaction(unspentValue, Direction.RECEIVED)); + } + } + + List sent = totalSpent.isPositive() ? Collections.singletonList(new Transaction(totalSpent, Direction.SENT)) + : Collections.emptyList(); + + Collections.sort(sent, Transaction.ORDER); + Collections.sort(received, Transaction.ORDER); + + List allTransactions = new ArrayList<>(sent.size() + received.size()); + allTransactions.addAll(sent); + allTransactions.addAll(received); + Collections.sort(allTransactions, Transaction.ORDER); + + return new TransactionReconstruction(sent, received, allTransactions); + } + + private TransactionReconstruction(@NonNull List sent, + @NonNull List received, + @NonNull List allTransactions) + { + this.sent = sent; + this.received = received; + this.allTransactions = allTransactions; + } + + public @NonNull List received() { + return new ArrayList<>(received); + } + + public @NonNull List sent() { + return new ArrayList<>(sent); + } + + public @NonNull List getAllTransactions() { + return new ArrayList<>(allTransactions); + } + + public static final class Transaction { + private static final Comparator RECEIVED_FIRST = (a, b) -> b.getDirection().compareTo(a.direction); + private static final Comparator ABSOLUTE_SIZE = (a, b) -> Money.MobileCoin.ASCENDING.compare(a.value, b.value); + + /** + * Received first so that if going through a list and keeping a running balance, the order of transactions will not cause that balance to go into negative. + *

+ * Then smaller first is just to show more important ones higher on a reversed list. + */ + public static final Comparator ORDER = ComparatorCompat.chain(RECEIVED_FIRST) + .thenComparing(ABSOLUTE_SIZE); + + private final Money.MobileCoin value; + private final Direction direction; + + private Transaction(@NonNull Money.MobileCoin value, + @NonNull Direction direction) + { + this.value = value; + this.direction = direction; + } + + public @NonNull Money.MobileCoin getValue() { + return value; + } + + public @NonNull Direction getDirection() { + return direction; + } + + public @NonNull Money.MobileCoin getValueWithDirection() { + return direction == Direction.SENT ? value.negate() : value; + } + + @Override + public @NonNull String toString() { + return "Transaction{" + + value + + ", " + direction + + '}'; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/LoadState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/LoadState.java new file mode 100644 index 0000000000..391809221c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/LoadState.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.payments.preferences; + +public enum LoadState { + INITIAL, + LOADING, + LOADED, + ERROR +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentCategory.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentCategory.java new file mode 100644 index 0000000000..534ec49092 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentCategory.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public enum PaymentCategory { + ALL("all"), + SENT("sent"), + RECEIVED("received"); + + private final String code; + + PaymentCategory(@NonNull String code) { + this.code = code; + } + + @NonNull String getCode() { + return code; + } + + static @NonNull PaymentCategory forCode(@Nullable String code) { + for (PaymentCategory type : values()) { + if (type.code.equals(code)) { + return type; + } + } + + return ALL; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java new file mode 100644 index 0000000000..af3ef8361c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ContactFilterToolbar; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog; +import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.libsignal.util.guava.Optional; + + +public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.ScrollCallback { + + private ContactFilterToolbar toolbar; + private ContactSelectionListFragment contactsFragment; + + public PaymentRecipientSelectionFragment() { + super(R.layout.payment_recipient_selection_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + toolbar = view.findViewById(R.id.payment_recipient_selection_fragment_toolbar); + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + Bundle arguments = new Bundle(); + arguments.putBoolean(ContactSelectionListFragment.REFRESHABLE, false); + arguments.putInt(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_PUSH | DisplayMode.FLAG_HIDE_NEW); + arguments.putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false); + + Fragment child = getChildFragmentManager().findFragmentById(R.id.contact_selection_list_fragment_holder); + if (child == null) { + FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + contactsFragment = new ContactSelectionListFragment(); + contactsFragment.setArguments(arguments); + transaction.add(R.id.contact_selection_list_fragment_holder, contactsFragment); + transaction.commit(); + } else { + contactsFragment = (ContactSelectionListFragment) child; + } + + initializeSearch(); + } + + private void initializeSearch() { + toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter)); + } + + @Override + public boolean onBeforeContactSelected(@NonNull Optional recipientId, @Nullable String number) { + if (recipientId.isPresent()) { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), + () -> Recipient.resolved(recipientId.get()), + this::createPaymentOrShowWarningDialog); + return false; + } + return false; + } + + @Override + public void onContactDeselected(@NonNull Optional recipientId, @Nullable String number) { } + + @Override + public void onBeginScroll() { + hideKeyboard(); + } + + private void hideKeyboard() { + ViewUtil.hideKeyboard(requireContext(), toolbar); + toolbar.clearFocus(); + } + + private void createPaymentOrShowWarningDialog(@NonNull Recipient recipient) { + if (recipient.hasProfileKeyCredential()) { + createPayment(recipient.getId()); + } else { + showWarningDialog(recipient.getId()); + } + } + + private void createPayment(@NonNull RecipientId recipientId) { + hideKeyboard(); + Navigation.findNavController(requireView()).navigate(PaymentRecipientSelectionFragmentDirections.actionPaymentRecipientSelectionToCreatePayment(new PayeeParcelable(recipientId))); + } + + private void showWarningDialog(@NonNull RecipientId recipientId) { + CanNotSendPaymentDialog.show(requireContext(), + () -> openConversation(recipientId)); + } + + private void openConversation(@NonNull RecipientId recipientId) { + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), + () -> DatabaseFactory.getThreadDatabase(requireContext()).getThreadIdIfExistsFor(recipientId), + threadId -> startActivity(ConversationIntents.createBuilder(requireContext(), recipientId, threadId).build())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentStateEvent.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentStateEvent.java new file mode 100644 index 0000000000..e87601e5fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentStateEvent.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.payments.preferences; + +enum PaymentStateEvent { + NO_BALANCE, + DEACTIVATE_WITHOUT_BALANCE, + DEACTIVATE_WITH_BALANCE, + DEACTIVATED, + ACTIVATED +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentType.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentType.java new file mode 100644 index 0000000000..37920450e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentType.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; + +public enum PaymentType { + REQUEST("request"), + PAYMENT("payment"); + + private final String code; + + PaymentType(@NonNull String code) { + this.code = code; + } + + @NonNull String getCode() { + return code; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsActivity.java new file mode 100644 index 0000000000..e092d38556 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsActivity.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class PaymentsActivity extends PassphraseRequiredActivity { + + public static final String EXTRA_PAYMENTS_STARTING_ACTION = "payments_starting_action"; + public static final String EXTRA_STARTING_ARGUMENTS = "payments_starting_arguments"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState, boolean ready) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.payments_activity); + + NavController controller = Navigation.findNavController(this, R.id.nav_host_fragment); + controller.setGraph(R.navigation.payments_preferences); + + int startingAction = getIntent().getIntExtra(EXTRA_PAYMENTS_STARTING_ACTION, R.id.paymentsHome); + if (startingAction != R.id.paymentsHome) { + controller.navigate(startingAction, getIntent().getBundleExtra(EXTRA_STARTING_ARGUMENTS)); + } + } + + @Override + protected void onResume() { + super.onResume(); + + dynamicTheme.onResume(this); + + ApplicationDependencies.getJobManager() + .add(PaymentLedgerUpdateJob.updateLedger()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsAllActivityFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsAllActivityFragment.java new file mode 100644 index 0000000000..9e02e965fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsAllActivityFragment.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.navigation.Navigation; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +public class PaymentsAllActivityFragment extends LoggingFragment { + + public PaymentsAllActivityFragment() { + super(R.layout.payments_activity_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + ViewPager viewPager = view.findViewById(R.id.payments_all_activity_fragment_view_pager); + TabLayout tabLayout = view.findViewById(R.id.payments_all_activity_fragment_tabs); + Toolbar toolbar = view.findViewById(R.id.payments_all_activity_fragment_toolbar); + + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + viewPager.setAdapter(new Adapter(getChildFragmentManager())); + tabLayout.setupWithViewPager(viewPager); + } + + private final class Adapter extends FragmentStatePagerAdapter { + + Adapter(@NonNull FragmentManager fm) { + super(fm, FragmentStatePagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + } + + @Override + public @NonNull CharSequence getPageTitle(int position) { + switch (position) { + case 0 : return getString(R.string.PaymentsAllActivityFragment__all); + case 1 : return getString(R.string.PaymentsAllActivityFragment__sent); + case 2 : return getString(R.string.PaymentsAllActivityFragment__received); + default: throw new IllegalStateException("Unknown position: " + position); + } + } + + @Override + public @NonNull Fragment getItem(int position) { + switch (position) { + case 0 : return PaymentsPagerItemFragment.getFragmentForAllPayments(); + case 1 : return PaymentsPagerItemFragment.getFragmentForSentPayments(); + case 2 : return PaymentsPagerItemFragment.getFragmentForReceivedPayments(); + default: throw new IllegalStateException("Unknown position: " + position); + } + } + + @Override + public int getCount() { + return 3; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeAdapter.java new file mode 100644 index 0000000000..17ef9aac58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeAdapter.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.SettingHeader; +import org.thoughtcrime.securesms.payments.preferences.model.InProgress; +import org.thoughtcrime.securesms.payments.preferences.model.InfoCard; +import org.thoughtcrime.securesms.payments.preferences.model.IntroducingPayments; +import org.thoughtcrime.securesms.payments.preferences.model.NoRecentActivity; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; +import org.thoughtcrime.securesms.payments.preferences.model.SeeAll; +import org.thoughtcrime.securesms.payments.preferences.viewholder.InProgressViewHolder; +import org.thoughtcrime.securesms.payments.preferences.viewholder.InfoCardViewHolder; +import org.thoughtcrime.securesms.payments.preferences.viewholder.IntroducingPaymentViewHolder; +import org.thoughtcrime.securesms.payments.preferences.viewholder.NoRecentActivityViewHolder; +import org.thoughtcrime.securesms.payments.preferences.viewholder.PaymentItemViewHolder; +import org.thoughtcrime.securesms.payments.preferences.viewholder.SeeAllViewHolder; +import org.thoughtcrime.securesms.util.MappingAdapter; + +public class PaymentsHomeAdapter extends MappingAdapter { + + public PaymentsHomeAdapter(@NonNull Callbacks callbacks) { + registerFactory(IntroducingPayments.class, p -> new IntroducingPaymentViewHolder(p, callbacks), R.layout.payments_home_introducing_payments_item); + registerFactory(NoRecentActivity.class, NoRecentActivityViewHolder::new, R.layout.payments_home_no_recent_activity_item); + registerFactory(InProgress.class, InProgressViewHolder::new, R.layout.payments_home_in_progress); + registerFactory(PaymentItem.class, p -> new PaymentItemViewHolder(p, callbacks), R.layout.payments_home_payment_item); + registerFactory(SettingHeader.Item.class, SettingHeader.ViewHolder::new, R.layout.base_settings_header_item); + registerFactory(SeeAll.class, p -> new SeeAllViewHolder(p, callbacks), R.layout.payments_home_see_all_item); + registerFactory(InfoCard.class, p -> new InfoCardViewHolder(p, callbacks), R.layout.payment_info_card); + } + + public interface Callbacks { + default void onActivatePayments() {} + default void onRestorePaymentsAccount() {} + default void onSeeAll(@NonNull PaymentType paymentType) {} + default void onPaymentItem(@NonNull PaymentItem model) {} + default void onInfoCardDismissed() {} + default void onViewRecoveryPhrase() {} + default void onUpdatePin() {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java new file mode 100644 index 0000000000..f3b2dec821 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java @@ -0,0 +1,306 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.app.AlertDialog; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.airbnb.lottie.LottieAnimationView; +import com.annimon.stream.Stream; +import com.google.android.material.snackbar.Snackbar; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.PaymentPreferencesDirections; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.help.HelpFragment; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.payments.FiatMoneyUtil; +import org.thoughtcrime.securesms.payments.MoneyView; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SpanUtil; + +public class PaymentsHomeFragment extends LoggingFragment { + + private static final String TAG = Log.tag(PaymentsHomeFragment.class); + + private PaymentsHomeViewModel viewModel; + + private final OnBackPressed onBackPressed = new OnBackPressed(); + + public PaymentsHomeFragment() { + super(R.layout.payments_home_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.payments_home_fragment_toolbar); + RecyclerView recycler = view.findViewById(R.id.payments_home_fragment_recycler); + View header = view.findViewById(R.id.payments_home_fragment_header); + MoneyView balance = view.findViewById(R.id.payments_home_fragment_header_balance); + TextView exchange = view.findViewById(R.id.payments_home_fragment_header_exchange); + View addMoney = view.findViewById(R.id.button_start_frame); + View sendMoney = view.findViewById(R.id.button_end_frame); + View refresh = view.findViewById(R.id.payments_home_fragment_header_refresh); + LottieAnimationView refreshAnimation = view.findViewById(R.id.payments_home_fragment_header_refresh_animation); + + toolbar.setNavigationOnClickListener(v -> { + viewModel.markAllPaymentsSeen(); + requireActivity().finish(); + }); + + toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); + + addMoney.setOnClickListener(v -> { + if (SignalStore.paymentsValues().getPaymentsAvailability().isSendAllowed()) { + Navigation.findNavController(v).navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsAddMoney()); + } else { + showPaymentsDisabledDialog(); + } + }); + sendMoney.setOnClickListener(v -> { + if (SignalStore.paymentsValues().getPaymentsAvailability().isSendAllowed()) { + Navigation.findNavController(v).navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentRecipientSelectionFragment()); + } else { + showPaymentsDisabledDialog(); + } + }); + + PaymentsHomeAdapter adapter = new PaymentsHomeAdapter(new HomeCallbacks()); + recycler.setAdapter(adapter); + + viewModel = ViewModelProviders.of(this, new PaymentsHomeViewModel.Factory()).get(PaymentsHomeViewModel.class); + + viewModel.getList().observe(getViewLifecycleOwner(), list -> { + // TODO [alex] -- this is a bit of a hack + boolean hadPaymentItems = Stream.of(adapter.getCurrentList()).anyMatch(model -> model instanceof PaymentItem); + + if (!hadPaymentItems) { + adapter.submitList(list, () -> recycler.scrollToPosition(0)); + } else { + adapter.submitList(list); + } + }); + + viewModel.getPaymentsEnabled().observe(getViewLifecycleOwner(), enabled -> { + if (enabled) { + toolbar.inflateMenu(R.menu.payments_home_fragment_menu); + } else { + toolbar.getMenu().clear(); + } + header.setVisibility(enabled ? View.VISIBLE : View.GONE); + }); + viewModel.getBalance().observe(getViewLifecycleOwner(), balance::setMoney); + viewModel.getExchange().observe(getViewLifecycleOwner(), amount -> { + if (amount != null) { + exchange.setText(FiatMoneyUtil.format(getResources(), amount)); + } else { + exchange.setText(R.string.PaymentsHomeFragment__unknown_amount); + } + }); + + refresh.setOnClickListener(v -> viewModel.refreshExchangeRates(true)); + exchange.setOnClickListener(v -> viewModel.refreshExchangeRates(true)); + + viewModel.getExchangeLoadState().observe(getViewLifecycleOwner(), loadState -> { + switch (loadState) { + case INITIAL: + case LOADED: + refresh.setVisibility(View.VISIBLE); + refreshAnimation.cancelAnimation(); + refreshAnimation.setVisibility(View.GONE); + break; + case LOADING: + refresh.setVisibility(View.INVISIBLE); + refreshAnimation.playAnimation(); + refreshAnimation.setVisibility(View.VISIBLE); + break; + case ERROR: + refresh.setVisibility(View.VISIBLE); + refreshAnimation.cancelAnimation(); + refreshAnimation.setVisibility(View.GONE); + exchange.setText(R.string.PaymentsHomeFragment__currency_conversion_not_available); + Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__cant_display_currency_conversion, Toast.LENGTH_SHORT).show(); + break; + } + }); + + viewModel.getPaymentStateEvents().observe(getViewLifecycleOwner(), paymentStateEvent -> { + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + + builder.setTitle(R.string.PaymentsHomeFragment__deactivate_payments_question); + builder.setMessage(R.string.PaymentsHomeFragment__you_will_not_be_able_to_send); + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()); + + switch (paymentStateEvent) { + case NO_BALANCE: + Toast.makeText(requireContext(), R.string.PaymentsHomeFragment__balance_is_not_currently_available, Toast.LENGTH_SHORT).show(); + return; + case DEACTIVATED: + Snackbar.make(requireView(), R.string.PaymentsHomeFragment__payments_deactivated, Snackbar.LENGTH_SHORT) + .setTextColor(Color.WHITE) + .show(); + return; + case DEACTIVATE_WITHOUT_BALANCE: + builder.setPositiveButton(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary), + getString(R.string.PaymentsHomeFragment__deactivate)), + (dialog, which) -> { + viewModel.confirmDeactivatePayments(); + dialog.dismiss(); + }); + break; + case DEACTIVATE_WITH_BALANCE: + builder.setPositiveButton(getString(R.string.PaymentsHomeFragment__continue), (dialog, which) -> { + dialog.dismiss(); + NavHostFragment.findNavController(this).navigate(R.id.deactivateWallet); + }); + break; + case ACTIVATED: + return; + default: + throw new IllegalStateException("Unsupported event type: " + paymentStateEvent.name()); + } + + builder.show(); + }); + + viewModel.getErrorEnablingPayments().observe(getViewLifecycleOwner(), errorEnabling -> { + switch (errorEnabling) { + case REGION: + Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__payments_is_not_available_in_your_region, Toast.LENGTH_LONG).show(); + break; + case NETWORK: + Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__could_not_enable_payments, Toast.LENGTH_SHORT).show(); + break; + default: + throw new AssertionError(); + } + }); + + requireActivity().getOnBackPressedDispatcher().addCallback(onBackPressed); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.checkPaymentActivationState(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + onBackPressed.setEnabled(false); + } + + private boolean onMenuItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.payments_home_fragment_menu_transfer_to_exchange) { + NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_paymentsTransfer); + return true; + } else if (item.getItemId() == R.id.payments_home_fragment_menu_set_currency) { + NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_setCurrency); + return true; + } else if (item.getItemId() == R.id.payments_home_fragment_menu_deactivate_wallet) { + viewModel.deactivatePayments(); + return true; + } else if (item.getItemId() == R.id.payments_home_fragment_menu_view_recovery_phrase) { + NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_paymentsBackup); + return true; + } else if (item.getItemId() == R.id.payments_home_fragment_menu_help) { + Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class); + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_HELP_FRAGMENT, true); + intent.putExtra(HelpFragment.START_CATEGORY_INDEX, HelpFragment.PAYMENT_INDEX); + + startActivity(intent); + return true; + } + + return false; + } + + private void showPaymentsDisabledDialog() { + new AlertDialog.Builder(requireActivity()) + .setMessage(R.string.PaymentsHomeFragment__payments_not_available) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + class HomeCallbacks implements PaymentsHomeAdapter.Callbacks { + @Override + public void onActivatePayments() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.PaymentsHomeFragment__you_can_use_signal_to_send) + .setPositiveButton(R.string.PaymentsHomeFragment__activate, (dialog, which) -> { + viewModel.activatePayments(); + dialog.dismiss(); + }) + .setNegativeButton(R.string.PaymentsHomeFragment__view_mobile_coin_terms, (dialog, which) -> { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PaymentsHomeFragment__mobile_coin_terms_url)); + }) + .setNeutralButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + } + + @Override + public void onRestorePaymentsAccount() { + NavHostFragment.findNavController(PaymentsHomeFragment.this) + .navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsBackup().setIsRestore(true)); + } + + @Override + public void onSeeAll(@NonNull PaymentType paymentType) { + NavHostFragment.findNavController(PaymentsHomeFragment.this) + .navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsAllActivity(paymentType)); + } + + @Override + public void onPaymentItem(@NonNull PaymentItem model) { + NavHostFragment.findNavController(PaymentsHomeFragment.this) + .navigate(PaymentPreferencesDirections.actionDirectlyToPaymentDetails(model.getPaymentDetailsParcelable())); + } + + @Override + public void onInfoCardDismissed() { + viewModel.onInfoCardDismissed(); + } + + @Override + public void onUpdatePin() { + startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN); + } + + @Override + public void onViewRecoveryPhrase() { + NavHostFragment.findNavController(PaymentsHomeFragment.this).navigate(R.id.action_paymentsHome_to_paymentsBackup); + } + } + + private class OnBackPressed extends OnBackPressedCallback { + + public OnBackPressed() { + super(true); + } + + @Override + public void handleOnBackPressed() { + viewModel.markAllPaymentsSeen(); + requireActivity().finish(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java new file mode 100644 index 0000000000..aef89b5c44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeRepository.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.payments.preferences; + +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.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.ProfileUtil; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException; + +import java.io.IOException; + +public class PaymentsHomeRepository { + + private static final String TAG = Log.tag(PaymentsHomeRepository.class); + + public void activatePayments(@NonNull AsynchronousCallback.WorkerThread callback) { + SignalExecutors.BOUNDED.execute(() -> { + SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(true); + try { + ProfileUtil.uploadProfile(ApplicationDependencies.getApplication()); + ApplicationDependencies.getJobManager().add(PaymentLedgerUpdateJob.updateLedger()); + callback.onComplete(null); + } catch (PaymentsRegionException e) { + SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false); + Log.w(TAG, "Problem enabling payments in region", e); + callback.onError(Error.RegionError); + } catch (NonSuccessfulResponseCodeException e) { + SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false); + Log.w(TAG, "Problem enabling payments", e); + callback.onError(Error.NetworkError); + } catch (IOException e) { + SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false); + Log.w(TAG, "Problem enabling payments", e); + tryToRestoreProfile(); + callback.onError(Error.NetworkError); + } + }); + } + + private void tryToRestoreProfile() { + try { + ProfileUtil.uploadProfile(ApplicationDependencies.getApplication()); + Log.i(TAG, "Restored profile"); + } catch (IOException e) { + Log.w(TAG, "Problem uploading profile", e); + } + } + + public void deactivatePayments(@NonNull Consumer consumer) { + SignalExecutors.BOUNDED.execute(() -> { + SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + consumer.accept(!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()); + }); + } + + public enum Error { + NetworkError, + RegionError + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeState.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeState.java new file mode 100644 index 0000000000..d2454a1dda --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeState.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.payments.currency.CurrencyExchange; +import org.thoughtcrime.securesms.payments.currency.FiatMoney; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; + +import java.util.Collections; +import java.util.List; + +public class PaymentsHomeState { + private final PaymentsState paymentsState; + private final FiatMoney exchangeAmount; + private final List requests; + private final List payments; + private final int totalPayments; + private final CurrencyExchange currencyExchange; + private final LoadState exchangeRateLoadState; + private final boolean recentPaymentsLoaded; + + public PaymentsHomeState(@NonNull PaymentsState paymentsState) { + this(paymentsState, + null, + Collections.emptyList(), + Collections.emptyList(), + 0, + new CurrencyExchange(Collections.emptyMap(), 0), + LoadState.INITIAL, + false); + } + + public PaymentsHomeState(@NonNull PaymentsState paymentsState, + @Nullable FiatMoney exchangeAmount, + @NonNull List requests, + @NonNull List payments, + int totalPayments, + @NonNull CurrencyExchange currencyExchange, + @NonNull LoadState exchangeRateLoadState, + boolean recentPaymentsLoaded) + { + this.paymentsState = paymentsState; + this.exchangeAmount = exchangeAmount; + this.requests = requests; + this.payments = payments; + this.totalPayments = totalPayments; + this.currencyExchange = currencyExchange; + this.exchangeRateLoadState = exchangeRateLoadState; + this.recentPaymentsLoaded = recentPaymentsLoaded; + } + + public @NonNull PaymentsState getPaymentsState() { + return paymentsState; + } + + public @Nullable FiatMoney getExchangeAmount() { + return exchangeAmount; + } + + public @NonNull List getRequests() { + return requests; + } + + public @NonNull List getPayments() { + return payments; + } + + public int getTotalPayments() { + return totalPayments; + } + + public @NonNull CurrencyExchange getCurrencyExchange() { + return currencyExchange; + } + + public @NonNull LoadState getExchangeRateLoadState() { + return exchangeRateLoadState; + } + + public boolean isRecentPaymentsLoaded() { + return recentPaymentsLoaded; + } + + public @NonNull PaymentsHomeState updatePaymentsEnabled(@NonNull PaymentsState paymentsEnabled) { + return new PaymentsHomeState(paymentsEnabled, + this.exchangeAmount, + this.requests, + this.payments, + this.totalPayments, + this.currencyExchange, + this.exchangeRateLoadState, + this.recentPaymentsLoaded); + } + + public @NonNull PaymentsHomeState updatePayments(@NonNull List payments, int totalPayments) { + return new PaymentsHomeState(this.paymentsState, + this.exchangeAmount, + this.requests, + payments, + totalPayments, + this.currencyExchange, + this.exchangeRateLoadState, + true); + } + + public @NonNull PaymentsHomeState updateCurrencyAmount(@Nullable FiatMoney exchangeAmount) { + return new PaymentsHomeState(this.paymentsState, + exchangeAmount, + this.requests, + this.payments, + this.totalPayments, + this.currencyExchange, + this.exchangeRateLoadState, + this.recentPaymentsLoaded); + } + + public @NonNull PaymentsHomeState updateExchangeRateLoadState(@NonNull LoadState exchangeRateLoadState) { + return new PaymentsHomeState(this.paymentsState, + this.exchangeAmount, + this.requests, + this.payments, + this.totalPayments, + this.currencyExchange, + exchangeRateLoadState, + this.recentPaymentsLoaded); + } + + public @NonNull PaymentsHomeState updateCurrencyExchange(@NonNull CurrencyExchange currencyExchange, @NonNull LoadState exchangeRateLoadState) { + return new PaymentsHomeState(this.paymentsState, + this.exchangeAmount, + this.requests, + this.payments, + this.totalPayments, + currencyExchange, + exchangeRateLoadState, + this.recentPaymentsLoaded); + } + + public enum PaymentsState { + NOT_ACTIVATED, + ACTIVATING, + ACTIVATED, + DEACTIVATING, + ACTIVATE_NOT_ALLOWED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeViewModel.java new file mode 100644 index 0000000000..0df85f2ba9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeViewModel.java @@ -0,0 +1,279 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.SettingHeader; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.PaymentsAvailability; +import org.thoughtcrime.securesms.keyvalue.PaymentsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.Balance; +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository; +import org.thoughtcrime.securesms.payments.currency.CurrencyExchange; +import org.thoughtcrime.securesms.payments.currency.CurrencyExchangeRepository; +import org.thoughtcrime.securesms.payments.currency.FiatMoney; +import org.thoughtcrime.securesms.payments.preferences.model.InProgress; +import org.thoughtcrime.securesms.payments.preferences.model.InfoCard; +import org.thoughtcrime.securesms.payments.preferences.model.IntroducingPayments; +import org.thoughtcrime.securesms.payments.preferences.model.NoRecentActivity; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; +import org.thoughtcrime.securesms.payments.preferences.model.SeeAll; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.MappingModelList; +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.List; + +public class PaymentsHomeViewModel extends ViewModel { + + private static final String TAG = Log.tag(PaymentsHomeViewModel.class); + + private static final int MAX_PAYMENT_ITEMS = 4; + + private final Store store; + private final LiveData list; + private final LiveData paymentsEnabled; + private final LiveData balance; + private final LiveData exchange; + private final SingleLiveEvent paymentStateEvents; + private final SingleLiveEvent errorEnablingPayments; + + private final PaymentsHomeRepository paymentsHomeRepository; + private final CurrencyExchangeRepository currencyExchangeRepository; + private final UnreadPaymentsRepository unreadPaymentsRepository; + private final LiveData exchangeLoadState; + + PaymentsHomeViewModel(@NonNull PaymentsHomeRepository paymentsHomeRepository, + @NonNull PaymentsRepository paymentsRepository, + @NonNull CurrencyExchangeRepository currencyExchangeRepository) + { + this.paymentsHomeRepository = paymentsHomeRepository; + this.currencyExchangeRepository = currencyExchangeRepository; + this.unreadPaymentsRepository = new UnreadPaymentsRepository(); + this.store = new Store<>(new PaymentsHomeState(getPaymentsState())); + this.balance = LiveDataUtil.mapDistinct(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getFullAmount); + this.list = Transformations.map(store.getStateLiveData(), this::createList); + this.paymentsEnabled = LiveDataUtil.mapDistinct(store.getStateLiveData(), state -> state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATED); + this.exchange = LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getExchangeAmount); + this.exchangeLoadState = LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getExchangeRateLoadState); + this.paymentStateEvents = new SingleLiveEvent<>(); + this.errorEnablingPayments = new SingleLiveEvent<>(); + + this.store.update(paymentsRepository.getRecentPayments(), this::updateRecentPayments); + + LiveData liveExchangeRate = LiveDataUtil.combineLatest(SignalStore.paymentsValues().liveCurrentCurrency(), + LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getCurrencyExchange), + (currency, exchange) -> exchange.getExchangeRate(currency)); + + LiveData> liveExchangeAmount = LiveDataUtil.combineLatest(this.balance, + liveExchangeRate, + (balance, exchangeRate) -> exchangeRate.exchange(balance)); + this.store.update(liveExchangeAmount, (amount, state) -> state.updateCurrencyAmount(amount.orNull())); + + refreshExchangeRates(true); + } + + private static PaymentsHomeState.PaymentsState getPaymentsState() { + PaymentsValues paymentsValues = SignalStore.paymentsValues(); + + PaymentsAvailability paymentsAvailability = paymentsValues.getPaymentsAvailability(); + + if (paymentsAvailability.canRegister()) { + return PaymentsHomeState.PaymentsState.NOT_ACTIVATED; + } else if (paymentsAvailability.isEnabled()) { + return PaymentsHomeState.PaymentsState.ACTIVATED; + } else { + return PaymentsHomeState.PaymentsState.ACTIVATE_NOT_ALLOWED; + } + } + + @NonNull LiveData getPaymentStateEvents() { + return paymentStateEvents; + } + + @NonNull LiveData getErrorEnablingPayments() { + return errorEnablingPayments; + } + + @NonNull LiveData getList() { + return list; + } + + @NonNull LiveData getPaymentsEnabled() { + return paymentsEnabled; + } + + @NonNull LiveData getBalance() { + return balance; + } + + @NonNull LiveData getExchange() { + return exchange; + } + + @NonNull LiveData getExchangeLoadState() { + return exchangeLoadState; + } + + void markAllPaymentsSeen() { + unreadPaymentsRepository.markAllPaymentsSeen(); + } + + void checkPaymentActivationState() { + PaymentsHomeState.PaymentsState storedState = store.getState().getPaymentsState(); + boolean paymentsEnabled = SignalStore.paymentsValues().mobileCoinPaymentsEnabled(); + + if (storedState.equals(PaymentsHomeState.PaymentsState.ACTIVATED) && !paymentsEnabled) { + store.update(s -> s.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.NOT_ACTIVATED)); + paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATED); + } else if (storedState.equals(PaymentsHomeState.PaymentsState.NOT_ACTIVATED) && paymentsEnabled) { + store.update(s -> s.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATED)); + paymentStateEvents.setValue(PaymentStateEvent.ACTIVATED); + } + } + + private @NonNull MappingModelList createList(@NonNull PaymentsHomeState state) { + MappingModelList list = new MappingModelList(); + + if (state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATED) { + if (state.getTotalPayments() > 0) { + list.add(new SettingHeader.Item(R.string.PaymentsHomeFragment__recent_activity)); + list.addAll(state.getPayments()); + if (state.getTotalPayments() > MAX_PAYMENT_ITEMS) { + list.add(new SeeAll(PaymentType.PAYMENT)); + } + } + + if (!state.isRecentPaymentsLoaded()) { + list.add(new InProgress()); + } else if (state.getRequests().isEmpty() && + state.getPayments().isEmpty() && + state.isRecentPaymentsLoaded()) + { + list.add(new NoRecentActivity()); + } + } else if (state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATE_NOT_ALLOWED) { + Log.w(TAG, "Payments remotely disabled or not in region"); + } else { + list.add(new IntroducingPayments(state.getPaymentsState())); + } + + list.addAll(InfoCard.getInfoCards()); + + return list; + } + + private @NonNull PaymentsHomeState updateRecentPayments(@NonNull List payments, + @NonNull PaymentsHomeState state) + { + List paymentItems = Stream.of(payments) + .limit(MAX_PAYMENT_ITEMS) + .map(PaymentItem::fromPayment) + .toList(); + + return state.updatePayments(paymentItems, payments.size()); + } + + public void onInfoCardDismissed() { + store.update(s -> s); + } + + public void activatePayments() { + if (store.getState().getPaymentsState() != PaymentsHomeState.PaymentsState.NOT_ACTIVATED) { + return; + } + + store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATING)); + + paymentsHomeRepository.activatePayments(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATED)); + } + + @Override + public void onError(@Nullable PaymentsHomeRepository.Error error) { + store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.NOT_ACTIVATED)); + if (error == PaymentsHomeRepository.Error.NetworkError) { + errorEnablingPayments.postValue(ErrorEnabling.NETWORK); + } else if (error == PaymentsHomeRepository.Error.RegionError) { + errorEnablingPayments.postValue(ErrorEnabling.REGION); + } else { + throw new AssertionError(); + } + } + }); + } + + public void deactivatePayments() { + Money money = balance.getValue(); + if (money == null) { + paymentStateEvents.setValue(PaymentStateEvent.NO_BALANCE); + } else if (money.isPositive()) { + paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATE_WITH_BALANCE); + } else { + paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATE_WITHOUT_BALANCE); + } + } + + public void confirmDeactivatePayments() { + if (store.getState().getPaymentsState() != PaymentsHomeState.PaymentsState.ACTIVATED) { + return; + } + + store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.DEACTIVATING)); + + paymentsHomeRepository.deactivatePayments(result -> { + store.update(state -> state.updatePaymentsEnabled(result ? PaymentsHomeState.PaymentsState.NOT_ACTIVATED : PaymentsHomeState.PaymentsState.ACTIVATED)); + + if (result) { + paymentStateEvents.postValue(PaymentStateEvent.DEACTIVATED); + } + }); + } + + public void refreshExchangeRates(boolean refreshIfAble) { + store.update(state -> state.updateExchangeRateLoadState(LoadState.LOADING)); + currencyExchangeRepository.getCurrencyExchange(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable CurrencyExchange result) { + store.update(state -> state.updateCurrencyExchange(result, LoadState.LOADED)); + } + + @Override + public void onError(@Nullable Throwable error) { + Log.w(TAG, error); + store.update(state -> state.updateExchangeRateLoadState(LoadState.ERROR)); + } + }, refreshIfAble); + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new PaymentsHomeViewModel(new PaymentsHomeRepository(), + new PaymentsRepository(), + new CurrencyExchangeRepository(ApplicationDependencies.getPayments()))); + } + } + + public enum ErrorEnabling { + REGION, + NETWORK + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemFragment.java new file mode 100644 index 0000000000..ea35d33ed7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemFragment.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.PaymentPreferencesDirections; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; + +public class PaymentsPagerItemFragment extends LoggingFragment { + + private static final String PAYMENT_CATEGORY = "payment_category"; + + private PaymentsPagerItemViewModel viewModel; + + static @NonNull Fragment getFragmentForAllPayments() { + return getFragment(PaymentCategory.ALL); + } + + static @NonNull Fragment getFragmentForSentPayments() { + return getFragment(PaymentCategory.SENT); + } + + static @NonNull Fragment getFragmentForReceivedPayments() { + return getFragment(PaymentCategory.RECEIVED); + } + + private static @NonNull Fragment getFragment(@NonNull PaymentCategory paymentCategory) { + Bundle arguments = new Bundle(); + arguments.putString(PAYMENT_CATEGORY, paymentCategory.getCode()); + + Fragment fragment = new PaymentsPagerItemFragment(); + fragment.setArguments(arguments); + return fragment; + } + + public PaymentsPagerItemFragment() { + super(R.layout.payment_preferences_all_pager_item_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + PaymentsPagerItemViewModel.Factory factory = new PaymentsPagerItemViewModel.Factory(PaymentCategory.forCode(requireArguments().getString(PAYMENT_CATEGORY))); + viewModel = ViewModelProviders.of(this, factory).get(PaymentsPagerItemViewModel.class); + + RecyclerView recycler = view.findViewById(R.id.payments_activity_pager_item_fragment_recycler); + PaymentsHomeAdapter adapter = new PaymentsHomeAdapter(new Callbacks()); + + recycler.setAdapter(adapter); + + viewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); + } + + private class Callbacks implements PaymentsHomeAdapter.Callbacks { + @Override + public void onPaymentItem(@NonNull PaymentItem model) { + NavHostFragment.findNavController(PaymentsPagerItemFragment.this) + .navigate(PaymentPreferencesDirections.actionDirectlyToPaymentDetails(model.getPaymentDetailsParcelable())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemViewModel.java new file mode 100644 index 0000000000..4103c13cdf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsPagerItemViewModel.java @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; +import org.thoughtcrime.securesms.util.MappingModelList; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.List; + +final class PaymentsPagerItemViewModel extends ViewModel { + + private final LiveData list; + + PaymentsPagerItemViewModel(@NonNull PaymentCategory paymentCategory, @NonNull PaymentsRepository paymentsRepository) { + LiveData> payments; + + switch (paymentCategory) { + case ALL: + payments = paymentsRepository.getRecentPayments(); + break; + case SENT: + payments = paymentsRepository.getRecentSentPayments(); + break; + case RECEIVED: + payments = paymentsRepository.getRecentReceivedPayments(); + break; + default: + throw new IllegalArgumentException(); + } + + this.list = LiveDataUtil.mapAsync(payments, PaymentItem::fromPayment); + } + + @NonNull LiveData getList() { + return list; + } + + public static final class Factory implements ViewModelProvider.Factory { + private final PaymentCategory paymentCategory; + + public Factory(@NonNull PaymentCategory paymentCategory) { + this.paymentCategory = paymentCategory; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new PaymentsPagerItemViewModel(paymentCategory, new PaymentsRepository())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsRepository.java new file mode 100644 index 0000000000..feacbde487 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsRepository.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; + +import com.annimon.stream.Stream; + +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.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.Direction; +import org.thoughtcrime.securesms.payments.MobileCoinLedgerWrapper; +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.reconciliation.LedgerReconcile; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +/** + * General repository for accessing payment information. + */ +public class PaymentsRepository { + + private static final String TAG = Log.tag(PaymentsRepository.class); + + private final PaymentDatabase paymentDatabase; + private final LiveData> recentPayments; + private final LiveData> recentSentPayments; + private final LiveData> recentReceivedPayments; + + public PaymentsRepository() { + paymentDatabase = DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication()); + + LiveData> localPayments = paymentDatabase.getAllLive(); + LiveData ledger = SignalStore.paymentsValues().liveMobileCoinLedger(); + + //noinspection NullableProblems + this.recentPayments = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(localPayments, ledger, Pair::create), p -> reconcile(p.first, p.second)); + this.recentSentPayments = LiveDataUtil.mapAsync(this.recentPayments, p -> filterPayments(p, Direction.SENT)); + this.recentReceivedPayments = LiveDataUtil.mapAsync(this.recentPayments, p -> filterPayments(p, Direction.RECEIVED)); + } + + @WorkerThread + private @NonNull List reconcile(@NonNull Collection paymentTransactions, @NonNull MobileCoinLedgerWrapper ledger) { + List reconcile = LedgerReconcile.reconcile(paymentTransactions, ledger); + + updateDatabaseWithNewBlockInformation(reconcile); + + return reconcile; + } + + private void updateDatabaseWithNewBlockInformation(@NonNull List reconcileOutput) { + List blockOverridePayments = Stream.of(reconcileOutput) + .select(LedgerReconcile.BlockOverridePayment.class) + .toList(); + + if (blockOverridePayments.isEmpty()) { + return; + } + Log.i(TAG, String.format(Locale.US, "%d payments have new block index or timestamp information", blockOverridePayments.size())); + + for (LedgerReconcile.BlockOverridePayment blockOverridePayment : blockOverridePayments) { + Payment inner = blockOverridePayment.getInner(); + boolean override = false; + if (inner.getBlockIndex() != blockOverridePayment.getBlockIndex()) { + override = true; + } + if (inner.getBlockTimestamp() != blockOverridePayment.getBlockTimestamp()) { + override = true; + } + if (!override) { + Log.w(TAG, " Unnecessary"); + } else { + if (paymentDatabase.updateBlockDetails(inner.getUuid(), blockOverridePayment.getBlockIndex(), blockOverridePayment.getBlockTimestamp())) { + Log.d(TAG, " Updated block details for " + inner.getUuid()); + } else { + Log.w(TAG, " Failed to update block details for " + inner.getUuid()); + } + } + } + } + + public @NonNull LiveData> getRecentPayments() { + return recentPayments; + } + + public @NonNull LiveData> getRecentSentPayments() { + return recentSentPayments; + } + + public @NonNull LiveData> getRecentReceivedPayments() { + return recentReceivedPayments; + } + + private @NonNull List filterPayments(@NonNull List payments, + @NonNull Direction direction) + { + return Stream.of(payments) + .filter(p -> p.getDirection() == direction) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java new file mode 100644 index 0000000000..53cc102c27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/RecipientHasNotEnabledPaymentsDialog.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.app.AlertDialog; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +/** + * Dialog to display if chosen Recipient has not enabled payments. + */ +public final class RecipientHasNotEnabledPaymentsDialog { + + private RecipientHasNotEnabledPaymentsDialog() { + } + + public static void show(@NonNull Context context) { + show(context, null); + } + public static void show(@NonNull Context context, @Nullable Runnable onDismissed) { + new AlertDialog.Builder(context).setTitle(R.string.ConfirmPaymentFragment__invalid_recipient) + .setMessage(R.string.ConfirmPaymentFragment__this_person_has_not_activated_payments) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + dialog.dismiss(); + if (onDismissed != null) { + onDismissed.run(); + } + }) + .setCancelable(false) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyFragment.java new file mode 100644 index 0000000000..836fdcd022 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyFragment.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.BaseSettingsAdapter; + +import java.util.Currency; + +public final class SetCurrencyFragment extends LoggingFragment { + + private boolean handledInitialScroll = false; + + public SetCurrencyFragment() { + super(R.layout.set_currency_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.set_currency_fragment_toolbar); + RecyclerView list = view.findViewById(R.id.set_currency_fragment_list); + + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + SetCurrencyViewModel viewModel = ViewModelProviders.of(this, new SetCurrencyViewModel.Factory()).get(SetCurrencyViewModel.class); + + BaseSettingsAdapter adapter = new BaseSettingsAdapter(); + adapter.configureSingleSelect(selection -> viewModel.select((Currency) selection)); + list.setAdapter(adapter); + + viewModel.getCurrencyListState().observe(getViewLifecycleOwner(), currencyListState -> { + adapter.submitList(currencyListState.getItems(), () -> { + if (currencyListState.isLoaded() && + currencyListState.getSelectedIndex() != -1 && + savedInstanceState == null && + !handledInitialScroll) + { + handledInitialScroll = true; + list.post(() -> list.scrollToPosition(currencyListState.getSelectedIndex())); + } + }); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyViewModel.java new file mode 100644 index 0000000000..8b639f9194 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/SetCurrencyViewModel.java @@ -0,0 +1,212 @@ +package org.thoughtcrime.securesms.payments.preferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.SettingHeader; +import org.thoughtcrime.securesms.components.settings.SettingProgress; +import org.thoughtcrime.securesms.components.settings.SingleSelectSetting; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.currency.CurrencyExchange; +import org.thoughtcrime.securesms.payments.currency.CurrencyExchangeRepository; +import org.thoughtcrime.securesms.payments.currency.CurrencyUtil; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.MappingModelList; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.livedata.Store; +import org.whispersystems.libsignal.util.Pair; + +import java.util.Collection; +import java.util.Currency; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; + +public final class SetCurrencyViewModel extends ViewModel { + + private static final String TAG = Log.tag(SetCurrencyViewModel.class); + + private final Store store; + private final LiveData list; + + public SetCurrencyViewModel(@NonNull CurrencyExchangeRepository currencyExchangeRepository) { + this.store = new Store<>(new SetCurrencyState(SignalStore.paymentsValues().currentCurrency())); + this.list = Transformations.map(this.store.getStateLiveData(), this::createListState); + + this.store.update(SignalStore.paymentsValues().liveCurrentCurrency(), (currency, state) -> state.updateCurrentCurrency(currency)); + + currencyExchangeRepository.getCurrencyExchange(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable CurrencyExchange result) { + store.update(state -> state.updateCurrencyExchange(Objects.requireNonNull(result))); + } + + @Override + public void onError(@Nullable Throwable error) { + Log.w(TAG, error); + store.update(state -> state.updateExchangeRateLoadState(LoadState.ERROR)); + } + }, false); + } + + public void select(@NonNull Currency selection) { + SignalStore.paymentsValues().setCurrentCurrency(selection); + } + + public LiveData getCurrencyListState() { + return list; + } + + private @NonNull CurrencyListState createListState(SetCurrencyState state) { + MappingModelList items = new MappingModelList(); + boolean areAllCurrenciesLoaded = state.getCurrencyExchangeLoadState() == LoadState.LOADED; + + items.addAll(fromCurrencies(state.getDefaultCurrencies(), state.getCurrentCurrency())); + items.add(new SettingHeader.Item(R.string.SetCurrencyFragment__all_currencies)); + if (areAllCurrenciesLoaded) { + items.addAll(fromCurrencies(state.getOtherCurrencies(), state.getCurrentCurrency())); + } else { + items.add(new SettingProgress.Item()); + } + + return new CurrencyListState(items, findSelectedIndex(items), areAllCurrenciesLoaded); + } + + private @NonNull MappingModelList fromCurrencies(@NonNull Collection currencies, @NonNull Currency currentCurrency) { + return Stream.of(currencies) + .map(c -> new SingleSelectSetting.Item(c, c.getDisplayName(Locale.getDefault()), c.getCurrencyCode(), c.equals(currentCurrency))) + .sortBy(SingleSelectSetting.Item::getText) + .collect(MappingModelList.toMappingModelList()); + } + + private int findSelectedIndex(MappingModelList items) { + return Stream.of(items) + .mapIndexed(Pair::new) + .filter(p -> p.second() instanceof SingleSelectSetting.Item) + .map(p -> new Pair<>(p.first(), (SingleSelectSetting.Item) p.second())) + .filter(pair -> pair.second().isSelected()) + .findFirst() + .map(Pair::first) + .orElse(-1); + } + + public static class CurrencyListState { + private final MappingModelList items; + private final int selectedIndex; + private final boolean isLoaded; + + public CurrencyListState(@NonNull MappingModelList items, int selectedIndex, boolean isLoaded) { + this.items = items; + this.isLoaded = isLoaded; + this.selectedIndex = selectedIndex; + } + + public boolean isLoaded() { + return isLoaded; + } + + public @NonNull MappingModelList getItems() { + return items; + } + + public int getSelectedIndex() { + return selectedIndex; + } + } + + public static class SetCurrencyState { + private static final List DEFAULT_CURRENCIES = Stream.of(BuildConfig.DEFAULT_CURRENCIES.split(",")) + .map(CurrencyUtil::getCurrencyByCurrencyCode) + .withoutNulls() + .toList(); + + private final Currency currentCurrency; + private final CurrencyExchange currencyExchange; + private final LoadState currencyExchangeLoadState; + private final Collection defaultCurrencies; + private final Collection otherCurrencies; + + public SetCurrencyState(@NonNull Currency currentCurrency) { + this(currentCurrency, new CurrencyExchange(emptyMap(), 0), LoadState.LOADING, DEFAULT_CURRENCIES, emptyList()); + } + + public SetCurrencyState(@NonNull Currency currentCurrency, + @NonNull CurrencyExchange currencyExchange, + @NonNull LoadState loadState, + @NonNull Collection defaultCurrencies, + @NonNull Collection otherCurrencies) + { + this.currentCurrency = currentCurrency; + this.currencyExchange = currencyExchange; + this.currencyExchangeLoadState = loadState; + this.defaultCurrencies = defaultCurrencies; + this.otherCurrencies = otherCurrencies; + } + + public @NonNull Currency getCurrentCurrency() { + return currentCurrency; + } + + public @NonNull LoadState getCurrencyExchangeLoadState() { + return currencyExchangeLoadState; + } + + public @NonNull Collection getDefaultCurrencies() { + return defaultCurrencies; + } + + public @NonNull Collection getOtherCurrencies() { + return otherCurrencies; + } + + public @NonNull SetCurrencyState updateExchangeRateLoadState(@NonNull LoadState currencyExchangeLoadState) { + return new SetCurrencyState(this.currentCurrency, + this.currencyExchange, + currencyExchangeLoadState, + this.defaultCurrencies, + this.otherCurrencies); + } + + public @NonNull SetCurrencyState updateCurrencyExchange(@NonNull CurrencyExchange currencyExchange) { + List currencies = currencyExchange.getSupportedCurrencies(); + + Collection defaultCurrencies = SetUtil.intersection(currencies, DEFAULT_CURRENCIES); + Collection otherCurrencies = SetUtil.difference(currencies, defaultCurrencies); + + return new SetCurrencyState(this.currentCurrency, + currencyExchange, + LoadState.LOADED, + defaultCurrencies, + otherCurrencies); + } + + public @NonNull SetCurrencyState updateCurrentCurrency(@NonNull Currency currentCurrency) { + return new SetCurrencyState(currentCurrency, + this.currencyExchange, + this.currencyExchangeLoadState, + this.defaultCurrencies, + this.otherCurrencies); + } + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new SetCurrencyViewModel(new CurrencyExchangeRepository(ApplicationDependencies.getPayments()))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/AddressAndUri.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/AddressAndUri.java new file mode 100644 index 0000000000..2682f7668b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/AddressAndUri.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.payments.preferences.addmoney; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +public final class AddressAndUri { + private final String addressB58; + private final Uri uri; + + AddressAndUri(@NonNull String addressB58, @NonNull Uri uri) { + this.addressB58 = addressB58; + this.uri = uri; + } + + public String getAddressB58() { + return addressB58; + } + + public Uri getUri() { + return uri; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyFragment.java new file mode 100644 index 0000000000..f837330463 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyFragment.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.payments.preferences.addmoney; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +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.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.qr.QrView; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +public final class PaymentsAddMoneyFragment extends LoggingFragment { + + public PaymentsAddMoneyFragment() { + super(R.layout.payments_add_money_fragment); + } + + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + PaymentsAddMoneyViewModel viewModel = ViewModelProviders.of(this, new PaymentsAddMoneyViewModel.Factory()).get(PaymentsAddMoneyViewModel.class); + + Toolbar toolbar = view.findViewById(R.id.payments_add_money_toolbar); + QrView qrImageView = view.findViewById(R.id.payments_add_money_qr_image); + TextView walletAddressAbbreviated = view.findViewById(R.id.payments_add_money_abbreviated_wallet_address); + View copyAddress = view.findViewById(R.id.payments_add_money_copy_address_button); + LearnMoreTextView info = view.findViewById(R.id.payments_add_money_info); + + info.setLearnMoreVisible(true); + info.setLink(getString(R.string.PaymentsAddMoneyFragment__learn_more__information)); + + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + viewModel.getSelfAddressAbbreviated().observe(getViewLifecycleOwner(), walletAddressAbbreviated::setText); + + viewModel.getSelfAddressB58().observe(getViewLifecycleOwner(), base58 -> copyAddress.setOnClickListener(v -> copyAddressToClipboard(base58))); + + // Note we are choosing to put Base58 directly into QR here + viewModel.getSelfAddressB58().observe(getViewLifecycleOwner(), qrImageView::setQrText); + + viewModel.getErrors().observe(getViewLifecycleOwner(), error -> { + switch (error) { + case PAYMENTS_NOT_ENABLED: throw new AssertionError("Payments are not enabled"); + default : throw new AssertionError(); + } + }); + } + + private void copyAddressToClipboard(@NonNull String base58) { + Context context = requireContext(); + ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + clipboard.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), base58)); + + Toast.makeText(context, R.string.PaymentsAddMoneyFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyRepository.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyRepository.java new file mode 100644 index 0000000000..8025dcfdc0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyRepository.java @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.payments.preferences.addmoney; + +import android.net.Uri; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.util.AsynchronousCallback; + +final class PaymentsAddMoneyRepository { + + @MainThread + void getWalletAddress(@NonNull AsynchronousCallback.MainThread callback) { + if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) { + callback.onError(Error.PAYMENTS_NOT_ENABLED); + } + + MobileCoinPublicAddress publicAddress = ApplicationDependencies.getPayments().getWallet().getMobileCoinPublicAddress(); + String paymentAddressBase58 = publicAddress.getPaymentAddressBase58(); + Uri paymentAddressUri = publicAddress.getPaymentAddressUri(); + + callback.onComplete(new AddressAndUri(paymentAddressBase58, paymentAddressUri)); + } + + enum Error { + PAYMENTS_NOT_ENABLED + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyViewModel.java new file mode 100644 index 0000000000..653cfeb82a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/addmoney/PaymentsAddMoneyViewModel.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.payments.preferences.addmoney; + +import android.net.Uri; + +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.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.StringUtil; + +final class PaymentsAddMoneyViewModel extends ViewModel { + + private final MutableLiveData selfAddressAndUri = new MutableLiveData<>(); + private final MutableLiveData errors = new MutableLiveData<>(); + private final LiveData selfAddressUri; + private final LiveData selfAddressB58; + private final LiveData selfAddressAbbreviated; + + PaymentsAddMoneyViewModel(@NonNull PaymentsAddMoneyRepository paymentsAddMoneyRepository) { + paymentsAddMoneyRepository.getWalletAddress(new AsynchronousCallback.MainThread() { + @Override + public void onComplete(@Nullable AddressAndUri result) { + selfAddressAndUri.setValue(result); + } + + @Override + public void onError(@Nullable PaymentsAddMoneyRepository.Error error) { + errors.setValue(error); + } + }); + + selfAddressB58 = Transformations.map(selfAddressAndUri, AddressAndUri::getAddressB58); + selfAddressUri = Transformations.map(selfAddressAndUri, AddressAndUri::getUri); + selfAddressAbbreviated = Transformations.map(selfAddressB58, longAddress -> StringUtil.abbreviateInMiddle(longAddress, 17)); + } + + LiveData getSelfAddressB58() { + return selfAddressB58; + } + + LiveData getSelfAddressAbbreviated() { + return selfAddressAbbreviated; + } + + LiveData getErrors() { + return errors; + } + + LiveData getSelfAddressUriForQr() { + return selfAddressUri; + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new PaymentsAddMoneyViewModel(new PaymentsAddMoneyRepository())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsFragment.java new file mode 100644 index 0000000000..185f80cec0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsFragment.java @@ -0,0 +1,248 @@ +package org.thoughtcrime.securesms.payments.preferences.details; + +import android.content.Context; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; +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.constraintlayout.widget.Group; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.database.PaymentDatabase; +import org.thoughtcrime.securesms.payments.Direction; +import org.thoughtcrime.securesms.payments.MoneyView; +import org.thoughtcrime.securesms.payments.Payee; +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.State; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; +import org.whispersystems.signalservice.api.payments.Money; + +import java.util.Locale; +import java.util.Objects; + +public final class PaymentDetailsFragment extends LoggingFragment { + + private static final String TAG = Log.tag(PaymentDetailsFragment.class); + + public PaymentDetailsFragment() { + super(R.layout.payment_details_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.payments_details_toolbar); + + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + + PaymentDetailsParcelable details = PaymentDetailsFragmentArgs.fromBundle(requireArguments()).getPaymentDetails(); + + AvatarImageView avatar = view.findViewById(R.id.payments_details_avatar); + TextView contactFromTo = view.findViewById(R.id.payments_details_contact_to_from); + MoneyView amount = view.findViewById(R.id.payments_details_amount); + TextView note = view.findViewById(R.id.payments_details_note); + TextView status = view.findViewById(R.id.payments_details_status); + View sentByHeader = view.findViewById(R.id.payments_details_sent_by_header); + TextView sentBy = view.findViewById(R.id.payments_details_sent_by); + LearnMoreTextView transactionInfo = view.findViewById(R.id.payments_details_info); + TextView sentTo = view.findViewById(R.id.payments_details_sent_to_header); + MoneyView sentToAmount = view.findViewById(R.id.payments_details_sent_to_amount); + View sentFeeHeader = view.findViewById(R.id.payments_details_sent_fee_header); + MoneyView sentFeeAmount = view.findViewById(R.id.payments_details_sent_fee_amount); + Group sentViews = view.findViewById(R.id.payments_details_sent_views); + View blockHeader = view.findViewById(R.id.payments_details_block_header); + TextView blockNumber = view.findViewById(R.id.payments_details_block); + + if (details.hasPayment()) { + Payment payment = details.requirePayment(); + avatar.disableQuickContact(); + avatar.setImageResource(R.drawable.ic_mobilecoin_avatar_24); + contactFromTo.setText(getContactFromToTextFromDirection(payment.getDirection())); + amount.setMoney(payment.getAmountPlusFeeWithDirection()); + note.setVisibility(View.GONE); + status.setText(getStatusFromPayment(payment)); + sentByHeader.setVisibility(View.GONE); + sentBy.setVisibility(View.GONE); + transactionInfo.setLearnMoreVisible(true); + transactionInfo.setText(R.string.PaymentsDetailsFragment__information); + transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__information)); + sentTo.setVisibility(View.GONE); + sentToAmount.setVisibility(View.GONE); + blockHeader.setVisibility(View.VISIBLE); + blockNumber.setVisibility(View.VISIBLE); + blockNumber.setText(String.valueOf(payment.getBlockIndex())); + + if (payment.getDirection() == Direction.SENT) { + sentFeeAmount.setMoney(payment.getFee()); + sentFeeHeader.setVisibility(View.VISIBLE); + sentFeeAmount.setVisibility(View.VISIBLE); + } + } else { + PaymentsDetailsViewModel viewModel = ViewModelProviders.of(this, new PaymentsDetailsViewModel.Factory(details.requireUuid())).get(PaymentsDetailsViewModel.class); + viewModel.getViewState() + .observe(getViewLifecycleOwner(), + state -> { + if (state.getRecipient().getId().isUnknown() || state.getPayment().isDefrag()) { + avatar.disableQuickContact(); + avatar.setImageResource(R.drawable.ic_mobilecoin_avatar_24); + } else { + avatar.setRecipient(state.getRecipient(), true); + } + contactFromTo.setText(describeToOrFrom(state)); + + if (state.getPayment().getState() == State.FAILED) { + amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary_disabled)); + amount.setMoney(state.getPayment().getAmountPlusFeeWithDirection(), false); + transactionInfo.setVisibility(View.GONE); + } else { + amount.setMoney(state.getPayment().getAmountPlusFeeWithDirection()); + if (state.getPayment().isDefrag()) { + transactionInfo.setLearnMoreVisible(true); + transactionInfo.setText(R.string.PaymentsDetailsFragment__coin_cleanup_information); + transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__cleanup_fee)); + } else { + transactionInfo.setLearnMoreVisible(true); + transactionInfo.setText(R.string.PaymentsDetailsFragment__information); + transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__information)); + } + transactionInfo.setVisibility(View.VISIBLE); + } + + String trimmedNote = state.getPayment().getNote().trim(); + note.setText(trimmedNote); + note.setVisibility(TextUtils.isEmpty(trimmedNote) ? View.GONE : View.VISIBLE); + status.setText(describeStatus(state.getPayment())); + sentBy.setText(describeSentBy(state)); + if (state.getPayment().getDirection().isReceived()) { + sentToAmount.setMoney(Money.MobileCoin.ZERO); + sentFeeAmount.setMoney(Money.MobileCoin.ZERO); + sentViews.setVisibility(View.GONE); + } else { + sentTo.setText(describeSentTo(state, state.getPayment())); + sentToAmount.setMoney(state.getPayment().getAmount()); + sentFeeAmount.setMoney(state.getPayment().getFee()); + sentViews.setVisibility(View.VISIBLE); + } + } + ); + + viewModel.getPaymentExists() + .observe(getViewLifecycleOwner(), exists -> { + if (!exists) { + Log.w(TAG, "Failed to find payment detail"); + FragmentActivity fragmentActivity = requireActivity(); + fragmentActivity.onBackPressed(); + Toast.makeText(fragmentActivity, R.string.PaymentsDetailsFragment__no_details_available, Toast.LENGTH_SHORT).show(); + } + }); + } + } + + private CharSequence describeToOrFrom(PaymentsDetailsViewModel.ViewState state) { + if (state.getPayment().isDefrag()) { + return getString(R.string.PaymentsDetailsFragment__coin_cleanup_fee); + } + SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); + switch (state.getPayment().getDirection()) { + case SENT: + stringBuilder.append(getString(R.string.PaymentsDetailsFragment__to)); + break; + case RECEIVED: + stringBuilder.append(getString(R.string.PaymentsDetailsFragment__from)); + break; + default: + throw new AssertionError(); + } + + stringBuilder.append(' ').append(describe(state.getPayment().getPayee(), state.getRecipient())); + return stringBuilder; + } + + private @NonNull CharSequence describe(@NonNull Payee payee, @NonNull Recipient recipient) { + if (payee.hasRecipientId()) { + return recipient.getDisplayName(requireContext()); + } else if (payee.hasPublicAddress()) { + return mono(requireContext(), Objects.requireNonNull(StringUtil.abbreviateInMiddle(payee.requirePublicAddress().getPaymentAddressBase58(), 17))); + } else { + throw new AssertionError(); + } + } + + private static @NonNull CharSequence mono(@NonNull Context context, @NonNull 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 CharSequence describeSentBy(PaymentsDetailsViewModel.ViewState state) { + switch (state.getPayment().getDirection()) { + case SENT: + return getResources().getString(R.string.PaymentsDetailsFragment__you_on_s_at_s, state.getDate(), state.getTime(requireContext())); + case RECEIVED: + return SpanUtil.replacePlaceHolder(getResources().getString(R.string.PaymentsDetailsFragment__s_on_s_at_s, SpanUtil.SPAN_PLACE_HOLDER, state.getDate(), state.getTime(requireContext())), + describe(state.getPayment().getPayee(), state.getRecipient())); + default: + throw new AssertionError(); + } + } + + private @NonNull CharSequence describeSentTo(@NonNull PaymentsDetailsViewModel.ViewState state, @NonNull PaymentDatabase.PaymentTransaction payment) { + if (payment.getDirection().isSent()) { + return SpanUtil.insertSingleSpan(getResources(), R.string.PaymentsDetailsFragment__sent_to_s, describe(payment.getPayee(), state.getRecipient())); + } else { + throw new AssertionError(); + } + } + + private @NonNull CharSequence describeStatus(@NonNull PaymentDatabase.PaymentTransaction payment) { + switch (payment.getState()) { + case INITIAL: + return getResources().getString(R.string.PaymentsDetailsFragment__submitting_payment); + case SUBMITTED: + return getResources().getString(R.string.PaymentsDetailsFragment__processing_payment); + case SUCCESSFUL: + return getResources().getString(R.string.PaymentsDetailsFragment__payment_complete); + case FAILED: + return SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary), getResources().getString(R.string.PaymentsDetailsFragment__payment_failed)); + default: + throw new AssertionError(); + } + } + + private @NonNull CharSequence getContactFromToTextFromDirection(@NonNull Direction direction) { + switch (direction) { + case SENT: + return getResources().getString(R.string.PaymentsDetailsFragment__sent_payment); + case RECEIVED: + return getResources().getString(R.string.PaymentsDetailsFragment__received_payment); + default: + throw new AssertionError(); + } + } + + private @NonNull CharSequence getStatusFromPayment(@NonNull Payment payment) { + return getResources().getString(R.string.PaymentsDeatilsFragment__payment_completed_s, DateUtils.getTimeString(requireContext(), Locale.getDefault(), payment.getDisplayTimestamp())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsParcelable.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsParcelable.java new file mode 100644 index 0000000000..d4fc426e7b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentDetailsParcelable.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.payments.preferences.details; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.PaymentParcelable; + +import java.util.Objects; +import java.util.UUID; + +/** + * Argument for PaymentDetailsFragment which takes EITHER a Payment OR a UUID, never both. + */ +public class PaymentDetailsParcelable implements Parcelable { + + private static final int TYPE_PAYMENT = 0; + private static final int TYPE_UUID = 1; + + private final Payment payment; + private final UUID uuid; + + private PaymentDetailsParcelable(@Nullable Payment payment, @Nullable UUID uuid) { + if ((uuid == null) == (payment == null)) { + throw new IllegalStateException("Must have exactly one of uuid or payment."); + } + this.payment = payment; + this.uuid = uuid; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + if (payment != null) { + dest.writeInt(TYPE_PAYMENT); + dest.writeParcelable(new PaymentParcelable(payment), flags); + } else { + dest.writeInt(TYPE_UUID); + dest.writeString(uuid.toString()); + } + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public PaymentDetailsParcelable createFromParcel(Parcel in) { + int type = in.readInt(); + switch (type) { + case TYPE_UUID : return forUuid(UUID.fromString(in.readString())); + case TYPE_PAYMENT: return forPayment(in.readParcelable(PaymentParcelable.class.getClassLoader()).getPayment()); + default : throw new IllegalStateException("Unexpected parcel type " + type); + } + } + + @Override + public PaymentDetailsParcelable[] newArray(int size) { + return new PaymentDetailsParcelable[size]; + } + }; + + public boolean hasPayment() { + return payment != null; + } + + public @NonNull Payment requirePayment() { + return Objects.requireNonNull(payment); + } + + public @NonNull UUID requireUuid() { + if (uuid != null) return uuid; + else return requirePayment().getUuid(); + } + + public static PaymentDetailsParcelable forUuid(@NonNull UUID uuid) { + return new PaymentDetailsParcelable(null, uuid); + } + + public static PaymentDetailsParcelable forPayment(@NonNull Payment payment) { + return new PaymentDetailsParcelable(payment, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentsDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentsDetailsViewModel.java new file mode 100644 index 0000000000..fecba73122 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/details/PaymentsDetailsViewModel.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.payments.preferences.details; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.PaymentDatabase; +import org.thoughtcrime.securesms.payments.PaymentTransactionLiveData; +import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Locale; +import java.util.UUID; + +final class PaymentsDetailsViewModel extends ViewModel { + + private final LiveData viewState; + private final LiveData paymentExists; + + PaymentsDetailsViewModel(@NonNull UUID paymentId) { + PaymentTransactionLiveData source = new PaymentTransactionLiveData(paymentId); + + LiveData recipientLiveData = Transformations.switchMap(source, + payment -> payment != null && payment.getPayee().hasRecipientId() ? Recipient.live(payment.getPayee().requireRecipientId()).getLiveData() + : LiveDataUtil.just(Recipient.UNKNOWN)); + + this.viewState = LiveDataUtil.combineLatest(source, recipientLiveData, ViewState::new); + this.paymentExists = Transformations.map(source, s -> s != null); + + new UnreadPaymentsRepository().markPaymentSeen(paymentId); + } + + LiveData getViewState() { + return viewState; + } + + LiveData getPaymentExists() { + return paymentExists; + } + + static class ViewState { + + private final PaymentDatabase.PaymentTransaction payment; + private final Recipient recipient; + + private ViewState(@NonNull PaymentDatabase.PaymentTransaction payment, @NonNull Recipient recipient) { + this.payment = payment; + this.recipient = recipient; + } + + Recipient getRecipient() { + return recipient; + } + + PaymentDatabase.PaymentTransaction getPayment() { + return payment; + } + + String getDate() { + return DateUtils.formatDate(Locale.getDefault(), payment.getDisplayTimestamp()); + } + + String getTime(@NonNull Context context) { + return DateUtils.getTimeString(context, Locale.getDefault(), payment.getDisplayTimestamp()); + } + } + + public static final class Factory implements ViewModelProvider.Factory { + private final UUID paymentId; + + public Factory(@NonNull UUID paymentId) { + this.paymentId = paymentId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new PaymentsDetailsViewModel(paymentId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InProgress.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InProgress.java new file mode 100644 index 0000000000..a492ba273f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InProgress.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.MappingModel; + +public class InProgress implements MappingModel { + @Override + public boolean areItemsTheSame(@NonNull InProgress newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull InProgress newItem) { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InfoCard.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InfoCard.java new file mode 100644 index 0000000000..783916d61a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/InfoCard.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.PaymentsValues; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.MappingModel; + +import java.util.ArrayList; +import java.util.List; + +public class InfoCard implements MappingModel { + + private final @StringRes int titleId; + private final @StringRes int messageId; + private final @StringRes int actionId; + private final @DrawableRes int iconId; + private final Type type; + private final Runnable dismiss; + + private InfoCard(@StringRes int titleId, + @StringRes int messageId, + @StringRes int actionId, + @DrawableRes int iconId, + @NonNull Type type, + @NonNull Runnable dismiss) + { + this.titleId = titleId; + this.messageId = messageId; + this.actionId = actionId; + this.iconId = iconId; + this.type = type; + this.dismiss = dismiss; + } + + public @StringRes int getTitleId() { + return titleId; + } + + public @StringRes int getMessageId() { + return messageId; + } + + public @StringRes int getActionId() { + return actionId; + } + + public @NonNull Type getType() { + return type; + } + + public @DrawableRes int getIconId() { + return iconId; + } + + public void dismiss() { + dismiss.run(); + } + + @Override + public boolean areItemsTheSame(@NonNull InfoCard newItem) { + return newItem.type == type; + } + + @Override + public boolean areContentsTheSame(@NonNull InfoCard newItem) { + return newItem.titleId == titleId && + newItem.messageId == messageId && + newItem.actionId == actionId && + newItem.iconId == iconId && + newItem.type == type; + } + + public static @NonNull List getInfoCards() { + List infoCards = new ArrayList<>(Type.values().length); + PaymentsValues paymentsValues = SignalStore.paymentsValues(); + + if (paymentsValues.showRecoveryPhraseInfoCard()) { + infoCards.add(new InfoCard(R.string.payment_info_card_record_recovery_phrase, + R.string.payment_info_card_your_recovery_phrase_gives_you, + R.string.payment_info_card_record_your_phrase, + R.drawable.ic_payments_info_card_restore_80, + Type.RECORD_RECOVERY_PHASE, + paymentsValues::dismissRecoveryPhraseInfoCard)); + } + + if (paymentsValues.showUpdatePinInfoCard()) { + infoCards.add(new InfoCard(R.string.payment_info_card_update_your_pin, + R.string.payment_info_card_with_a_high_balance, + R.string.payment_info_card_update_pin, + R.drawable.ic_payments_info_card_pin_80, + Type.UPDATE_YOUR_PIN, + paymentsValues::dismissUpdatePinInfoCard)); + } + + if (paymentsValues.showAboutMobileCoinInfoCard()) { + infoCards.add(new InfoCard(R.string.payment_info_card_about_mobilecoin, + R.string.payment_info_card_mobilecoin_is_a_new_privacy_focused_digital_currency, + R.string.LearnMoreTextView_learn_more, + R.drawable.ic_about_mc_80, + Type.ABOUT_MOBILECOIN, + paymentsValues::dismissAboutMobileCoinInfoCard)); + } + + if (paymentsValues.showAddingToYourWalletInfoCard()) { + infoCards.add(new InfoCard(R.string.payment_info_card_adding_funds, + R.string.payment_info_card_you_can_add_funds_for_use_in, + R.string.LearnMoreTextView_learn_more, + R.drawable.ic_add_money_80, + Type.ADDING_TO_YOUR_WALLET, + paymentsValues::dismissAddingToYourWalletInfoCard)); + } + + if (paymentsValues.showCashingOutInfoCard()) { + infoCards.add(new InfoCard(R.string.payment_info_card_cashing_out, + R.string.payment_info_card_you_can_cash_out_mobilecoin, + R.string.LearnMoreTextView_learn_more, + R.drawable.ic_cash_out_80, + Type.CASHING_OUT, + paymentsValues::dismissCashingOutInfoCard)); + } + + return infoCards; + } + + public enum Type { + RECORD_RECOVERY_PHASE, + UPDATE_YOUR_PIN, + ABOUT_MOBILECOIN, + ADDING_TO_YOUR_WALLET, + CASHING_OUT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/IntroducingPayments.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/IntroducingPayments.java new file mode 100644 index 0000000000..af46ab7773 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/IntroducingPayments.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeState; +import org.thoughtcrime.securesms.util.MappingModel; + +public class IntroducingPayments implements MappingModel { + private PaymentsHomeState.PaymentsState paymentsState; + + public IntroducingPayments(@NonNull PaymentsHomeState.PaymentsState paymentsState) { + this.paymentsState = paymentsState; + } + + @Override + public boolean areItemsTheSame(@NonNull IntroducingPayments newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull IntroducingPayments newItem) { + return this.paymentsState == newItem.paymentsState; + } + + public boolean isActivating() { + return paymentsState == PaymentsHomeState.PaymentsState.ACTIVATING; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/NoRecentActivity.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/NoRecentActivity.java new file mode 100644 index 0000000000..20d1d82af3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/NoRecentActivity.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.MappingModel; + +public class NoRecentActivity implements MappingModel { + @Override + public boolean areItemsTheSame(@NonNull NoRecentActivity newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull NoRecentActivity newItem) { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PayeeParcelable.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PayeeParcelable.java new file mode 100644 index 0000000000..73bc1d8ab9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PayeeParcelable.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.payments.Payee; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public final class PayeeParcelable implements Parcelable { + private final Payee payee; + + public PayeeParcelable(@NonNull Payee payee) { + this.payee = payee; + } + + public PayeeParcelable(@NonNull RecipientId recipientId) { + this(new Payee(recipientId)); + } + + public PayeeParcelable(@NonNull RecipientId recipientId, @NonNull MobileCoinPublicAddress address) { + this(Payee.fromRecipientAndAddress(recipientId, address)); + } + + public PayeeParcelable(@NonNull MobileCoinPublicAddress publicAddress) { + this(new Payee(publicAddress)); + } + + public Payee getPayee() { + return payee; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) return true; + if (!(o instanceof PayeeParcelable)) return false; + + PayeeParcelable other = (PayeeParcelable) o; + return payee.equals(other.payee); + } + + @Override + public int hashCode() { + return payee.hashCode(); + } + + private static final int UNKNOWN = 0; + private static final int CONTAINS_RECIPIENT_ID = 1; + private static final int CONTAINS_ADDRESS = 2; + private static final int CONTAINS_RECIPIENT_ID_AND_ADDRESS = 3; + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + if (payee.hasRecipientId()) { + if (payee.hasPublicAddress()) { + dest.writeInt(CONTAINS_RECIPIENT_ID_AND_ADDRESS); + dest.writeParcelable(payee.requireRecipientId(), flags); + dest.writeString(payee.requirePublicAddress().getPaymentAddressBase58()); + } else { + dest.writeInt(CONTAINS_RECIPIENT_ID); + dest.writeParcelable(payee.requireRecipientId(), flags); + } + } else if (payee.hasPublicAddress()) { + dest.writeInt(CONTAINS_ADDRESS); + dest.writeString(payee.requirePublicAddress().getPaymentAddressBase58()); + } else { + dest.writeInt(UNKNOWN); + } + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull PayeeParcelable createFromParcel(@NonNull Parcel in) { + + switch (in.readInt()) { + case UNKNOWN: { + return new PayeeParcelable(Payee.UNKNOWN); + } + case CONTAINS_RECIPIENT_ID: { + RecipientId recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + return new PayeeParcelable(new Payee(recipientId)); + } + case CONTAINS_RECIPIENT_ID_AND_ADDRESS: { + RecipientId recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + MobileCoinPublicAddress publicAddress = MobileCoinPublicAddress.fromBase58OrThrow(in.readString()); + return new PayeeParcelable(Payee.fromRecipientAndAddress(recipientId, publicAddress)); + } + case CONTAINS_ADDRESS: { + return new PayeeParcelable(new Payee(MobileCoinPublicAddress.fromBase58OrThrow(in.readString()))); + } + default: { + throw new AssertionError(); + } + } + } + + @Override + public @NonNull PayeeParcelable[] newArray(int size) { + return new PayeeParcelable[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PaymentItem.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PaymentItem.java new file mode 100644 index 0000000000..8b1874286e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/PaymentItem.java @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import android.content.Context; + +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.ReconstructedPayment; +import org.thoughtcrime.securesms.payments.State; +import org.thoughtcrime.securesms.payments.preferences.PaymentType; +import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.MappingModelList; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.whispersystems.signalservice.api.payments.FormatterOptions; +import org.whispersystems.signalservice.api.payments.PaymentsConstants; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import static org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel.RecipientIdMappingModel; + +public final class PaymentItem implements MappingModel { + + private final Payment payment; + private final PaymentType paymentType; + + public static @NonNull MappingModelList fromPayment(@NonNull List transactions) { + return Stream.of(transactions) + .map(PaymentItem::fromPayment) + .collect(MappingModelList.toMappingModelList()); + } + + public static @NonNull PaymentItem fromPayment(@NonNull Payment transaction) { + return new PaymentItem(transaction, + PaymentType.PAYMENT); + } + + private PaymentItem(@NonNull Payment payment, + @NonNull PaymentType paymentType) + { + this.payment = payment; + this.paymentType = paymentType; + } + + public @NonNull PaymentDetailsParcelable getPaymentDetailsParcelable() { + if (payment instanceof ReconstructedPayment) { + return PaymentDetailsParcelable.forPayment(payment); + } else { + return PaymentDetailsParcelable.forUuid(payment.getUuid()); + } + } + + public boolean isInProgress() { + return payment.getState().isInProgress(); + } + + public boolean isUnread() { + return !payment.isSeen(); + } + + public @NonNull CharSequence getDate(@NonNull Context context) { + if (isInProgress()) { + return context.getString(R.string.PaymentsHomeFragment__processing_payment); + } + + if (payment.getState() == State.FAILED) { + return SpanUtil.color(ContextCompat.getColor(context, R.color.signal_alert_primary), context.getString(R.string.PaymentsHomeFragment__payment_failed)); + } + + String date = DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), payment.getDisplayTimestamp()); + int prefix = payment.getDirection().isReceived() ? R.string.PaymentsHomeFragment__received_s : R.string.PaymentsHomeFragment__sent_s; + + return context.getString(prefix, date); + } + + public @NonNull String getAmount(@NonNull Context context) { + if (isInProgress() && payment.getDirection().isReceived()) { + return context.getString(R.string.PaymentsHomeFragment__unknown_amount); + } + + if (payment.getState() == State.FAILED) { + return context.getString(R.string.PaymentsHomeFragment__details); + } + + return payment.getAmountPlusFeeWithDirection() + .toString(FormatterOptions.builder(Locale.getDefault()) + .alwaysPrefixWithSign() + .withMaximumFractionDigits(PaymentsConstants.SHORT_FRACTION_LENGTH) + .build()); + } + + public @ColorRes int getAmountColor() { + if (isInProgress()) { + return R.color.signal_text_primary_disabled; + } else if (payment.getState() == State.FAILED) { + return R.color.signal_text_secondary; + } else if (paymentType == PaymentType.REQUEST) { + return R.color.core_grey_45; + } else if (payment.getDirection().isReceived()) { + return R.color.core_green; + } else { + return R.color.signal_text_primary; + } + } + + public boolean isDefrag() { + return payment.isDefrag(); + } + + public boolean hasRecipient() { + return payment.getPayee().hasRecipientId(); + } + + public @Nullable String getTransactionName(@NonNull Context context) { + return context.getString(payment.isDefrag() ? R.string.PaymentsHomeFragment__coin_cleanup_fee + : payment.getDirection().isSent() ? R.string.PaymentsHomeFragment__sent_payment + : R.string.PaymentsHomeFragment__received_payment); + } + + public @DrawableRes int getTransactionAvatar() { + return R.drawable.ic_mobilecoin_avatar_24; + } + + public @NonNull RecipientIdMappingModel getRecipientIdModel() { + return new RecipientIdMappingModel(payment.getPayee().requireRecipientId()); + } + + @Override + public boolean areItemsTheSame(@NonNull PaymentItem newItem) { + return payment.getUuid().equals(newItem.payment.getUuid()); + } + + @Override + public boolean areContentsTheSame(@NonNull PaymentItem newItem) { + return payment.getDisplayTimestamp() == newItem.payment.getDisplayTimestamp() && + payment.getAmount().equals(newItem.payment.getAmount()) && + paymentType == newItem.paymentType && + payment.getDirection() == newItem.payment.getDirection() && + payment.getState() == newItem.payment.getState() && + Objects.equals(payment.getPayee(), newItem.payment.getPayee()) && + payment.isSeen() == newItem.payment.isSeen() && + payment.isDefrag() == newItem.payment.isDefrag(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/SeeAll.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/SeeAll.java new file mode 100644 index 0000000000..5924c898a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/model/SeeAll.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.payments.preferences.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.payments.preferences.PaymentType; +import org.thoughtcrime.securesms.util.MappingModel; + +public class SeeAll implements MappingModel { + + private final PaymentType paymentType; + + public SeeAll(PaymentType paymentType) { + this.paymentType = paymentType; + } + + public @NonNull PaymentType getPaymentType() { + return paymentType; + } + + @Override + public boolean areItemsTheSame(@NonNull SeeAll newItem) { + return paymentType == newItem.paymentType; + } + + @Override + public boolean areContentsTheSame(@NonNull SeeAll newItem) { + return areItemsTheSame(newItem); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferFragment.java new file mode 100644 index 0000000000..e3d01b431e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferFragment.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.payments.preferences.transfer; + +import android.Manifest; +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.ViewUtil; + +public final class PaymentsTransferFragment extends LoggingFragment { + + private static final String TAG = Log.tag(PaymentsTransferFragment.class); + + private EditText address; + + public PaymentsTransferFragment() { + super(R.layout.payments_transfer_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + PaymentsTransferViewModel viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_transfer), new PaymentsTransferViewModel.Factory()).get(PaymentsTransferViewModel.class); + + Toolbar toolbar = view.findViewById(R.id.payments_transfer_toolbar); + + view.findViewById(R.id.payments_transfer_scan_qr).setOnClickListener(v -> scanQrCode()); + view.findViewById(R.id.payments_transfer_next).setOnClickListener(v -> next(viewModel.getOwnAddress())); + + address = view.findViewById(R.id.payments_transfer_to_address); + address.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + return next(viewModel.getOwnAddress()); + } + return false; + }); + + viewModel.getAddress().observe(getViewLifecycleOwner(), address::setText); + + toolbar.setNavigationOnClickListener(v -> { + ViewUtil.hideKeyboard(requireContext(), v); + Navigation.findNavController(v).popBackStack(); + }); + } + + private boolean next(@NonNull MobileCoinPublicAddress ownAddress) { + try { + String base58Address = address.getText().toString(); + MobileCoinPublicAddress publicAddress = MobileCoinPublicAddress.fromBase58(base58Address); + + if (ownAddress.equals(publicAddress)) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.PaymentsTransferFragment__invalid_address) + .setMessage(R.string.PaymentsTransferFragment__you_cant_transfer_to_your_own_signal_wallet_address) + .setPositiveButton(android.R.string.ok, null) + .show(); + return false; + } + + NavDirections action = PaymentsTransferFragmentDirections.actionPaymentsTransferToCreatePayment(new PayeeParcelable(publicAddress)) + .setFinishOnConfirm(PaymentsTransferFragmentArgs.fromBundle(requireArguments()).getFinishOnConfirm()); + + Navigation.findNavController(requireView()).navigate(action); + return true; + } catch (MobileCoinPublicAddress.AddressException e) { + Log.w(TAG, "Address is not valid", e); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.PaymentsTransferFragment__invalid_address) + .setMessage(R.string.PaymentsTransferFragment__check_the_wallet_address) + .setPositiveButton(android.R.string.ok, null) + .show(); + return false; + } + } + + private void scanQrCode() { + Permissions.with(requireActivity()) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs), R.drawable.ic_camera_24) + .onAnyPermanentlyDenied(this::onCameraPermissionPermanentlyDenied) + .onAllGranted(() -> Navigation.findNavController(requireView()).navigate(R.id.action_paymentsTransfer_to_paymentsScanQr)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera, Toast.LENGTH_LONG).show()) + .execute(); + } + + private void onCameraPermissionPermanentlyDenied() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Permissions_permission_required) + .setMessage(R.string.PaymentsTransferFragment__signal_needs_the_camera_permission_to_capture_qr_code_go_to_settings) + .setPositiveButton(R.string.PaymentsTransferFragment__settings, (dialog, which) -> requireActivity().startActivity(Permissions.getApplicationSettingsIntent(requireContext()))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java new file mode 100644 index 0000000000..2c0c7cd598 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.payments.preferences.transfer; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.Navigation; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.camera.CameraView; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.qr.ScanningThread; + +public final class PaymentsTransferQrScanFragment extends LoggingFragment { + + private static final String TAG = Log.tag(PaymentsTransferQrScanFragment.class); + + private LinearLayout overlay; + private CameraView scannerView; + private ScanningThread scanningThread; + private PaymentsTransferViewModel viewModel; + + public PaymentsTransferQrScanFragment() { + super(R.layout.payments_transfer_qr_scan_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + overlay = view.findViewById(R.id.overlay); + scannerView = view.findViewById(R.id.scanner); + + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + overlay.setOrientation(LinearLayout.HORIZONTAL); + } else { + overlay.setOrientation(LinearLayout.VERTICAL); + } + + viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_transfer), new PaymentsTransferViewModel.Factory()).get(PaymentsTransferViewModel.class); + + Toolbar toolbar = view.findViewById(R.id.payments_transfer_scan_qr); + toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); + } + + @Override + public void onResume() { + super.onResume(); + scanningThread = new ScanningThread(); + scanningThread.setScanListener(data -> { + try { + viewModel.postQrData(MobileCoinPublicAddress.fromQr(data).getPaymentAddressBase58()); + Navigation.findNavController(requireView()).navigate(R.id.action_paymentsScanQr_pop); + } catch (MobileCoinPublicAddress.AddressException e) { + Log.e(TAG, "Not a valid address"); + } + }); + scannerView.onResume(); + scannerView.setPreviewCallback(scanningThread); + scanningThread.start(); + } + + @Override + public void onPause() { + super.onPause(); + scannerView.onPause(); + scanningThread.stopScanning(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfiguration) { + super.onConfigurationChanged(newConfiguration); + + scannerView.onPause(); + + if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + overlay.setOrientation(LinearLayout.HORIZONTAL); + } else { + overlay.setOrientation(LinearLayout.VERTICAL); + } + + scannerView.onResume(); + scannerView.setPreviewCallback(scanningThread); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferViewModel.java new file mode 100644 index 0000000000..4a7cbb9eb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferViewModel.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.payments.preferences.transfer; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; + +final class PaymentsTransferViewModel extends ViewModel { + + private final MutableLiveData address = new MutableLiveData<>(); + private final MobileCoinPublicAddress ownAddress; + + PaymentsTransferViewModel() { + ownAddress = ApplicationDependencies.getPayments().getWallet().getMobileCoinPublicAddress(); + } + + LiveData getAddress() { + return address; + } + + MobileCoinPublicAddress getOwnAddress() { + return ownAddress; + } + + @AnyThread + void postQrData(@NonNull String qrData) { + address.postValue(qrData); + } + + public static final class Factory implements ViewModelProvider.Factory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new PaymentsTransferViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InProgressViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InProgressViewHolder.java new file mode 100644 index 0000000000..b49ea618c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InProgressViewHolder.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.payments.preferences.model.InProgress; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class InProgressViewHolder extends MappingViewHolder { + + public InProgressViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + public void bind(@NonNull InProgress model) { } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InfoCardViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InfoCardViewHolder.java new file mode 100644 index 0000000000..562baa60d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/InfoCardViewHolder.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.app.AlertDialog; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeAdapter; +import org.thoughtcrime.securesms.payments.preferences.model.InfoCard; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class InfoCardViewHolder extends MappingViewHolder { + + private final Toolbar toolbar; + private final TextView message; + private final ImageView icon; + private final TextView learnMore; + + private final PaymentsHomeAdapter.Callbacks callbacks; + + public InfoCardViewHolder(@NonNull View itemView, @NonNull PaymentsHomeAdapter.Callbacks callbacks) { + super(itemView); + + this.callbacks = callbacks; + + toolbar = itemView.findViewById(R.id.payment_info_card_toolbar); + message = itemView.findViewById(R.id.payment_info_card_message); + icon = itemView.findViewById(R.id.payment_info_card_icon); + learnMore = itemView.findViewById(R.id.payment_info_card_learn_more); + + learnMore.setMovementMethod(LinkMovementMethod.getInstance()); + } + + @Override + public void bind(@NonNull InfoCard model) { + toolbar.setTitle(model.getTitleId()); + + toolbar.getMenu().clear(); + toolbar.inflateMenu(R.menu.payment_info_card_overflow); + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.action_hide) { + new AlertDialog.Builder(getContext()) + .setMessage(R.string.payment_info_card_hide_this_card) + .setPositiveButton(R.string.payment_info_card_hide, (dialog, which) -> { + model.dismiss(); + dialog.dismiss(); + callbacks.onInfoCardDismissed(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + return true; + } + + return false; + }); + + message.setText(model.getMessageId()); + icon.setImageDrawable(AppCompatResources.getDrawable(getContext(), model.getIconId())); + + SpannableString spannableString = new SpannableString(itemView.getContext().getString(model.getActionId())); + spannableString.setSpan(getSpan(model.getType()), 0, spannableString.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + learnMore.setText(spannableString); + } + + private ClickableSpan getSpan(@NonNull InfoCard.Type type) { + switch (type) { + case RECORD_RECOVERY_PHASE: + return new CallbackSpan(callbacks::onViewRecoveryPhrase); + case UPDATE_YOUR_PIN: + return new CallbackSpan(callbacks::onUpdatePin); + case ABOUT_MOBILECOIN: + return new LearnMoreURLSpan(getContext().getString(R.string.payment_info_card__learn_more__about_mobilecoin)); + case ADDING_TO_YOUR_WALLET: + return new LearnMoreURLSpan(getContext().getString(R.string.payment_info_card__learn_more__adding_to_your_wallet)); + case CASHING_OUT: + return new LearnMoreURLSpan(getContext().getString(R.string.payment_info_card__learn_more__cashing_out)); + } + + throw new IllegalArgumentException("Unexpected type " + type.name()); + } + + private static final class CallbackSpan extends ClickableSpan { + + private final Runnable runnable; + + private CallbackSpan(@NonNull Runnable runnable) { + this.runnable = runnable; + } + + @Override + public void onClick(@NonNull View widget) { + runnable.run(); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.setUnderlineText(false); + } + } + + private static final class LearnMoreURLSpan extends URLSpan { + + public LearnMoreURLSpan(String url) { + super(url); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.setUnderlineText(false); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/IntroducingPaymentViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/IntroducingPaymentViewHolder.java new file mode 100644 index 0000000000..6f0510d5e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/IntroducingPaymentViewHolder.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.Group; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeAdapter; +import org.thoughtcrime.securesms.payments.preferences.model.IntroducingPayments; +import org.thoughtcrime.securesms.util.MappingViewHolder; +import org.thoughtcrime.securesms.util.views.LearnMoreTextView; + +public class IntroducingPaymentViewHolder extends MappingViewHolder { + + private final PaymentsHomeAdapter.Callbacks callbacks; + private final View activateButton; + private final Group activatingGroup; + private final View restoreButton; + private final LearnMoreTextView learnMoreView; + + public IntroducingPaymentViewHolder(@NonNull View itemView, @NonNull PaymentsHomeAdapter.Callbacks callbacks) { + super(itemView); + this.callbacks = callbacks; + this.activateButton = findViewById(R.id.payment_preferences_splash_activate); + this.activatingGroup = findViewById(R.id.payment_preferences_splash_activating_group); + this.restoreButton = findViewById(R.id.payment_preferences_splash_restore); + this.learnMoreView = findViewById(R.id.payment_preferences_splash_text_1); + } + + @Override + public void bind(@NonNull IntroducingPayments model) { + if (model.isActivating()) { + activateButton.setVisibility(View.INVISIBLE); + activatingGroup.setVisibility(View.VISIBLE); + } else { + activateButton.setVisibility(View.VISIBLE); + activatingGroup.setVisibility(View.GONE); + } + + learnMoreView.setLearnMoreVisible(true); + learnMoreView.setLink(getContext().getString(R.string.PaymentsHomeFragment__learn_more__activate_payments)); + + activateButton.setOnClickListener(v -> callbacks.onActivatePayments()); + + if (SignalStore.paymentsValues().hasPaymentsEntropy()) { + restoreButton.setVisibility(View.GONE); + } else { + restoreButton.setVisibility(View.VISIBLE); + restoreButton.setOnClickListener(v -> callbacks.onRestorePaymentsAccount()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/NoRecentActivityViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/NoRecentActivityViewHolder.java new file mode 100644 index 0000000000..32bc9da124 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/NoRecentActivityViewHolder.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.payments.preferences.model.NoRecentActivity; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class NoRecentActivityViewHolder extends MappingViewHolder { + + public NoRecentActivityViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + public void bind(@NonNull NoRecentActivity model) { } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/PaymentItemViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/PaymentItemViewHolder.java new file mode 100644 index 0000000000..2256665729 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/PaymentItemViewHolder.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeAdapter; +import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem; +import org.thoughtcrime.securesms.util.MappingViewHolder; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel.RecipientIdMappingModel; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; + +public class PaymentItemViewHolder extends MappingViewHolder { + + private final RecipientViewHolder delegate; + private final PaymentsHomeAdapter.Callbacks callbacks; + private final AvatarImageView avatar; + private final TextView name; + private final TextView date; + private final TextView amount; + private final View inProgress; + private final View unreadIndicator; + + public PaymentItemViewHolder(@NonNull View itemView, @NonNull PaymentsHomeAdapter.Callbacks callbacks) { + super(itemView); + this.delegate = new RecipientViewHolder<>(itemView, null, true); + this.callbacks = callbacks; + this.avatar = findViewById(R.id.recipient_view_avatar); + this.name = findViewById(R.id.recipient_view_name); + this.date = findViewById(R.id.payments_home_payment_item_date); + this.amount = findViewById(R.id.payments_home_payment_item_amount); + this.inProgress = findViewById(R.id.payments_home_payment_item_avatar_progress_overlay); + this.unreadIndicator = findViewById(R.id.unread_indicator); + } + + @Override + public void bind(@NonNull PaymentItem model) { + if (model.hasRecipient() && !model.isDefrag()) { + delegate.bind(model.getRecipientIdModel()); + } else { + name.setText(model.getTransactionName(getContext())); + avatar.disableQuickContact(); + avatar.setNonAvatarImageResource(model.getTransactionAvatar()); + } + + inProgress.setVisibility(model.isInProgress() ? View.VISIBLE : View.GONE); + date.setText(model.getDate(context)); + amount.setText(SpanUtil.color(ContextCompat.getColor(context, model.getAmountColor()), model.getAmount(getContext()))); + + itemView.setOnClickListener(v -> callbacks.onPaymentItem(model)); + + unreadIndicator.setVisibility(model.isUnread() ? View.VISIBLE : View.GONE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/SeeAllViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/SeeAllViewHolder.java new file mode 100644 index 0000000000..783abf4b24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/viewholder/SeeAllViewHolder.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.payments.preferences.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeAdapter; +import org.thoughtcrime.securesms.payments.preferences.model.SeeAll; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class SeeAllViewHolder extends MappingViewHolder { + + private final PaymentsHomeAdapter.Callbacks callbacks; + private final View seeAllButton; + + public SeeAllViewHolder(@NonNull View itemView, PaymentsHomeAdapter.Callbacks callbacks) { + super(itemView); + this.callbacks = callbacks; + this.seeAllButton = itemView.findViewById(R.id.payments_home_see_all_item_button); + } + + @Override + public void bind(@NonNull SeeAll model) { + seeAllButton.setOnClickListener(v -> callbacks.onSeeAll(model.getPaymentType())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/LedgerReconcile.java b/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/LedgerReconcile.java new file mode 100644 index 0000000000..979d3598cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/LedgerReconcile.java @@ -0,0 +1,267 @@ +package org.thoughtcrime.securesms.payments.reconciliation; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.ComparatorCompat; +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + +import org.signal.core.util.MapUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.payments.MobileCoinLedgerWrapper; +import org.thoughtcrime.securesms.payments.Payment; +import org.thoughtcrime.securesms.payments.PaymentDecorator; +import org.thoughtcrime.securesms.payments.ReconstructedPayment; +import org.thoughtcrime.securesms.payments.State; +import org.thoughtcrime.securesms.payments.history.TransactionReconstruction; +import org.thoughtcrime.securesms.payments.proto.PaymentMetaData; +import org.whispersystems.signalservice.api.payments.Money; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; + +public final class LedgerReconcile { + + private static final String TAG = Log.tag(LedgerReconcile.class); + + @WorkerThread + public static @NonNull List reconcile(@NonNull Collection localPaymentTransactions, + @NonNull MobileCoinLedgerWrapper ledgerWrapper) + { + long start = System.currentTimeMillis(); + try { + return reconcile(localPaymentTransactions, ledgerWrapper.getAllTxos()); + } finally { + Log.d(TAG, String.format(Locale.US, "Took %d ms - Ledger %d, Local %d", System.currentTimeMillis() - start, ledgerWrapper.getAllTxos().size(), localPaymentTransactions.size())); + } + } + + @WorkerThread + private static @NonNull List reconcile(@NonNull Collection allLocalPaymentTransactions, + @NonNull List allTxOuts) + { + List nonFailedLocalPayments = Stream.of(allLocalPaymentTransactions).filter(i -> i.getState() != State.FAILED).toList(); + Set allKnownPublicKeys = new HashSet<>(nonFailedLocalPayments.size()); + Set allKnownKeyImages = new HashSet<>(nonFailedLocalPayments.size()); + + for (Payment paymentTransaction : nonFailedLocalPayments) { + PaymentMetaData.MobileCoinTxoIdentification txoIdentification = paymentTransaction.getPaymentMetaData().getMobileCoinTxoIdentification(); + + allKnownPublicKeys.addAll(txoIdentification.getPublicKeyList()); + allKnownKeyImages.addAll(txoIdentification.getKeyImagesList()); + } + + Set knownTxosByKeyImage = Stream.of(allTxOuts) + .filter(t -> allKnownKeyImages.contains(t.getKeyImage())) + .collect(Collectors.toSet()); + + Set knownTxosByPublicKeys = Stream.of(allTxOuts) + .filter(t -> allKnownPublicKeys.contains(t.getPublicKey())) + .collect(Collectors.toSet()); + + // any TXO that we can't pair up the pub key for, we don't have detail for how it got into the account + Set unknownTxOutsReceived = new HashSet<>(allTxOuts); + unknownTxOutsReceived.removeAll(knownTxosByPublicKeys); + + // any TXO that we can't pair up the keyimage for, we don't have detail for how it got spent + Set unknownTxOutsSpent = Stream.of(allTxOuts).filter(MobileCoinLedgerWrapper.OwnedTxo::isSpent).collect(Collectors.toSet()); + unknownTxOutsSpent.removeAll(knownTxosByKeyImage); + + if (unknownTxOutsReceived.isEmpty() && unknownTxOutsSpent.isEmpty()) { + return Stream.of(allLocalPaymentTransactions).map(t -> (Payment) t).toList(); + } + + List detailedTransactions = reconstructAllTransactions(unknownTxOutsReceived, unknownTxOutsSpent); + + List reconstructedPayments = new ArrayList<>(detailedTransactions.size()); + + List blockDecoratedLocalPayments = decoratePaymentsWithBlockIndexes(allLocalPaymentTransactions, allTxOuts); + + for (DetailedTransaction detailedTransaction : detailedTransactions) { + reconstructedPayments.add(new ReconstructedPayment(detailedTransaction.blockDetail.getBlockIndex(), + detailedTransaction.blockDetail.getBlockTimestampOrZero(), + detailedTransaction.transaction.getDirection(), + detailedTransaction.transaction.getValue())); + } + + Collections.sort(reconstructedPayments, Payment.DESCENDING_BLOCK_INDEX); + + return ZipList.zipList(blockDecoratedLocalPayments, reconstructedPayments, Payment.DESCENDING_BLOCK_INDEX_UNKNOWN_FIRST); + } + + private static List decoratePaymentsWithBlockIndexes(@NonNull Collection localPaymentTransactions, + @NonNull List allTxOuts) + { + List result = new ArrayList<>(localPaymentTransactions.size()); + Map blockDetailMap = new HashMap<>(allTxOuts.size() * 2); + + for (MobileCoinLedgerWrapper.OwnedTxo txo : allTxOuts) { + blockDetailMap.put(txo.getPublicKey(), txo); + blockDetailMap.put(txo.getKeyImage(), txo); + } + + for (Payment local : localPaymentTransactions) { + result.add(findBlock(local, blockDetailMap)); + } + return result; + } + + private static @NonNull Payment findBlock(@NonNull Payment local, @NonNull Map allTxOuts) { + if (local.getDirection().isReceived()) { + for (ByteString publicKey : local.getPaymentMetaData().getMobileCoinTxoIdentification().getPublicKeyList()) { + MobileCoinLedgerWrapper.OwnedTxo ownedTxo = allTxOuts.get(publicKey); + + if (ownedTxo != null) { + long receivedInBlock = ownedTxo.getReceivedInBlock(); + long receivedInBlockTimestamp = ownedTxo.getReceivedInBlockTimestamp() != null ? ownedTxo.getReceivedInBlockTimestamp() : 0L; + + return BlockOverridePayment.override(local, receivedInBlock, receivedInBlockTimestamp); + } + } + } else { + for (ByteString keyImage : local.getPaymentMetaData().getMobileCoinTxoIdentification().getKeyImagesList()) { + MobileCoinLedgerWrapper.OwnedTxo ownedTxo = allTxOuts.get(keyImage); + + if (ownedTxo != null && ownedTxo.getSpentInBlock() != null) { + long spentInBlock = ownedTxo.getSpentInBlock(); + long spentInBlockTimestamp = ownedTxo.getSpentInBlockTimestamp() != null ? ownedTxo.getSpentInBlockTimestamp() : 0L; + + return BlockOverridePayment.override(local, spentInBlock, spentInBlockTimestamp); + } + } + } + return local; + } + + public static class BlockDetail { + + public static final Comparator BLOCK_INDEX = (a, b) -> Long.compare(a.blockIndex, b.blockIndex); + private final long blockIndex; + + private final Long blockTimestamp; + + public BlockDetail(long blockIndex, @Nullable Long blockTimestamp) { + this.blockIndex = blockIndex; + this.blockTimestamp = blockTimestamp; + } + + public long getBlockTimestampOrZero() { + return blockTimestamp == null ? 0 : blockTimestamp; + } + + public long getBlockIndex() { + return blockIndex; + } + } + + public static class DetailedTransaction { + private static final Comparator BLOCK_INDEX = (a, b) -> BlockDetail.BLOCK_INDEX.compare(a.blockDetail, b.blockDetail); + private static final Comparator TRANSACTION = (a, b) -> TransactionReconstruction.Transaction.ORDER.compare(a.transaction, b.transaction); + public static final Comparator ASCENDING = ComparatorCompat.chain(BLOCK_INDEX) + .thenComparing(TRANSACTION); + public static final Comparator DESCENDING = ComparatorCompat.reversed(ASCENDING); + + private final BlockDetail blockDetail; + + private final TransactionReconstruction.Transaction transaction; + + public DetailedTransaction(@NonNull BlockDetail blockDetail, @NonNull TransactionReconstruction.Transaction transaction) { + this.blockDetail = blockDetail; + this.transaction = transaction; + } + + } + + private static @NonNull List reconstructAllTransactions(@NonNull Set unknownReceived, @NonNull Set unknownSpent) { + Set allBlocksWithActivity = Stream.of(unknownReceived) + .map(MobileCoinLedgerWrapper.OwnedTxo::getReceivedInBlock) + .collect(Collectors.toSet()); + + allBlocksWithActivity + .addAll(Stream.of(unknownSpent) + .map(MobileCoinLedgerWrapper.OwnedTxo::getSpentInBlock) + .collect(Collectors.toSet())); + + Map> receivedInBlock = Stream.of(unknownReceived) + .collect(Collectors.groupingBy(MobileCoinLedgerWrapper.OwnedTxo::getReceivedInBlock)); + + Map> spentInBlock = Stream.of(unknownSpent) + .filter(MobileCoinLedgerWrapper.OwnedTxo::isSpent) + .collect(Collectors.groupingBy(MobileCoinLedgerWrapper.OwnedTxo::getSpentInBlock)); + + return Stream.of(allBlocksWithActivity) + .sorted((a, b) -> b.compareTo(a)) + .flatMap(blockIndex -> { + List unspent = MapUtil.getOrDefault(receivedInBlock, blockIndex, Collections.emptyList()); + List spent = MapUtil.getOrDefault(spentInBlock, blockIndex, Collections.emptyList()); + + if (spent.size() + unspent.size() == 0) { + throw new AssertionError(); + } + + Long timeStamp = null; + if (spent.size() > 0) { + timeStamp = spent.get(0).getSpentInBlockTimestamp(); + } + if (timeStamp == null && unspent.size() > 0) { + timeStamp = unspent.get(0).getReceivedInBlockTimestamp(); + } + + TransactionReconstruction transactionReconstruction = TransactionReconstruction.estimateBlockLevelActivity(toMobileCoinList(spent), toMobileCoinList(unspent)); + + BlockDetail blockDetail = new BlockDetail(blockIndex, timeStamp); + return Stream.of(transactionReconstruction.getAllTransactions()) + .map(t -> new DetailedTransaction(blockDetail, t)); + }) + .sorted(DetailedTransaction.DESCENDING) + .toList(); + } + + private static @NonNull List toMobileCoinList(@NonNull List spent) { + return Stream.of(spent) + .map(MobileCoinLedgerWrapper.OwnedTxo::getValue) + .toList(); + } + + public static class BlockOverridePayment extends PaymentDecorator { + private final long blockIndex; + private final long blockTimestamp; + + static Payment override(@NonNull Payment payment, long blockIndex, long blockTimestamp) { + if (payment.getBlockTimestamp() == blockTimestamp && payment.getBlockIndex() == blockIndex) { + return payment; + } else { + return new BlockOverridePayment(payment, blockIndex, blockTimestamp); + } + } + + private BlockOverridePayment(@NonNull Payment inner, long blockIndex, long blockTimestamp) { + super(inner); + this.blockIndex = blockIndex; + this.blockTimestamp = blockTimestamp; + } + + @Override + public long getBlockIndex() { + return blockIndex; + } + + @Override + public long getBlockTimestamp() { + return blockTimestamp != 0 ? blockTimestamp + : super.getBlockTimestamp(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/ZipList.java b/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/ZipList.java new file mode 100644 index 0000000000..49d0f87a73 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/reconciliation/ZipList.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.payments.reconciliation; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +final class ZipList { + + /** + * Builds a list that contains all items from {@param a} and {@param b}. + *

+ * The items in the resulting list will keep their position relative to other items in their source list. + *

+ * The {@param comparator} is used to decide how to interleave the items in the result by considering the heads of the lists. + */ + static @NonNull List zipList(@NonNull List a, @NonNull List b, @NonNull Comparator comparator) { + ArrayList result = new ArrayList<>(a.size() + b.size()); + + if (a.isEmpty()) { + return new ArrayList<>(b); + } + + if (b.isEmpty()) { + return new ArrayList<>(a); + } + + int bIndex = 0; + int aIndex = 0; + + do { + T itemA = a.get(aIndex); + T itemB = b.get(bIndex); + if (comparator.compare(itemA, itemB) > 0) { + result.add(itemB); + bIndex++; + } else { + result.add(itemA); + aIndex++; + } + } while (aIndex < a.size() && bIndex < b.size()); + + for (int i = aIndex; i < a.size(); i++) { + result.add(a.get(i)); + } + for (int i = bIndex; i < b.size(); i++) { + result.add(b.get(i)); + } + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java index ad53ba7277..b1bf21cfb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java @@ -14,6 +14,8 @@ import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; +import org.thoughtcrime.securesms.payments.backup.PaymentsRecoveryStartFragmentArgs; +import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; import org.thoughtcrime.securesms.pin.PinOptOutDialog; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -80,6 +82,22 @@ public class AdvancedPinPreferenceFragment extends ListSummaryPreferenceFragment .setCancelable(true) .setPositiveButton(android.R.string.ok, (d, which) -> d.dismiss()) .show(); + } else if (!enabled && SignalStore.paymentsValues().mobileCoinPaymentsEnabled() && !SignalStore.paymentsValues().userConfirmedMnemonic()) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.ApplicationPreferencesActivity_record_payments_recovery_phrase) + .setMessage(R.string.ApplicationPreferencesActivity_before_you_can_disable_your_pin) + .setPositiveButton(R.string.ApplicationPreferencesActivity_record_phrase, (dialog, which) -> { + Intent intent = new Intent(requireContext(), PaymentsActivity.class); + intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_paymentsBackup); + intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new PaymentsRecoveryStartFragmentArgs.Builder().setFinishOnConfirm(true).build().toBundle()); + + startActivity(intent); + + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .show(); } else if (!enabled) { PinOptOutDialog.show(requireContext(), () -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java index f181b06fc6..92a92722fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java @@ -32,14 +32,18 @@ import org.thoughtcrime.securesms.delete.DeleteAccountFragment; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; +import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; +import org.thoughtcrime.securesms.payments.preferences.transfer.PaymentsTransferFragmentArgs; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; -import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.payments.FormatterOptions; +import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import java.io.IOException; @@ -54,6 +58,7 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { private static final String DELETE_ACCOUNT = "pref_delete_account"; private static final int PICK_IDENTITY_CONTACT = 1; + private static final int TRANSFER_CURRENCY = 2; @Override public void onCreate(Bundle paramBundle) { @@ -84,7 +89,27 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { Preference deleteAccount = this.findPreference(DELETE_ACCOUNT); deleteAccount.setOnPreferenceClickListener(preference -> { - getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); + Money.MobileCoin latestBalance = SignalStore.paymentsValues().mobileCoinLatestBalance().getFullAmount().requireMobileCoin(); + + if (!latestBalance.equals(Money.MobileCoin.ZERO)) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.AdvancedPreferenceFragment__transfer_mob_balance) + .setMessage(getString(R.string.AdvancedPreferenceFragment__you_have_a_balance_of_s, latestBalance.toString(FormatterOptions.defaults()))) + .setPositiveButton(R.string.AdvancedPreferenceFragment__transfer, (dialog, which) -> { + Intent intent = new Intent(requireContext(), PaymentsActivity.class); + intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_paymentsTransfer); + intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new PaymentsTransferFragmentArgs.Builder().setFinishOnConfirm(true).build().toBundle()); + startActivityForResult(intent, TRANSFER_CURRENCY); + dialog.dismiss(); + }) + .setNegativeButton(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary), getString(R.string.AdvancedPreferenceFragment__dont_transfer)), (dialog, which) -> { + getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); + dialog.dismiss(); + }) + .show(); + } else { + getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); + } return false; }); } @@ -123,6 +148,8 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { Log.i(TAG, "Got result: " + resultCode + " for req: " + reqCode); if (resultCode == Activity.RESULT_OK && reqCode == PICK_IDENTITY_CONTACT) { handleIdentitySelection(data); + } else if (resultCode == Activity.RESULT_OK && reqCode == TRANSFER_CURRENCY) { + getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java index 2356c46e4e..7da62d2c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.preferences; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.Context; import android.os.Bundle; import android.widget.Toast; @@ -7,6 +10,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceDataStore; +import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; @@ -19,7 +23,9 @@ import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; import org.thoughtcrime.securesms.jobs.StorageForcePushJob; import org.thoughtcrime.securesms.keyvalue.InternalValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.DataExportUtil; import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragment { private static final String TAG = Log.tag(InternalOptionsPreferenceFragment.class); @@ -44,6 +50,30 @@ public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragme initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DISABLE_AUTOMIGRATE_NOTIFICATION, SignalStore.internalValues().disableGv1AutoMigrateNotification()); initializeSwitchPreference(preferenceDataStore, InternalValues.FORCE_CENSORSHIP, SignalStore.internalValues().forcedCensorship()); + findPreference("pref_copy_payments_data").setOnPreferenceClickListener(preference -> { + new AlertDialog.Builder(getContext()) + .setMessage("Local payments history will be copied to the clipboard.\n" + + "It may therefore compromise privacy.\n" + + "However, no private keys will be copied.") + .setPositiveButton("Copy", (dialog, which) -> { + SimpleTask.run(SignalExecutors.UNBOUNDED, + () -> { + Context context = ApplicationDependencies.getApplication(); + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + String tsv = DataExportUtil.createTsv(); + ClipData clip = ClipData.newPlainText(context.getString(R.string.app_name), tsv); + clipboard.setPrimaryClip(clip); + return null; + }, + r -> Toast.makeText(getContext(), "Payments have been copied", Toast.LENGTH_SHORT).show() + ); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return true; + }); + findPreference("pref_refresh_attributes").setOnPreferenceClickListener(preference -> { ApplicationDependencies.getJobManager() .startChain(new RefreshAttributesJob()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java index 80b4c0f190..6b94870b4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java @@ -137,7 +137,7 @@ public class StoragePreferenceFragment extends ListSummaryPreferenceFragment { public @NonNull MappingModelList getSettings() { KeepMessagesDuration currentDuration = SignalStore.settings().getKeepMessagesDuration(); return Stream.of(KeepMessagesDuration.values()) - .map(duration -> new SingleSelectSetting.Item(duration, activity.getString(duration.getStringResource()), duration.equals(currentDuration))) + .map(duration -> new SingleSelectSetting.Item(duration, activity.getString(duration.getStringResource()), null, duration.equals(currentDuration))) .collect(MappingModelList.toMappingModelList()); } @@ -187,7 +187,7 @@ public class StoragePreferenceFragment extends ListSummaryPreferenceFragment { String text = option == 0 ? activity.getString(R.string.preferences_storage__none) : activity.getString(R.string.preferences_storage__s_messages, NumberFormat.getInstance().format(option)); - settings.add(new SingleSelectSetting.Item(option, text, isSelected)); + settings.add(new SingleSelectSetting.Item(option, text, null, isSelected)); hasSelection = hasSelection || isSelected; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/PaymentsPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/PaymentsPreference.java new file mode 100644 index 0000000000..5126503f36 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/PaymentsPreference.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.preferences.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import org.thoughtcrime.securesms.R; + +public class PaymentsPreference extends Preference { + + private TextView unreadIndicator; + private int unreadCount; + + public PaymentsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(); + } + + public PaymentsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + public PaymentsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public PaymentsPreference(Context context) { + super(context); + initialize(); + } + + private void initialize() { + setLayoutResource(R.layout.payments_preference); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + unreadIndicator = holder.itemView.findViewById(R.id.unread_indicator); + + setUnreadCount(unreadCount); + } + + public void setUnreadCount(int unreadCount) { + this.unreadCount = unreadCount; + + if (unreadIndicator != null) { + unreadIndicator.setVisibility(unreadCount > 0 ? View.VISIBLE : View.GONE); + unreadIndicator.setText(String.valueOf(unreadCount)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java index a674fba44b..b35020a212 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -47,6 +47,10 @@ public class RecipientId implements Parcelable, Comparable { } } + public static @Nullable RecipientId fromNullable(@Nullable String id) { + return id != null ? from(id) : null; + } + @AnyThread public static @NonNull RecipientId from(@NonNull SignalServiceAddress address) { return from(address.getUuid().orNull(), address.getNumber().orNull(), false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java index e940796fdc..d29271ac89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java @@ -71,8 +71,10 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger pinnedConversations = remote.getPinnedConversations(); AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode(); boolean preferContactAvatars = remote.isPreferContactAvatars(); - boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); - boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); + boolean paymentsEnabled = remote.getPayments().isEnabled(); + byte[] paymentsEntropy = remote.getPayments().getEntropy().or(local.getPayments().getEntropy()).orNull(); + boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, paymentsEnabled, paymentsEntropy); + boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, paymentsEnabled, paymentsEntropy); if (matchesRemote) { return remote; @@ -96,6 +98,7 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger pinnedConversations, - boolean preferContactAvatars) + boolean preferContactAvatars, + boolean paymentsEnabled, + @Nullable byte[] paymentsEntropy) { - return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && - Objects.equals(contact.getGivenName().or(""), givenName) && - Objects.equals(contact.getFamilyName().or(""), familyName) && - Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) && - Arrays.equals(contact.getProfileKey().orNull(), profileKey) && - contact.isNoteToSelfArchived() == noteToSelfArchived && - contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread && - contact.isReadReceiptsEnabled() == readReceipts && - contact.isTypingIndicatorsEnabled() == typingIndicators && - contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && - contact.isLinkPreviewsEnabled() == linkPreviewsEnabled && - contact.getPhoneNumberSharingMode() == phoneNumberSharingMode && - contact.isPhoneNumberUnlisted() == unlistedPhoneNumber && - contact.isPreferContactAvatars() == preferContactAvatars && - Objects.equals(contact.getPinnedConversations(), pinnedConversations); + return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && + Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + contact.isNoteToSelfArchived() == noteToSelfArchived && + contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread && + contact.isReadReceiptsEnabled() == readReceipts && + contact.isTypingIndicatorsEnabled() == typingIndicators && + contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && + contact.isLinkPreviewsEnabled() == linkPreviewsEnabled && + contact.getPhoneNumberSharingMode() == phoneNumberSharingMode && + contact.isPhoneNumberUnlisted() == unlistedPhoneNumber && + contact.isPreferContactAvatars() == preferContactAvatars && + Objects.equals(contact.getPinnedConversations(), pinnedConversations) && + contact.getPayments().isEnabled() == paymentsEnabled && + Arrays.equals(contact.getPayments().getEntropy().orNull(), paymentsEntropy); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java index 9df8cb14a6..7cb2b02d4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.payments.Entropy; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.Base64; @@ -353,7 +354,7 @@ public final class StorageSyncHelper { @VisibleForTesting static void setTestKeyGenerator(@Nullable KeyGenerator testKeyGenerator) { - keyGenerator = testKeyGenerator; + keyGenerator = testKeyGenerator != null ? testKeyGenerator : KEY_GENERATOR; } private static @NonNull RecordMergeResult resolveRecordConflict(@NonNull Collection remoteOnlyRecords, @@ -432,6 +433,7 @@ public final class StorageSyncHelper { .setPhoneNumberSharingMode(StorageSyncModels.localToRemotePhoneNumberSharingMode(SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode())) .setPinnedConversations(StorageSyncModels.localToRemotePinnedConversations(pinned)) .setPreferContactAvatars(SignalStore.settings().isPreferSystemContactPhotos()) + .setPayments(SignalStore.paymentsValues().mobileCoinPaymentsEnabled(), Optional.fromNullable(SignalStore.paymentsValues().getPaymentsEntropy()).transform(Entropy::getBytes).orNull()) .build(); return SignalStorageRecord.forAccount(account); @@ -454,6 +456,7 @@ public final class StorageSyncHelper { SignalStore.phoneNumberPrivacy().setPhoneNumberListingMode(update.isPhoneNumberUnlisted() ? PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED : PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED); SignalStore.phoneNumberPrivacy().setPhoneNumberSharingMode(StorageSyncModels.remoteToLocalPhoneNumberSharingMode(update.getPhoneNumberSharingMode())); SignalStore.settings().setPreferSystemContactPhotos(update.isPreferContactAvatars()); + SignalStore.paymentsValues().setEnabledAndEntropy(update.getPayments().isEnabled(), Entropy.fromBytes(update.getPayments().getEntropy().orNull())); if (fetchProfile && update.getAvatarUrlPath().isPresent()) { ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java index 83665faa65..842fea48d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java @@ -34,6 +34,10 @@ public final class CursorUtil { return cursor.getBlob(cursor.getColumnIndexOrThrow(column)); } + public static boolean isNull(@NonNull Cursor cursor, @NonNull String column) { + return cursor.isNull(cursor.getColumnIndexOrThrow(column)); + } + public static boolean requireMaskedBoolean(@NonNull Cursor cursor, @NonNull String column, int position) { return Bitmask.read(requireLong(cursor, column), position); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index c646332f88..9fdb5d7687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.util; -import android.os.Build; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -50,6 +49,7 @@ public final class FeatureFlags { private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); + private static final String PAYMENTS_KILL_SWITCH = "android.payments.kill"; private static final String USERNAMES = "android.usernames"; private static final String GROUPS_V2_RECOMMENDED_LIMIT = "global.groupsv2.maxGroupSize"; private static final String GROUPS_V2_HARD_LIMIT = "global.groupsv2.groupSizeHardLimit"; @@ -81,6 +81,7 @@ public final class FeatureFlags { */ @VisibleForTesting static final Set REMOTE_CAPABLE = SetUtil.newHashSet( + PAYMENTS_KILL_SWITCH, GROUPS_V2_RECOMMENDED_LIMIT, GROUPS_V2_HARD_LIMIT, INTERNAL_USER, @@ -229,6 +230,11 @@ public final class FeatureFlags { getInteger(GROUPS_V2_HARD_LIMIT, 1001)); } + /** Payments Support */ + public static boolean payments() { + return !getBoolean(PAYMENTS_KILL_SWITCH, false); + } + /** Internal testing extensions. */ public static boolean internalUser() { return getBoolean(INTERNAL_USER, false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java index 8ed655e3f7..dffd0b32e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java @@ -7,8 +7,10 @@ import android.view.ViewGroup; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; import org.whispersystems.libsignal.util.guava.Function; @@ -63,12 +65,24 @@ public class MappingAdapter extends ListAdapter, MappingViewHold holder.onDetachedFromWindow(); } + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + if (recyclerView.getItemAnimator() != null && recyclerView.getItemAnimator().getClass() == DefaultItemAnimator.class) { + recyclerView.setItemAnimator(new NoCrossfadeChangeDefaultAnimator()); + } + } + public > void registerFactory(Class clazz, Factory factory) { int type = typeCount++; factories.put(type, factory); itemTypes.put(clazz, type); } + public > void registerFactory(@NonNull Class clazz, @NonNull Function> creator, @LayoutRes int layout) { + registerFactory(clazz, new LayoutFactory<>(creator, layout)); + } + @Override public int getItemViewType(int position) { Integer type = itemTypes.get(getItem(position).getClass()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java index 7648e2d89b..4fa8c76eb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java @@ -25,4 +25,13 @@ public abstract class MappingViewHolder> exten } public abstract void bind(@NonNull Model model); + + public static final class SimpleViewHolder> extends MappingViewHolder { + public SimpleViewHolder(@NonNull View itemView) { + super(itemView); + } + + @Override + public void bind(@NonNull Model model) { } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NoCrossfadeChangeDefaultAnimator.java b/app/src/main/java/org/thoughtcrime/securesms/util/NoCrossfadeChangeDefaultAnimator.java new file mode 100644 index 0000000000..0b44be4ffe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NoCrossfadeChangeDefaultAnimator.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +/** + * Disable animations for changes to same item + */ +public class NoCrossfadeChangeDefaultAnimator extends DefaultItemAnimator { + @Override + public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromX, int fromY, int toX, int toY) { + if (oldHolder == newHolder) { + if (oldHolder != null) { + dispatchChangeFinished(oldHolder, true); + } + } else { + if (oldHolder != null) { + dispatchChangeFinished(oldHolder, true); + } + if (newHolder != null) { + dispatchChangeFinished(newHolder, false); + } + } + return false; + } + + @Override + public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull List payloads) { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index ca39402284..2c7376f71b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -7,18 +7,26 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; +import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.payments.MobileCoinPublicAddressProfileUtil; +import org.thoughtcrime.securesms.payments.PaymentsAddressException; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessagePipe; @@ -33,6 +41,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.util.StreamDetails; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture; import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; @@ -94,15 +103,85 @@ public final class ProfileUtil { } } - public static @Nullable String decryptName(@NonNull ProfileKey profileKey, @Nullable String encryptedName) + public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable byte[] encryptedString) throws InvalidCiphertextException, IOException { - if (encryptedName == null) { + if (encryptedString == null) { return null; } ProfileCipher profileCipher = new ProfileCipher(profileKey); - return new String(profileCipher.decryptName(Base64.decode(encryptedName))); + return profileCipher.decryptString(encryptedString); + } + + public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable String encryptedStringBase64) + throws InvalidCiphertextException, IOException + { + if (encryptedStringBase64 == null) { + return null; + } + + return decryptString(profileKey, Base64.decode(encryptedStringBase64)); + } + + @WorkerThread + public static @NonNull MobileCoinPublicAddress getAddressForRecipient(@NonNull Recipient recipient) + throws InterruptedException, ExecutionException, PaymentsAddressException + { + ProfileKey profileKey; + try { + profileKey = getProfileKey(recipient); + } catch (IOException e) { + Log.w(TAG, "Profile key not available for " + recipient.getId()); + throw new PaymentsAddressException(PaymentsAddressException.Code.NO_PROFILE_KEY); + } + ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfile(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE).get(); + SignalServiceProfile profile = profileAndCredential.getProfile(); + byte[] encryptedPaymentsAddress = profile.getPaymentAddress(); + + if (encryptedPaymentsAddress == null) { + Log.w(TAG, "Payments not enabled for " + recipient.getId()); + throw new PaymentsAddressException(PaymentsAddressException.Code.NOT_ENABLED); + } + + try { + IdentityKey identityKey = new IdentityKey(Base64.decode(profileAndCredential.getProfile().getIdentityKey()), 0); + ProfileCipher profileCipher = new ProfileCipher(profileKey); + byte[] decrypted = profileCipher.decryptWithLength(encryptedPaymentsAddress); + SignalServiceProtos.PaymentAddress paymentAddress = SignalServiceProtos.PaymentAddress.parseFrom(decrypted); + byte[] bytes = MobileCoinPublicAddressProfileUtil.verifyPaymentsAddress(paymentAddress, identityKey); + MobileCoinPublicAddress mobileCoinPublicAddress = MobileCoinPublicAddress.fromBytes(bytes); + + if (mobileCoinPublicAddress == null) { + throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS); + } + + return mobileCoinPublicAddress; + } catch (InvalidCiphertextException | IOException e) { + Log.w(TAG, "Could not decrypt payments address, ProfileKey may be outdated for " + recipient.getId(), e); + throw new PaymentsAddressException(PaymentsAddressException.Code.COULD_NOT_DECRYPT); + } catch (InvalidKeyException e) { + Log.w(TAG, "Could not verify payments address due to bad identity key " + recipient.getId(), e); + throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS_SIGNATURE); + } + } + + private static ProfileKey getProfileKey(@NonNull Recipient recipient) throws IOException { + byte[] profileKeyBytes = recipient.getProfileKey(); + + if (profileKeyBytes == null) { + Log.w(TAG, "Profile key unknown for " + recipient.getId()); + throw new IOException("No profile key"); + } + + ProfileKey profileKey; + try { + profileKey = new ProfileKey(profileKeyBytes); + } catch (InvalidInputException e) { + Log.w(TAG, "Profile key invalid for " + recipient.getId()); + throw new IOException("Invalid profile key"); + } + return profileKey; } /** @@ -116,6 +195,7 @@ public final class ProfileUtil { profileName, Optional.fromNullable(Recipient.self().getAbout()).or(""), Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + getSelfPaymentsAddressProtobuf(), avatar); } } @@ -131,10 +211,20 @@ public final class ProfileUtil { Recipient.self().getProfileName(), about, emoji, + getSelfPaymentsAddressProtobuf(), avatar); } } + /** + * Uploads the profile based on all state that's already written to disk. + */ + public static void uploadProfile(@NonNull Context context) throws IOException { + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + uploadProfileWithAvatar(context, avatar); + } + } + /** * Uploads the profile based on all state that's written to disk, except we'll use the provided * avatar instead. This is useful when you want to ensure that the profile has been uploaded @@ -145,39 +235,48 @@ public final class ProfileUtil { Recipient.self().getProfileName(), Optional.fromNullable(Recipient.self().getAbout()).or(""), Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + getSelfPaymentsAddressProtobuf(), avatar); } - /** - * Uploads the profile based on all state that's already written to disk. - */ - public static void uploadProfile(@NonNull Context context) throws IOException { - try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { - uploadProfile(context, - Recipient.self().getProfileName(), - Optional.fromNullable(Recipient.self().getAbout()).or(""), - Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), - avatar); - } - } - private static void uploadProfile(@NonNull Context context, @NonNull ProfileName profileName, @Nullable String about, @Nullable String aboutEmoji, + @Nullable SignalServiceProtos.PaymentAddress paymentsAddress, @Nullable StreamDetails avatar) throws IOException { Log.d(TAG, "Uploading " + (!Util.isEmpty(about) ? "non-" : "") + "empty about."); Log.d(TAG, "Uploading " + (!Util.isEmpty(aboutEmoji) ? "non-" : "") + "empty emoji."); + Log.d(TAG, "Uploading " + (paymentsAddress != null ? "non-" : "") + "empty payments address."); ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); - String avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), about, aboutEmoji, avatar).orNull(); + String avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), + profileKey, + profileName.serialize(), + about, + aboutEmoji, + Optional.fromNullable(paymentsAddress), + avatar).orNull(); DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); } + private static @Nullable SignalServiceProtos.PaymentAddress getSelfPaymentsAddressProtobuf() { + if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) { + return null; + } else { + IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(ApplicationDependencies.getApplication()); + MobileCoinPublicAddress publicAddress = ApplicationDependencies.getPayments() + .getWallet() + .getMobileCoinPublicAddress(); + + return MobileCoinPublicAddressProfileUtil.signPaymentsAddress(publicAddress.serialize(), identityKeyPair); + } + } + private static @NonNull ListenableFuture getPipeRetrievalFuture(@NonNull SignalServiceAddress address, @NonNull Optional profileKey, @NonNull Optional unidentifiedAccess, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java index 4581cc211a..060cb5a18c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -1,11 +1,13 @@ package org.thoughtcrime.securesms.util; import android.content.Context; +import android.content.res.Resources; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Spannable; import android.text.SpannableString; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextPaint; import android.text.TextUtils; @@ -20,11 +22,16 @@ import android.text.style.StyleSpan; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; -public class SpanUtil { +public final class SpanUtil { + + private SpanUtil() {} + + public static final String SPAN_PLACE_HOLDER = "<<>>"; public static CharSequence italic(CharSequence sequence) { return italic(sequence, sequence.length()); @@ -116,4 +123,18 @@ public class SpanUtil { return spannable; } + + public static @NonNull CharSequence insertSingleSpan(@NonNull Resources resources, @StringRes int res, @NonNull CharSequence span) { + return replacePlaceHolder(resources.getString(res, SPAN_PLACE_HOLDER), span); + } + + public static CharSequence replacePlaceHolder(@NonNull String string, @NonNull CharSequence span) { + int index = string.indexOf(SpanUtil.SPAN_PLACE_HOLDER); + if (index == -1) { + return string; + } + SpannableStringBuilder builder = new SpannableStringBuilder(string); + builder.replace(index, index + SpanUtil.SPAN_PLACE_HOLDER.length(), span); + return builder; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java index 48369c45c7..faa3ffeea6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java @@ -247,4 +247,22 @@ public final class StringUtil { } return (startIndex > 0 || length < text.length()) ? text.subSequence(startIndex, length) : text; } + + /** + * If the {@param text} exceeds the {@param maxChars} it is trimmed in the middle so that the result is exactly {@param maxChars} long including an added + * ellipsis character. + *

+ * Otherwise the string is returned untouched. + *

+ * When {@param maxChars} is even, one more character is kept from the end of the string than the start. + */ + public static @Nullable CharSequence abbreviateInMiddle(@Nullable CharSequence text, int maxChars) { + if (text == null || text.length() <= maxChars) { + return text; + } + + int start = (maxChars - 1) / 2; + int end = (maxChars - 1) - start; + return text.subSequence(0, start) + "…" + text.subSequence(text.length() - end, text.length()); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index bf76b0615f..b34d0f4ff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -18,6 +18,7 @@ import org.whispersystems.libsignal.util.guava.Function; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Executor; @@ -178,6 +179,14 @@ public final class LiveDataUtil { }; } + public static LiveData never() { + return new MutableLiveData<>(); + } + + public static LiveData distinctUntilChanged(@NonNull LiveData source, @NonNull Function selector) { + return LiveDataUtil.distinctUntilChanged(source, (current, next) -> Objects.equals(selector.apply(current), selector.apply(next))); + } + public static LiveData distinctUntilChanged(@NonNull LiveData source, @NonNull EqualityChecker checker) { final MediatorLiveData outputLiveData = new MediatorLiveData<>(); outputLiveData.addSource(source, new Observer() { @@ -226,6 +235,10 @@ public final class LiveDataUtil { @NonNull R apply(@NonNull A a, @NonNull B b); } + public interface Combine3 { + @NonNull R apply(@NonNull A a, @NonNull B b, @NonNull C c); + } + public interface EqualityChecker { boolean contentsMatch(@NonNull T current, @NonNull T next); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java index e07ee76c51..420c8916cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.MappingModel; import java.util.Objects; @@ -28,4 +29,18 @@ public abstract class RecipientMappingModel> Context context = ApplicationDependencies.getApplication(); return getName(context).equals(newItem.getName(context)) && Objects.equals(getRecipient().getContactPhoto(), newItem.getRecipient().getContactPhoto()); } + + public static class RecipientIdMappingModel extends RecipientMappingModel { + + private final RecipientId recipientId; + + public RecipientIdMappingModel(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull Recipient getRecipient() { + return Recipient.resolved(recipientId); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java index de667bada9..7e76fe37dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java @@ -18,10 +18,16 @@ public class RecipientViewHolder> extends Map protected final @Nullable AvatarImageView avatar; protected final @Nullable TextView name; protected final @Nullable EventListener eventListener; + private final boolean quickContactEnabled; public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener eventListener) { + this(itemView, eventListener, false); + } + + public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener eventListener, boolean quickContactEnabled) { super(itemView); - this.eventListener = eventListener; + this.eventListener = eventListener; + this.quickContactEnabled = quickContactEnabled; avatar = findViewById(R.id.recipient_view_avatar); name = findViewById(R.id.recipient_view_name); @@ -30,7 +36,7 @@ public class RecipientViewHolder> extends Map @Override public void bind(@NonNull T model) { if (avatar != null) { - avatar.setRecipient(model.getRecipient()); + avatar.setRecipient(model.getRecipient(), quickContactEnabled); } if (name != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java index 17d67b1805..0adf24a1da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java @@ -17,6 +17,7 @@ import androidx.annotation.StringRes; import androidx.appcompat.widget.AppCompatTextView; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.ThemeUtil; public class LearnMoreTextView extends AppCompatTextView { @@ -68,6 +69,10 @@ public class LearnMoreTextView extends AppCompatTextView { setTextInternal(baseText, visible ? BufferType.SPANNABLE : BufferType.NORMAL); } + public void setLink(@NonNull String url) { + setOnLinkClickListener(new OpenUrlOnClickListener(url)); + } + private void setLinkTextInternal(@StringRes int linkText) { ClickableSpan clickable = new ClickableSpan() { @Override @@ -99,5 +104,19 @@ public class LearnMoreTextView extends AppCompatTextView { super.setText(text, type); } } + + private static class OpenUrlOnClickListener implements OnClickListener { + + private final String url; + + public OpenUrlOnClickListener(@NonNull String url) { + this.url = url; + } + + @Override + public void onClick(View v) { + CommunicationActions.openBrowserLink(v.getContext(), url); + } + } } diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index bbf45539a7..928e138c9f 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -70,6 +70,16 @@ message BodyRangeList { repeated BodyRange ranges = 1; } +message CryptoValue { + oneof Value { + MobileCoinValue mobileCoinValue = 1; + } + + message MobileCoinValue { + string picoMobileCoin = 1; + } +} + message GroupCallUpdateDetails { string eraId = 1; string startedCallUuid = 2; diff --git a/app/src/main/proto/Payments.proto b/app/src/main/proto/Payments.proto new file mode 100644 index 0000000000..33f8e50d8b --- /dev/null +++ b/app/src/main/proto/Payments.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.payments.proto"; +option java_multiple_files = true; + +message MobileCoinLedger { + + message OwnedTXO { + uint64 amount = 1; + bytes keyImage = 2; + bytes publicKey = 3; + Block receivedInBlock = 4; + Block spentInBlock = 5; + } + + message Block { + uint64 blockNumber = 1; + uint64 timestamp = 2; + } + + uint64 balance = 1; + uint64 transferableBalance = 2; + Block highestBlock = 3; + uint64 asOfTimeStamp = 4; + repeated OwnedTXO spentTxos = 5; + repeated OwnedTXO unspentTxos = 6; +} + +message PaymentMetaData { + + message MobileCoinTxoIdentification { + repeated bytes PublicKey = 1; + repeated bytes KeyImages = 2; + } + + MobileCoinTxoIdentification mobileCoinTxoIdentification = 1; +} diff --git a/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_end.xml b/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_end.xml new file mode 100644 index 0000000000..eb12a2ef8d --- /dev/null +++ b/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_end.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_start.xml b/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_start.xml new file mode 100644 index 0000000000..2ccb31bf43 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl-v21/circled_rectangle_outline_start.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_end.xml b/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_end.xml new file mode 100644 index 0000000000..918f12d74b --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_end.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_start.xml b/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_start.xml new file mode 100644 index 0000000000..218add1349 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/circled_rectangle_outline_start.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl/payment_info_pad.xml b/app/src/main/res/drawable-ldrtl/payment_info_pad.xml new file mode 100644 index 0000000000..ea3ee61187 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/payment_info_pad.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_about_mc_80.xml b/app/src/main/res/drawable-night/ic_about_mc_80.xml new file mode 100644 index 0000000000..3ccbec73c6 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_about_mc_80.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/ic_add_money_80.xml b/app/src/main/res/drawable-night/ic_add_money_80.xml new file mode 100644 index 0000000000..13ad58ba0e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_add_money_80.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_beta_24.xml b/app/src/main/res/drawable-night/ic_beta_24.xml new file mode 100644 index 0000000000..cdb3d97d91 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_beta_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_cash_out_80.xml b/app/src/main/res/drawable-night/ic_cash_out_80.xml new file mode 100644 index 0000000000..b1b528105d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_cash_out_80.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_check_circle_24.xml b/app/src/main/res/drawable-night/ic_check_circle_24.xml new file mode 100644 index 0000000000..0a0132a249 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_check_circle_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_payments_info_card_pin_80.xml b/app/src/main/res/drawable-night/ic_payments_info_card_pin_80.xml new file mode 100644 index 0000000000..ee97a4c8a8 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_payments_info_card_pin_80.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-night/ic_payments_info_card_restore_80.xml b/app/src/main/res/drawable-night/ic_payments_info_card_restore_80.xml new file mode 100644 index 0000000000..62bb78e4ec --- /dev/null +++ b/app/src/main/res/drawable-night/ic_payments_info_card_restore_80.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-v21/circled_rectangle_outline_end.xml b/app/src/main/res/drawable-v21/circled_rectangle_outline_end.xml new file mode 100644 index 0000000000..2ccb31bf43 --- /dev/null +++ b/app/src/main/res/drawable-v21/circled_rectangle_outline_end.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/circled_rectangle_outline_start.xml b/app/src/main/res/drawable-v21/circled_rectangle_outline_start.xml new file mode 100644 index 0000000000..eb12a2ef8d --- /dev/null +++ b/app/src/main/res/drawable-v21/circled_rectangle_outline_start.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_inverse_transparent_60.xml b/app/src/main/res/drawable/circle_inverse_transparent_60.xml new file mode 100644 index 0000000000..12e56ef26b --- /dev/null +++ b/app/src/main/res/drawable/circle_inverse_transparent_60.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/circled_rectangle_outline.xml b/app/src/main/res/drawable/circled_rectangle_outline.xml new file mode 100644 index 0000000000..3bc641e528 --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circled_rectangle_outline_end.xml b/app/src/main/res/drawable/circled_rectangle_outline_end.xml new file mode 100644 index 0000000000..50ec633568 --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_end.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/circled_rectangle_outline_left_normal.xml b/app/src/main/res/drawable/circled_rectangle_outline_left_normal.xml new file mode 100644 index 0000000000..454ce0637f --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_left_normal.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circled_rectangle_outline_left_pressed.xml b/app/src/main/res/drawable/circled_rectangle_outline_left_pressed.xml new file mode 100644 index 0000000000..a323e60b4f --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_left_pressed.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circled_rectangle_outline_middle.xml b/app/src/main/res/drawable/circled_rectangle_outline_middle.xml new file mode 100644 index 0000000000..3858d2f71a --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_middle.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circled_rectangle_outline_right_normal.xml b/app/src/main/res/drawable/circled_rectangle_outline_right_normal.xml new file mode 100644 index 0000000000..10f4409051 --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_right_normal.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/circled_rectangle_outline_right_pressed.xml b/app/src/main/res/drawable/circled_rectangle_outline_right_pressed.xml new file mode 100644 index 0000000000..9938551aed --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_right_pressed.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circled_rectangle_outline_start.xml b/app/src/main/res/drawable/circled_rectangle_outline_start.xml new file mode 100644 index 0000000000..28dceca80e --- /dev/null +++ b/app/src/main/res/drawable/circled_rectangle_outline_start.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_about_mc_80.xml b/app/src/main/res/drawable/ic_about_mc_80.xml new file mode 100644 index 0000000000..174c0a0113 --- /dev/null +++ b/app/src/main/res/drawable/ic_about_mc_80.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_activate_payments.xml b/app/src/main/res/drawable/ic_activate_payments.xml new file mode 100644 index 0000000000..83f0554675 --- /dev/null +++ b/app/src/main/res/drawable/ic_activate_payments.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_money_20.xml b/app/src/main/res/drawable/ic_add_money_20.xml new file mode 100644 index 0000000000..413a0ac9e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_money_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_money_80.xml b/app/src/main/res/drawable/ic_add_money_80.xml new file mode 100644 index 0000000000..91fe75ed5b --- /dev/null +++ b/app/src/main/res/drawable/ic_add_money_80.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_left_20.xml b/app/src/main/res/drawable/ic_arrow_left_20.xml new file mode 100644 index 0000000000..6b145f2aaf --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left_20.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_beta_24.xml b/app/src/main/res/drawable/ic_beta_24.xml new file mode 100644 index 0000000000..d352a772ca --- /dev/null +++ b/app/src/main/res/drawable/ic_beta_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_cash_out_80.xml b/app/src/main/res/drawable/ic_cash_out_80.xml new file mode 100644 index 0000000000..5eff73ca5a --- /dev/null +++ b/app/src/main/res/drawable/ic_cash_out_80.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_check_circle_24.xml b/app/src/main/res/drawable/ic_check_circle_24.xml new file mode 100644 index 0000000000..8db3e53b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_circle_x_24.xml b/app/src/main/res/drawable/ic_circle_x_24.xml new file mode 100644 index 0000000000..caf3805b5a --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_x_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mobilecoin_24.xml b/app/src/main/res/drawable/ic_mobilecoin_24.xml new file mode 100644 index 0000000000..d5a01afe4e --- /dev/null +++ b/app/src/main/res/drawable/ic_mobilecoin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mobilecoin_avatar_24.xml b/app/src/main/res/drawable/ic_mobilecoin_avatar_24.xml new file mode 100644 index 0000000000..fbc5707dc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_mobilecoin_avatar_24.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_payments_24.xml b/app/src/main/res/drawable/ic_payments_24.xml new file mode 100644 index 0000000000..14a9083f19 --- /dev/null +++ b/app/src/main/res/drawable/ic_payments_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_payments_32.xml b/app/src/main/res/drawable/ic_payments_32.xml new file mode 100644 index 0000000000..60f859d891 --- /dev/null +++ b/app/src/main/res/drawable/ic_payments_32.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_payments_info_card_pin_80.xml b/app/src/main/res/drawable/ic_payments_info_card_pin_80.xml new file mode 100644 index 0000000000..e55cba3580 --- /dev/null +++ b/app/src/main/res/drawable/ic_payments_info_card_pin_80.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_payments_info_card_restore_80.xml b/app/src/main/res/drawable/ic_payments_info_card_restore_80.xml new file mode 100644 index 0000000000..8a66d3403c --- /dev/null +++ b/app/src/main/res/drawable/ic_payments_info_card_restore_80.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_recovery_phrase.xml b/app/src/main/res/drawable/ic_recovery_phrase.xml new file mode 100644 index 0000000000..0bc8cca5bf --- /dev/null +++ b/app/src/main/res/drawable/ic_recovery_phrase.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_refresh_20.xml b/app/src/main/res/drawable/ic_refresh_20.xml new file mode 100644 index 0000000000..915e0bbbca --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_request_20.xml b/app/src/main/res/drawable/ic_request_20.xml new file mode 100644 index 0000000000..54b7fb7bb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_request_20.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_send_20.xml b/app/src/main/res/drawable/ic_send_20.xml new file mode 100644 index 0000000000..b55f1874ba --- /dev/null +++ b/app/src/main/res/drawable/ic_send_20.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_send_money_20.xml b/app/src/main/res/drawable/ic_send_money_20.xml new file mode 100644 index 0000000000..6a777c2661 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_money_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_toggle_24.xml b/app/src/main/res/drawable/ic_toggle_24.xml new file mode 100644 index 0000000000..fb98ca0f98 --- /dev/null +++ b/app/src/main/res/drawable/ic_toggle_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/payment_info_pad.xml b/app/src/main/res/drawable/payment_info_pad.xml new file mode 100644 index 0000000000..af94f50577 --- /dev/null +++ b/app/src/main/res/drawable/payment_info_pad.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/payments_add_money_qr_border.xml b/app/src/main/res/drawable/payments_add_money_qr_border.xml new file mode 100644 index 0000000000..690f16d2cf --- /dev/null +++ b/app/src/main/res/drawable/payments_add_money_qr_border.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml index 5bb8794466..fed1110a49 100644 --- a/app/src/main/res/drawable/rounded_outline.xml +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_rectangle_tertiary.xml b/app/src/main/res/drawable/rounded_rectangle_tertiary.xml new file mode 100644 index 0000000000..b16b7c5e75 --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle_tertiary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/base_settings_header_item.xml b/app/src/main/res/layout/base_settings_header_item.xml new file mode 100644 index 0000000000..34c4472b3c --- /dev/null +++ b/app/src/main/res/layout/base_settings_header_item.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/base_settings_progress_item.xml b/app/src/main/res/layout/base_settings_progress_item.xml new file mode 100644 index 0000000000..3a8b133437 --- /dev/null +++ b/app/src/main/res/layout/base_settings_progress_item.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/confirm_payment_divider.xml b/app/src/main/res/layout/confirm_payment_divider.xml new file mode 100644 index 0000000000..33509e59aa --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_divider.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_payment_fragment.xml b/app/src/main/res/layout/confirm_payment_fragment.xml new file mode 100644 index 0000000000..a7e6faadbe --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_fragment.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_payment_line_item.xml b/app/src/main/res/layout/confirm_payment_line_item.xml new file mode 100644 index 0000000000..57de202714 --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_line_item.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_payment_loading_item.xml b/app/src/main/res/layout/confirm_payment_loading_item.xml new file mode 100644 index 0000000000..d093120a21 --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_loading_item.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_payment_status.xml b/app/src/main/res/layout/confirm_payment_status.xml new file mode 100644 index 0000000000..544781e20f --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_status.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_payment_total_line_item.xml b/app/src/main/res/layout/confirm_payment_total_line_item.xml new file mode 100644 index 0000000000..6203b7b35e --- /dev/null +++ b/app/src/main/res/layout/confirm_payment_total_line_item.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index 1cd4f04003..bedef43fbd 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -4,6 +4,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/constraint_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:background="?android:windowBackground"> @@ -38,6 +39,18 @@ app:layout_constraintTop_toTopOf="parent" tools:src="@drawable/ic_contact_picture" /> + + + + + + + tools:visibility="visible" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/create_payment_fragment_amount.xml b/app/src/main/res/layout/create_payment_fragment_amount.xml new file mode 100644 index 0000000000..ecef259464 --- /dev/null +++ b/app/src/main/res/layout/create_payment_fragment_amount.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/create_payment_fragment_amount_toggle.xml b/app/src/main/res/layout/create_payment_fragment_amount_toggle.xml new file mode 100644 index 0000000000..bda48b690b --- /dev/null +++ b/app/src/main/res/layout/create_payment_fragment_amount_toggle.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/customizable_single_select_item.xml b/app/src/main/res/layout/customizable_single_select_item.xml index dc43e1015a..a19f561a8a 100644 --- a/app/src/main/res/layout/customizable_single_select_item.xml +++ b/app/src/main/res/layout/customizable_single_select_item.xml @@ -13,7 +13,7 @@ android:paddingBottom="8dp"> - + app:srcCompat="@drawable/ic_settings_outline_24" + app:tint="@color/signal_icon_tint_primary" /> @@ -73,6 +74,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" - app:constraint_referenced_ids="customizable_single_select_summary,customizable_single_select_customize,customizable_single_select_divider" /> + app:constraint_referenced_ids="single_select_item_summary,customizable_single_select_customize,customizable_single_select_divider" + tools:visibility="visible" /> diff --git a/app/src/main/res/layout/deactivate_wallet_fragment.xml b/app/src/main/res/layout/deactivate_wallet_fragment.xml new file mode 100644 index 0000000000..2e25de458f --- /dev/null +++ b/app/src/main/res/layout/deactivate_wallet_fragment.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/edit_note_fragment.xml b/app/src/main/res/layout/edit_note_fragment.xml new file mode 100644 index 0000000000..6b6ffafb4b --- /dev/null +++ b/app/src/main/res/layout/edit_note_fragment.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/mnemonic_part_adapter_item.xml b/app/src/main/res/layout/mnemonic_part_adapter_item.xml new file mode 100644 index 0000000000..088b623856 --- /dev/null +++ b/app/src/main/res/layout/mnemonic_part_adapter_item.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/payment_details_fragment.xml b/app/src/main/res/layout/payment_details_fragment.xml new file mode 100644 index 0000000000..9e77299568 --- /dev/null +++ b/app/src/main/res/layout/payment_details_fragment.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payment_info_card.xml b/app/src/main/res/layout/payment_info_card.xml new file mode 100644 index 0000000000..b49c40ba5a --- /dev/null +++ b/app/src/main/res/layout/payment_info_card.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payment_notification_view.xml b/app/src/main/res/layout/payment_notification_view.xml new file mode 100644 index 0000000000..ba3165dd5c --- /dev/null +++ b/app/src/main/res/layout/payment_notification_view.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payment_pill_strip.xml b/app/src/main/res/layout/payment_pill_strip.xml new file mode 100644 index 0000000000..2944e237d9 --- /dev/null +++ b/app/src/main/res/layout/payment_pill_strip.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payment_preferences_all_pager_item_fragment.xml b/app/src/main/res/layout/payment_preferences_all_pager_item_fragment.xml new file mode 100644 index 0000000000..4f060a855c --- /dev/null +++ b/app/src/main/res/layout/payment_preferences_all_pager_item_fragment.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/payment_recipient_selection_fragment.xml b/app/src/main/res/layout/payment_recipient_selection_fragment.xml new file mode 100644 index 0000000000..c1ef97495c --- /dev/null +++ b/app/src/main/res/layout/payment_recipient_selection_fragment.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_activity.xml b/app/src/main/res/layout/payments_activity.xml new file mode 100644 index 0000000000..40be42bd89 --- /dev/null +++ b/app/src/main/res/layout/payments_activity.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/payments_activity_fragment.xml b/app/src/main/res/layout/payments_activity_fragment.xml new file mode 100644 index 0000000000..fc792c3177 --- /dev/null +++ b/app/src/main/res/layout/payments_activity_fragment.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_add_money_fragment.xml b/app/src/main/res/layout/payments_add_money_fragment.xml new file mode 100644 index 0000000000..73074f0e5c --- /dev/null +++ b/app/src/main/res/layout/payments_add_money_fragment.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_fragment.xml b/app/src/main/res/layout/payments_home_fragment.xml new file mode 100644 index 0000000000..7390defe27 --- /dev/null +++ b/app/src/main/res/layout/payments_home_fragment.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_fragment_header.xml b/app/src/main/res/layout/payments_home_fragment_header.xml new file mode 100644 index 0000000000..15a05f4352 --- /dev/null +++ b/app/src/main/res/layout/payments_home_fragment_header.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_home_in_progress.xml b/app/src/main/res/layout/payments_home_in_progress.xml new file mode 100644 index 0000000000..fd61e9bd38 --- /dev/null +++ b/app/src/main/res/layout/payments_home_in_progress.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_introducing_payments_item.xml b/app/src/main/res/layout/payments_home_introducing_payments_item.xml new file mode 100644 index 0000000000..975126d71a --- /dev/null +++ b/app/src/main/res/layout/payments_home_introducing_payments_item.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_no_recent_activity_item.xml b/app/src/main/res/layout/payments_home_no_recent_activity_item.xml new file mode 100644 index 0000000000..ead72ff7f9 --- /dev/null +++ b/app/src/main/res/layout/payments_home_no_recent_activity_item.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_payment_item.xml b/app/src/main/res/layout/payments_home_payment_item.xml new file mode 100644 index 0000000000..82a5aa8805 --- /dev/null +++ b/app/src/main/res/layout/payments_home_payment_item.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_home_see_all_item.xml b/app/src/main/res/layout/payments_home_see_all_item.xml new file mode 100644 index 0000000000..af737225e6 --- /dev/null +++ b/app/src/main/res/layout/payments_home_see_all_item.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/payments_preference.xml b/app/src/main/res/layout/payments_preference.xml new file mode 100644 index 0000000000..da0edc5874 --- /dev/null +++ b/app/src/main/res/layout/payments_preference.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_recovery_entry_fragment.xml b/app/src/main/res/layout/payments_recovery_entry_fragment.xml new file mode 100644 index 0000000000..dc4d157a22 --- /dev/null +++ b/app/src/main/res/layout/payments_recovery_entry_fragment.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_recovery_paste_fragment.xml b/app/src/main/res/layout/payments_recovery_paste_fragment.xml new file mode 100644 index 0000000000..90e615697a --- /dev/null +++ b/app/src/main/res/layout/payments_recovery_paste_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/payments_recovery_phrase_confirm_fragment.xml b/app/src/main/res/layout/payments_recovery_phrase_confirm_fragment.xml new file mode 100644 index 0000000000..ed76c7193b --- /dev/null +++ b/app/src/main/res/layout/payments_recovery_phrase_confirm_fragment.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_recovery_phrase_fragment.xml b/app/src/main/res/layout/payments_recovery_phrase_fragment.xml new file mode 100644 index 0000000000..517cba136c --- /dev/null +++ b/app/src/main/res/layout/payments_recovery_phrase_fragment.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_recovery_start_fragment.xml b/app/src/main/res/layout/payments_recovery_start_fragment.xml new file mode 100644 index 0000000000..c5472700f8 --- /dev/null +++ b/app/src/main/res/layout/payments_recovery_start_fragment.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/payments_terms_of_service_fragment.xml b/app/src/main/res/layout/payments_terms_of_service_fragment.xml new file mode 100644 index 0000000000..167c91904c --- /dev/null +++ b/app/src/main/res/layout/payments_terms_of_service_fragment.xml @@ -0,0 +1,13 @@ + + + +