diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index c72188f6f9..ba3ff74d31 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -460,7 +460,13 @@
+ android:theme="@style/TextSecure.LightNoActionBar"
+ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
+
+
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 7355564614..873d23ad1f 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
@@ -453,6 +453,14 @@ public class SignalServiceAccountManager {
this.pushServiceSocket.setProfileAvatar(profileAvatarData);
}
+ public void setUsername(String username) throws IOException {
+ this.pushServiceSocket.setUsername(username);
+ }
+
+ public void deleteUsername() throws IOException {
+ this.pushServiceSocket.deleteUsername();
+ }
+
public void setSoTimeoutMillis(long soTimeoutMillis) {
this.pushServiceSocket.setSoTimeoutMillis(soTimeoutMillis);
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java
index c133e5183f..9dda99656b 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java
@@ -116,6 +116,12 @@ public class SignalServiceMessageReceiver {
return socket.retrieveProfile(address, unidentifiedAccess);
}
+ public SignalServiceProfile retrieveProfileByUsername(String username, Optional unidentifiedAccess)
+ throws IOException
+ {
+ return socket.retrieveProfileByUsername(username, unidentifiedAccess);
+ }
+
public InputStream retrieveProfileAvatar(String path, File destination, byte[] profileKey, int maxSizeBytes)
throws IOException
{
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java
index 9fbe30767a..2ec4a13122 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java
@@ -2,6 +2,12 @@ package org.whispersystems.signalservice.api.profiles;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.whispersystems.signalservice.internal.util.JsonUtil;
+
+import java.util.UUID;
public class SignalServiceProfile {
@@ -23,6 +29,14 @@ public class SignalServiceProfile {
@JsonProperty
private Capabilities capabilities;
+ @JsonProperty
+ private String username;
+
+ @JsonProperty
+ @JsonSerialize(using = JsonUtil.UuidSerializer.class)
+ @JsonDeserialize(using = JsonUtil.UuidDeserializer.class)
+ private UUID uuid;
+
public SignalServiceProfile() {}
public String getIdentityKey() {
@@ -49,6 +63,14 @@ public class SignalServiceProfile {
return capabilities;
}
+ public String getUsername() {
+ return username;
+ }
+
+ public UUID getUuid() {
+ return uuid;
+ }
+
public static class Capabilities {
@JsonProperty
private boolean uuid;
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameMalformedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameMalformedException.java
new file mode 100644
index 0000000000..2463380157
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameMalformedException.java
@@ -0,0 +1,4 @@
+package org.whispersystems.signalservice.api.push.exceptions;
+
+public class UsernameMalformedException extends NonSuccessfulResponseCodeException {
+}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameTakenException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameTakenException.java
new file mode 100644
index 0000000000..09782da2f7
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameTakenException.java
@@ -0,0 +1,4 @@
+package org.whispersystems.signalservice.api.push.exceptions;
+
+public class UsernameTakenException extends NonSuccessfulResponseCodeException {
+}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
index a9d4454b1c..f051c4e2a0 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
@@ -34,6 +34,8 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationResponseExpiredException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
import org.whispersystems.signalservice.api.util.UuidUtil;
@@ -108,6 +110,8 @@ public class PushServiceSocket {
private static final String PIN_PATH = "/v1/accounts/pin/";
private static final String REQUEST_PUSH_CHALLENGE = "/v1/accounts/fcm/preauth/%s/%s";
private static final String WHO_AM_I = "/v1/accounts/whoami";
+ private static final String SET_USERNAME_PATH = "/v1/accounts/username/%s";
+ private static final String DELETE_USERNAME_PATH = "/v1/accounts/username";
private static final String PREKEY_METADATA_PATH = "/v2/keys/";
private static final String PREKEY_PATH = "/v2/keys/%s";
@@ -128,6 +132,7 @@ public class PushServiceSocket {
private static final String ATTACHMENT_PATH = "/v2/attachments/form/upload";
private static final String PROFILE_PATH = "/v1/profile/%s";
+ private static final String PROFILE_USERNAME_PATH = "/v1/profile/username/%s";
private static final String SENDER_CERTIFICATE_LEGACY_PATH = "/v1/certificate/delivery";
private static final String SENDER_CERTIFICATE_PATH = "/v1/certificate/delivery?includeUuid=true";
@@ -491,8 +496,22 @@ public class PushServiceSocket {
public SignalServiceProfile retrieveProfile(SignalServiceAddress target, Optional unidentifiedAccess)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
+ String response = makeServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, NO_HEADERS, unidentifiedAccess);
+
+ try {
+ return JsonUtil.fromJson(response, SignalServiceProfile.class);
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ throw new NonSuccessfulResponseCodeException("Unable to parse entity");
+ }
+ }
+
+ public SignalServiceProfile retrieveProfileByUsername(String username, Optional unidentifiedAccess)
+ throws NonSuccessfulResponseCodeException, PushNetworkException
+ {
+ String response = makeServiceRequest(String.format(PROFILE_USERNAME_PATH, username), "GET", null, NO_HEADERS, unidentifiedAccess);
+
try {
- String response = makeServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, NO_HEADERS, unidentifiedAccess);
return JsonUtil.fromJson(response, SignalServiceProfile.class);
} catch (IOException e) {
Log.w(TAG, e);
@@ -533,6 +552,22 @@ public class PushServiceSocket {
}
}
+ public void setUsername(String username) throws IOException {
+ makeServiceRequest(String.format(SET_USERNAME_PATH, username), "PUT", "", NO_HEADERS, new ResponseCodeHandler() {
+ @Override
+ public void handle(int responseCode) throws NonSuccessfulResponseCodeException {
+ switch (responseCode) {
+ case 400: throw new UsernameMalformedException();
+ case 409: throw new UsernameTakenException();
+ }
+ }
+ }, Optional.absent());
+ }
+
+ public void deleteUsername() throws IOException {
+ makeServiceRequest(DELETE_USERNAME_PATH, "DELETE", null);
+ }
+
public List retrieveDirectory(Set contactTokens)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java
index 0001a0cb4c..5bb8d0d903 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java
@@ -20,9 +20,11 @@ import com.fasterxml.jackson.databind.SerializerProvider;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.logging.Log;
+import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.util.Base64;
import java.io.IOException;
+import java.util.UUID;
public class JsonUtil {
@@ -69,5 +71,19 @@ public class JsonUtil {
}
}
+ public static class UuidSerializer extends JsonSerializer {
+ @Override
+ public void serialize(UUID value, JsonGenerator gen, SerializerProvider serializers)
+ throws IOException
+ {
+ gen.writeString(value.toString());
+ }
+ }
+ public static class UuidDeserializer extends JsonDeserializer {
+ @Override
+ public UUID deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ return UuidUtil.parseOrNull(p.getValueAsString());
+ }
+ }
}
diff --git a/res/drawable/progress_button_state_grey.xml b/res/drawable/progress_button_state_grey.xml
new file mode 100644
index 0000000000..eac1ee4115
--- /dev/null
+++ b/res/drawable/progress_button_state_grey.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/progress_button_state_red.xml b/res/drawable/progress_button_state_red.xml
new file mode 100644
index 0000000000..4bfd3eb6ed
--- /dev/null
+++ b/res/drawable/progress_button_state_red.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/profile_edit_activity_v2.xml b/res/layout/profile_edit_activity_v2.xml
new file mode 100644
index 0000000000..208258c031
--- /dev/null
+++ b/res/layout/profile_edit_activity_v2.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/profile_edit_name_fragment.xml b/res/layout/profile_edit_name_fragment.xml
new file mode 100644
index 0000000000..f2b5549121
--- /dev/null
+++ b/res/layout/profile_edit_name_fragment.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/profile_edit_overview_fragment.xml b/res/layout/profile_edit_overview_fragment.xml
new file mode 100644
index 0000000000..94a6354894
--- /dev/null
+++ b/res/layout/profile_edit_overview_fragment.xml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/username_edit_fragment.xml b/res/layout/username_edit_fragment.xml
new file mode 100644
index 0000000000..818fea90e4
--- /dev/null
+++ b/res/layout/username_edit_fragment.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/navigation/profile_edit.xml b/res/navigation/profile_edit.xml
new file mode 100644
index 0000000000..5d5e884a8b
--- /dev/null
+++ b/res/navigation/profile_edit.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index cf5d5a03ec..8252754d34 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -53,7 +53,7 @@
5dp
4dp
- 120dp
+ 120dp
4dp
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 6758e9e9b6..eb688f4399 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -130,6 +130,8 @@
Recent chats
Contacts
Groups
+ Phone number search
+ Username search
Message %s
@@ -596,6 +598,19 @@
The version of Google Play Services you have installed is not functioning correctly. Please reinstall Google Play Services and try again.
+
+ Profile name
+ Your profile name can be seen by your contacts and by other users or groups when you initiate a conversation or accept a conversation request.
+ Save
+
+
+ Profile
+ Profile name
+ Username
+ Create a profile name
+ Create a username
+ Your Signal Profile can be seen by your contacts and by other users or groups when you initiate a conversation or accept a conversation request. Tap here to learn more.
+
Rate this app
If you enjoy using this app, please take a moment to help us by rating it.
@@ -785,6 +800,21 @@
Send message?
Send
+
+ Username
+ Submit
+ Delete
+ Successfully set username.
+ Successfully removed username.
+ Encountered a network error.
+ This username is taken.
+ This username is available.
+ Usernames can only include a-Z, 0-9, and underscores.
+ Usernames cannot begin with a number.
+ Username is invalid.
+ Usernames must be between %1$d and %2$d characters.
+ Other Signal users can send message requests to your unique username without knowing your phone number. Choosing a username is optional.
+
Your contact is running an old version of Signal. Please ask them to update before verifying your safety number.
Your contact is running a newer version of Signal with an incompatible QR code format. Please update to compare.
@@ -867,6 +897,10 @@
Messages
Unknown
+
+ Successfully set profile name.
+ Encountered a network error.
+
Quick response unavailable when Signal is locked!
Problem sending message!
@@ -966,6 +1000,9 @@
Signal requires the Contacts permission in order to display your contacts, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Contacts\".
Error retrieving contacts, check your network connection
+ Username not found
+ "%1$s" is not a Signal user. Please check the username and try again.
+ Okay
No blocked contacts
diff --git a/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java
index 0b13a567c8..483c6078e9 100644
--- a/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java
+++ b/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java
@@ -39,8 +39,10 @@ import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment;
import org.thoughtcrime.securesms.preferences.SmsMmsPreferenceFragment;
import org.thoughtcrime.securesms.preferences.widgets.ProfilePreference;
import org.thoughtcrime.securesms.service.KeyCachingService;
+import org.thoughtcrime.securesms.usernames.ProfileEditActivityV2;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -257,11 +259,14 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA
private class ProfileClickListener implements Preference.OnPreferenceClickListener {
@Override
public boolean onPreferenceClick(Preference preference) {
- Intent intent = new Intent(preference.getContext(), CreateProfileActivity.class);
- intent.putExtra(CreateProfileActivity.EXCLUDE_SYSTEM, true);
+ if (FeatureFlags.USERNAMES) {
+ requireActivity().startActivity(ProfileEditActivityV2.getLaunchIntent(requireContext()));
+ } else {
+ Intent intent = new Intent(preference.getContext(), CreateProfileActivity.class);
+ intent.putExtra(CreateProfileActivity.EXCLUDE_SYSTEM, true);
- getActivity().startActivity(intent);
-// ((BaseActionBarActivity)getActivity()).startActivitySceneTransition(intent, getActivity().findViewById(R.id.avatar), "avatar");
+ requireActivity().startActivity(intent);
+ }
return true;
}
}
diff --git a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java
index 70abd59900..5a8d597bd2 100644
--- a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java
+++ b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java
@@ -33,6 +33,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
@@ -47,16 +48,21 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
+import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
+import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@@ -82,7 +88,7 @@ public final class ContactSelectionListFragment extends Fragment
public static final String RECENTS = "recents";
private TextView emptyText;
- private Set selectedContacts;
+ private Set selectedContacts;
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private View showContactsLayout;
@@ -163,8 +169,8 @@ public final class ContactSelectionListFragment extends Fragment
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
- public @NonNull List getSelectedContacts() {
- List selected = new LinkedList<>();
+ public @NonNull List getSelectedContacts() {
+ List selected = new LinkedList<>();
if (selectedContacts != null) {
selected.addAll(selectedContacts);
}
@@ -327,14 +333,48 @@ public final class ContactSelectionListFragment extends Fragment
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
@Override
public void onItemClick(ContactSelectionListItem contact) {
- if (!isMulti() || !selectedContacts.contains(contact.getNumber())) {
- selectedContacts.add(contact.getNumber());
- contact.setChecked(true);
- if (onContactSelectedListener != null) onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
+ SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
+ : SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
+
+ if (!isMulti() || !selectedContacts.contains(selectedContact)) {
+ if (contact.isUsernameType()) {
+ AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
+
+ SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
+ return UsernameUtil.fetchUuidForUsername(requireContext(), contact.getNumber());
+ }, uuid -> {
+ loadingDialog.dismiss();
+ if (uuid.isPresent()) {
+ Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
+ selectedContacts.add(SelectedContact.forUsername(recipient.getId(), contact.getNumber()));
+ contact.setChecked(true);
+
+ if (onContactSelectedListener != null) {
+ onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
+ }
+ } else {
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.ContactSelectionListFragment_username_not_found)
+ .setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
+ .setPositiveButton(R.string.ContactSelectionListFragment_okay, (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+ });
+ } else {
+ selectedContacts.add(selectedContact);
+ contact.setChecked(true);
+
+ if (onContactSelectedListener != null) {
+ onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
+ }
+ }
} else {
- selectedContacts.remove(contact.getNumber());
+ selectedContacts.remove(selectedContact);
contact.setChecked(false);
- if (onContactSelectedListener != null) onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
+
+ if (onContactSelectedListener != null) {
+ onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
+ }
}
}
}
diff --git a/src/org/thoughtcrime/securesms/ConversationListActivity.java b/src/org/thoughtcrime/securesms/ConversationListActivity.java
index d515068267..db89d3a429 100644
--- a/src/org/thoughtcrime/securesms/ConversationListActivity.java
+++ b/src/org/thoughtcrime/securesms/ConversationListActivity.java
@@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.search.SearchFragment;
import org.thoughtcrime.securesms.service.KeyCachingService;
+import org.thoughtcrime.securesms.usernames.ProfileEditActivityV2;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
diff --git a/src/org/thoughtcrime/securesms/GroupCreateActivity.java b/src/org/thoughtcrime/securesms/GroupCreateActivity.java
index 945cdcad36..0e05503349 100644
--- a/src/org/thoughtcrime/securesms/GroupCreateActivity.java
+++ b/src/org/thoughtcrime/securesms/GroupCreateActivity.java
@@ -291,12 +291,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
switch (reqCode) {
case PICK_CONTACT:
- List selected = data.getStringArrayListExtra("contacts");
+ List selected = data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS);
- for (String contact : selected) {
- Recipient recipient = Recipient.external(this, contact);
+ for (RecipientId contact : selected) {
+ Recipient recipient = Recipient.resolved(contact);
addSelectedContacts(recipient);
}
+
break;
case AvatarSelection.REQUEST_CODE_AVATAR:
diff --git a/src/org/thoughtcrime/securesms/NewConversationActivity.java b/src/org/thoughtcrime/securesms/NewConversationActivity.java
index ea492c9b76..0b135cf944 100644
--- a/src/org/thoughtcrime/securesms/NewConversationActivity.java
+++ b/src/org/thoughtcrime/securesms/NewConversationActivity.java
@@ -22,14 +22,29 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
+import android.widget.Toast;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
+import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Optional;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.UsernameUtil;
+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.profiles.SignalServiceProfile;
+import org.whispersystems.signalservice.api.util.UuidUtil;
+
+import java.io.IOException;
+import java.util.UUID;
/**
* Activity container for starting a new conversation.
@@ -60,7 +75,10 @@ public class NewConversationActivity extends ContactSelectionActivity
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
recipient = Recipient.external(this, number);
}
+ launch(recipient);
+ }
+ private void launch(Recipient recipient) {
Intent intent = new Intent(this, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA));
diff --git a/src/org/thoughtcrime/securesms/PushContactSelectionActivity.java b/src/org/thoughtcrime/securesms/PushContactSelectionActivity.java
index 699e9e55e8..bda0009ee9 100644
--- a/src/org/thoughtcrime/securesms/PushContactSelectionActivity.java
+++ b/src/org/thoughtcrime/securesms/PushContactSelectionActivity.java
@@ -19,6 +19,11 @@ package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
+import com.annimon.stream.Stream;
+
+import org.thoughtcrime.securesms.contacts.SelectedContact;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+
import java.util.ArrayList;
import java.util.List;
@@ -30,6 +35,8 @@ import java.util.List;
*/
public class PushContactSelectionActivity extends ContactSelectionActivity {
+ public static final String KEY_SELECTED_RECIPIENTS = "recipients";
+
@SuppressWarnings("unused")
private final static String TAG = PushContactSelectionActivity.class.getSimpleName();
@@ -40,12 +47,11 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
getToolbar().setNavigationIcon(R.drawable.ic_check_24);
getToolbar().setNavigationOnClickListener(v -> {
- Intent resultIntent = getIntent();
- List selectedContacts = contactsFragment.getSelectedContacts();
+ Intent resultIntent = getIntent();
+ List selectedContacts = contactsFragment.getSelectedContacts();
+ List recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
- if (selectedContacts != null) {
- resultIntent.putStringArrayListExtra("contacts", new ArrayList<>(selectedContacts));
- }
+ resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
setResult(RESULT_OK, resultIntent);
finish();
diff --git a/src/org/thoughtcrime/securesms/avatar/AvatarSelection.java b/src/org/thoughtcrime/securesms/avatar/AvatarSelection.java
index 6dfdb354dd..952aa3d9af 100644
--- a/src/org/thoughtcrime/securesms/avatar/AvatarSelection.java
+++ b/src/org/thoughtcrime/securesms/avatar/AvatarSelection.java
@@ -6,9 +6,12 @@ import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.MediaStore;
+
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
import com.theartofdev.edmodo.cropper.CropImage;
import com.theartofdev.edmodo.cropper.CropImageView;
@@ -52,6 +55,22 @@ public final class AvatarSelection {
.start(activity);
}
+ /**
+ * Returns result on {@link #REQUEST_CODE_CROP_IMAGE}
+ */
+ public static void circularCropImage(Fragment fragment, Uri inputFile, Uri outputFile, @StringRes int title) {
+ CropImage.activity(inputFile)
+ .setGuidelines(CropImageView.Guidelines.ON)
+ .setAspectRatio(1, 1)
+ .setCropShape(CropImageView.CropShape.OVAL)
+ .setOutputUri(outputFile)
+ .setAllowRotation(true)
+ .setAllowFlipping(true)
+ .setBackgroundColor(ContextCompat.getColor(fragment.requireContext(), R.color.avatar_background))
+ .setActivityTitle(fragment.requireContext().getString(title))
+ .start(fragment.requireContext(), fragment);
+ }
+
public static Uri getResultUri(Intent data) {
return CropImage.getActivityResult(data).getUri();
}
@@ -62,24 +81,39 @@ public final class AvatarSelection {
* @return Temporary capture file if created.
*/
public static File startAvatarSelection(Activity activity, boolean includeClear, boolean attemptToIncludeCamera) {
- File captureFile = null;
-
- if (attemptToIncludeCamera) {
- if (Permissions.hasAll(activity, Manifest.permission.CAMERA)) {
- try {
- captureFile = File.createTempFile("capture", "jpg", activity.getExternalCacheDir());
- } catch (IOException e) {
- Log.w(TAG, e);
- captureFile = null;
- }
- }
- }
+ File captureFile = attemptToIncludeCamera ? getCaptureFile(activity) : null;
Intent chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear);
activity.startActivityForResult(chooserIntent, REQUEST_CODE_AVATAR);
return captureFile;
}
+ /**
+ * Returns result on {@link #REQUEST_CODE_AVATAR}
+ *
+ * @return Temporary capture file if created.
+ */
+ public static File startAvatarSelection(Fragment fragment, boolean includeClear, boolean attemptToIncludeCamera) {
+ File captureFile = attemptToIncludeCamera ? getCaptureFile(fragment.requireContext()) : null;
+
+ Intent chooserIntent = createAvatarSelectionIntent(fragment.requireContext(), captureFile, includeClear);
+ fragment.startActivityForResult(chooserIntent, REQUEST_CODE_AVATAR);
+ return captureFile;
+ }
+
+ private static @Nullable File getCaptureFile(@NonNull Context context) {
+ if (!Permissions.hasAll(context, Manifest.permission.CAMERA)) {
+ return null;
+ }
+
+ try {
+ return File.createTempFile("capture", "jpg", context.getExternalCacheDir());
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ return null;
+ }
+ }
+
private static Intent createAvatarSelectionIntent(Context context, @Nullable File tempCaptureFile, boolean includeClear) {
List extraIntents = new LinkedList<>();
Intent galleryIntent = new Intent(Intent.ACTION_PICK);
diff --git a/src/org/thoughtcrime/securesms/contacts/ContactRepository.java b/src/org/thoughtcrime/securesms/contacts/ContactRepository.java
index acc477bd03..bdf961a75f 100644
--- a/src/org/thoughtcrime/securesms/contacts/ContactRepository.java
+++ b/src/org/thoughtcrime/securesms/contacts/ContactRepository.java
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import java.util.ArrayList;
@@ -41,11 +42,12 @@ public class ContactRepository {
static final String LABEL_COLUMN = "label";
static final String CONTACT_TYPE_COLUMN = "contact_type";
- static final int NORMAL_TYPE = 0;
- static final int PUSH_TYPE = 1;
- static final int NEW_TYPE = 2;
- static final int RECENT_TYPE = 3;
- static final int DIVIDER_TYPE = 4;
+ static final int NORMAL_TYPE = 0;
+ static final int PUSH_TYPE = 1;
+ static final int NEW_PHONE_TYPE = 2;
+ static final int NEW_USERNAME_TYPE = 3;
+ static final int RECENT_TYPE = 4;
+ static final int DIVIDER_TYPE = 5;
/** Maps the recipient results to the legacy contact column names */
private static final List> SEARCH_CURSOR_MAPPERS = new ArrayList>() {{
@@ -55,14 +57,14 @@ public class ContactRepository {
String system = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.SYSTEM_DISPLAY_NAME));
String profile = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.SIGNAL_PROFILE_NAME));
- return !TextUtils.isEmpty(system) ? system : profile;
+ return Util.getFirstNonEmpty(system, profile);
}));
add(new Pair<>(NUMBER_COLUMN, cursor -> {
String phone = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.PHONE));
String email = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.EMAIL));
- return !TextUtils.isEmpty(phone) ? phone : email;
+ return Util.getFirstNonEmpty(phone, email);
}));
add(new Pair<>(NUMBER_TYPE_COLUMN, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(RecipientDatabase.SYSTEM_PHONE_TYPE))));
diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java
index 23f338a423..30407e2acf 100644
--- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java
+++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java
@@ -70,7 +70,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter selectedContacts = new HashSet<>();
+ private final Set selectedContacts = new HashSet<>();
public abstract static class ViewHolder extends RecyclerView.ViewHolder {
@@ -189,7 +189,12 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter getSelectedContacts() {
+ public Set getSelectedContacts() {
return selectedContacts;
}
diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java
index ff7c1c95a0..3f3b7e19e5 100644
--- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java
+++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java
@@ -1,16 +1,17 @@
package org.thoughtcrime.securesms.contacts;
+import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.TextView;
+import org.thoughtcrime.securesms.ConversationListFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
@@ -35,6 +36,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
private CheckBox checkBox;
private String number;
+ private int contactType;
private LiveRecipient recipient;
private GlideRequests glideRequests;
@@ -69,8 +71,9 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
{
this.glideRequests = glideRequests;
this.number = number;
+ this.contactType = type;
- if (type == ContactRepository.NEW_TYPE) {
+ if (type == ContactRepository.NEW_PHONE_TYPE || type == ContactRepository.NEW_USERNAME_TYPE) {
this.recipient = null;
this.contactPhotoImage.setAvatar(glideRequests, null, false);
} else if (recipientId != null) {
@@ -102,6 +105,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
}
}
+ @SuppressLint("SetTextI18n")
private void setText(@Nullable Recipient recipient, int type, String name, String number, String label) {
if (number == null || number.isEmpty() || GroupUtil.isEncodedGroup(number)) {
this.nameView.setEnabled(false);
@@ -111,6 +115,11 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
this.numberView.setText(number);
this.nameView.setEnabled(true);
this.labelView.setVisibility(View.GONE);
+ } else if (type == ContactRepository.NEW_USERNAME_TYPE) {
+ this.numberView.setText("@" + number);
+ this.nameView.setEnabled(true);
+ this.labelView.setText(label);
+ this.labelView.setVisibility(View.VISIBLE);
} else {
this.numberView.setText(number);
this.nameView.setEnabled(true);
@@ -129,6 +138,10 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
return number;
}
+ public boolean isUsernameType() {
+ return contactType == ContactRepository.NEW_USERNAME_TYPE;
+ }
+
public Optional getRecipientId() {
return recipient != null ? Optional.of(recipient.getId()) : Optional.absent();
}
diff --git a/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java
index 3402e5e6a7..8c7065c032 100644
--- a/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java
+++ b/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java
@@ -23,6 +23,7 @@ import android.database.MatrixCursor;
import android.database.MergeCursor;
import android.provider.ContactsContract;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.loader.content.CursorLoader;
import android.text.TextUtils;
@@ -37,6 +38,8 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.UsernameUtil;
import java.util.ArrayList;
import java.util.List;
@@ -81,7 +84,7 @@ public class ContactsCursorLoader extends CursorLoader {
throw new AssertionError("Inactive group flag set, but the active group flag isn't!");
}
- this.filter = filter == null ? "" : filter;
+ this.filter = sanitizeFilter(filter);
this.mode = mode;
this.recents = recents;
this.contactRepository = new ContactRepository(context);
@@ -97,6 +100,16 @@ public class ContactsCursorLoader extends CursorLoader {
return null;
}
+ private static @NonNull String sanitizeFilter(@Nullable String filter) {
+ if (filter == null) {
+ return "";
+ } else if (filter.startsWith("@")) {
+ return filter.substring(1);
+ } else {
+ return filter;
+ }
+ }
+
private List getUnfilteredResults() {
ArrayList cursorList = new ArrayList<>();
@@ -132,8 +145,17 @@ public class ContactsCursorLoader extends CursorLoader {
cursorList.addAll(getContactsCursors());
}
- if (NumberUtil.isValidSmsOrEmail(filter)) {
+ if (FeatureFlags.USERNAMES && NumberUtil.isVisuallyValidNumberOrEmail(filter)) {
+ cursorList.add(getPhoneNumberSearchHeaderCursor());
cursorList.add(getNewNumberCursor());
+ } else if (!FeatureFlags.USERNAMES && NumberUtil.isValidSmsOrEmail(filter)){
+ cursorList.add(getContactsHeaderCursor());
+ cursorList.add(getNewNumberCursor());
+ }
+
+ if (FeatureFlags.USERNAMES && UsernameUtil.isValidUsernameForSearch(filter)) {
+ cursorList.add(getUsernameSearchHeaderCursor());
+ cursorList.add(getUsernameSearchCursor());
}
return cursorList;
@@ -172,6 +194,28 @@ public class ContactsCursorLoader extends CursorLoader {
return groupHeader;
}
+ private Cursor getPhoneNumberSearchHeaderCursor() {
+ MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
+ contactsHeader.addRow(new Object[] { null,
+ getContext().getString(R.string.ContactsCursorLoader_phone_number_search),
+ "",
+ ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
+ "",
+ ContactRepository.DIVIDER_TYPE });
+ return contactsHeader;
+ }
+
+ private Cursor getUsernameSearchHeaderCursor() {
+ MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
+ contactsHeader.addRow(new Object[] { null,
+ getContext().getString(R.string.ContactsCursorLoader_username_search),
+ "",
+ ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
+ "",
+ ContactRepository.DIVIDER_TYPE });
+ return contactsHeader;
+ }
+
private Cursor getRecentConversationsCursor() {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(getContext());
@@ -237,10 +281,21 @@ public class ContactsCursorLoader extends CursorLoader {
filter,
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"\u21e2",
- ContactRepository.NEW_TYPE });
+ ContactRepository.NEW_PHONE_TYPE});
return newNumberCursor;
}
+ private Cursor getUsernameSearchCursor() {
+ MatrixCursor cursor = new MatrixCursor(CONTACT_PROJECTION, 1);
+ cursor.addRow(new Object[] { null,
+ getContext().getString(R.string.contact_selection_list__unknown_contact),
+ filter,
+ ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
+ "\u21e2",
+ ContactRepository.NEW_USERNAME_TYPE});
+ return cursor;
+ }
+
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
try {
final long startMillis = System.currentTimeMillis();
diff --git a/src/org/thoughtcrime/securesms/contacts/SelectedContact.java b/src/org/thoughtcrime/securesms/contacts/SelectedContact.java
new file mode 100644
index 0000000000..55a23e06a6
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/contacts/SelectedContact.java
@@ -0,0 +1,64 @@
+package org.thoughtcrime.securesms.contacts;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+
+import java.util.Objects;
+
+/**
+ * Model for a contact and the various ways it could be represented. Used in situations where we
+ * don't want to create Recipients for the wrapped data (like a custom-entered phone number for
+ * someone you don't yet have a conversation with).
+ *
+ * Designed so that two instances will be equal if *any* of its properties match.
+ */
+public class SelectedContact {
+ private final RecipientId recipientId;
+ private final String number;
+ private final String username;
+
+ public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) {
+ return new SelectedContact(recipientId, number, null);
+ }
+
+ public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) {
+ return new SelectedContact(recipientId, null, username);
+ }
+
+ private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
+ this.recipientId = recipientId;
+ this.number = number;
+ this.username = username;
+ }
+
+ public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) {
+ if (recipientId != null) {
+ return recipientId;
+ } else if (number != null) {
+ return Recipient.external(context, number).getId();
+ } else {
+ throw new AssertionError();
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SelectedContact that = (SelectedContact) o;
+
+ return Objects.equals(recipientId, that.recipientId) ||
+ Objects.equals(number, that.number) ||
+ Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(recipientId, number, username);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java b/src/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java
index 994827541d..a011608f82 100644
--- a/src/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java
+++ b/src/org/thoughtcrime/securesms/contacts/sync/DirectoryHelperV1.java
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.crypto.SessionUtil;
+import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult;
import org.thoughtcrime.securesms.database.RecipientDatabase;
@@ -36,14 +37,20 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.recipients.RecipientUtil;
+import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
+import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
+import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
import org.whispersystems.signalservice.api.util.UuidUtil;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import java.io.IOException;
import java.util.Calendar;
@@ -107,7 +114,19 @@ class DirectoryHelperV1 {
@WorkerThread
static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
- RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
+ RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
+
+ if (recipient.getUuid().isPresent() && !recipient.getE164().isPresent()) {
+ boolean isRegistered = isUuidRegistered(context, recipient);
+ if (isRegistered) {
+ recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get());
+ } else {
+ recipientDatabase.markUnregistered(recipient.getId());
+ }
+
+ return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED;
+ }
+
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
Future legacyRequest = getLegacyRegisteredState(context, accountManager, recipientDatabase, recipient);
@@ -309,6 +328,32 @@ class DirectoryHelperV1 {
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
}
+ private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
+ Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient);
+ SignalServiceMessagePipe authPipe = IncomingMessageObserver.getPipe();
+ SignalServiceMessagePipe unidentifiedPipe = IncomingMessageObserver.getUnidentifiedPipe();
+ SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent() ? unidentifiedPipe : authPipe;
+ SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient);
+
+ if (pipe != null) {
+ try {
+ pipe.getProfile(address, unidentifiedAccess.get().getTargetUnidentifiedAccess());
+ return true;
+ } catch (NotFoundException e) {
+ return false;
+ } catch (IOException e) {
+ Log.w(TAG, "Websocket request failed. Falling back to REST.");
+ }
+ }
+
+ try {
+ ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfile(address, unidentifiedAccess.get().getTargetUnidentifiedAccess());
+ return true;
+ } catch (NotFoundException e) {
+ return false;
+ }
+ }
+
private static class DirectoryResult {
private final Set numbers;
diff --git a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
index 1bafa01d3f..06a12a023b 100644
--- a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
@@ -45,6 +45,7 @@ public class RecipientDatabase extends Database {
static final String TABLE_NAME = "recipient";
public static final String ID = "_id";
private static final String UUID = "uuid";
+ private static final String USERNAME = "username";
public static final String PHONE = "phone";
public static final String EMAIL = "email";
static final String GROUP_ID = "group_id";
@@ -76,7 +77,7 @@ public class RecipientDatabase extends Database {
private static final String SORT_NAME = "sort_name";
private static final String[] RECIPIENT_PROJECTION = new String[] {
- UUID, PHONE, EMAIL, GROUP_ID,
+ UUID, USERNAME, PHONE, EMAIL, GROUP_ID,
BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
@@ -84,8 +85,8 @@ public class RecipientDatabase extends Database {
FORCE_SMS_SELECTION, UUID_SUPPORTED
};
- private static final String[] ID_PROJECTION = new String[]{ID };
- public static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, SIGNAL_PROFILE_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "IFNULL(" + SYSTEM_DISPLAY_NAME + ", " + SIGNAL_PROFILE_NAME + ") AS " + SORT_NAME};
+ private static final String[] ID_PROJECTION = new String[]{ID};
+ public static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, SIGNAL_PROFILE_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + SYSTEM_DISPLAY_NAME + ", " + SIGNAL_PROFILE_NAME + ", " + USERNAME + ") AS " + SORT_NAME};
static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
.map(columnName -> TABLE_NAME + "." + columnName)
.toList();
@@ -169,6 +170,7 @@ public class RecipientDatabase extends Database {
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
+ USERNAME + " TEXT UNIQUE DEFAULT NULL, " +
PHONE + " TEXT UNIQUE DEFAULT NULL, " +
EMAIL + " TEXT UNIQUE DEFAULT NULL, " +
GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " +
@@ -240,6 +242,10 @@ public class RecipientDatabase extends Database {
return getByColumn(UUID, uuid.toString());
}
+ public @NonNull Optional getByUsername(@NonNull String username) {
+ return getByColumn(USERNAME, username);
+ }
+
public @NonNull RecipientId getOrInsertFromUuid(@NonNull UUID uuid) {
return getOrInsertByColumn(UUID, uuid.toString());
}
@@ -292,6 +298,7 @@ public class RecipientDatabase extends Database {
@NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
UUID uuid = UuidUtil.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(UUID)));
+ String username = cursor.getString(cursor.getColumnIndexOrThrow(USERNAME));
String e164 = cursor.getString(cursor.getColumnIndexOrThrow(PHONE));
String email = cursor.getString(cursor.getColumnIndexOrThrow(EMAIL));
String groupId = cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID));
@@ -338,7 +345,7 @@ public class RecipientDatabase extends Database {
}
}
- return new RecipientSettings(RecipientId.from(id), uuid, e164, email, groupId, blocked, muteUntil,
+ return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, blocked, muteUntil,
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
@@ -515,6 +522,30 @@ public class RecipientDatabase extends Database {
Recipient.live(id).refresh();
}
+ public void setUsername(@NonNull RecipientId id, @Nullable String username) {
+ if (username != null) {
+ Optional existingUsername = getByUsername(username);
+
+ if (existingUsername.isPresent() && !id.equals(existingUsername.get())) {
+ Log.i(TAG, "Username was previously thought to be owned by " + existingUsername.get() + ". Clearing their username.");
+ setUsername(existingUsername.get(), null);
+ }
+ }
+
+ ContentValues contentValues = new ContentValues(1);
+ contentValues.put(USERNAME, username);
+ update(id, contentValues);
+ Recipient.live(id).refresh();
+ }
+
+ public void clearUsernameIfExists(@NonNull String username) {
+ Optional existingUsername = getByUsername(username);
+
+ if (existingUsername.isPresent()) {
+ setUsername(existingUsername.get(), null);
+ }
+ }
+
public Set getAllPhoneNumbers() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Set results = new HashSet<>();
@@ -685,9 +716,9 @@ public class RecipientDatabase extends Database {
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
- "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + SIGNAL_PROFILE_NAME + " NOT NULL)";
+ "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + SIGNAL_PROFILE_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)";
String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1" };
- String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SIGNAL_PROFILE_NAME + ", " + PHONE;
+ String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SIGNAL_PROFILE_NAME + ", " + USERNAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
@@ -699,13 +730,14 @@ public class RecipientDatabase extends Database {
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
- "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
+ "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ? OR " + USERNAME + " NOT NULL) AND " +
"(" +
PHONE + " LIKE ? OR " +
SYSTEM_DISPLAY_NAME + " LIKE ? OR " +
- SIGNAL_PROFILE_NAME + " LIKE ?" +
+ SIGNAL_PROFILE_NAME + " LIKE ? OR " +
+ USERNAME + " LIKE ?" +
")";
- String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query };
+ String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query, query };
String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SIGNAL_PROFILE_NAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
@@ -877,6 +909,7 @@ public class RecipientDatabase extends Database {
public static class RecipientSettings {
private final RecipientId id;
private final UUID uuid;
+ private final String username;
private final String e164;
private final String email;
private final String groupId;
@@ -906,6 +939,7 @@ public class RecipientDatabase extends Database {
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
+ @Nullable String username,
@Nullable String e164,
@Nullable String email,
@Nullable String groupId,
@@ -934,6 +968,7 @@ public class RecipientDatabase extends Database {
{
this.id = id;
this.uuid = uuid;
+ this.username = username;
this.e164 = e164;
this.email = email;
this.groupId = groupId;
@@ -970,6 +1005,10 @@ public class RecipientDatabase extends Database {
return uuid;
}
+ public @Nullable String getUsername() {
+ return username;
+ }
+
public @Nullable String getE164() {
return e164;
}
diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index 3f2f3e7c31..fbb8c10c46 100644
--- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -92,8 +92,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int ATTACHMENT_CLEAR_HASHES = 33;
private static final int ATTACHMENT_CLEAR_HASHES_2 = 34;
private static final int UUIDS = 35;
+ private static final int USERNAMES = 36;
- private static final int DATABASE_VERSION = 35;
+ private static final int DATABASE_VERSION = 36;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -620,6 +621,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("ALTER TABLE push ADD COLUMN source_uuid TEXT DEFAULT NULL");
}
+ if (oldVersion < USERNAMES) {
+ db.execSQL("ALTER TABLE recipient ADD COLUMN username TEXT DEFAULT NULL");
+ db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_username_index ON recipient (username)");
+ }
+
db.setTransactionSuccessful();
} finally {
db.endTransaction();
diff --git a/src/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/src/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
index dd7a6f4fc6..483bf60703 100644
--- a/src/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
+++ b/src/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
@@ -45,6 +45,10 @@ public class ApplicationDependencies {
ApplicationDependencies.provider = provider;
}
+ public static @NonNull Application getApplication() {
+ return application;
+ }
+
public static synchronized @NonNull SignalServiceAccountManager getSignalServiceAccountManager() {
assertInitialization();
diff --git a/src/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/src/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java
index b04c80f5b2..418e9514f8 100644
--- a/src/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.util.Base64;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.ProfileUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -104,6 +105,7 @@ public class RetrieveProfileJob extends BaseJob {
setProfileName(recipient, profile.getName());
setProfileAvatar(recipient, profile.getAvatar());
+ if (FeatureFlags.USERNAMES) setUsername(recipient, profile.getUsername());
setProfileCapabilities(recipient, profile.getCapabilities());
setIdentityKey(recipient, profile.getIdentityKey());
setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess());
@@ -197,6 +199,10 @@ public class RetrieveProfileJob extends BaseJob {
}
}
+ private void setUsername(Recipient recipient, @Nullable String username) {
+ DatabaseFactory.getRecipientDatabase(context).setUsername(recipient.getId(), username);
+ }
+
private void setProfileCapabilities(@NonNull Recipient recipient, @Nullable SignalServiceProfile.Capabilities capabilities) {
if (capabilities == null) {
return;
diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
index 79e70a1770..850831aff4 100644
--- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
+++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
@@ -75,6 +75,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.VideoUtil;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -848,13 +849,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
protected void onPreExecute() {
renderTimer = new Stopwatch("ProcessMedia");
progressTimer = () -> {
- dialog = new AlertDialog.Builder(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog))
- .setView(R.layout.progress_dialog)
- .setCancelable(false)
- .create();
- dialog.show();
- dialog.getWindow().setLayout(getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size),
- getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size));
+ dialog = SimpleProgressDialog.show(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog));
};
Util.runOnMainDelayed(progressTimer, 250);
}
diff --git a/src/org/thoughtcrime/securesms/phonenumbers/NumberUtil.java b/src/org/thoughtcrime/securesms/phonenumbers/NumberUtil.java
index f0f43ad01f..670f794dc7 100644
--- a/src/org/thoughtcrime/securesms/phonenumbers/NumberUtil.java
+++ b/src/org/thoughtcrime/securesms/phonenumbers/NumberUtil.java
@@ -23,13 +23,29 @@ import java.util.regex.Pattern;
public class NumberUtil {
- private static final Pattern emailPattern = android.util.Patterns.EMAIL_ADDRESS;
+ private static final Pattern EMAIL_PATTERN = android.util.Patterns.EMAIL_ADDRESS;
+ private static final Pattern PHONE_PATTERN = android.util.Patterns.PHONE;
public static boolean isValidEmail(String number) {
- Matcher matcher = emailPattern.matcher(number);
+ Matcher matcher = EMAIL_PATTERN.matcher(number);
return matcher.matches();
}
+ public static boolean isVisuallyValidNumber(String number) {
+ Matcher matcher = PHONE_PATTERN.matcher(number);
+ return matcher.matches();
+ }
+
+ /**
+ * Whether or not a number entered by the user is a valid phone or email address. Differs from
+ * {@link #isValidSmsOrEmail(String)} in that it only returns true for numbers that a user would
+ * enter themselves, as opposed to the crazy network prefixes that could theoretically be in an
+ * SMS address.
+ */
+ public static boolean isVisuallyValidNumberOrEmail(String number) {
+ return isVisuallyValidNumber(number) || isValidEmail(number);
+ }
+
public static boolean isValidSmsOrEmail(String number) {
return PhoneNumberUtils.isWellFormedSmsAddress(number) || isValidEmail(number);
}
diff --git a/src/org/thoughtcrime/securesms/recipients/Recipient.java b/src/org/thoughtcrime/securesms/recipients/Recipient.java
index e5cf69dbed..73fdeac7d7 100644
--- a/src/org/thoughtcrime/securesms/recipients/Recipient.java
+++ b/src/org/thoughtcrime/securesms/recipients/Recipient.java
@@ -59,6 +59,7 @@ public class Recipient {
private final RecipientId id;
private final boolean resolving;
private final UUID uuid;
+ private final String username;
private final String e164;
private final String email;
private final String groupId;
@@ -111,6 +112,16 @@ public class Recipient {
return live(id).resolve();
}
+ /**
+ * Returns a fully-populated {@link Recipient} and associates it with the provided username.
+ */
+ @WorkerThread
+ public static @NonNull Recipient externalUsername(@NonNull Context context, @NonNull UUID uuid, @NonNull String username) {
+ Recipient recipient = externalPush(context, uuid, null);
+ DatabaseFactory.getRecipientDatabase(context).setUsername(recipient.getId(), username);
+ return recipient;
+ }
+
/**
* Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress},
* creating one in the database if necessary. Convenience overload of
@@ -252,6 +263,7 @@ public class Recipient {
this.id = id;
this.resolving = true;
this.uuid = null;
+ this.username = null;
this.e164 = null;
this.email = null;
this.groupId = null;
@@ -287,6 +299,7 @@ public class Recipient {
this.id = id;
this.resolving = false;
this.uuid = details.uuid;
+ this.username = details.username;
this.e164 = details.e164;
this.email = details.email;
this.groupId = details.groupId;
@@ -356,20 +369,12 @@ public class Recipient {
public @NonNull String getDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context),
getProfileName(),
- getUsername().orNull(),
+ getDisplayUsername(),
e164,
email,
context.getString(R.string.Recipient_unknown));
}
- public @NonNull Optional getUsername() {
- if (FeatureFlags.USERNAMES) {
- // TODO [greyson] Replace with actual username
- return Optional.of("@caycepollard");
- }
- return Optional.absent();
- }
-
public @NonNull MaterialColor getColor() {
if (isGroupInternal()) return MaterialColor.GROUP;
else if (color != null) return color;
@@ -381,6 +386,14 @@ public class Recipient {
return Optional.fromNullable(uuid);
}
+ public @NonNull Optional getUsername() {
+ if (FeatureFlags.USERNAMES) {
+ return Optional.fromNullable(username);
+ } else {
+ return Optional.absent();
+ }
+ }
+
public @NonNull Optional getE164() {
return Optional.fromNullable(e164);
}
@@ -621,7 +634,11 @@ public class Recipient {
* @return True if this recipient can support receiving UUID-only messages, otherwise false.
*/
public boolean isUuidSupported() {
- return FeatureFlags.UUIDS && uuidSupported;
+ if (FeatureFlags.USERNAMES) {
+ return true;
+ } else {
+ return FeatureFlags.UUIDS && uuidSupported;
+ }
}
public @Nullable byte[] getProfileKey() {
@@ -652,6 +669,14 @@ public class Recipient {
return ApplicationDependencies.getRecipientCache().getLive(id);
}
+ private @Nullable String getDisplayUsername() {
+ if (!TextUtils.isEmpty(username)) {
+ return "@" + username;
+ } else {
+ return null;
+ }
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/src/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/src/org/thoughtcrime/securesms/recipients/RecipientDetails.java
index 86dee24c31..bbb1fbbca0 100644
--- a/src/org/thoughtcrime/securesms/recipients/RecipientDetails.java
+++ b/src/org/thoughtcrime/securesms/recipients/RecipientDetails.java
@@ -24,6 +24,7 @@ import java.util.UUID;
public class RecipientDetails {
final UUID uuid;
+ final String username;
final String e164;
final String email;
final String groupId;
@@ -68,6 +69,7 @@ public class RecipientDetails {
this.customLabel = settings.getSystemPhoneLabel();
this.contactUri = Util.uri(settings.getSystemContactUri());
this.uuid = settings.getUuid();
+ this.username = settings.getUsername();
this.e164 = settings.getE164();
this.email = settings.getEmail();
this.groupId = settings.getGroupId();
@@ -104,6 +106,7 @@ public class RecipientDetails {
this.customLabel = null;
this.contactUri = null;
this.uuid = null;
+ this.username = null;
this.e164 = null;
this.email = null;
this.groupId = null;
diff --git a/src/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/src/org/thoughtcrime/securesms/recipients/RecipientUtil.java
index 86c1c69639..ad74d4c8d4 100644
--- a/src/org/thoughtcrime/securesms/recipients/RecipientUtil.java
+++ b/src/org/thoughtcrime/securesms/recipients/RecipientUtil.java
@@ -103,7 +103,7 @@ public class RecipientUtil {
if (!isBlockable(recipient)) {
throw new AssertionError("Recipient is not blockable!");
}
-
+
DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), false);
ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob());
}
diff --git a/src/org/thoughtcrime/securesms/usernames/ProfileEditActivityV2.java b/src/org/thoughtcrime/securesms/usernames/ProfileEditActivityV2.java
new file mode 100644
index 0000000000..d1a19d378f
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/ProfileEditActivityV2.java
@@ -0,0 +1,64 @@
+package org.thoughtcrime.securesms.usernames;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.Toolbar;
+import androidx.navigation.ActivityNavigator;
+import androidx.navigation.NavController;
+import androidx.navigation.NavDestination;
+import androidx.navigation.Navigation;
+import androidx.navigation.ui.AppBarConfiguration;
+import androidx.navigation.ui.NavigationUI;
+
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+
+public class ProfileEditActivityV2 extends PassphraseRequiredActionBarActivity {
+
+ private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
+
+ public static Intent getLaunchIntent(@NonNull Context context) {
+ return new Intent(context, ProfileEditActivityV2.class);
+ }
+
+ @Override
+ protected void onPreCreate() {
+ super.onPreCreate();
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ super.onCreate(savedInstanceState, ready);
+ setContentView(R.layout.profile_edit_activity_v2);
+ initToolbar();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ }
+
+ private void initToolbar() {
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+
+ //noinspection ConstantConditions
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ toolbar.setNavigationOnClickListener(v -> onBackPressed());
+
+ NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment);
+
+ navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
+ getSupportActionBar().setTitle(destination.getLabel());
+ });
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewFragment.java b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewFragment.java
new file mode 100644
index 0000000000..91d599d4d7
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewFragment.java
@@ -0,0 +1,130 @@
+package org.thoughtcrime.securesms.usernames;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.method.LinkMovementMethod;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.navigation.Navigation;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.util.CommunicationActions;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+public class ProfileEditOverviewFragment extends Fragment {
+
+ private ImageView avatarView;
+ private TextView profileText;
+ private TextView usernameText;
+ private AlertDialog loadingDialog;
+
+ private ProfileEditOverviewViewModel viewModel;
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.profile_edit_overview_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ avatarView = view.findViewById(R.id.profile_overview_avatar);
+ profileText = view.findViewById(R.id.profile_overview_profile_name);
+ usernameText = view.findViewById(R.id.profile_overview_username);
+
+ View profileButton = view.findViewById(R.id.profile_overview_profile_edit_button );
+ View usernameButton = view.findViewById(R.id.profile_overview_username_edit_button);
+ TextView infoText = view.findViewById(R.id.profile_overview_info_text);
+
+ profileButton.setOnClickListener(v -> {
+ Navigation.findNavController(view).navigate(ProfileEditOverviewFragmentDirections.actionProfileEdit());
+ });
+
+ usernameButton.setOnClickListener(v -> {
+ Navigation.findNavController(view).navigate(ProfileEditOverviewFragmentDirections.actionUsernameEdit());
+ });
+
+ infoText.setMovementMethod(LinkMovementMethod.getInstance());
+
+ profileText.setOnClickListener(v -> profileButton.callOnClick());
+ usernameText.setOnClickListener(v -> usernameButton.callOnClick());
+
+ avatarView.setOnClickListener(v -> Permissions.with(this)
+ .request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .ifNecessary()
+ .onAnyResult(() -> viewModel.onAvatarClicked(this))
+ .execute());
+
+ viewModel = ViewModelProviders.of(this, new ProfileEditOverviewViewModel.Factory()).get(ProfileEditOverviewViewModel.class);
+ viewModel.getAvatar().observe(getViewLifecycleOwner(), this::onAvatarChanged);
+ viewModel.getLoading().observe(getViewLifecycleOwner(), this::onLoadingChanged);
+ viewModel.getProfileName().observe(getViewLifecycleOwner(), this::onProfileNameChanged);
+ viewModel.getUsername().observe(getViewLifecycleOwner(), this::onUsernameChanged);
+ }
+
+ @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, @Nullable Intent data) {
+ if (!viewModel.onActivityResult(this, requestCode, resultCode, data)) {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ viewModel.onResume();
+ }
+
+ private void onAvatarChanged(@NonNull Optional avatar) {
+ if (avatar.isPresent()) {
+ GlideApp.with(this)
+ .load(avatar.get())
+ .circleCrop()
+ .into(avatarView);
+ } else {
+ avatarView.setImageDrawable(null);
+ }
+ }
+
+ private void onLoadingChanged(boolean loading) {
+ if (loadingDialog == null && loading) {
+ loadingDialog = SimpleProgressDialog.show(requireContext());
+ } else if (loadingDialog != null) {
+ loadingDialog.dismiss();
+ loadingDialog = null;
+ }
+ }
+
+ private void onProfileNameChanged(@NonNull Optional profileName) {
+ profileText.setText(profileName.or(""));
+ }
+
+ @SuppressLint("SetTextI18n")
+ private void onUsernameChanged(@NonNull Optional username) {
+ if (username.isPresent()) {
+ usernameText.setText("@" + username.get());
+ } else {
+ usernameText.setText("");
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewRepository.java b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewRepository.java
new file mode 100644
index 0000000000..49b4abe0d6
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewRepository.java
@@ -0,0 +1,171 @@
+package org.thoughtcrime.securesms.usernames;
+
+import android.app.Application;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.CreateProfileActivity;
+import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.mediasend.Media;
+import org.thoughtcrime.securesms.profiles.AvatarHelper;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.service.IncomingMessageObserver;
+import org.thoughtcrime.securesms.util.Base64;
+import org.thoughtcrime.securesms.util.MediaUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.w3c.dom.Text;
+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;
+import org.whispersystems.signalservice.api.crypto.ProfileCipher;
+import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.util.StreamDetails;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.concurrent.Executor;
+
+class ProfileEditOverviewRepository {
+
+ private static final String TAG = Log.tag(ProfileEditOverviewRepository.class);
+
+ private final Application application;
+ private final SignalServiceAccountManager accountManager;
+ private final Executor executor;
+
+ ProfileEditOverviewRepository() {
+ this.application = ApplicationDependencies.getApplication();
+ this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
+ this.executor = SignalExecutors.UNBOUNDED;
+ }
+
+ void getProfileAvatar(@NonNull Callback> callback) {
+ executor.execute(() -> callback.onResult(getProfileAvatarInternal()));
+ }
+
+ void setProfileAvatar(@NonNull byte[] data, @NonNull Callback callback) {
+ executor.execute(() -> callback.onResult(setProfileAvatarInternal(data)));
+ }
+
+ void deleteProfileAvatar(@NonNull Callback callback) {
+ executor.execute(() -> callback.onResult(deleteProfileAvatarInternal()));
+ }
+
+ void getProfileName(@NonNull Callback> callback) {
+ executor.execute(() -> callback.onResult(getProfileNameInternal()));
+ }
+
+ void getUsername(@NonNull Callback> callback) {
+ executor.execute(() -> callback.onResult(getUsernameInternal()));
+ }
+
+ @WorkerThread
+ private @NonNull Optional getProfileAvatarInternal() {
+ RecipientId selfId = Recipient.self().getId();
+
+ if (AvatarHelper.getAvatarFile(application, selfId).exists() && AvatarHelper.getAvatarFile(application, selfId).length() > 0) {
+ try {
+ return Optional.of(Util.readFully(AvatarHelper.getInputStreamFor(application, selfId)));
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to read avatar!", e);
+ return Optional.absent();
+ }
+ } else {
+ return Optional.absent();
+ }
+ }
+
+ @WorkerThread
+ private @NonNull ProfileAvatarResult setProfileAvatarInternal(@NonNull byte[] data) {
+ StreamDetails avatar = new StreamDetails(new ByteArrayInputStream(data), MediaUtil.IMAGE_JPEG, data.length);
+ try {
+ accountManager.setProfileAvatar(ProfileKeyUtil.getProfileKey(application), avatar);
+ AvatarHelper.setAvatar(application, Recipient.self().getId(), data);
+ TextSecurePreferences.setProfileAvatarId(application, new SecureRandom().nextInt());
+ return ProfileAvatarResult.SUCCESS;
+ } catch (IOException e) {
+ return ProfileAvatarResult.NETWORK_FAILURE;
+ }
+ }
+
+ @WorkerThread
+ private @NonNull ProfileAvatarResult deleteProfileAvatarInternal() {
+ try {
+ accountManager.setProfileAvatar(ProfileKeyUtil.getProfileKey(application), null);
+ AvatarHelper.delete(application, Recipient.self().getId());
+ TextSecurePreferences.setProfileAvatarId(application, 0);
+ return ProfileAvatarResult.SUCCESS;
+ } catch (IOException e) {
+ return ProfileAvatarResult.NETWORK_FAILURE;
+ }
+ }
+
+ @WorkerThread
+ private @NonNull Optional getProfileNameInternal() {
+ try {
+ SignalServiceProfile profile = retrieveOwnProfile();
+ String encryptedProfileName = profile.getName();
+ String plaintextProfileName = null;
+
+ if (encryptedProfileName != null) {
+ ProfileCipher profileCipher = new ProfileCipher(ProfileKeyUtil.getProfileKey(application));
+ plaintextProfileName = new String(profileCipher.decryptName(Base64.decode(encryptedProfileName)));
+ }
+
+ TextSecurePreferences.setProfileName(application, plaintextProfileName);
+ DatabaseFactory.getRecipientDatabase(application).setProfileName(Recipient.self().getId(), plaintextProfileName);
+ } catch (IOException | InvalidCiphertextException e) {
+ Log.w(TAG, "Failed to retrieve profile name remotely! Using locally-cached version.");
+ }
+
+ return Optional.fromNullable(TextSecurePreferences.getProfileName(application));
+ }
+
+ @WorkerThread
+ private @NonNull Optional getUsernameInternal() {
+ try {
+ SignalServiceProfile profile = retrieveOwnProfile();
+ TextSecurePreferences.setLocalUsername(application, profile.getUsername());
+ DatabaseFactory.getRecipientDatabase(application).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(application));
+ }
+
+
+ private SignalServiceProfile retrieveOwnProfile() throws IOException {
+ SignalServiceAddress address = new SignalServiceAddress(TextSecurePreferences.getLocalUuid(application), TextSecurePreferences.getLocalNumber(application));
+ 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());
+ }
+
+ enum ProfileAvatarResult {
+ SUCCESS, NETWORK_FAILURE
+ }
+
+ interface Callback {
+ void onResult(@NonNull E result);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewViewModel.java b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewViewModel.java
new file mode 100644
index 0000000000..80d34511b6
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/ProfileEditOverviewViewModel.java
@@ -0,0 +1,212 @@
+package org.thoughtcrime.securesms.usernames;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import org.thoughtcrime.securesms.CreateProfileActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.avatar.AvatarSelection;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.profiles.AvatarHelper;
+import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints;
+import org.thoughtcrime.securesms.util.BitmapDecodingException;
+import org.thoughtcrime.securesms.util.BitmapUtil;
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+class ProfileEditOverviewViewModel extends ViewModel {
+
+ private static final String TAG = Log.tag(ProfileEditOverviewViewModel.class);
+
+ private final Application application;
+ private final ProfileEditOverviewRepository repo;
+ private final SingleLiveEvent event;
+ private final MutableLiveData> avatar;
+ private final MutableLiveData loading;
+ private final MutableLiveData> profileName;
+ private final MutableLiveData> username;
+
+ private File captureFile;
+
+ private ProfileEditOverviewViewModel() {
+ this.application = ApplicationDependencies.getApplication();
+ this.repo = new ProfileEditOverviewRepository();
+ this.avatar = new MutableLiveData<>();
+ this.loading = new MutableLiveData<>();
+ this.profileName = new MutableLiveData<>();
+ this.username = new MutableLiveData<>();
+ this.event = new SingleLiveEvent<>();
+
+ profileName.setValue(Optional.fromNullable(TextSecurePreferences.getProfileName(application)));
+ username.setValue(Optional.fromNullable(TextSecurePreferences.getLocalUsername(application)));
+ loading.setValue(false);
+
+ repo.getProfileAvatar(avatar::postValue);
+ repo.getProfileName(profileName::postValue);
+ repo.getUsername(username::postValue);
+ }
+
+ void onAvatarClicked(@NonNull Fragment fragment) {
+ //noinspection ConstantConditions Initial value is set
+ captureFile = AvatarSelection.startAvatarSelection(fragment, avatar.getValue().isPresent(), true);
+ }
+
+ boolean onActivityResult(@NonNull Fragment fragment, int requestCode, int resultCode, @Nullable Intent data) {
+ switch (requestCode) {
+ case AvatarSelection.REQUEST_CODE_AVATAR:
+ handleAvatarResult(fragment, resultCode, data);
+ return true;
+ case AvatarSelection.REQUEST_CODE_CROP_IMAGE:
+ handleCropImage(resultCode, data);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ void onResume() {
+ profileName.setValue(Optional.fromNullable(TextSecurePreferences.getProfileName(application)));
+ username.setValue(Optional.fromNullable(TextSecurePreferences.getLocalUsername(application)));
+ }
+
+ @NonNull LiveData> getAvatar() {
+ return avatar;
+ }
+
+ @NonNull LiveData getLoading() {
+ return loading;
+ }
+
+ @NonNull LiveData> getProfileName() {
+ return profileName;
+ }
+
+ @NonNull LiveData> getUsername() {
+ return username;
+ }
+
+ @NonNull LiveData getEvents() {
+ return event;
+ }
+
+ private void handleAvatarResult(@NonNull Fragment fragment, int resultCode, @Nullable Intent data) {
+ if (resultCode != Activity.RESULT_OK) {
+ Log.w(TAG, "Bad result for REQUEST_CODE_AVATAR.");
+ event.postValue(Event.IMAGE_SAVE_FAILURE);
+ return;
+ }
+
+ if (data != null && data.getBooleanExtra("delete", false)) {
+ Log.i(TAG, "Deleting profile avatar.");
+
+ Optional oldAvatar = avatar.getValue();
+
+ avatar.setValue(Optional.absent());
+ loading.setValue(true);
+
+ repo.deleteProfileAvatar(result -> {
+ switch (result) {
+ case SUCCESS:
+ loading.postValue(false);
+ break;
+ case NETWORK_FAILURE:
+ loading.postValue(false);
+ avatar.postValue(oldAvatar);
+ event.postValue(Event.NETWORK_ERROR);
+ break;
+ }
+ });
+ } else {
+ Uri outputFile = Uri.fromFile(new File(application.getCacheDir(), "cropped"));
+ Uri inputFile = (data != null ? data.getData() : null);
+
+ if (inputFile == null && captureFile != null) {
+ inputFile = Uri.fromFile(captureFile);
+ }
+
+ if (inputFile != null) {
+ AvatarSelection.circularCropImage(fragment, inputFile, outputFile, R.string.CropImageActivity_profile_avatar);
+ } else {
+ Log.w(TAG, "No input file!");
+ event.postValue(Event.IMAGE_SAVE_FAILURE);
+ }
+ }
+ }
+
+ private void handleCropImage(int resultCode, @Nullable Intent data) {
+ if (resultCode != Activity.RESULT_OK) {
+ Log.w(TAG, "Bad result for REQUEST_CODE_CROP_IMAGE.");
+ event.postValue(Event.IMAGE_SAVE_FAILURE);
+ return;
+ }
+
+ Optional oldAvatar = avatar.getValue();
+
+ loading.setValue(true);
+
+ SignalExecutors.BOUNDED.execute(() -> {
+ try {
+ BitmapUtil.ScaleResult scaled = BitmapUtil.createScaledBytes(application, AvatarSelection.getResultUri(data), new ProfileMediaConstraints());
+
+ if (captureFile != null) {
+ captureFile.delete();
+ }
+
+ avatar.postValue(Optional.of(scaled.getBitmap()));
+
+ repo.setProfileAvatar(scaled.getBitmap(), result -> {
+ switch (result) {
+ case SUCCESS:
+ loading.postValue(false);
+ break;
+ case NETWORK_FAILURE:
+ loading.postValue(false);
+ avatar.postValue(oldAvatar);
+ event.postValue(Event.NETWORK_ERROR);
+ break;
+ }
+ });
+ } catch (BitmapDecodingException e) {
+ event.postValue(Event.IMAGE_SAVE_FAILURE);
+ }
+ });
+ }
+
+ @Override
+ protected void onCleared() {
+ if (captureFile != null) {
+ captureFile.delete();
+ }
+ }
+
+ enum Event {
+ IMAGE_SAVE_FAILURE, NETWORK_ERROR
+ }
+
+ static class Factory extends ViewModelProvider.NewInstanceFactory {
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ //noinspection ConstantConditions
+ return modelClass.cast(new ProfileEditOverviewViewModel());
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameFragment.java b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameFragment.java
new file mode 100644
index 0000000000..77b87febd8
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameFragment.java
@@ -0,0 +1,79 @@
+package org.thoughtcrime.securesms.usernames.profile;
+
+import android.os.Bundle;
+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.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.navigation.fragment.NavHostFragment;
+
+import com.dd.CircularProgressButton;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+public class ProfileEditNameFragment extends Fragment {
+
+ private EditText profileText;
+ private CircularProgressButton submitButton;
+ private ProfileEditNameViewModel viewModel;
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.profile_edit_name_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ profileText = view.findViewById(R.id.profile_name_text);
+ submitButton = view.findViewById(R.id.profile_name_submit);
+
+ viewModel = ViewModelProviders.of(this, new ProfileEditNameViewModel.Factory()).get(ProfileEditNameViewModel.class);
+
+ viewModel.isLoading().observe(getViewLifecycleOwner(), this::onLoadingChanged);
+ viewModel.getEvents().observe(getViewLifecycleOwner(), this::onEvent);
+
+ profileText.setText(TextSecurePreferences.getProfileName(requireContext()));
+ submitButton.setOnClickListener(v -> viewModel.onSubmitPressed(profileText.getText().toString()));
+ }
+
+ private void onLoadingChanged(boolean loading) {
+ if (loading) {
+ profileText.setEnabled(false);
+ setSpinning(submitButton);
+ } else {
+ profileText.setEnabled(true);
+ cancelSpinning(submitButton);
+ }
+ }
+
+ private void onEvent(@NonNull ProfileEditNameViewModel.Event event) {
+ switch (event) {
+ case SUCCESS:
+ Toast.makeText(requireContext(), R.string.ProfileEditNameFragment_successfully_set_profile_name, Toast.LENGTH_SHORT).show();
+ NavHostFragment.findNavController(this).popBackStack();
+ break;
+ case NETWORK_FAILURE:
+ Toast.makeText(requireContext(), R.string.ProfileEditNameFragment_encountered_a_network_error, Toast.LENGTH_SHORT).show();
+ break;
+ }
+ }
+
+ private static void setSpinning(@NonNull CircularProgressButton button) {
+ button.setClickable(false);
+ button.setIndeterminateProgressMode(true);
+ button.setProgress(50);
+ }
+
+ private static void cancelSpinning(@NonNull CircularProgressButton button) {
+ button.setProgress(0);
+ button.setIndeterminateProgressMode(false);
+ button.setClickable(true);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameRepository.java b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameRepository.java
new file mode 100644
index 0000000000..0b72541a5d
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameRepository.java
@@ -0,0 +1,57 @@
+package org.thoughtcrime.securesms.usernames.profile;
+
+import android.app.Application;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.SignalServiceAccountManager;
+
+import java.io.IOException;
+import java.util.concurrent.Executor;
+
+class ProfileEditNameRepository {
+
+ private final Application application;
+ private final SignalServiceAccountManager accountManager;
+ private final Executor executor;
+
+ ProfileEditNameRepository() {
+ this.application = ApplicationDependencies.getApplication();
+ this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
+ this.executor = SignalExecutors.UNBOUNDED;
+ }
+
+ void setProfileName(@NonNull String profileName, @NonNull Callback callback) {
+ executor.execute(() -> callback.onResult(setProfileNameInternal(profileName)));
+ }
+
+ @WorkerThread
+ private @NonNull ProfileNameResult setProfileNameInternal(@NonNull String profileName) {
+ Util.sleep(1000);
+ try {
+ accountManager.setProfileName(ProfileKeyUtil.getProfileKey(application), profileName);
+ TextSecurePreferences.setProfileName(application, profileName);
+ DatabaseFactory.getRecipientDatabase(application).setProfileName(Recipient.self().getId(), profileName);
+ return ProfileNameResult.SUCCESS;
+ } catch (IOException e) {
+ return ProfileNameResult.NETWORK_FAILURE;
+ }
+ }
+
+ enum ProfileNameResult {
+ SUCCESS, NETWORK_FAILURE
+ }
+
+ interface Callback {
+ void onResult(@NonNull E result);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameViewModel.java b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameViewModel.java
new file mode 100644
index 0000000000..97861c3e1f
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/profile/ProfileEditNameViewModel.java
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms.usernames.profile;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+
+class ProfileEditNameViewModel extends ViewModel {
+
+ private final ProfileEditNameRepository repo;
+ private final SingleLiveEvent events;
+ private final MutableLiveData loading;
+
+ private ProfileEditNameViewModel() {
+ this.repo = new ProfileEditNameRepository();
+ this.events = new SingleLiveEvent<>();
+ this.loading = new MutableLiveData<>();
+ }
+
+ void onSubmitPressed(@NonNull String profileName) {
+ loading.setValue(true);
+
+ repo.setProfileName(profileName, result -> {
+ switch (result) {
+ case SUCCESS:
+ events.postValue(Event.SUCCESS);
+ break;
+ case NETWORK_FAILURE:
+ events.postValue(Event.NETWORK_FAILURE);
+ break;
+ }
+
+ loading.postValue(false);
+ });
+ }
+
+ @NonNull LiveData getEvents() {
+ return events;
+ }
+
+ @NonNull LiveData isLoading() {
+ return loading;
+ }
+
+ enum Event {
+ SUCCESS, NETWORK_FAILURE
+ }
+
+ static class Factory extends ViewModelProvider.NewInstanceFactory {
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ //noinspection ConstantConditions
+ return modelClass.cast(new ProfileEditNameViewModel());
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java
new file mode 100644
index 0000000000..ae26e999eb
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditFragment.java
@@ -0,0 +1,182 @@
+package org.thoughtcrime.securesms.usernames.username;
+
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.navigation.fragment.NavHostFragment;
+
+import com.dd.CircularProgressButton;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.UsernameUtil;
+
+public class UsernameEditFragment extends Fragment {
+
+ private static final float DISABLED_ALPHA = 0.5f;
+
+ private UsernameEditViewModel viewModel;
+
+ private EditText usernameInput;
+ private TextView usernameSubtext;
+ private CircularProgressButton submitButton;
+ private CircularProgressButton deleteButton;
+
+ public static UsernameEditFragment newInstance() {
+ return new UsernameEditFragment();
+ }
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.username_edit_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ usernameInput = view.findViewById(R.id.username_text);
+ usernameSubtext = view.findViewById(R.id.username_subtext);
+ submitButton = view.findViewById(R.id.username_submit_button);
+ deleteButton = view.findViewById(R.id.username_delete_button);
+
+ viewModel = ViewModelProviders.of(this, new UsernameEditViewModel.Factory()).get(UsernameEditViewModel.class);
+
+ viewModel.getUiState().observe(getViewLifecycleOwner(), this::onUiStateChanged);
+ viewModel.getEvents().observe(getViewLifecycleOwner(), this::onEvent);
+
+ submitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted(usernameInput.getText().toString()));
+ deleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted());
+
+ usernameInput.setText(TextSecurePreferences.getLocalUsername(requireContext()));
+ usernameInput.addTextChangedListener(new SimpleTextWatcher() {
+ @Override
+ public void onTextChanged(String text) {
+ viewModel.onUsernameUpdated(text);
+ }
+ });
+ }
+
+ private void onUiStateChanged(@NonNull UsernameEditViewModel.State state) {
+ usernameInput.setEnabled(true);
+
+ switch (state.getButtonState()) {
+ case SUBMIT:
+ cancelSpinning(submitButton);
+ submitButton.setVisibility(View.VISIBLE);
+ submitButton.setEnabled(true);
+ submitButton.setAlpha(1);
+ deleteButton.setVisibility(View.GONE);
+ break;
+ case SUBMIT_DISABLED:
+ cancelSpinning(submitButton);
+ submitButton.setVisibility(View.VISIBLE);
+ submitButton.setEnabled(false);
+ submitButton.setAlpha(DISABLED_ALPHA);
+ deleteButton.setVisibility(View.GONE);
+ break;
+ case SUBMIT_LOADING:
+ setSpinning(submitButton);
+ submitButton.setVisibility(View.VISIBLE);
+ submitButton.setAlpha(1);
+ deleteButton.setVisibility(View.GONE);
+ usernameInput.setEnabled(false);
+ break;
+ case DELETE:
+ cancelSpinning(deleteButton);
+ deleteButton.setVisibility(View.VISIBLE);
+ deleteButton.setEnabled(true);
+ deleteButton.setAlpha(1);
+ submitButton.setVisibility(View.GONE);
+ break;
+ case DELETE_DISABLED:
+ cancelSpinning(deleteButton);
+ deleteButton.setVisibility(View.VISIBLE);
+ deleteButton.setEnabled(false);
+ deleteButton.setAlpha(DISABLED_ALPHA);
+ submitButton.setVisibility(View.GONE);
+ break;
+ case DELETE_LOADING:
+ setSpinning(deleteButton);
+ deleteButton.setVisibility(View.VISIBLE);
+ deleteButton.setAlpha(1);
+ submitButton.setVisibility(View.GONE);
+ usernameInput.setEnabled(false);
+ break;
+ }
+
+ switch (state.getUsernameStatus()) {
+ case NONE:
+ usernameSubtext.setText("");
+ break;
+ case TOO_SHORT:
+ case TOO_LONG:
+ usernameSubtext.setText(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH));
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
+ break;
+ case INVALID_CHARACTERS:
+ usernameSubtext.setText(R.string.UsernameEditFragment_usernames_can_only_include);
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
+ break;
+ case CANNOT_START_WITH_NUMBER:
+ usernameSubtext.setText(R.string.UsernameEditFragment_usernames_cannot_begin_with_a_number);
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
+ break;
+ case INVALID_GENERIC:
+ usernameSubtext.setText(R.string.UsernameEditFragment_username_is_invalid);
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
+ break;
+ case TAKEN:
+ usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_taken);
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
+ break;
+ case AVAILABLE:
+ usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_available);
+ usernameSubtext.setTextColor(getResources().getColor(R.color.core_green));
+ break;
+ }
+ }
+
+ private void onEvent(@NonNull UsernameEditViewModel.Event event) {
+ switch (event) {
+ case SUBMIT_SUCCESS:
+ Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_set_username, Toast.LENGTH_SHORT).show();
+ NavHostFragment.findNavController(this).popBackStack();
+ break;
+ case SUBMIT_FAIL_TAKEN:
+ Toast.makeText(requireContext(), R.string.UsernameEditFragment_this_username_is_taken, Toast.LENGTH_SHORT).show();
+ break;
+ case SUBMIT_FAIL_INVALID:
+ Toast.makeText(requireContext(), R.string.UsernameEditFragment_username_is_invalid, Toast.LENGTH_SHORT).show();
+ break;
+ case DELETE_SUCCESS:
+ Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_removed_username, Toast.LENGTH_SHORT).show();
+ NavHostFragment.findNavController(this).popBackStack();
+ break;
+ case NETWORK_FAILURE:
+ Toast.makeText(requireContext(), R.string.UsernameEditFragment_encountered_a_network_error, Toast.LENGTH_SHORT).show();
+ break;
+ }
+ }
+
+ private static void setSpinning(@NonNull CircularProgressButton button) {
+ button.setClickable(false);
+ button.setIndeterminateProgressMode(true);
+ button.setProgress(50);
+ }
+
+ private static void cancelSpinning(@NonNull CircularProgressButton button) {
+ button.setProgress(0);
+ button.setIndeterminateProgressMode(false);
+ button.setClickable(true);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java
new file mode 100644
index 0000000000..7b257fb546
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditRepository.java
@@ -0,0 +1,98 @@
+package org.thoughtcrime.securesms.usernames.username;
+
+import android.app.Application;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.SignalServiceAccountManager;
+import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
+import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
+import org.whispersystems.signalservice.api.util.UuidUtil;
+
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+
+class UsernameEditRepository {
+
+ private static final String TAG = Log.tag(UsernameEditRepository.class);
+
+ private final Application application;
+ private final SignalServiceAccountManager accountManager;
+ private final Executor executor;
+
+ UsernameEditRepository() {
+ this.application = ApplicationDependencies.getApplication();
+ this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
+ this.executor = SignalExecutors.UNBOUNDED;
+ }
+
+ void setUsername(@NonNull String username, @NonNull Callback callback) {
+ executor.execute(() -> callback.onComplete(setUsernameInternal(username)));
+ }
+
+ void deleteUsername(@NonNull Callback callback) {
+ executor.execute(() -> callback.onComplete(deleteUsernameInternal()));
+ }
+
+ @WorkerThread
+ private @NonNull UsernameSetResult setUsernameInternal(@NonNull String username) {
+ try {
+ accountManager.setUsername(username);
+ TextSecurePreferences.setLocalUsername(application, username);
+ DatabaseFactory.getRecipientDatabase(application).setUsername(Recipient.self().getId(), username);
+ Log.i(TAG, "[setUsername] Successfully set username.");
+ return UsernameSetResult.SUCCESS;
+ } catch (UsernameTakenException e) {
+ Log.w(TAG, "[setUsername] Username taken.");
+ return UsernameSetResult.USERNAME_UNAVAILABLE;
+ } catch (UsernameMalformedException e) {
+ Log.w(TAG, "[setUsername] Username malformed.");
+ return UsernameSetResult.USERNAME_INVALID;
+ } catch (IOException e) {
+ Log.w(TAG, "[setUsername] Generic network exception.", e);
+ return UsernameSetResult.NETWORK_ERROR;
+ }
+ }
+
+ @WorkerThread
+ private @NonNull UsernameDeleteResult deleteUsernameInternal() {
+ try {
+ accountManager.deleteUsername();
+ TextSecurePreferences.setLocalUsername(application, null);
+ DatabaseFactory.getRecipientDatabase(application).setUsername(Recipient.self().getId(), null);
+ Log.i(TAG, "[deleteUsername] Successfully deleted the username.");
+ return UsernameDeleteResult.SUCCESS;
+ } catch (IOException e) {
+ Log.w(TAG, "[deleteUsername] Generic network exception.", e);
+ return UsernameDeleteResult.NETWORK_ERROR;
+ }
+ }
+
+ enum UsernameSetResult {
+ SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR
+ }
+
+ enum UsernameDeleteResult {
+ SUCCESS, NETWORK_ERROR
+ }
+
+ enum UsernameAvailableResult {
+ TRUE, FALSE, NETWORK_ERROR
+ }
+
+ interface Callback {
+ void onComplete(E result);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java
new file mode 100644
index 0000000000..15fe83406c
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/usernames/username/UsernameEditViewModel.java
@@ -0,0 +1,177 @@
+package org.thoughtcrime.securesms.usernames.username;
+
+import android.app.Application;
+import android.text.TextUtils;
+
+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.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.util.Debouncer;
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.UsernameUtil;
+import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason;
+import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+class UsernameEditViewModel extends ViewModel {
+
+ private static final String TAG = Log.tag(UsernameEditViewModel.class);
+
+ private final Application application;
+ private final MutableLiveData uiState;
+ private final SingleLiveEvent events;
+ private final UsernameEditRepository repo;
+
+ private UsernameEditViewModel() {
+ this.application = ApplicationDependencies.getApplication();
+ this.repo = new UsernameEditRepository();
+ this.uiState = new MutableLiveData<>();
+ this.events = new SingleLiveEvent<>();
+
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
+ }
+
+ void onUsernameUpdated(@NonNull String username) {
+ if (TextUtils.isEmpty(username) && TextSecurePreferences.getLocalUsername(application) != null) {
+ uiState.setValue(new State(ButtonState.DELETE, UsernameStatus.NONE));
+ return;
+ }
+
+ if (username.equals(TextSecurePreferences.getLocalUsername(application))) {
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
+ return;
+ }
+
+ Optional invalidReason = UsernameUtil.checkUsername(username);
+
+ if (invalidReason.isPresent()) {
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get())));
+ return;
+ }
+
+ uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE));
+ }
+
+ void onUsernameSubmitted(@NonNull String username) {
+ if (username.equals(TextSecurePreferences.getLocalUsername(application))) {
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
+ return;
+ }
+
+ Optional invalidReason = UsernameUtil.checkUsername(username);
+
+ if (invalidReason.isPresent()) {
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get())));
+ return;
+ }
+
+ uiState.setValue(new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE));
+
+ repo.setUsername(username, (result) -> {
+ Util.runOnMain(() -> {
+ switch (result) {
+ case SUCCESS:
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
+ events.postValue(Event.SUBMIT_SUCCESS);
+ break;
+ case USERNAME_INVALID:
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC));
+ events.postValue(Event.SUBMIT_FAIL_INVALID);
+ break;
+ case USERNAME_UNAVAILABLE:
+ uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN));
+ events.postValue(Event.SUBMIT_FAIL_TAKEN);
+ break;
+ case NETWORK_ERROR:
+ uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE));
+ events.postValue(Event.NETWORK_FAILURE);
+ break;
+ }
+ });
+ });
+ }
+
+ void onUsernameDeleted() {
+ uiState.setValue(new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE));
+
+ repo.deleteUsername((result) -> {
+ Util.runOnMain(() -> {
+ switch (result) {
+ case SUCCESS:
+ uiState.postValue(new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE));
+ events.postValue(Event.DELETE_SUCCESS);
+ break;
+ case NETWORK_ERROR:
+ uiState.postValue(new State(ButtonState.DELETE, UsernameStatus.NONE));
+ events.postValue(Event.NETWORK_FAILURE);
+ break;
+ }
+ });
+ });
+ }
+
+ @NonNull LiveData getUiState() {
+ return uiState;
+ }
+
+ @NonNull LiveData getEvents() {
+ return events;
+ }
+
+ private static UsernameStatus mapUsernameError(@NonNull InvalidReason invalidReason) {
+ switch (invalidReason) {
+ case TOO_SHORT: return UsernameStatus.TOO_SHORT;
+ case TOO_LONG: return UsernameStatus.TOO_LONG;
+ case STARTS_WITH_NUMBER: return UsernameStatus.CANNOT_START_WITH_NUMBER;
+ case INVALID_CHARACTERS: return UsernameStatus.INVALID_CHARACTERS;
+ default: return UsernameStatus.INVALID_GENERIC;
+ }
+ }
+
+ static class State {
+ private final ButtonState buttonState;
+ private final UsernameStatus usernameStatus;
+
+ private State(@NonNull ButtonState buttonState,
+ @NonNull UsernameStatus usernameStatus)
+ {
+ this.buttonState = buttonState;
+ this.usernameStatus = usernameStatus;
+ }
+
+ @NonNull ButtonState getButtonState() {
+ return buttonState;
+ }
+
+ @NonNull UsernameStatus getUsernameStatus() {
+ return usernameStatus;
+ }
+ }
+
+ enum UsernameStatus {
+ NONE, AVAILABLE, TAKEN, TOO_SHORT, TOO_LONG, CANNOT_START_WITH_NUMBER, INVALID_CHARACTERS, INVALID_GENERIC
+ }
+
+ enum ButtonState {
+ SUBMIT, SUBMIT_DISABLED, SUBMIT_LOADING, DELETE, DELETE_LOADING, DELETE_DISABLED
+ }
+
+ enum Event {
+ NETWORK_FAILURE, SUBMIT_SUCCESS, DELETE_SUCCESS, SUBMIT_FAIL_INVALID, SUBMIT_FAIL_TAKEN
+ }
+
+ static class Factory extends ViewModelProvider.NewInstanceFactory {
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ //noinspection ConstantConditions
+ return modelClass.cast(new UsernameEditViewModel());
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/util/FeatureFlags.java b/src/org/thoughtcrime/securesms/util/FeatureFlags.java
index 580db5af75..d80fe4b1de 100644
--- a/src/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/src/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -11,12 +11,12 @@ public class FeatureFlags {
/** UUID-related stuff that shouldn't be activated until the user-facing launch. */
public static final boolean UUIDS = false;
- /** Usernames. */
- public static final boolean USERNAMES = false;
-
- /** New Profile Display */
+ /** Favoring profile names when displaying contacts. */
public static final boolean PROFILE_DISPLAY = UUIDS;
/** MessageRequest stuff */
public static final boolean MESSAGE_REQUESTS = UUIDS;
+
+ /** Creating usernames, sending messages by username. Requires {@link #UUIDS}. */
+ public static final boolean USERNAMES = false;
}
diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
index 6828db096b..ce5035905f 100644
--- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
+++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java
@@ -74,6 +74,7 @@ public class TextSecurePreferences {
private static final String THREAD_TRIM_ENABLED = "pref_trim_threads";
private static final String LOCAL_NUMBER_PREF = "pref_local_number";
private static final String LOCAL_UUID_PREF = "pref_local_uuid";
+ private static final String LOCAL_USERNAME_PREF = "pref_local_username";
private static final String VERIFYING_STATE_PREF = "pref_verifying";
public static final String REGISTERED_GCM_PREF = "pref_gcm_registered";
private static final String GCM_PASSWORD_PREF = "pref_gcm_password";
@@ -683,6 +684,14 @@ public class TextSecurePreferences {
setStringPreference(context, LOCAL_UUID_PREF, uuid.toString());
}
+ public static String getLocalUsername(Context context) {
+ return getStringPreference(context, LOCAL_USERNAME_PREF, null);
+ }
+
+ public static void setLocalUsername(Context context, String username) {
+ setStringPreference(context, LOCAL_USERNAME_PREF, username);
+ }
+
public static String getPushServerPassword(Context context) {
return getStringPreference(context, GCM_PASSWORD_PREF, null);
}
diff --git a/src/org/thoughtcrime/securesms/util/UsernameUtil.java b/src/org/thoughtcrime/securesms/util/UsernameUtil.java
new file mode 100644
index 0000000000..d7480fce65
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/UsernameUtil.java
@@ -0,0 +1,80 @@
+package org.thoughtcrime.securesms.util;
+
+import android.content.Context;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
+
+import java.io.IOException;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+public class UsernameUtil {
+
+ private static final String TAG = Log.tag(UsernameUtil.class);
+
+ public static final int MIN_LENGTH = 4;
+ public static final int MAX_LENGTH = 26;
+
+ private static final Pattern FULL_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
+
+ public static boolean isValidUsernameForSearch(@Nullable String value) {
+ return !TextUtils.isEmpty(value) && !DIGIT_START_PATTERN.matcher(value).matches();
+ }
+
+ public static Optional checkUsername(@Nullable String value) {
+ if (value == null) {
+ return Optional.of(InvalidReason.TOO_SHORT);
+ } else if (value.length() < MIN_LENGTH) {
+ return Optional.of(InvalidReason.TOO_SHORT);
+ } else if (value.length() > MAX_LENGTH) {
+ return Optional.of(InvalidReason.TOO_LONG);
+ } else if (DIGIT_START_PATTERN.matcher(value).matches()) {
+ return Optional.of(InvalidReason.STARTS_WITH_NUMBER);
+ } else if (!FULL_PATTERN.matcher(value).matches()) {
+ return Optional.of(InvalidReason.INVALID_CHARACTERS);
+ } else {
+ return Optional.absent();
+ }
+ }
+
+ @WorkerThread
+ public static @NonNull Optional fetchUuidForUsername(@NonNull Context context, @NonNull String username) {
+ Optional localId = DatabaseFactory.getRecipientDatabase(context).getByUsername(username);
+
+ if (localId.isPresent()) {
+ Recipient recipient = Recipient.resolved(localId.get());
+
+ if (recipient.getUuid().isPresent()) {
+ Log.i(TAG, "Found username locally -- using associated UUID.");
+ return recipient.getUuid();
+ } else {
+ Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it.");
+ DatabaseFactory.getRecipientDatabase(context).clearUsernameIfExists(username);
+ }
+ }
+
+ try {
+ Log.d(TAG, "No local user with this username. Searching remotely.");
+ SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
+ return Optional.fromNullable(profile.getUuid());
+ } catch (IOException e) {
+ return Optional.absent();
+ }
+ }
+
+ public enum InvalidReason {
+ TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java b/src/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java
new file mode 100644
index 0000000000..9c334fabff
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/views/SimpleProgressDialog.java
@@ -0,0 +1,28 @@
+package org.thoughtcrime.securesms.util.views;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+
+import org.thoughtcrime.securesms.R;
+
+/**
+ * Helper class to show a fullscreen blocking indeterminate progress dialog.
+ */
+public final class SimpleProgressDialog {
+
+ private SimpleProgressDialog() {}
+
+ public static @NonNull AlertDialog show(@NonNull Context context) {
+ AlertDialog dialog = new AlertDialog.Builder(context)
+ .setView(R.layout.progress_dialog)
+ .setCancelable(false)
+ .create();
+ dialog.show();
+ dialog.getWindow().setLayout(context.getResources().getDimensionPixelSize(R.dimen.progress_dialog_size),
+ context.getResources().getDimensionPixelSize(R.dimen.progress_dialog_size));
+
+ return dialog;
+ }
+}
diff --git a/test/unitTest/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java b/test/unitTest/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java
new file mode 100644
index 0000000000..8925473f6d
--- /dev/null
+++ b/test/unitTest/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java
@@ -0,0 +1,48 @@
+package org.thoughtcrime.securesms.util;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+public class UsernameUtilTest {
+
+ @Test
+ public void checkUsername_tooShort() {
+ assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername(null).get());
+ assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("").get());
+ assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("abc").get());
+ }
+
+ @Test
+ public void checkUsername_tooLong() {
+ assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1").get());
+ }
+
+ @Test
+ public void checkUsername_startsWithNumber() {
+ assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("0abcdefg").get());
+ assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("9abcdefg").get());
+ assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("8675309").get());
+ }
+
+ @Test
+ public void checkUsername_invalidCharacters() {
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("$abcd").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername(" abcd").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("ab cde").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("%%%%%").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("-----").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("asĸ_me").get());
+ assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("+18675309").get());
+ }
+
+ @Test
+ public void checkUsername_validUsernames() {
+ assertFalse(UsernameUtil.checkUsername("abcd").isPresent());
+ assertFalse(UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz").isPresent());
+ assertFalse(UsernameUtil.checkUsername("ABCDEFGHIJKLMNOPQRSTUVWXYZ").isPresent());
+ assertFalse(UsernameUtil.checkUsername("web_head").isPresent());
+ assertFalse(UsernameUtil.checkUsername("Spider_Fan_1991").isPresent());
+ }
+}