kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add internal pre-alpha support for usernames.
rodzic
fb49efa34d
commit
608815a69b
|
@ -460,7 +460,13 @@
|
|||
<activity
|
||||
android:name=".maps.PlacePickerActivity"
|
||||
android:label="@string/PlacePickerActivity_title"
|
||||
android:theme="@style/TextSecure.LightNoActionBar" />
|
||||
android:theme="@style/TextSecure.LightNoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity
|
||||
android:name=".usernames.ProfileEditActivityV2"
|
||||
android:theme="@style/TextSecure.LightNoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"/>
|
||||
|
||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -116,6 +116,12 @@ public class SignalServiceMessageReceiver {
|
|||
return socket.retrieveProfile(address, unidentifiedAccess);
|
||||
}
|
||||
|
||||
public SignalServiceProfile retrieveProfileByUsername(String username, Optional<UnidentifiedAccess> unidentifiedAccess)
|
||||
throws IOException
|
||||
{
|
||||
return socket.retrieveProfileByUsername(username, unidentifiedAccess);
|
||||
}
|
||||
|
||||
public InputStream retrieveProfileAvatar(String path, File destination, byte[] profileKey, int maxSizeBytes)
|
||||
throws IOException
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
public class UsernameMalformedException extends NonSuccessfulResponseCodeException {
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
public class UsernameTakenException extends NonSuccessfulResponseCodeException {
|
||||
}
|
|
@ -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> 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> 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.<UnidentifiedAccess>absent());
|
||||
}
|
||||
|
||||
public void deleteUsername() throws IOException {
|
||||
makeServiceRequest(DELETE_USERNAME_PATH, "DELETE", null);
|
||||
}
|
||||
|
||||
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException
|
||||
{
|
||||
|
|
|
@ -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<UUID> {
|
||||
@Override
|
||||
public void serialize(UUID value, JsonGenerator gen, SerializerProvider serializers)
|
||||
throws IOException
|
||||
{
|
||||
gen.writeString(value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public static class UuidDeserializer extends JsonDeserializer<UUID> {
|
||||
@Override
|
||||
public UUID deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
return UuidUtil.parseOrNull(p.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true"
|
||||
android:color="@color/core_grey_40"/>
|
||||
<item android:state_focused="true"
|
||||
android:color="@color/core_grey_40"/>
|
||||
<item android:state_enabled="false"
|
||||
android:color="@color/core_grey_40"/>
|
||||
<item android:state_enabled="true"
|
||||
android:color="@color/core_grey_40"/>
|
||||
</selector>
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true"
|
||||
android:color="@color/core_red"/>
|
||||
<item android:state_focused="true"
|
||||
android:color="@color/core_red"/>
|
||||
<item android:state_enabled="false"
|
||||
android:color="@color/core_red"/>
|
||||
<item android:state_enabled="true"
|
||||
android:color="@color/core_red"/>
|
||||
</selector>
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:titleTextAppearance="@style/TextSecure.TitleTextStyle" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_host_fragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
app:defaultNavHost="true"
|
||||
app:navGraph="@navigation/profile_edit" />
|
||||
|
||||
</LinearLayout>
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/profile_name_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="48dp"
|
||||
android:hint="@string/ProfileEditNameFragment_profile_name"
|
||||
style="@style/Signal.Text.Body"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profile_name_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/ProfileEditNameFragment_your_profile_name_can_be_seen_by_your_contacts"
|
||||
style="@style/Signal.Text.Caption"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_name_text"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/profile_name_submit"/>
|
||||
|
||||
<com.dd.CircularProgressButton
|
||||
android:id="@+id/profile_name_submit"
|
||||
style="@style/Button.Registration"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cpb_colorIndicator="@color/white"
|
||||
app:cpb_colorProgress="@color/textsecure_primary"
|
||||
app:cpb_cornerRadius="4dp"
|
||||
app:cpb_selectorIdle="@drawable/progress_button_state"
|
||||
app:cpb_textIdle="@string/ProfileEditNameFragment_save"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,159 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:src="@drawable/circle_tintable"
|
||||
android:tint="@color/core_grey_05"
|
||||
app:layout_constraintTop_toTopOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintBottom_toBottomOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_avatar" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_overview_avatar_placeholder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:padding="16dp"
|
||||
android:tint="@color/core_grey_60"
|
||||
app:srcCompat="@drawable/ic_profile_outline_40"
|
||||
app:layout_constraintTop_toTopOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintBottom_toBottomOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_avatar" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_overview_avatar"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_marginTop="36dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_overview_camera_button"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:layout_marginStart="50dp"
|
||||
android:layout_marginTop="50dp"
|
||||
android:cropToPadding="false"
|
||||
android:src="@drawable/ic_profile_camera"
|
||||
app:layout_constraintStart_toStartOf="@+id/profile_overview_avatar"
|
||||
app:layout_constraintTop_toTopOf="@+id/profile_overview_avatar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profile_overview_profile_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/ProfileEditOverviewFragment_profile_name"
|
||||
style="@style/Signal.Text.Caption"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_avatar"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/profile_overview_profile_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:editable="false"
|
||||
android:focusable="false"
|
||||
android:hint="@string/ProfileEditOverviewFragment_create_a_profile_name"
|
||||
android:background="@null"
|
||||
style="@style/Signal.Text.Body"
|
||||
tools:text="Peter Parker"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_profile_label"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toStartOf="@id/profile_overview_profile_edit_button" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_overview_profile_edit_button"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:padding="8dp"
|
||||
android:tint="@color/core_grey_55"
|
||||
app:srcCompat="@drawable/ic_compose_solid_24"
|
||||
app:layout_constraintTop_toTopOf="@id/profile_overview_profile_name"
|
||||
app:layout_constraintBottom_toBottomOf="@id/profile_overview_profile_name"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter" />
|
||||
|
||||
<View
|
||||
android:id="@+id/profile_overview_divider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="@color/transparent_black_20"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_profile_name"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter"/>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profile_overview_username_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/ProfileEditOverviewFragment_username"
|
||||
style="@style/Signal.Text.Caption"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_divider"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/profile_overview_username"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:editable="false"
|
||||
android:focusable="false"
|
||||
android:hint="@string/ProfileEditOverviewFragment_create_a_username"
|
||||
android:background="@null"
|
||||
style="@style/Signal.Text.Body"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_username_label"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toStartOf="@id/profile_overview_username_edit_button"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_overview_username_edit_button"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:padding="8dp"
|
||||
android:tint="@color/core_grey_55"
|
||||
app:srcCompat="@drawable/ic_compose_solid_24"
|
||||
app:layout_constraintTop_toTopOf="@id/profile_overview_username"
|
||||
app:layout_constraintBottom_toBottomOf="@id/profile_overview_username"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/profile_overview_info_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/ProfileEditOverviewFragment_your_signal_profile_can_be_seen_by"
|
||||
style="@style/Signal.Text.Caption"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_overview_username"
|
||||
app:layout_constraintStart_toStartOf="@id/profile_overview_left_gutter"
|
||||
app:layout_constraintEnd_toEndOf="@id/profile_overview_right_gutter"/>
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/profile_overview_left_gutter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_begin="16dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/profile_overview_right_gutter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_end="16dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/username_text"
|
||||
style="@style/Signal.Text.Body"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="48dp"
|
||||
android:hint="@string/ProfileEditOverviewFragment_create_a_username"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/username_subtext"
|
||||
style="@style/Signal.Text.Caption"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/username_text"
|
||||
tools:text="Some error code" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/username_description"
|
||||
style="@style/Signal.Text.Caption"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/UsernameEditFragment_other_signal_users_can_send_message_requests_to_your_unique_username"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/username_subtext"
|
||||
app:layout_constraintBottom_toTopOf="@id/username_button_barrier" />
|
||||
|
||||
<com.dd.CircularProgressButton
|
||||
android:id="@+id/username_submit_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:background="@color/signal_primary"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/white"
|
||||
app:cpb_colorIndicator="@color/white"
|
||||
app:cpb_colorProgress="@color/textsecure_primary"
|
||||
app:cpb_cornerRadius="4dp"
|
||||
app:cpb_selectorIdle="@drawable/progress_button_state"
|
||||
app:cpb_textIdle="@string/UsernameEditFragment_submit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<com.dd.CircularProgressButton
|
||||
android:id="@+id/username_delete_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:background="@color/core_red"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/white"
|
||||
android:visibility="gone"
|
||||
app:cpb_colorIndicator="@color/white"
|
||||
app:cpb_colorProgress="@color/core_red"
|
||||
app:cpb_cornerRadius="4dp"
|
||||
app:cpb_selectorIdle="@drawable/progress_button_state_red"
|
||||
app:cpb_textIdle="@string/UsernameEditFragment_delete"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/username_button_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="username_submit_button,username_delete_button"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/profile_edit"
|
||||
app:startDestination="@id/profileEditOverviewFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/profileEditOverviewFragment"
|
||||
android:name="org.thoughtcrime.securesms.usernames.ProfileEditOverviewFragment"
|
||||
android:label="@string/ProfileEditOverviewFragment_profile">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_profileEdit"
|
||||
app:destination="@id/profileEditNameFragment"
|
||||
app:enterAnim="@anim/slide_from_end"
|
||||
app:exitAnim="@anim/slide_to_start"
|
||||
app:popEnterAnim="@anim/slide_from_start"
|
||||
app:popExitAnim="@anim/slide_to_end" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_usernameEdit"
|
||||
app:destination="@id/usernameEditFragment"
|
||||
app:enterAnim="@anim/slide_from_end"
|
||||
app:exitAnim="@anim/slide_to_start"
|
||||
app:popEnterAnim="@anim/slide_from_start"
|
||||
app:popExitAnim="@anim/slide_to_end" />
|
||||
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/profileEditNameFragment"
|
||||
android:name="org.thoughtcrime.securesms.usernames.profile.ProfileEditNameFragment"
|
||||
android:label="@string/ProfileEditNameFragment_profile_name" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/usernameEditFragment"
|
||||
android:name="org.thoughtcrime.securesms.usernames.username.UsernameEditFragment"
|
||||
android:label="@string/UsernameEditFragment_username"
|
||||
tools:layout="@layout/username_edit_fragment" />
|
||||
</navigation>
|
|
@ -53,7 +53,7 @@
|
|||
<dimen name="media_keyboard_provider_icon_padding">5dp</dimen>
|
||||
<dimen name="media_keyboard_provider_icon_margin">4dp</dimen>
|
||||
|
||||
<dimen name="mediasend_progress_dialog_size">120dp</dimen>
|
||||
<dimen name="progress_dialog_size">120dp</dimen>
|
||||
|
||||
<dimen name="thumbnail_default_radius">4dp</dimen>
|
||||
|
||||
|
|
|
@ -130,6 +130,8 @@
|
|||
<string name="ContactsCursorLoader_recent_chats">Recent chats</string>
|
||||
<string name="ContactsCursorLoader_contacts">Contacts</string>
|
||||
<string name="ContactsCursorLoader_groups">Groups</string>
|
||||
<string name="ContactsCursorLoader_phone_number_search">Phone number search</string>
|
||||
<string name="ContactsCursorLoader_username_search">Username search</string>
|
||||
|
||||
<!-- ContactsDatabase -->
|
||||
<string name="ContactsDatabase_message_s">Message %s</string>
|
||||
|
@ -596,6 +598,19 @@
|
|||
<!-- PlayServicesProblemFragment -->
|
||||
<string name="PlayServicesProblemFragment_the_version_of_google_play_services_you_have_installed_is_not_functioning">The version of Google Play Services you have installed is not functioning correctly. Please reinstall Google Play Services and try again.</string>
|
||||
|
||||
<!-- ProfileEditNameFragment -->
|
||||
<string name="ProfileEditNameFragment_profile_name">Profile name</string>
|
||||
<string name="ProfileEditNameFragment_your_profile_name_can_be_seen_by_your_contacts">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.</string>
|
||||
<string name="ProfileEditNameFragment_save">Save</string>
|
||||
|
||||
<!-- ProfileEditOverviewFragment -->
|
||||
<string name="ProfileEditOverviewFragment_profile">Profile</string>
|
||||
<string name="ProfileEditOverviewFragment_profile_name">Profile name</string>
|
||||
<string name="ProfileEditOverviewFragment_username">Username</string>
|
||||
<string name="ProfileEditOverviewFragment_create_a_profile_name">Create a profile name</string>
|
||||
<string name="ProfileEditOverviewFragment_create_a_username">Create a username</string>
|
||||
<string name="ProfileEditOverviewFragment_your_signal_profile_can_be_seen_by">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. <a href="https://support.signal.org/hc/en-us/articles/360007459591-Signal-Profiles">Tap here to learn more</a>.</string>
|
||||
|
||||
<!-- RatingManager -->
|
||||
<string name="RatingManager_rate_this_app">Rate this app</string>
|
||||
<string name="RatingManager_if_you_enjoy_using_this_app_please_take_a_moment">If you enjoy using this app, please take a moment to help us by rating it.</string>
|
||||
|
@ -785,6 +800,21 @@
|
|||
<string name="UnverifiedSendDialog_send_message">Send message?</string>
|
||||
<string name="UnverifiedSendDialog_send">Send</string>
|
||||
|
||||
<!-- UsernameEditFragment -->
|
||||
<string name="UsernameEditFragment_username">Username</string>
|
||||
<string name="UsernameEditFragment_submit">Submit</string>
|
||||
<string name="UsernameEditFragment_delete">Delete</string>
|
||||
<string name="UsernameEditFragment_successfully_set_username">Successfully set username.</string>
|
||||
<string name="UsernameEditFragment_successfully_removed_username">Successfully removed username.</string>
|
||||
<string name="UsernameEditFragment_encountered_a_network_error">Encountered a network error.</string>
|
||||
<string name="UsernameEditFragment_this_username_is_taken">This username is taken.</string>
|
||||
<string name="UsernameEditFragment_this_username_is_available">This username is available.</string>
|
||||
<string name="UsernameEditFragment_usernames_can_only_include">Usernames can only include a-Z, 0-9, and underscores.</string>
|
||||
<string name="UsernameEditFragment_usernames_cannot_begin_with_a_number">Usernames cannot begin with a number.</string>
|
||||
<string name="UsernameEditFragment_username_is_invalid">Username is invalid.</string>
|
||||
<string name="UsernameEditFragment_usernames_must_be_between_a_and_b_characters">Usernames must be between %1$d and %2$d characters.</string>
|
||||
<string name="UsernameEditFragment_other_signal_users_can_send_message_requests_to_your_unique_username">Other Signal users can send message requests to your unique username without knowing your phone number. Choosing a username is optional.</string>
|
||||
|
||||
<!-- VerifyIdentityActivity -->
|
||||
<string name="VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal">Your contact is running an old version of Signal. Please ask them to update before verifying your safety number.</string>
|
||||
<string name="VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal">Your contact is running a newer version of Signal with an incompatible QR code format. Please update to compare.</string>
|
||||
|
@ -867,6 +897,10 @@
|
|||
<string name="NotificationChannel_group_messages">Messages</string>
|
||||
<string name="NotificationChannel_missing_display_name">Unknown</string>
|
||||
|
||||
<!-- ProfileEditNameFragment -->
|
||||
<string name="ProfileEditNameFragment_successfully_set_profile_name">Successfully set profile name.</string>
|
||||
<string name="ProfileEditNameFragment_encountered_a_network_error">Encountered a network error.</string>
|
||||
|
||||
<!-- QuickResponseService -->
|
||||
<string name="QuickResponseService_quick_response_unavailable_when_Signal_is_locked">Quick response unavailable when Signal is locked!</string>
|
||||
<string name="QuickResponseService_problem_sending_message">Problem sending message!</string>
|
||||
|
@ -966,6 +1000,9 @@
|
|||
<!-- ContactSelectionListFragment-->
|
||||
<string name="ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts">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\".</string>
|
||||
<string name="ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection">Error retrieving contacts, check your network connection</string>
|
||||
<string name="ContactSelectionListFragment_username_not_found">Username not found</string>
|
||||
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"%1$s" is not a Signal user. Please check the username and try again.</string>
|
||||
<string name="ContactSelectionListFragment_okay">Okay</string>
|
||||
|
||||
<!-- blocked_contacts_fragment -->
|
||||
<string name="blocked_contacts_fragment__no_blocked_contacts">No blocked contacts</string>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String> selectedContacts;
|
||||
private Set<SelectedContact> 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<String> getSelectedContacts() {
|
||||
List<String> selected = new LinkedList<>();
|
||||
public @NonNull List<SelectedContact> getSelectedContacts() {
|
||||
List<SelectedContact> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -291,12 +291,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
|
|||
|
||||
switch (reqCode) {
|
||||
case PICK_CONTACT:
|
||||
List<String> selected = data.getStringArrayListExtra("contacts");
|
||||
List<RecipientId> 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:
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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<String> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> 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();
|
||||
|
|
|
@ -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<Intent> extraIntents = new LinkedList<>();
|
||||
Intent galleryIntent = new Intent(Intent.ACTION_PICK);
|
||||
|
|
|
@ -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<Pair<String, ValueMapper>> SEARCH_CURSOR_MAPPERS = new ArrayList<Pair<String, ValueMapper>>() {{
|
||||
|
@ -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))));
|
||||
|
|
|
@ -70,7 +70,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
|||
private final ItemClickListener clickListener;
|
||||
private final GlideRequests glideRequests;
|
||||
|
||||
private final Set<String> selectedContacts = new HashSet<>();
|
||||
private final Set<SelectedContact> selectedContacts = new HashSet<>();
|
||||
|
||||
public abstract static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
|
@ -189,7 +189,12 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
|||
|
||||
viewHolder.unbind(glideRequests);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, color, multiSelect);
|
||||
viewHolder.setChecked(selectedContacts.contains(number));
|
||||
|
||||
if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -222,7 +227,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
|||
return getHeaderString(position);
|
||||
}
|
||||
|
||||
public Set<String> getSelectedContacts() {
|
||||
public Set<SelectedContact> getSelectedContacts() {
|
||||
return selectedContacts;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<RecipientId> getRecipientId() {
|
||||
return recipient != null ? Optional.of(recipient.getId()) : Optional.absent();
|
||||
}
|
||||
|
|
|
@ -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<Cursor> getUnfilteredResults() {
|
||||
ArrayList<Cursor> 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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<RegisteredState> 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<UnidentifiedAccessPair> 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<String> numbers;
|
||||
|
|
|
@ -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<String> 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<RecipientId> 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<RecipientId> 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<RecipientId> existingUsername = getByUsername(username);
|
||||
|
||||
if (existingUsername.isPresent()) {
|
||||
setUsername(existingUsername.get(), null);
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> getAllPhoneNumbers() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Set<String> 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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -45,6 +45,10 @@ public class ApplicationDependencies {
|
|||
ApplicationDependencies.provider = provider;
|
||||
}
|
||||
|
||||
public static @NonNull Application getApplication() {
|
||||
return application;
|
||||
}
|
||||
|
||||
public static synchronized @NonNull SignalServiceAccountManager getSignalServiceAccountManager() {
|
||||
assertInitialization();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<String> 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<String> getUsername() {
|
||||
if (FeatureFlags.USERNAMES) {
|
||||
return Optional.fromNullable(username);
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Optional<String> 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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<byte[]> 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<String> profileName) {
|
||||
profileText.setText(profileName.or(""));
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private void onUsernameChanged(@NonNull Optional<String> username) {
|
||||
if (username.isPresent()) {
|
||||
usernameText.setText("@" + username.get());
|
||||
} else {
|
||||
usernameText.setText("");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Optional<byte[]>> callback) {
|
||||
executor.execute(() -> callback.onResult(getProfileAvatarInternal()));
|
||||
}
|
||||
|
||||
void setProfileAvatar(@NonNull byte[] data, @NonNull Callback<ProfileAvatarResult> callback) {
|
||||
executor.execute(() -> callback.onResult(setProfileAvatarInternal(data)));
|
||||
}
|
||||
|
||||
void deleteProfileAvatar(@NonNull Callback<ProfileAvatarResult> callback) {
|
||||
executor.execute(() -> callback.onResult(deleteProfileAvatarInternal()));
|
||||
}
|
||||
|
||||
void getProfileName(@NonNull Callback<Optional<String>> callback) {
|
||||
executor.execute(() -> callback.onResult(getProfileNameInternal()));
|
||||
}
|
||||
|
||||
void getUsername(@NonNull Callback<Optional<String>> callback) {
|
||||
executor.execute(() -> callback.onResult(getUsernameInternal()));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull Optional<byte[]> 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<String> 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<String> 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<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
|
@ -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> event;
|
||||
private final MutableLiveData<Optional<byte[]>> avatar;
|
||||
private final MutableLiveData<Boolean> loading;
|
||||
private final MutableLiveData<Optional<String>> profileName;
|
||||
private final MutableLiveData<Optional<String>> 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<Optional<byte[]>> getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getLoading() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<String>> getProfileName() {
|
||||
return profileName;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<String>> getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Event> 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<byte[]> 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<byte[]> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ProfileEditOverviewViewModel());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<ProfileNameResult> 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<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
|
@ -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<Event> events;
|
||||
private final MutableLiveData<Boolean> 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<Event> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> isLoading() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
enum Event {
|
||||
SUCCESS, NETWORK_FAILURE
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
@Override
|
||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ProfileEditNameViewModel());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<UsernameSetResult> callback) {
|
||||
executor.execute(() -> callback.onComplete(setUsernameInternal(username)));
|
||||
}
|
||||
|
||||
void deleteUsername(@NonNull Callback<UsernameDeleteResult> 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<E> {
|
||||
void onComplete(E result);
|
||||
}
|
||||
}
|
|
@ -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<State> uiState;
|
||||
private final SingleLiveEvent<Event> 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> 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> 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<State> getUiState() {
|
||||
return uiState;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Event> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new UsernameEditViewModel());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<InvalidReason> 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<UUID> fetchUuidForUsername(@NonNull Context context, @NonNull String username) {
|
||||
Optional<RecipientId> 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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
Ładowanie…
Reference in New Issue