From 8ca54bcc7b7761bb064aae048ed18ed027fdc11a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 14 Jan 2021 13:05:03 -0500 Subject: [PATCH] Create a new manage profile screen. --- app/src/main/AndroidManifest.xml | 4 + .../ApplicationPreferencesActivity.java | 5 +- .../securesms/profiles/ProfileName.java | 2 +- .../profiles/edit/EditProfileActivity.java | 21 +- .../profiles/edit/EditProfileFragment.java | 71 +----- .../profiles/edit/EditProfileViewModel.java | 9 - .../edit/EditSelfProfileRepository.java | 2 +- .../manage/EditProfileNameFragment.java | 82 +++++++ .../manage/ManageProfileActivity.java | 64 +++++ .../manage/ManageProfileFragment.java | 182 ++++++++++++++ .../manage/ManageProfileViewModel.java | 191 +++++++++++++++ .../manage}/UsernameEditFragment.java | 2 +- .../manage}/UsernameEditRepository.java | 2 +- .../manage}/UsernameEditViewModel.java | 2 +- .../securesms/util/FeatureFlags.java | 12 +- .../main/res/drawable-night/ic_compose_24.xml | 9 + .../res/drawable-night/ic_profile_name_24.xml | 10 + app/src/main/res/drawable/ic_compose_24.xml | 9 + .../main/res/drawable/ic_profile_name_24.xml | 9 + .../res/layout/edit_profile_name_fragment.xml | 61 +++++ .../res/layout/manage_profile_activity.xml | 21 ++ .../res/layout/manage_profile_fragment.xml | 227 ++++++++++++++++++ .../res/layout/profile_create_fragment.xml | 50 +--- app/src/main/res/navigation/edit_profile.xml | 2 +- .../main/res/navigation/manage_profile.xml | 44 ++++ app/src/main/res/values/strings.xml | 17 +- .../securesms/profiles/ProfileNameTest.java | 6 +- .../api/SignalServiceAccountManager.java | 2 +- .../api/crypto/ProfileCipher.java | 15 +- .../api/crypto/ProfileCipherTest.java | 29 ++- 30 files changed, 1004 insertions(+), 158 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java rename app/src/main/java/org/thoughtcrime/securesms/{usernames/username => profiles/manage}/UsernameEditFragment.java (99%) rename app/src/main/java/org/thoughtcrime/securesms/{usernames/username => profiles/manage}/UsernameEditRepository.java (98%) rename app/src/main/java/org/thoughtcrime/securesms/{usernames/username => profiles/manage}/UsernameEditViewModel.java (99%) create mode 100644 app/src/main/res/drawable-night/ic_compose_24.xml create mode 100644 app/src/main/res/drawable-night/ic_profile_name_24.xml create mode 100644 app/src/main/res/drawable/ic_compose_24.xml create mode 100644 app/src/main/res/drawable/ic_profile_name_24.xml create mode 100644 app/src/main/res/layout/edit_profile_name_fragment.xml create mode 100644 app/src/main/res/layout/manage_profile_activity.xml create mode 100644 app/src/main/res/layout/manage_profile_fragment.xml create mode 100644 app/src/main/res/navigation/manage_profile.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7606e5809a..35461dd30c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -460,6 +460,10 @@ android:theme="@style/TextSecure.LightRegistrationTheme" android:windowSoftInputMode="stateVisible|adjustResize" /> + + startAvatarSelection()); view.findViewById(R.id.mms_group_hint) @@ -228,12 +188,14 @@ public class EditProfileFragment extends LoggingFragment { view.findViewById(R.id.description_text).setVisibility(View.GONE); view.findViewById(R.id.avatar_placeholder).setImageResource(R.drawable.ic_group_outline_40); } else { + EditTextUtil.addGraphemeClusterLimitFilter(givenName, EditProfileNameFragment.NAME_MAX_GLYPHS); + EditTextUtil.addGraphemeClusterLimitFilter(familyName, EditProfileNameFragment.NAME_MAX_GLYPHS); this.givenName.addTextChangedListener(new AfterTextChanged(s -> { - trimInPlace(s); + EditProfileNameFragment.trimFieldToMaxByteLength(s); viewModel.setGivenName(s.toString()); })); this.familyName.addTextChangedListener(new AfterTextChanged(s -> { - trimInPlace(s); + EditProfileNameFragment.trimFieldToMaxByteLength(s); viewModel.setFamilyName(s.toString()); })); LearnMoreTextView descriptionText = view.findViewById(R.id.description_text); @@ -249,11 +211,6 @@ public class EditProfileFragment extends LoggingFragment { this.finishButton.setText(arguments.getInt(NEXT_BUTTON_TEXT, R.string.CreateProfileActivity_next)); - this.usernameEditButton.setOnClickListener(v -> { - NavDirections action = EditProfileFragmentDirections.actionEditUsername(); - Navigation.findNavController(v).navigate(action); - }); - if (arguments.getBoolean(SHOW_TOOLBAR, true)) { this.toolbar.setVisibility(View.VISIBLE); this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish()); @@ -285,10 +242,6 @@ public class EditProfileFragment extends LoggingFragment { }); } - private void initializeUsername() { - viewModel.username().observe(getViewLifecycleOwner(), this::onUsernameChanged); - } - private static void updateFieldIfNeeded(@NonNull EditText field, @NonNull String value) { String fieldTrimmed = field.getText().toString().trim(); String valueTrimmed = value.trim(); @@ -304,10 +257,6 @@ public class EditProfileFragment extends LoggingFragment { } } - private void onUsernameChanged(@NonNull Optional username) { - this.username.setText(username.transform(s -> "@" + s).or("")); - } - private void startAvatarSelection() { AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveProfilePhoto(), true, @@ -375,14 +324,6 @@ public class EditProfileFragment extends LoggingFragment { animation.start(); } - private static void trimInPlace(Editable s) { - int trimmedLength = StringUtil.trimToFit(s.toString(), ProfileName.MAX_PART_LENGTH).length(); - - if (s.length() > trimmedLength) { - s.delete(trimmedLength, s.length()); - } - } - public interface Controller { void onProfileNameUploadCompleted(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java index ec0cb5fb78..53978025b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java @@ -27,7 +27,6 @@ class EditProfileViewModel extends ViewModel { private final LiveData internalProfileName = LiveDataUtil.combineLatest(trimmedGivenName, trimmedFamilyName, ProfileName::fromParts); private final MutableLiveData internalAvatar = new MutableLiveData<>(); private final MutableLiveData originalAvatar = new MutableLiveData<>(); - private final MutableLiveData> internalUsername = new MutableLiveData<>(); private final MutableLiveData originalDisplayName = new MutableLiveData<>(); private final LiveData isFormValid; private final EditProfileRepository repository; @@ -77,10 +76,6 @@ class EditProfileViewModel extends ViewModel { return Transformations.distinctUntilChanged(internalAvatar); } - public LiveData> username() { - return internalUsername; - } - public boolean hasAvatar() { return internalAvatar.getValue() != null; } @@ -105,10 +100,6 @@ class EditProfileViewModel extends ViewModel { internalAvatar.setValue(avatar); } - public void refreshUsername() { - repository.getCurrentUsername(internalUsername::postValue); - } - public void submitProfile(Consumer uploadResultConsumer) { ProfileName profileName = isGroup() ? ProfileName.EMPTY : internalProfileName.getValue(); String displayName = isGroup() ? givenName.getValue() : ""; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java index 3da341a25a..45f2f60a86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java @@ -29,7 +29,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.concurrent.ExecutionException; -class EditSelfProfileRepository implements EditProfileRepository { +public class EditSelfProfileRepository implements EditProfileRepository { private static final String TAG = Log.tag(EditSelfProfileRepository.class); 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 new file mode 100644 index 0000000000..63059a8f88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +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.signal.core.util.EditTextUtil; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.ProfileUploadJob; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.StringUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +/** + * Simple fragment to edit your profile name. + */ +public class EditProfileNameFragment extends Fragment { + + public static final int NAME_MAX_GLYPHS = 26; + + private EditText givenName; + private EditText familyName; + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.edit_profile_name_fragment, container, false); + } + + @Override + 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.givenName.setText(Recipient.self().getProfileName().getGivenName()); + this.familyName.setText(Recipient.self().getProfileName().getFamilyName()); + + view.findViewById(R.id.toolbar) + .setNavigationOnClickListener(v -> Navigation.findNavController(view) + .popBackStack()); + + EditTextUtil.addGraphemeClusterLimitFilter(givenName, NAME_MAX_GLYPHS); + EditTextUtil.addGraphemeClusterLimitFilter(familyName, NAME_MAX_GLYPHS); + + 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); + } + + private void onSaveClicked(View view) { + ProfileName profileName = ProfileName.fromParts(givenName.getText().toString(), familyName.getText().toString()); + + SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { + DatabaseFactory.getRecipientDatabase(requireContext()).setProfileName(Recipient.self().getId(), profileName); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + return null; + }, (nothing) -> { + Navigation.findNavController(view).popBackStack(); + }); + } + + public static void trimFieldToMaxByteLength(Editable s) { + int trimmedLength = StringUtil.trimToFit(s.toString(), ProfileName.MAX_PART_LENGTH).length(); + + if (s.length() > trimmedLength) { + s.delete(trimmedLength, s.length()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java new file mode 100644 index 0000000000..b2d3e4d0fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileActivity.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.navigation.NavDirections; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.profiles.edit.EditProfileFragmentDirections; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +/** + * Activity that manages the local user's profile, as accessed via the settings. + */ +public class ManageProfileActivity extends BaseActivity { + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static final String START_AT_USERNAME = "start_at_username"; + + public static @NonNull Intent getIntent(@NonNull Context context) { + return new Intent(context, ManageProfileActivity.class); + } + + public static @NonNull Intent getIntentForUsernameEdit(@NonNull Context context) { + Intent intent = new Intent(context, ManageProfileActivity.class); + intent.putExtra(START_AT_USERNAME, true); + return intent; + } + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + dynamicTheme.onCreate(this); + + setContentView(R.layout.manage_profile_activity); + + if (bundle == null) { + Bundle extras = getIntent().getExtras(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle()); + + if (extras != null && extras.getBoolean(START_AT_USERNAME, false)) { + NavDirections action = ManageProfileFragmentDirections.actionManageUsername(); + Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action); + } + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java new file mode 100644 index 0000000000..d061b4c65b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; + +import com.bumptech.glide.Glide; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import static android.app.Activity.RESULT_OK; + +public class ManageProfileFragment extends LoggingFragment { + + private static final String TAG = Log.tag(ManageProfileFragment.class); + private static final short REQUEST_CODE_SELECT_AVATAR = 31726; + + private Toolbar toolbar; + private ImageView avatarView; + private View avatarPlaceholderView; + private TextView profileNameView; + private View profileNameContainer; + private TextView usernameView; + private View usernameContainer; + private TextView aboutView; + private View aboutContainer; + private TextView aboutEmojiView; + private AlertDialog avatarProgress; + + private ManageProfileViewModel viewModel; + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.manage_profile_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.toolbar = view.findViewById(R.id.toolbar); + this.avatarView = view.findViewById(R.id.manage_profile_avatar); + this.avatarPlaceholderView = view.findViewById(R.id.manage_profile_avatar_placeholder); + this.profileNameView = view.findViewById(R.id.manage_profile_name); + this.profileNameContainer = view.findViewById(R.id.manage_profile_name_container); + this.usernameView = view.findViewById(R.id.manage_profile_username); + this.usernameContainer = view.findViewById(R.id.manage_profile_username_container); + this.aboutView = view.findViewById(R.id.manage_profile_about); + this.aboutContainer = view.findViewById(R.id.manage_profile_about_container); + + initializeViewModel(); + + this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish()); + this.avatarView.setOnClickListener(v -> onAvatarClicked()); + + this.profileNameContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageProfileName()); + }); + + this.usernameContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageUsername()); + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { + if (data != null && data.getBooleanExtra("delete", false)) { + viewModel.onAvatarSelected(requireContext(), null); + return; + } + + Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + + viewModel.onAvatarSelected(requireContext(), result); + } + } + + private void initializeViewModel() { + viewModel = ViewModelProviders.of(this, new ManageProfileViewModel.Factory()).get(ManageProfileViewModel.class); + + viewModel.getAvatar().observe(getViewLifecycleOwner(), this::presentAvatar); + viewModel.getProfileName().observe(getViewLifecycleOwner(), this::presentProfileName); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent); + + if (viewModel.shouldShowAbout()) { + viewModel.getAbout().observe(getViewLifecycleOwner(), this::presentAbout); + viewModel.getAboutEmoji().observe(getViewLifecycleOwner(), this::presentAboutEmoji); + } else { + aboutContainer.setVisibility(View.GONE); + } + + if (viewModel.shouldShowUsername()) { + viewModel.getUsername().observe(getViewLifecycleOwner(), this::presentUsername); + } else { + usernameContainer.setVisibility(View.GONE); + } + } + + private void presentAvatar(@NonNull AvatarState avatarState) { + if (avatarState.getAvatar() == null) { + avatarView.setImageDrawable(null); + avatarPlaceholderView.setVisibility(View.VISIBLE); + } else { + avatarPlaceholderView.setVisibility(View.GONE); + Glide.with(this) + .load(avatarState.getAvatar()) + .circleCrop() + .into(avatarView); + } + + if (avatarProgress == null && avatarState.getLoadingState() == ManageProfileViewModel.LoadingState.LOADING) { + avatarProgress = SimpleProgressDialog.show(requireContext()); + } else if (avatarProgress != null && avatarState.getLoadingState() == ManageProfileViewModel.LoadingState.LOADED) { + avatarProgress.dismiss(); + } + } + + private void presentProfileName(@Nullable ProfileName profileName) { + if (profileName == null || profileName.isEmpty()) { + profileNameView.setText(R.string.ManageProfileFragment_profile_name); + } else { + profileNameView.setText(profileName.toString()); + } + } + + private void presentUsername(@Nullable String username) { + if (username == null || username.isEmpty()) { + usernameView.setText(R.string.ManageProfileFragment_username); + usernameView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_secondary)); + } else { + usernameView.setText(username); + usernameView.setTextColor(requireContext().getResources().getColor(R.color.signal_text_primary)); + } + } + + private void presentAbout(@Nullable String about) { + if (about == null || about.isEmpty()) { + aboutView.setHint(R.string.ManageProfileFragment_about); + } else { + aboutView.setText(about); + } + } + + private void presentAboutEmoji(@NonNull String aboutEmoji) { + + } + + private void presentEvent(@NonNull ManageProfileViewModel.Event event) { + if (event == ManageProfileViewModel.Event.AVATAR_FAILURE) { + Toast.makeText(requireContext(), R.string.ManageProfileFragment_failed_to_set_avatar, Toast.LENGTH_LONG).show(); + } + } + + private void onAvatarClicked() { + AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveAvatar(), + true, + REQUEST_CODE_SELECT_AVATAR, + false) + .show(getChildFragmentManager(), null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java new file mode 100644 index 0000000000..f3cf6e0455 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.profiles.manage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.StreamUtil; +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.ProfileUploadJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +class ManageProfileViewModel extends ViewModel { + + private static final String TAG = Log.tag(ManageProfileViewModel.class); + + private final MutableLiveData avatar; + private final MutableLiveData profileName; + private final MutableLiveData username; + private final MutableLiveData about; + private final MutableLiveData aboutEmoji; + private final SingleLiveEvent events; + private final RecipientForeverObserver observer; + + public ManageProfileViewModel() { + this.avatar = new MutableLiveData<>(); + this.profileName = new MutableLiveData<>(); + this.username = new MutableLiveData<>(); + this.about = new MutableLiveData<>(); + this.aboutEmoji = new MutableLiveData<>(); + this.events = new SingleLiveEvent<>(); + this.observer = this::onRecipientChanged; + + SignalExecutors.BOUNDED.execute(() -> { + onRecipientChanged(Recipient.self().fresh()); + + StreamDetails details = AvatarHelper.getSelfProfileAvatarStream(ApplicationDependencies.getApplication()); + if (details != null) { + try { + avatar.postValue(AvatarState.loaded(StreamUtil.readFully(details.getStream()))); + } catch (IOException e) { + Log.w(TAG, "Failed to read avatar!"); + avatar.postValue(AvatarState.none()); + } + } else { + avatar.postValue(AvatarState.none()); + } + + ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(Recipient.self().getId())); + }); + + Recipient.self().live().observeForever(observer); + } + + public @NonNull LiveData getAvatar() { + return avatar; + } + + public @NonNull LiveData getProfileName() { + return profileName; + } + + public @NonNull LiveData getUsername() { + return username; + } + + public @NonNull LiveData getAbout() { + return about; + } + + public @NonNull LiveData getAboutEmoji() { + return aboutEmoji; + } + + public @NonNull LiveData getEvents() { + return events; + } + + public boolean shouldShowUsername() { + return FeatureFlags.usernames(); + } + + public boolean shouldShowAbout() { + return FeatureFlags.about(); + } + + public void onAvatarSelected(@NonNull Context context, @Nullable Media media) { + if (media == null) { + SignalExecutors.BOUNDED.execute(() -> { + AvatarHelper.delete(context, Recipient.self().getId()); + avatar.postValue(AvatarState.none()); + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + }); + } else { + SignalExecutors.BOUNDED.execute(() -> { + try { + InputStream stream = BlobProvider.getInstance().getStream(context, media.getUri()); + byte[] data = StreamUtil.readFully(stream); + + AvatarHelper.setAvatar(context, Recipient.self().getId(), new ByteArrayInputStream(data)); + avatar.postValue(AvatarState.loaded(data)); + + ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); + } catch (IOException e) { + Log.w(TAG, "Failed to save avatar!", e); + events.postValue(Event.AVATAR_FAILURE); + } + }); + } + } + + public boolean canRemoveAvatar() { + return avatar.getValue() != null; + } + + private void onRecipientChanged(@NonNull Recipient recipient) { + profileName.postValue(recipient.getProfileName()); + username.postValue(recipient.getUsername().orNull()); + } + + @Override + protected void onCleared() { + Recipient.self().live().removeForeverObserver(observer); + } + + public static class AvatarState { + private final byte[] avatar; + private final LoadingState loadingState; + + public AvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) { + this.avatar = avatar; + this.loadingState = loadingState; + } + + private static @NonNull AvatarState none() { + return new AvatarState(null, LoadingState.LOADED); + } + + private static @NonNull AvatarState loaded(@Nullable byte[] avatar) { + return new AvatarState(avatar, LoadingState.LOADED); + } + + private static @NonNull AvatarState loading(@Nullable byte[] avatar) { + return new AvatarState(avatar, LoadingState.LOADING); + } + + public @Nullable byte[] getAvatar() { + return avatar; + } + + public LoadingState getLoadingState() { + return loadingState; + } + } + + public enum LoadingState { + LOADING, LOADED + } + + enum Event { + AVATAR_FAILURE + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new ManageProfileViewModel())); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java rename to app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java index fb54ccd749..4d4c176b9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.usernames.username; +package org.thoughtcrime.securesms.profiles.manage; import android.os.Bundle; import android.view.LayoutInflater; diff --git a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java similarity index 98% rename from app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java rename to app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java index f117b0ebab..430256b6c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.usernames.username; +package org.thoughtcrime.securesms.profiles.manage; import android.app.Application; diff --git a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java rename to app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java index b0046a13aa..c6873cec3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.usernames.username; +package org.thoughtcrime.securesms.profiles.manage; import android.app.Application; import android.text.TextUtils; 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 c9164d51fa..f75dc05f02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -71,6 +71,7 @@ public final class FeatureFlags { private static final String AUTOMATIC_SESSION_INTERVAL = "android.automaticSessionResetInterval"; private static final String DEFAULT_MAX_BACKOFF = "android.defaultMaxBackoff"; private static final String OKHTTP_AUTOMATIC_RETRY = "android.okhttpAutomaticRetry"; + private static final String ABOUT = "android.about"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -98,7 +99,8 @@ public final class FeatureFlags { AUTOMATIC_SESSION_RESET, AUTOMATIC_SESSION_INTERVAL, DEFAULT_MAX_BACKOFF, - OKHTTP_AUTOMATIC_RETRY + OKHTTP_AUTOMATIC_RETRY, + ABOUT ); @VisibleForTesting @@ -136,7 +138,8 @@ public final class FeatureFlags { AUTOMATIC_SESSION_RESET, AUTOMATIC_SESSION_INTERVAL, DEFAULT_MAX_BACKOFF, - OKHTTP_AUTOMATIC_RETRY + OKHTTP_AUTOMATIC_RETRY, + ABOUT ); /** @@ -316,6 +319,11 @@ public final class FeatureFlags { return getBoolean(OKHTTP_AUTOMATIC_RETRY, false); } + /** Whether or not the 'About' section of the profile is enabled. */ + public static boolean about() { + return getBoolean(ABOUT, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable-night/ic_compose_24.xml b/app/src/main/res/drawable-night/ic_compose_24.xml new file mode 100644 index 0000000000..6c06b6e39d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_compose_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_profile_name_24.xml b/app/src/main/res/drawable-night/ic_profile_name_24.xml new file mode 100644 index 0000000000..809476751e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_profile_name_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_compose_24.xml b/app/src/main/res/drawable/ic_compose_24.xml new file mode 100644 index 0000000000..3112150a36 --- /dev/null +++ b/app/src/main/res/drawable/ic_compose_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_name_24.xml b/app/src/main/res/drawable/ic_profile_name_24.xml new file mode 100644 index 0000000000..23dc66e2c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_name_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/edit_profile_name_fragment.xml b/app/src/main/res/layout/edit_profile_name_fragment.xml new file mode 100644 index 0000000000..00935281df --- /dev/null +++ b/app/src/main/res/layout/edit_profile_name_fragment.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/manage_profile_activity.xml b/app/src/main/res/layout/manage_profile_activity.xml new file mode 100644 index 0000000000..f8ac740ca3 --- /dev/null +++ b/app/src/main/res/layout/manage_profile_activity.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/layout/manage_profile_fragment.xml b/app/src/main/res/layout/manage_profile_fragment.xml new file mode 100644 index 0000000000..e1d0ec1ac7 --- /dev/null +++ b/app/src/main/res/layout/manage_profile_fragment.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/profile_create_fragment.xml b/app/src/main/res/layout/profile_create_fragment.xml index 4f6df2e3de..d6f8797184 100644 --- a/app/src/main/res/layout/profile_create_fragment.xml +++ b/app/src/main/res/layout/profile_create_fragment.xml @@ -165,54 +165,6 @@ android:singleLine="true" /> - - - - - - diff --git a/app/src/main/res/navigation/edit_profile.xml b/app/src/main/res/navigation/edit_profile.xml index 3f21938693..a37ec5fb93 100644 --- a/app/src/main/res/navigation/edit_profile.xml +++ b/app/src/main/res/navigation/edit_profile.xml @@ -23,7 +23,7 @@ diff --git a/app/src/main/res/navigation/manage_profile.xml b/app/src/main/res/navigation/manage_profile.xml new file mode 100644 index 0000000000..bff808c09a --- /dev/null +++ b/app/src/main/res/navigation/manage_profile.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index abcc8612ed..a727d72e23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -802,7 +802,16 @@ Default (Don\'t notify me) Always notify me Don\'t notify me - + + + Profile name + Username + About + Write a few words about yourself + Your name + Your username + Failed to set avatar + Add to system contacts This person is in your contacts @@ -2177,6 +2186,12 @@ Group name https://support.signal.org/hc/articles/360007459591 + + Your name + First name + Last name (optional) + Save + Shared media diff --git a/app/src/test/java/org/thoughtcrime/securesms/profiles/ProfileNameTest.java b/app/src/test/java/org/thoughtcrime/securesms/profiles/ProfileNameTest.java index 577b867c89..30d08da6e0 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/profiles/ProfileNameTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/profiles/ProfileNameTest.java @@ -167,10 +167,10 @@ public final class ProfileNameTest { @Test public void fromParts_with_long_name_parts() { - ProfileName name = ProfileName.fromParts("GivenSomeVeryLongNameSomeVeryLongName", "FamilySomeVeryLongNameSomeVeryLongName"); + ProfileName name = ProfileName.fromParts("GivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLongNameSomeVeryLongName", "FamilySomeVeryLongNameSomeVeryLongName"); - assertEquals("GivenSomeVeryLongNameSomeV", name.getGivenName()); - assertEquals("FamilySomeVeryLongNameSome", name.getFamilyName()); + assertEquals("GivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLongNameSomeVeryLongNameGivenSomeVeryLong", name.getGivenName()); + assertEquals("FamilySomeVeryLongNameSomeVeryLongName", name.getFamilyName()); } @Test diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 5c949e0d40..6ce1229ccb 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -639,7 +639,7 @@ public class SignalServiceAccountManager { { if (name == null) name = ""; - byte[] ciphertextName = new ProfileCipher(profileKey).encryptName(name.getBytes(StandardCharsets.UTF_8), ProfileCipher.NAME_PADDED_LENGTH); + byte[] ciphertextName = new ProfileCipher(profileKey).encryptName(name.getBytes(StandardCharsets.UTF_8), ProfileCipher.getTargetNameLength(name)); boolean hasAvatar = avatar != null; ProfileAvatarData profileAvatarData = null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java index 2e16aecbf9..446c6dd832 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ProfileCipher.java @@ -5,6 +5,7 @@ import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.libsignal.util.ByteUtil; import org.whispersystems.signalservice.internal.util.Util; +import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.MessageDigest; @@ -20,7 +21,10 @@ import javax.crypto.spec.SecretKeySpec; public class ProfileCipher { - public static final int NAME_PADDED_LENGTH = 53; + private static final int NAME_PADDED_LENGTH_1 = 53; + private static final int NAME_PADDED_LENGTH_2 = 257; + + public static final int MAX_POSSIBLE_NAME_LENGTH = NAME_PADDED_LENGTH_2; private final ProfileKey key; @@ -99,4 +103,13 @@ public class ProfileCipher { } } + public static int getTargetNameLength(String name) { + int nameLength = name.getBytes(StandardCharsets.UTF_8).length; + + if (nameLength <= NAME_PADDED_LENGTH_1) { + return NAME_PADDED_LENGTH_1; + } else { + return NAME_PADDED_LENGTH_2; + } + } } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java index 62c7b0f3ef..c03ac68b1c 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/crypto/ProfileCipherTest.java @@ -7,9 +7,11 @@ import org.conscrypt.Conscrypt; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.util.Base64; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; import java.security.Security; public class ProfileCipherTest extends TestCase { @@ -21,7 +23,7 @@ public class ProfileCipherTest extends TestCase { public void testEncryptDecrypt() throws InvalidCiphertextException, InvalidInputException { ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); ProfileCipher cipher = new ProfileCipher(key); - byte[] name = cipher.encryptName("Clement\0Duval".getBytes(), ProfileCipher.NAME_PADDED_LENGTH); + byte[] name = cipher.encryptName("Clement\0Duval".getBytes(), 53); byte[] plaintext = cipher.decryptName(name); assertEquals(new String(plaintext), "Clement\0Duval"); } @@ -59,4 +61,29 @@ public class ProfileCipherTest extends TestCase { assertEquals(new String(result.toByteArray()), "This is an avatar"); } + public void testEncryptLengthBucket1() throws InvalidInputException { + ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); + ProfileCipher cipher = new ProfileCipher(key); + byte[] name = cipher.encryptName("Peter\0Parker".getBytes(), 53); + + String encoded = Base64.encodeBytes(name); + + assertEquals(108, encoded.length()); + } + + public void testEncryptLengthBucket2() throws InvalidInputException { + ProfileKey key = new ProfileKey(Util.getSecretBytes(32)); + ProfileCipher cipher = new ProfileCipher(key); + byte[] name = cipher.encryptName("Peter\0Parker".getBytes(), 257); + + String encoded = Base64.encodeBytes(name); + + assertEquals(380, encoded.length()); + } + + public void testTargetNameLength() { + assertEquals(53, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")); + assertEquals(53, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1")); + assertEquals(257, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12")); + } }