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 6f7efecb62..670527e026 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java @@ -1,24 +1,14 @@ package org.thoughtcrime.securesms.jobs; -import android.content.Context; import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; -import org.signal.zkgroup.profiles.ProfileKey; -import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; -import org.thoughtcrime.securesms.database.DatabaseFactory; -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.profiles.AvatarHelper; -import org.thoughtcrime.securesms.profiles.ProfileName; -import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.util.StreamDetails; import java.util.concurrent.TimeUnit; @@ -30,9 +20,6 @@ public final class ProfileUploadJob extends BaseJob { public static final String QUEUE = "ProfileAlteration"; - private final Context context; - private final SignalServiceAccountManager accountManager; - public ProfileUploadJob() { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) @@ -45,9 +32,6 @@ public final class ProfileUploadJob extends BaseJob { private ProfileUploadJob(@NonNull Parameters parameters) { super(parameters); - - this.context = ApplicationDependencies.getApplication(); - this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); } @Override @@ -57,17 +41,7 @@ public final class ProfileUploadJob extends BaseJob { return; } - ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - ProfileName profileName = Recipient.self().getProfileName(); - String about = Optional.fromNullable(Recipient.self().getAbout()).or(""); - String aboutEmoji = Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""); - String avatarPath; - - try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { - avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), about, aboutEmoji, avatar).orNull(); - } - - DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); + ProfileUtil.uploadProfile(context); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java index be77dea24b..ce17c6a157 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutFragment.java @@ -9,30 +9,30 @@ import android.view.ViewGroup; import android.widget.EditText; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; import androidx.navigation.Navigation; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; +import com.dd.CircularProgressButton; + import org.signal.core.util.BreakIteratorCompat; import org.signal.core.util.EditTextUtil; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiUtil; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.ProfileUploadJob; import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; -import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.util.text.AfterTextChanged; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.crypto.ProfileCipher; @@ -60,9 +60,11 @@ public class EditAboutFragment extends Fragment implements ManageProfileActivity new AboutPreset("\uD83D\uDE80", R.string.EditAboutFragment_working_on_something_new) ); - private ImageView emojiView; - private EditText bodyView; - private TextView countView; + private ImageView emojiView; + private EditText bodyView; + private TextView countView; + private CircularProgressButton saveButton; + private EditAboutViewModel viewModel; private String selectedEmoji; @@ -76,6 +78,9 @@ public class EditAboutFragment extends Fragment implements ManageProfileActivity this.emojiView = view.findViewById(R.id.edit_about_emoji); this.bodyView = view.findViewById(R.id.edit_about_body); this.countView = view.findViewById(R.id.edit_about_count); + this.saveButton = view.findViewById(R.id.edit_about_save); + + initializeViewModel(); view.findViewById(R.id.toolbar) .setNavigationOnClickListener(v -> Navigation.findNavController(view) @@ -92,9 +97,13 @@ public class EditAboutFragment extends Fragment implements ManageProfileActivity .show(requireFragmentManager(), "BOTTOM"); }); - view.findViewById(R.id.edit_about_save).setOnClickListener(this::onSaveClicked); view.findViewById(R.id.edit_about_clear).setOnClickListener(v -> onClearClicked()); + saveButton.setOnClickListener(v -> viewModel.onSaveClicked(requireContext(), + bodyView.getText().toString(), + selectedEmoji)); + + RecyclerView presetList = view.findViewById(R.id.edit_about_presets); PresetAdapter presetAdapter = new PresetAdapter(); @@ -135,6 +144,13 @@ public class EditAboutFragment extends Fragment implements ManageProfileActivity } } + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this).get(EditAboutViewModel.class); + + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + } + private void presentCount(@NonNull String aboutBody) { BreakIteratorCompat breakIterator = BreakIteratorCompat.getInstance(); breakIterator.setText(aboutBody); @@ -148,14 +164,26 @@ public class EditAboutFragment extends Fragment implements ManageProfileActivity } } - private void onSaveClicked(View view) { - SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { - DatabaseFactory.getRecipientDatabase(requireContext()).setAbout(Recipient.self().getId(), bodyView.getText().toString(), selectedEmoji); - ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); - return null; - }, (nothing) -> { - Navigation.findNavController(view).popBackStack(); - }); + private void presentSaveState(@NonNull EditAboutViewModel.SaveState state) { + switch (state) { + case IDLE: + saveButton.setIndeterminateProgressMode(false); + saveButton.setProgress(0); + break; + case IN_PROGRESS: + saveButton.setIndeterminateProgressMode(true); + saveButton.setProgress(50); + break; + case DONE: + Navigation.findNavController(requireView()).popBackStack(); + break; + } + } + + private void presentEvent(@NonNull EditAboutViewModel.Event event) { + if (event == EditAboutViewModel.Event.NETWORK_FAILURE) { + Toast.makeText(requireContext(), R.string.EditProfileNameFragment_failed_to_save_due_to_network_issues_try_again_later, Toast.LENGTH_SHORT).show(); + } } private void onClearClicked() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java new file mode 100644 index 0000000000..016223a2a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditAboutViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public final class EditAboutViewModel extends ViewModel { + + private final ManageProfileRepository repository; + private final MutableLiveData saveState; + private final SingleLiveEvent events; + + public EditAboutViewModel() { + this.repository = new ManageProfileRepository(); + this.saveState = new MutableLiveData<>(SaveState.IDLE); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData getSaveState() { + return saveState; + } + + @NonNull LiveData getEvents() { + return events; + } + + void onSaveClicked(@NonNull Context context, @NonNull String about, @NonNull String emoji) { + saveState.setValue(SaveState.IN_PROGRESS); + repository.setAbout(context, about, emoji, result -> { + switch (result) { + case SUCCESS: + saveState.postValue(SaveState.DONE); + break; + case FAILURE_NETWORK: + saveState.postValue(SaveState.IDLE); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + } + + enum SaveState { + IDLE, IN_PROGRESS, DONE + } + + enum Event { + NETWORK_FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java index 63059a8f88..015cc26368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java @@ -6,13 +6,17 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; +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 com.dd.CircularProgressButton; + import org.signal.core.util.EditTextUtil; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -31,8 +35,10 @@ public class EditProfileNameFragment extends Fragment { public static final int NAME_MAX_GLYPHS = 26; - private EditText givenName; - private EditText familyName; + private EditText givenName; + private EditText familyName; + private CircularProgressButton saveButton; + private EditProfileNameViewModel viewModel; @Override public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -43,6 +49,9 @@ public class EditProfileNameFragment extends Fragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { this.givenName = view.findViewById(R.id.edit_profile_name_given_name); this.familyName = view.findViewById(R.id.edit_profile_name_family_name); + this.saveButton = view.findViewById(R.id.edit_profile_name_save); + + initializeViewModel(); this.givenName.setText(Recipient.self().getProfileName().getGivenName()); this.familyName.setText(Recipient.self().getProfileName().getFamilyName()); @@ -57,19 +66,38 @@ public class EditProfileNameFragment extends Fragment { this.givenName.addTextChangedListener(new AfterTextChanged(EditProfileNameFragment::trimFieldToMaxByteLength)); this.familyName.addTextChangedListener(new AfterTextChanged(EditProfileNameFragment::trimFieldToMaxByteLength)); - view.findViewById(R.id.edit_profile_name_save).setOnClickListener(this::onSaveClicked); + saveButton.setOnClickListener(v -> viewModel.onSaveClicked(requireContext(), + givenName.getText().toString(), + familyName.getText().toString())); } - private void onSaveClicked(View view) { - ProfileName profileName = ProfileName.fromParts(givenName.getText().toString(), familyName.getText().toString()); + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this).get(EditProfileNameViewModel.class); - SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { - DatabaseFactory.getRecipientDatabase(requireContext()).setProfileName(Recipient.self().getId(), profileName); - ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); - return null; - }, (nothing) -> { - Navigation.findNavController(view).popBackStack(); - }); + viewModel.getSaveState().observe(getViewLifecycleOwner(), this::presentSaveState); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + } + + private void presentSaveState(@NonNull EditProfileNameViewModel.SaveState state) { + switch (state) { + case IDLE: + saveButton.setIndeterminateProgressMode(false); + saveButton.setProgress(0); + break; + case IN_PROGRESS: + saveButton.setIndeterminateProgressMode(true); + saveButton.setProgress(50); + break; + case DONE: + Navigation.findNavController(requireView()).popBackStack(); + break; + } + } + + private void presentEvent(@NonNull EditProfileNameViewModel.Event event) { + if (event == EditProfileNameViewModel.Event.NETWORK_FAILURE) { + Toast.makeText(requireContext(), R.string.EditProfileNameFragment_failed_to_save_due_to_network_issues_try_again_later, Toast.LENGTH_SHORT).show(); + } } public static void trimFieldToMaxByteLength(Editable s) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java new file mode 100644 index 0000000000..5c80cd576b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +public final class EditProfileNameViewModel extends ViewModel { + + private final ManageProfileRepository repository; + private final MutableLiveData saveState; + private final SingleLiveEvent events; + + public EditProfileNameViewModel() { + this.repository = new ManageProfileRepository(); + this.saveState = new MutableLiveData<>(SaveState.IDLE); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData getSaveState() { + return saveState; + } + + @NonNull LiveData getEvents() { + return events; + } + + void onSaveClicked(@NonNull Context context, @NonNull String givenName, @NonNull String familyName) { + saveState.setValue(SaveState.IN_PROGRESS); + repository.setName(context, ProfileName.fromParts(givenName, familyName), result -> { + switch (result) { + case SUCCESS: + saveState.postValue(SaveState.DONE); + break; + case FAILURE_NETWORK: + saveState.postValue(SaveState.IDLE); + events.postValue(Event.NETWORK_FAILURE); + break; + } + }); + } + + enum SaveState { + IDLE, IN_PROGRESS, DONE + } + + enum Event { + NETWORK_FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java new file mode 100644 index 0000000000..87108a1b40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileRepository.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +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.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ProfileUtil; + +import java.io.IOException; + +final class ManageProfileRepository { + + private static final String TAG = Log.tag(ManageProfileRepository.class); + + public void setName(@NonNull Context context, @NonNull ProfileName profileName, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithName(context, profileName); + DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during name change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + public void setAbout(@NonNull Context context, @NonNull String about, @NonNull String emoji, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + ProfileUtil.uploadProfileWithAbout(context, about, emoji); + DatabaseFactory.getRecipientDatabase(context).setAbout(Recipient.self().getId(), about, emoji); + callback.accept(Result.SUCCESS); + } catch (IOException e) { + Log.w(TAG, "Failed to upload profile during name change.", e); + callback.accept(Result.FAILURE_NETWORK); + } + }); + } + + enum Result { + SUCCESS, FAILURE_NETWORK + } +} 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 a27d60d6b3..05ad24eec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -9,12 +9,16 @@ import androidx.annotation.WorkerThread; import org.signal.zkgroup.profiles.ProfileKey; 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.messages.IncomingMessageObserver; +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.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessagePipe; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; @@ -26,6 +30,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; 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.util.concurrent.CascadingFuture; import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; @@ -96,6 +101,57 @@ public final class ProfileUtil { return new String(profileCipher.decryptName(Base64.decode(encryptedName))); } + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * profile name instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithName(@NonNull Context context, @NonNull ProfileName profileName) throws IOException { + uploadProfile(context, + profileName, + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or("")); + } + + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * about/emoji instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithAbout(@NonNull Context context, @NonNull String about, @NonNull String emoji) throws IOException { + uploadProfile(context, + Recipient.self().getProfileName(), + about, + emoji); + } + + /** + * Uploads the profile based on all state that's already written to disk. + */ + public static void uploadProfile(@NonNull Context context) throws IOException { + uploadProfile(context, + Recipient.self().getProfileName(), + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or("")); + } + + public static void uploadProfile(@NonNull Context context, + @NonNull ProfileName profileName, + @Nullable String about, + @Nullable String aboutEmoji) + throws IOException + { + ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); + String avatarPath; + + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), about, aboutEmoji, avatar).orNull(); + } + + DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); + } + private static @NonNull ListenableFuture getPipeRetrievalFuture(@NonNull SignalServiceAddress address, @NonNull Optional profileKey, @NonNull Optional unidentifiedAccess, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d018d60988..e36ff88754 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2021,6 +2021,7 @@ First name Last name (optional) Save + Failed to save due to network issues. Try again later. Shared media