mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 08:39:22 +01:00
Add support for setting an optional last name in profiles.
This commit is contained in:
committed by
Greyson Parrelli
parent
f2b9bf0b8c
commit
3907ec8b51
@@ -0,0 +1,155 @@
|
||||
package org.thoughtcrime.securesms.profiles;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.util.cjkv.CJKVUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class ProfileName implements Parcelable {
|
||||
|
||||
public static final ProfileName EMPTY = new ProfileName("", "");
|
||||
|
||||
private static final int MAX_PART_LENGTH = (ProfileCipher.NAME_PADDED_LENGTH - 1) / 2;
|
||||
|
||||
private final String givenName;
|
||||
private final String familyName;
|
||||
private final String joinedName;
|
||||
|
||||
private ProfileName(@Nullable String givenName, @Nullable String familyName) {
|
||||
this.givenName = sanitize(givenName);
|
||||
this.familyName = sanitize(familyName);
|
||||
this.joinedName = getJoinedName(this.givenName, this.familyName);
|
||||
}
|
||||
|
||||
private ProfileName(Parcel in) {
|
||||
this(in.readString(), in.readString());
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
String getGivenName() {
|
||||
return givenName;
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
String getFamilyName() {
|
||||
return familyName;
|
||||
}
|
||||
|
||||
public boolean isProfileNameCJKV() {
|
||||
return isCJKV(givenName, familyName);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return joinedName.isEmpty();
|
||||
}
|
||||
|
||||
public @NonNull String serialize() {
|
||||
if (isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String.format("%s\0%s", givenName, familyName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return joinedName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a profile name, trims if exceeds the limits.
|
||||
*/
|
||||
public static @NonNull ProfileName fromSerialized(@Nullable String profileName) {
|
||||
if (profileName == null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
String[] parts = profileName.split("\0");
|
||||
|
||||
if (parts.length == 0) {
|
||||
return EMPTY;
|
||||
} else if (parts.length == 1) {
|
||||
return fromParts(parts[0], "");
|
||||
} else {
|
||||
return fromParts(parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a profile name, trimming chars until it fits the limits.
|
||||
*/
|
||||
public static @NonNull ProfileName fromParts(@Nullable String givenName, @Nullable String familyName) {
|
||||
if (givenName == null || givenName.isEmpty()) return EMPTY;
|
||||
|
||||
return new ProfileName(givenName, familyName);
|
||||
}
|
||||
|
||||
private static @NonNull String sanitize(@Nullable String name) {
|
||||
if (name == null) return "";
|
||||
|
||||
// At least one byte per char, so shorten string to reduce loop
|
||||
if (name.length() > ProfileName.MAX_PART_LENGTH) {
|
||||
name = name.substring(0, ProfileName.MAX_PART_LENGTH);
|
||||
}
|
||||
|
||||
// Remove one char at a time until fits in byte allowance
|
||||
while (name.getBytes(StandardCharsets.UTF_8).length > ProfileName.MAX_PART_LENGTH) {
|
||||
name = name.substring(0, name.length() - 1);
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private static @NonNull String getJoinedName(@NonNull String givenName, @NonNull String familyName) {
|
||||
if (givenName.isEmpty() && familyName.isEmpty()) return "";
|
||||
else if (givenName.isEmpty()) return familyName;
|
||||
else if (familyName.isEmpty()) return givenName;
|
||||
else if (isCJKV(givenName, familyName)) return String.format("%s %s",
|
||||
familyName,
|
||||
givenName);
|
||||
else return String.format("%s %s",
|
||||
givenName,
|
||||
familyName);
|
||||
}
|
||||
|
||||
private static boolean isCJKV(@NonNull String givenName, @NonNull String familyName) {
|
||||
if (givenName.isEmpty() && familyName.isEmpty()) {
|
||||
return false;
|
||||
} else {
|
||||
return Stream.of(givenName, familyName)
|
||||
.filterNot(String::isEmpty)
|
||||
.reduce(true, (a, s) -> a && CJKVUtil.isCJKV(s));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(givenName);
|
||||
dest.writeString(familyName);
|
||||
}
|
||||
|
||||
public static final Creator<ProfileName> CREATOR = new Creator<ProfileName>() {
|
||||
@Override
|
||||
public ProfileName createFromParcel(Parcel in) {
|
||||
return new ProfileName(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProfileName[] newArray(int size) {
|
||||
return new ProfileName[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.profiles.edit;
|
||||
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.navigation.NavGraph;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.BaseActionBarActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public class EditProfileActivity extends BaseActionBarActivity implements EditProfileFragment.Controller {
|
||||
|
||||
public static final String NEXT_INTENT = "next_intent";
|
||||
public static final String EXCLUDE_SYSTEM = "exclude_system";
|
||||
public static final String DISPLAY_USERNAME = "display_username";
|
||||
public static final String NEXT_BUTTON_TEXT = "next_button_text";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicRegistrationTheme();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
|
||||
dynamicTheme.onCreate(this);
|
||||
|
||||
setContentView(R.layout.profile_create_activity);
|
||||
|
||||
NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph();
|
||||
Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, getIntent().getExtras());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProfileNameUploadCompleted() {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package org.thoughtcrime.securesms.profiles.edit;
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Selection;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewAnimationUtils;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
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.RequiresApi;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.NavDirections;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.dd.CircularProgressButton;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarSelection;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.DISPLAY_USERNAME;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_INTENT;
|
||||
|
||||
public class EditProfileFragment extends Fragment {
|
||||
|
||||
private static final String TAG = Log.tag(EditProfileFragment.class);
|
||||
|
||||
private ImageView avatar;
|
||||
private CircularProgressButton finishButton;
|
||||
private EditText givenName;
|
||||
private EditText familyName;
|
||||
private View reveal;
|
||||
private TextView preview;
|
||||
private View usernameLabel;
|
||||
private View usernameEditButton;
|
||||
private TextView username;
|
||||
|
||||
private Intent nextIntent;
|
||||
private File captureFile;
|
||||
|
||||
private EditProfileViewModel viewModel;
|
||||
|
||||
private Controller controller;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof Controller) {
|
||||
controller = (Controller) context;
|
||||
} else {
|
||||
throw new IllegalStateException("Context must subclass Controller");
|
||||
}
|
||||
}
|
||||
|
||||
public static EditProfileFragment create(boolean excludeSystem,
|
||||
Intent nextIntent,
|
||||
boolean displayUsernameField,
|
||||
@StringRes int nextButtonText) {
|
||||
|
||||
EditProfileFragment fragment = new EditProfileFragment();
|
||||
Bundle args = new Bundle();
|
||||
|
||||
args.putBoolean(EXCLUDE_SYSTEM, excludeSystem);
|
||||
args.putParcelable(NEXT_INTENT, nextIntent);
|
||||
args.putBoolean(DISPLAY_USERNAME, displayUsernameField);
|
||||
args.putInt(NEXT_BUTTON_TEXT, nextButtonText);
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.profile_create_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
initializeResources(view);
|
||||
initializeViewModel(getArguments().getBoolean(EXCLUDE_SYSTEM, false));
|
||||
initializeProfileName();
|
||||
initializeProfileAvatar();
|
||||
initializeUsername();
|
||||
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
switch (requestCode) {
|
||||
case AvatarSelection.REQUEST_CODE_AVATAR:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
Uri outputFile = Uri.fromFile(new File(requireActivity().getCacheDir(), "cropped"));
|
||||
Uri inputFile = (data != null ? data.getData() : null);
|
||||
|
||||
if (inputFile == null && captureFile != null) {
|
||||
inputFile = Uri.fromFile(captureFile);
|
||||
}
|
||||
|
||||
if (data != null && data.getBooleanExtra("delete", false)) {
|
||||
viewModel.setAvatar(null);
|
||||
avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), getResources().getColor(R.color.grey_400)));
|
||||
} else {
|
||||
AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case AvatarSelection.REQUEST_CODE_CROP_IMAGE:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
new AsyncTask<Void, Void, byte[]>() {
|
||||
@Override
|
||||
protected byte[] doInBackground(Void... params) {
|
||||
try {
|
||||
BitmapUtil.ScaleResult result = BitmapUtil.createScaledBytes(requireActivity(), AvatarSelection.getResultUri(data), new ProfileMediaConstraints());
|
||||
return result.getBitmap();
|
||||
} catch (BitmapDecodingException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(byte[] result) {
|
||||
if (result != null) {
|
||||
viewModel.setAvatar(result);
|
||||
GlideApp.with(EditProfileFragment.this)
|
||||
.load(result)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.circleCrop()
|
||||
.into(avatar);
|
||||
} else {
|
||||
Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeViewModel(boolean excludeSystem) {
|
||||
EditProfileRepository repository = new EditProfileRepository(requireContext(), excludeSystem);
|
||||
EditProfileViewModel.Factory factory = new EditProfileViewModel.Factory(repository);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(EditProfileViewModel.class);
|
||||
}
|
||||
|
||||
private void initializeResources(@NonNull View view) {
|
||||
|
||||
this.avatar = view.findViewById(R.id.avatar);
|
||||
this.givenName = view.findViewById(R.id.given_name);
|
||||
this.familyName = view.findViewById(R.id.family_name);
|
||||
this.finishButton = view.findViewById(R.id.finish_button);
|
||||
this.reveal = view.findViewById(R.id.reveal);
|
||||
this.preview = view.findViewById(R.id.name_preview);
|
||||
this.username = view.findViewById(R.id.profile_overview_username);
|
||||
this.usernameEditButton = view.findViewById(R.id.profile_overview_username_edit_button);
|
||||
this.usernameLabel = view.findViewById(R.id.profile_overview_username_label);
|
||||
this.nextIntent = getArguments().getParcelable(NEXT_INTENT);
|
||||
|
||||
if (FeatureFlags.USERNAMES && getArguments().getBoolean(DISPLAY_USERNAME, false)) {
|
||||
username.setVisibility(View.VISIBLE);
|
||||
usernameEditButton.setVisibility(View.VISIBLE);
|
||||
usernameLabel.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
this.avatar.setOnClickListener(v -> Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.onAnyResult(this::startAvatarSelection)
|
||||
.execute());
|
||||
|
||||
this.givenName.addTextChangedListener(new AfterTextChanged(s -> viewModel.setGivenName(s.toString())));
|
||||
this.familyName.addTextChangedListener(new AfterTextChanged(s -> viewModel.setFamilyName(s.toString())));
|
||||
|
||||
this.finishButton.setOnClickListener(v -> {
|
||||
this.finishButton.setIndeterminateProgressMode(true);
|
||||
this.finishButton.setProgress(50);
|
||||
handleUpload();
|
||||
});
|
||||
|
||||
this.finishButton.setText(getArguments().getInt(NEXT_BUTTON_TEXT, R.string.CreateProfileActivity_next));
|
||||
|
||||
this.usernameEditButton.setOnClickListener(v -> {
|
||||
NavDirections action = EditProfileFragmentDirections.actionEditUsername();
|
||||
Navigation.findNavController(v).navigate(action);
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeProfileName() {
|
||||
viewModel.profileName().observe(this, profileName -> {
|
||||
|
||||
updateFieldIfNeeded(givenName, profileName.getGivenName());
|
||||
updateFieldIfNeeded(familyName, profileName.getFamilyName());
|
||||
|
||||
finishButton.setEnabled(!profileName.isEmpty());
|
||||
finishButton.setAlpha(!profileName.isEmpty() ? 1f : 0.5f);
|
||||
|
||||
preview.setText(profileName.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeProfileAvatar() {
|
||||
viewModel.avatar().observe(this, bytes -> {
|
||||
if (bytes == null) return;
|
||||
|
||||
GlideApp.with(this)
|
||||
.load(bytes)
|
||||
.circleCrop()
|
||||
.into(avatar);
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeUsername() {
|
||||
viewModel.username().observe(this, this::onUsernameChanged);
|
||||
}
|
||||
|
||||
private void updateFieldIfNeeded(@NonNull EditText field, @NonNull String value) {
|
||||
if (!field.getText().toString().equals(value)) {
|
||||
|
||||
boolean setSelectionToEnd = field.getText().length() == 0;
|
||||
|
||||
field.setText(value);
|
||||
|
||||
if (setSelectionToEnd) {
|
||||
field.setSelection(field.getText().length());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private void onUsernameChanged(@NonNull Optional<String> username) {
|
||||
if (username.isPresent()) {
|
||||
this.username.setText("@" + username.get());
|
||||
} else {
|
||||
this.username.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void startAvatarSelection() {
|
||||
captureFile = AvatarSelection.startAvatarSelection(this, viewModel.hasAvatar(), true);
|
||||
}
|
||||
|
||||
private void handleUpload() {
|
||||
|
||||
viewModel.submitProfile(uploadResult -> {
|
||||
if (uploadResult == EditProfileRepository.UploadResult.SUCCESS) {
|
||||
if (captureFile != null) captureFile.delete();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) handleFinishedLollipop();
|
||||
else handleFinishedLegacy();
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.CreateProfileActivity_problem_setting_profile, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleFinishedLegacy() {
|
||||
finishButton.setProgress(0);
|
||||
if (nextIntent != null) startActivity(nextIntent);
|
||||
|
||||
controller.onProfileNameUploadCompleted();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
private void handleFinishedLollipop() {
|
||||
int[] finishButtonLocation = new int[2];
|
||||
int[] revealLocation = new int[2];
|
||||
|
||||
finishButton.getLocationInWindow(finishButtonLocation);
|
||||
reveal.getLocationInWindow(revealLocation);
|
||||
|
||||
int finishX = finishButtonLocation[0] - revealLocation[0];
|
||||
int finishY = finishButtonLocation[1] - revealLocation[1];
|
||||
|
||||
finishX += finishButton.getWidth() / 2;
|
||||
finishY += finishButton.getHeight() / 2;
|
||||
|
||||
Animator animation = ViewAnimationUtils.createCircularReveal(reveal, finishX, finishY, 0f, (float) Math.max(reveal.getWidth(), reveal.getHeight()));
|
||||
animation.setDuration(500);
|
||||
animation.addListener(new Animator.AnimatorListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animator animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
finishButton.setProgress(0);
|
||||
if (nextIntent != null) startActivity(nextIntent);
|
||||
|
||||
controller.onProfileNameUploadCompleted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationCancel(Animator animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animator animation) {}
|
||||
});
|
||||
|
||||
reveal.setVisibility(View.VISIBLE);
|
||||
animation.start();
|
||||
}
|
||||
|
||||
public interface Controller {
|
||||
void onProfileNameUploadCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.thoughtcrime.securesms.profiles.edit;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.profiles.SystemProfileUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
class EditProfileRepository {
|
||||
|
||||
private static final String TAG = Log.tag(EditProfileRepository.class);
|
||||
|
||||
private final Context context;
|
||||
private final boolean excludeSystem;
|
||||
|
||||
EditProfileRepository(@NonNull Context context, boolean excludeSystem) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.excludeSystem = excludeSystem;
|
||||
}
|
||||
|
||||
void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer) {
|
||||
ProfileName storedProfileName = TextSecurePreferences.getProfileName(context);
|
||||
if (!storedProfileName.isEmpty()) {
|
||||
profileNameConsumer.accept(storedProfileName);
|
||||
} else if (!excludeSystem) {
|
||||
SystemProfileUtil.getSystemProfileName(context).addListener(new ListenableFuture.Listener<String>() {
|
||||
@Override
|
||||
public void onSuccess(String result) {
|
||||
if (!TextUtils.isEmpty(result)) {
|
||||
profileNameConsumer.accept(ProfileName.fromSerialized(result));
|
||||
} else {
|
||||
profileNameConsumer.accept(storedProfileName);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {
|
||||
Log.w(TAG, e);
|
||||
profileNameConsumer.accept(storedProfileName);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
profileNameConsumer.accept(storedProfileName);
|
||||
}
|
||||
}
|
||||
|
||||
void getCurrentAvatar(@NonNull Consumer<byte[]> avatarConsumer) {
|
||||
RecipientId selfId = Recipient.self().getId();
|
||||
|
||||
if (AvatarHelper.getAvatarFile(context, selfId).exists() && AvatarHelper.getAvatarFile(context, selfId).length() > 0) {
|
||||
SimpleTask.run(() -> {
|
||||
try {
|
||||
return Util.readFully(AvatarHelper.getInputStreamFor(context, selfId));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}, avatarConsumer::accept);
|
||||
} else if (!excludeSystem) {
|
||||
SystemProfileUtil.getSystemProfileAvatar(context, new ProfileMediaConstraints()).addListener(new ListenableFuture.Listener<byte[]>() {
|
||||
@Override
|
||||
public void onSuccess(byte[] result) {
|
||||
avatarConsumer.accept(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {
|
||||
Log.w(TAG, e);
|
||||
avatarConsumer.accept(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void uploadProfile(@NonNull ProfileName profileName, @Nullable byte[] avatar, @NonNull Consumer<UploadResult> uploadResultConsumer) {
|
||||
SimpleTask.run(() -> {
|
||||
TextSecurePreferences.setProfileName(context, profileName);
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName);
|
||||
|
||||
try {
|
||||
AvatarHelper.setAvatar(context, Recipient.self().getId(), avatar);
|
||||
TextSecurePreferences.setProfileAvatarId(context, new SecureRandom().nextInt());
|
||||
} catch (IOException e) {
|
||||
return UploadResult.ERROR_FILE_IO;
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new ProfileUploadJob())
|
||||
.then(Arrays.asList(new MultiDeviceProfileKeyUpdateJob(), new MultiDeviceProfileContentUpdateJob()))
|
||||
.enqueue();
|
||||
|
||||
return UploadResult.SUCCESS;
|
||||
}, uploadResultConsumer::accept);
|
||||
}
|
||||
|
||||
void getCurrentUsername(@NonNull Consumer<Optional<String>> callback) {
|
||||
callback.accept(Optional.fromNullable(TextSecurePreferences.getLocalUsername(context)));
|
||||
SignalExecutors.UNBOUNDED.execute(() -> callback.accept(getUsernameInternal()));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull Optional<String> getUsernameInternal() {
|
||||
try {
|
||||
SignalServiceProfile profile = retrieveOwnProfile();
|
||||
TextSecurePreferences.setLocalUsername(context, profile.getUsername());
|
||||
DatabaseFactory.getRecipientDatabase(context).setUsername(Recipient.self().getId(), profile.getUsername());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to retrieve username remotely! Using locally-cached version.");
|
||||
}
|
||||
return Optional.fromNullable(TextSecurePreferences.getLocalUsername(context));
|
||||
}
|
||||
|
||||
private SignalServiceProfile retrieveOwnProfile() throws IOException {
|
||||
SignalServiceAddress address = new SignalServiceAddress(TextSecurePreferences.getLocalUuid(context), TextSecurePreferences.getLocalNumber(context));
|
||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||
SignalServiceMessagePipe pipe = IncomingMessageObserver.getPipe();
|
||||
|
||||
if (pipe != null) {
|
||||
try {
|
||||
return pipe.getProfile(address, Optional.absent());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
return receiver.retrieveProfile(address, Optional.absent());
|
||||
}
|
||||
|
||||
public enum UploadResult {
|
||||
SUCCESS,
|
||||
ERROR_FILE_IO
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.profiles.edit;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
class EditProfileViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<String> givenName = new MutableLiveData<>();
|
||||
private final MutableLiveData<String> familyName = new MutableLiveData<>();
|
||||
private final LiveData<ProfileName> internalProfileName = Transformations.map(new LiveDataPair<>(givenName, familyName),
|
||||
pair -> ProfileName.fromParts(pair.first(), pair.second()));
|
||||
private final MutableLiveData<byte[]> internalAvatar = new MutableLiveData<>();
|
||||
private final MutableLiveData<Optional<String>> internalUsername = new MutableLiveData<>();
|
||||
private final EditProfileRepository repository;
|
||||
|
||||
private EditProfileViewModel(@NonNull EditProfileRepository repository) {
|
||||
this.repository = repository;
|
||||
|
||||
repository.getCurrentUsername(internalUsername::postValue);
|
||||
repository.getCurrentProfileName(name -> {
|
||||
givenName.setValue(name.getGivenName());
|
||||
familyName.setValue(name.getFamilyName());
|
||||
});
|
||||
repository.getCurrentAvatar(internalAvatar::setValue);
|
||||
}
|
||||
|
||||
public LiveData<ProfileName> profileName() {
|
||||
return internalProfileName;
|
||||
}
|
||||
|
||||
public LiveData<byte[]> avatar() {
|
||||
return Transformations.distinctUntilChanged(internalAvatar);
|
||||
}
|
||||
|
||||
public LiveData<Optional<String>> username() {
|
||||
return internalUsername;
|
||||
}
|
||||
|
||||
public boolean hasAvatar() {
|
||||
return internalAvatar.getValue() != null;
|
||||
}
|
||||
|
||||
public void setGivenName(String givenName) {
|
||||
this.givenName.setValue(givenName);
|
||||
}
|
||||
|
||||
public void setFamilyName(String familyName) {
|
||||
this.familyName.setValue(familyName);
|
||||
}
|
||||
|
||||
public void setAvatar(byte[] avatar) {
|
||||
internalAvatar.setValue(avatar);
|
||||
}
|
||||
|
||||
public void submitProfile(Consumer<EditProfileRepository.UploadResult> uploadResultConsumer) {
|
||||
ProfileName profileName = internalProfileName.getValue();
|
||||
if (profileName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.uploadProfile(profileName, internalAvatar.getValue(), uploadResultConsumer);
|
||||
}
|
||||
|
||||
private ProfileName currentProfileName() {
|
||||
return internalProfileName.getValue();
|
||||
}
|
||||
|
||||
static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final EditProfileRepository repository;
|
||||
|
||||
Factory(EditProfileRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection unchecked
|
||||
return (T) new EditProfileViewModel(repository);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user