Update API endpoints and integration for usernames.
This commit is contained in:
parent
803154c544
commit
2c48d40375
22 changed files with 172 additions and 142 deletions
7
.idea/codeStyles/Project.xml
generated
7
.idea/codeStyles/Project.xml
generated
|
@ -41,13 +41,6 @@
|
|||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
|
|
|
@ -141,9 +141,6 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
profileAndCredential.getExpiringProfileKeyCredential()
|
||||
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));
|
||||
|
||||
String username = ApplicationDependencies.getSignalServiceAccountManager().getWhoAmI().getUsername();
|
||||
SignalDatabase.recipients().setUsername(Recipient.self().getId(), username);
|
||||
|
||||
StoryOnboardingDownloadJob.Companion.enqueueIfNeeded();
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -44,6 +46,7 @@ import org.thoughtcrime.securesms.util.NameUtil;
|
|||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
|
@ -242,13 +245,14 @@ public class ManageProfileFragment extends LoggingFragment {
|
|||
private void presentUsername(@Nullable String username) {
|
||||
if (username == null || username.isEmpty()) {
|
||||
binding.manageProfileUsername.setText(R.string.ManageProfileFragment_username);
|
||||
binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username);
|
||||
binding.manageProfileUsernameShare.setVisibility(View.GONE);
|
||||
} else {
|
||||
binding.manageProfileUsername.setText(username);
|
||||
|
||||
try {
|
||||
binding.manageProfileUsernameSubtitle.setText(getString(R.string.signal_me_username_url_no_scheme, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
binding.manageProfileUsernameSubtitle.setText(getString(R.string.signal_me_username_url_no_scheme, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))));
|
||||
} catch (BaseUsernameException e) {
|
||||
Log.w(TAG, "Could not format username link", e);
|
||||
binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username);
|
||||
}
|
||||
|
|
|
@ -5,17 +5,22 @@ import androidx.annotation.WorkerThread;
|
|||
|
||||
import org.signal.core.util.Result;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
@ -30,12 +35,12 @@ class UsernameEditRepository {
|
|||
this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
}
|
||||
|
||||
@NonNull Single<Result<ReserveUsernameResponse, UsernameSetResult>> reserveUsername(@NonNull String nickname) {
|
||||
@NonNull Single<Result<UsernameState.Reserved, UsernameSetResult>> reserveUsername(@NonNull String nickname) {
|
||||
return Single.fromCallable(() -> reserveUsernameInternal(nickname)).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
@NonNull Single<UsernameSetResult> confirmUsername(@NonNull ReserveUsernameResponse reserveUsernameResponse) {
|
||||
return Single.fromCallable(() -> confirmUsernameInternal(reserveUsernameResponse)).subscribeOn(Schedulers.io());
|
||||
@NonNull Single<UsernameSetResult> confirmUsername(@NonNull UsernameState.Reserved reserved) {
|
||||
return Single.fromCallable(() -> confirmUsernameInternal(reserved)).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
@NonNull Single<UsernameDeleteResult> deleteUsername() {
|
||||
|
@ -43,11 +48,28 @@ class UsernameEditRepository {
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull Result<ReserveUsernameResponse, UsernameSetResult> reserveUsernameInternal(@NonNull String nickname) {
|
||||
private @NonNull Result<UsernameState.Reserved, UsernameSetResult> reserveUsernameInternal(@NonNull String nickname) {
|
||||
try {
|
||||
ReserveUsernameResponse username = accountManager.reserveUsername(nickname);
|
||||
List<String> candidates = Username.generateCandidates(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH);
|
||||
List<String> hashes = new ArrayList<>();
|
||||
|
||||
for (String candidate : candidates) {
|
||||
byte[] hash = Username.hash(candidate);
|
||||
hashes.add(Base64UrlSafe.encodeBytesWithoutPadding(hash));
|
||||
}
|
||||
|
||||
ReserveUsernameResponse response = accountManager.reserveUsername(hashes);
|
||||
int hashIndex = hashes.indexOf(response.getUsernameHash());
|
||||
if (hashIndex == -1) {
|
||||
Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.");
|
||||
return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
|
||||
}
|
||||
|
||||
Log.i(TAG, "[reserveUsername] Successfully reserved username.");
|
||||
return Result.success(username);
|
||||
return Result.success(new UsernameState.Reserved(candidates.get(hashIndex), response));
|
||||
} catch (BaseUsernameException e) {
|
||||
Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.");
|
||||
return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
|
||||
} catch (UsernameTakenException e) {
|
||||
Log.w(TAG, "[reserveUsername] Username taken.");
|
||||
return Result.failure(UsernameSetResult.USERNAME_UNAVAILABLE);
|
||||
|
@ -61,11 +83,10 @@ class UsernameEditRepository {
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull ReserveUsernameResponse reserveUsernameResponse) {
|
||||
private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull UsernameState.Reserved reserved) {
|
||||
try {
|
||||
accountManager.confirmUsername(reserveUsernameResponse);
|
||||
SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserveUsernameResponse.getUsername());
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
|
||||
accountManager.confirmUsername(reserved.getUsername(), reserved.getReserveUsernameResponse());
|
||||
SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserved.getUsername());
|
||||
Log.i(TAG, "[confirmUsername] Successfully reserved username.");
|
||||
return UsernameSetResult.SUCCESS;
|
||||
} catch (UsernameTakenException e) {
|
||||
|
@ -94,7 +115,7 @@ class UsernameEditRepository {
|
|||
}
|
||||
|
||||
enum UsernameSetResult {
|
||||
SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR
|
||||
SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR
|
||||
}
|
||||
|
||||
enum UsernameDeleteResult {
|
||||
|
|
|
@ -110,7 +110,7 @@ class UsernameEditViewModel extends ViewModel {
|
|||
|
||||
uiState.update(state -> new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, state.usernameState));
|
||||
|
||||
Disposable confirmUsernameDisposable = repo.confirmUsername(((UsernameState.Reserved) usernameState).getReserveUsernameResponse())
|
||||
Disposable confirmUsernameDisposable = repo.confirmUsername((UsernameState.Reserved) usernameState)
|
||||
.subscribe(result -> {
|
||||
String nickname = usernameState.getNickname();
|
||||
|
||||
|
@ -181,8 +181,8 @@ class UsernameEditViewModel extends ViewModel {
|
|||
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading.INSTANCE));
|
||||
Disposable reserveDisposable = repo.reserveUsername(nickname).subscribe(result -> {
|
||||
result.either(
|
||||
reserveUsernameJsonResponse -> {
|
||||
uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, new UsernameState.Reserved(reserveUsernameJsonResponse)));
|
||||
reserved -> {
|
||||
uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, reserved));
|
||||
return null;
|
||||
},
|
||||
failure -> {
|
||||
|
@ -199,6 +199,10 @@ class UsernameEditViewModel extends ViewModel {
|
|||
uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, UsernameState.NoUsername.INSTANCE));
|
||||
events.onNext(Event.NETWORK_FAILURE);
|
||||
break;
|
||||
case CANDIDATE_GENERATION_ERROR:
|
||||
// TODO -- Retry
|
||||
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername.INSTANCE));
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
|
@ -19,8 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||
import org.thoughtcrime.securesms.util.FragmentResultContract
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.whispersystems.util.Base64UrlSafe
|
||||
|
||||
/**
|
||||
* Allows the user to either share their username directly or to copy it to their clipboard.
|
||||
|
@ -71,7 +71,7 @@ class UsernameShareBottomSheet : DSLSettingsBottomSheetFragment() {
|
|||
|
||||
customPref(
|
||||
CopyButton.Model(
|
||||
text = getString(R.string.signal_me_username_url, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
|
||||
text = getString(R.string.signal_me_username_url, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))),
|
||||
onClick = {
|
||||
copyToClipboard(it)
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ class UsernameShareBottomSheet : DSLSettingsBottomSheetFragment() {
|
|||
|
||||
customPref(
|
||||
ShareButton.Model(
|
||||
text = getString(R.string.signal_me_username_url, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
|
||||
text = getString(R.string.signal_me_username_url, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))),
|
||||
onClick = {
|
||||
openShareSheet(it.text)
|
||||
}
|
||||
|
|
|
@ -17,10 +17,9 @@ sealed class UsernameState {
|
|||
object NoUsername : UsernameState()
|
||||
|
||||
data class Reserved(
|
||||
override val username: String,
|
||||
val reserveUsernameResponse: ReserveUsernameResponse
|
||||
) : UsernameState() {
|
||||
override val username: String? = reserveUsernameResponse.username
|
||||
}
|
||||
) : UsernameState()
|
||||
|
||||
data class Set(
|
||||
override val username: String
|
||||
|
|
|
@ -125,8 +125,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
boolean storiesDisabled = remote.isStoriesDisabled();
|
||||
boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ;
|
||||
boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory);
|
||||
String username = remote.getUsername();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
|
||||
|
||||
if (matchesRemote) {
|
||||
return remote;
|
||||
|
@ -163,6 +164,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
.setStoriesDisabled(storiesDisabled)
|
||||
.setHasReadOnboardingStory(hasReadOnboardingStory)
|
||||
.setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation)
|
||||
.setUsername(username)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
@ -211,7 +213,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
boolean hasViewedOnboardingStory,
|
||||
boolean storiesDisabled,
|
||||
@NonNull OptionalBool storyViewReceiptsState,
|
||||
boolean hasReadOnboardingStory)
|
||||
boolean hasReadOnboardingStory,
|
||||
@Nullable String username)
|
||||
{
|
||||
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
|
||||
Objects.equals(contact.getGivenName().orElse(""), givenName) &&
|
||||
|
@ -241,6 +244,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
contact.hasViewedOnboardingStory() == hasViewedOnboardingStory &&
|
||||
contact.isStoriesDisabled() == storiesDisabled &&
|
||||
contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) &&
|
||||
contact.hasReadOnboardingStory() == hasReadOnboardingStory;
|
||||
contact.hasReadOnboardingStory() == hasReadOnboardingStory &&
|
||||
Objects.equals(contact.getUsername(), username);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -153,6 +153,7 @@ public final class StorageSyncHelper {
|
|||
.setStoryViewReceiptsState(storyViewReceiptsState)
|
||||
.setHasReadOnboardingStory(hasReadOnboardingStory)
|
||||
.setHasSeenGroupStoryEducationSheet(SignalStore.storyValues().getUserHasSeenGroupStoryEducationSheet())
|
||||
.setUsername(self.getUsername().orElse(null))
|
||||
.build();
|
||||
|
||||
return SignalStorageRecord.forAccount(account);
|
||||
|
|
|
@ -291,7 +291,7 @@ public class CommunicationActions {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Optional<ServiceId> serviceId = UsernameUtil.fetchAciForUsername(username);
|
||||
Optional<ServiceId> serviceId = UsernameUtil.fetchAciForUsernameHash(username);
|
||||
if (serviceId.isPresent()) {
|
||||
recipient = Recipient.externalUsername(serviceId.get(), username);
|
||||
}
|
||||
|
|
|
@ -7,13 +7,15 @@ import androidx.annotation.Nullable;
|
|||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
@ -24,10 +26,10 @@ 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;
|
||||
public static final int MIN_LENGTH = 3;
|
||||
public static final int MAX_LENGTH = 32;
|
||||
|
||||
private static final Pattern FULL_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
|
||||
|
||||
public static boolean isValidUsernameForSearch(@Nullable String value) {
|
||||
|
@ -66,9 +68,19 @@ public class UsernameUtil {
|
|||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "No local user with this username. Searching remotely.");
|
||||
try {
|
||||
Log.d(TAG, "No local user with this username. Searching remotely.");
|
||||
ACI aci = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsername(username);
|
||||
return fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
|
||||
} catch (BaseUsernameException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull Optional<ServiceId> fetchAciForUsernameHash(@NonNull String base64UrlSafeEncodedUsernameHash) {
|
||||
try {
|
||||
ACI aci = ApplicationDependencies.getSignalServiceAccountManager()
|
||||
.getAciByUsernameHash(base64UrlSafeEncodedUsernameHash);
|
||||
return Optional.ofNullable(aci);
|
||||
} catch (IOException e) {
|
||||
return Optional.empty();
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
android:minHeight="56dp"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:textAlignment="viewStart"
|
||||
|
|
|
@ -186,6 +186,8 @@
|
|||
style="@style/Signal.Text.Preview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
android:text="@string/ManageProfileFragment_your_username"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
|
|
|
@ -11,12 +11,12 @@ public class UsernameUtilTest {
|
|||
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());
|
||||
assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("ab").get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkUsername_tooLong() {
|
||||
assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1").get());
|
||||
assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1234567").get());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -20,7 +20,6 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
|||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
|
||||
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
|
||||
|
@ -55,19 +54,11 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
|||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.ContactDiscoveryCipher;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.Quote;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestation;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse;
|
||||
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
import org.whispersystems.signalservice.internal.push.CdsiAuthResponse;
|
||||
import org.whispersystems.signalservice.internal.push.ProfileAvatarData;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil;
|
||||
import org.whispersystems.signalservice.internal.push.RemoteConfigResponse;
|
||||
import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse;
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
|
||||
|
@ -85,13 +76,10 @@ import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
|
|||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SignatureException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
@ -102,7 +90,6 @@ import java.util.Locale;
|
|||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
@ -823,20 +810,16 @@ public class SignalServiceAccountManager {
|
|||
}
|
||||
}
|
||||
|
||||
public ACI getAciByUsername(String username) throws IOException {
|
||||
return this.pushServiceSocket.getAciByUsername(username);
|
||||
public ACI getAciByUsernameHash(String usernameHash) throws IOException {
|
||||
return this.pushServiceSocket.getAciByUsernameHash(usernameHash);
|
||||
}
|
||||
|
||||
public void setUsername(String nickname, String existingUsername) throws IOException {
|
||||
this.pushServiceSocket.setUsername(nickname, existingUsername);
|
||||
public ReserveUsernameResponse reserveUsername(List<String> usernameHashes) throws IOException {
|
||||
return this.pushServiceSocket.reserveUsername(usernameHashes);
|
||||
}
|
||||
|
||||
public ReserveUsernameResponse reserveUsername(String nickname) throws IOException {
|
||||
return this.pushServiceSocket.reserveUsername(nickname);
|
||||
}
|
||||
|
||||
public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException {
|
||||
this.pushServiceSocket.confirmUsername(reserveUsernameResponse);
|
||||
public void confirmUsername(String username, ReserveUsernameResponse reserveUsernameResponse) throws IOException {
|
||||
this.pushServiceSocket.confirmUsername(username, reserveUsernameResponse);
|
||||
}
|
||||
|
||||
public void deleteUsername() throws IOException {
|
||||
|
|
|
@ -19,6 +19,8 @@ import java.util.List;
|
|||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public final class SignalAccountRecord implements SignalRecord {
|
||||
|
||||
private static final String TAG = SignalAccountRecord.class.getSimpleName();
|
||||
|
@ -195,6 +197,10 @@ public final class SignalAccountRecord implements SignalRecord {
|
|||
diff.add("HasSeenGroupStoryEducationSheet");
|
||||
}
|
||||
|
||||
if (!Objects.equals(getUsername(), that.getUsername())) {
|
||||
diff.add("Username");
|
||||
}
|
||||
|
||||
return diff.toString();
|
||||
} else {
|
||||
return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName();
|
||||
|
@ -325,6 +331,10 @@ public final class SignalAccountRecord implements SignalRecord {
|
|||
return proto.getHasSeenGroupStoryEducationSheet();
|
||||
}
|
||||
|
||||
public @Nullable String getUsername() {
|
||||
return proto.getUsername();
|
||||
}
|
||||
|
||||
public AccountRecord toProto() {
|
||||
return proto;
|
||||
}
|
||||
|
@ -697,6 +707,16 @@ public final class SignalAccountRecord implements SignalRecord {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder setUsername(@Nullable String username) {
|
||||
if (username == null || username.isEmpty()) {
|
||||
builder.clearUsername();
|
||||
} else {
|
||||
builder.setUsername(username);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) {
|
||||
try {
|
||||
return AccountRecord.parseFrom(serializedUnknowns).toBuilder();
|
||||
|
|
|
@ -4,13 +4,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||
|
||||
class ConfirmUsernameRequest {
|
||||
@JsonProperty
|
||||
private String usernameToConfirm;
|
||||
private String usernameHash;
|
||||
|
||||
@JsonProperty
|
||||
private String reservationToken;
|
||||
private String zkProof;
|
||||
|
||||
ConfirmUsernameRequest(String usernameToConfirm, String reservationToken) {
|
||||
this.usernameToConfirm = usernameToConfirm;
|
||||
this.reservationToken = reservationToken;
|
||||
ConfirmUsernameRequest(String usernameHash, String zkProof) {
|
||||
this.usernameHash = usernameHash;
|
||||
this.zkProof = zkProof;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ import org.signal.libsignal.protocol.state.PreKeyBundle;
|
|||
import org.signal.libsignal.protocol.state.PreKeyRecord;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
|
@ -97,8 +99,6 @@ import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
|||
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalUrl;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
@ -161,7 +161,6 @@ import java.util.concurrent.TimeUnit;
|
|||
import java.util.function.Function;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
@ -202,10 +201,10 @@ public class PushServiceSocket {
|
|||
private static final String REGISTRATION_LOCK_PATH = "/v1/accounts/registration_lock";
|
||||
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 GET_USERNAME_PATH = "/v1/accounts/username/%s";
|
||||
private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username";
|
||||
private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username/reserved";
|
||||
private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username/confirm";
|
||||
private static final String GET_USERNAME_PATH = "/v1/accounts/username_hash/%s";
|
||||
private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username_hash";
|
||||
private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username_hash/reserve";
|
||||
private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username_hash/confirm";
|
||||
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
|
||||
private static final String CHANGE_NUMBER_PATH = "/v1/accounts/number";
|
||||
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
|
||||
|
@ -896,7 +895,9 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the ACI for the given username, if it exists. This is an unauthenticated request.
|
||||
* GET /v1/accounts/username_hash/{usernameHash}
|
||||
*
|
||||
* Gets the ACI for the given username hash, if it exists. This is an unauthenticated request.
|
||||
*
|
||||
* This network request can have the following error responses:
|
||||
* <ul>
|
||||
|
@ -905,13 +906,13 @@ public class PushServiceSocket {
|
|||
* <li>400 - Bad Request. The request included authentication.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param username The username to look up.
|
||||
* @param usernameHash The usernameHash to look up.
|
||||
* @return The ACI for the given username if it exists.
|
||||
* @throws IOException if a network exception occurs.
|
||||
*/
|
||||
public @NonNull ACI getAciByUsername(String username) throws IOException {
|
||||
public @NonNull ACI getAciByUsernameHash(String usernameHash) throws IOException {
|
||||
String response = makeServiceRequestWithoutAuthentication(
|
||||
String.format(GET_USERNAME_PATH, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
|
||||
String.format(GET_USERNAME_PATH, URLEncoder.encode(usernameHash, StandardCharsets.UTF_8.toString())),
|
||||
"GET",
|
||||
null,
|
||||
NO_HEADERS,
|
||||
|
@ -927,38 +928,16 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the username for the account without seeing the discriminator first.
|
||||
*
|
||||
* @param nickname The user-supplied nickname, which must meet the requirements for usernames.
|
||||
* @param existingUsername (Optional) If the account has a current username, indicates what the client thinks the current username is. Allows the server to
|
||||
* deduplicate repeated requests.
|
||||
* @return The username as set by the server, which includes both the nickname and discriminator.
|
||||
* @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
|
||||
*/
|
||||
public @NonNull String setUsername(@NonNull String nickname, @Nullable String existingUsername) throws IOException {
|
||||
SetUsernameRequest setUsernameRequest = new SetUsernameRequest(nickname, existingUsername);
|
||||
|
||||
String responseString = makeServiceRequest(MODIFY_USERNAME_PATH, "PUT", JsonUtil.toJson(setUsernameRequest), NO_HEADERS, (responseCode, body) -> {
|
||||
switch (responseCode) {
|
||||
case 422: throw new UsernameMalformedException();
|
||||
case 409: throw new UsernameTakenException();
|
||||
}
|
||||
}, Optional.empty());
|
||||
|
||||
SetUsernameResponse response = JsonUtil.fromJsonResponse(responseString, SetUsernameResponse.class);
|
||||
return response.getUsername();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /v1/accounts/username_hash/reserve
|
||||
* Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can
|
||||
* be confirmed with confirmUsername.
|
||||
*
|
||||
* @param nickname The user-supplied nickname, which must meet the requirements for usernames.
|
||||
* @param usernameHashes A list of hashed usernames encoded as web-safe base64 strings without padding. The list will have a max length of 20, and each hash will be 32 bytes.
|
||||
* @return The reserved username. It is available for confirmation for 5 minutes.
|
||||
* @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
|
||||
*/
|
||||
public @NonNull ReserveUsernameResponse reserveUsername(@NonNull String nickname) throws IOException {
|
||||
ReserveUsernameRequest reserveUsernameRequest = new ReserveUsernameRequest(nickname);
|
||||
public @NonNull ReserveUsernameResponse reserveUsername(@NonNull List<String> usernameHashes) throws IOException {
|
||||
ReserveUsernameRequest reserveUsernameRequest = new ReserveUsernameRequest(usernameHashes);
|
||||
|
||||
String responseString = makeServiceRequest(RESERVE_USERNAME_PATH, "PUT", JsonUtil.toJson(reserveUsernameRequest), NO_HEADERS, (responseCode, body) -> {
|
||||
switch (responseCode) {
|
||||
|
@ -971,20 +950,33 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
/**
|
||||
* PUT /v1/accounts/username_hash/confirm
|
||||
* Set a previously reserved username for the account.
|
||||
*
|
||||
* @param username The username the user wishes to confirm. For example, myusername.27
|
||||
* @param reserveUsernameResponse The response object from the reservation
|
||||
* @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
|
||||
* @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
|
||||
*/
|
||||
public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException {
|
||||
ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsername(), reserveUsernameResponse.getReservationToken());
|
||||
public void confirmUsername(String username, ReserveUsernameResponse reserveUsernameResponse) throws IOException {
|
||||
try {
|
||||
byte[] randomness = new byte[32];
|
||||
random.nextBytes(randomness);
|
||||
|
||||
makeServiceRequest(CONFIRM_USERNAME_PATH, "PUT", JsonUtil.toJson(confirmUsernameRequest), NO_HEADERS, (responseCode, body) -> {
|
||||
switch (responseCode) {
|
||||
case 409: throw new UsernameIsNotReservedException();
|
||||
case 410: throw new UsernameTakenException();
|
||||
}
|
||||
}, Optional.empty());
|
||||
byte[] proof = Username.generateProof(username, randomness);
|
||||
ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsernameHash(),
|
||||
Base64UrlSafe.encodeBytesWithoutPadding(proof));
|
||||
|
||||
makeServiceRequest(CONFIRM_USERNAME_PATH, "PUT", JsonUtil.toJson(confirmUsernameRequest), NO_HEADERS, (responseCode, body) -> {
|
||||
switch (responseCode) {
|
||||
case 409:
|
||||
throw new UsernameIsNotReservedException();
|
||||
case 410:
|
||||
throw new UsernameTakenException();
|
||||
}
|
||||
}, Optional.empty());
|
||||
} catch (BaseUsernameException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,15 +2,18 @@ package org.whispersystems.signalservice.internal.push;
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
class ReserveUsernameRequest {
|
||||
@JsonProperty
|
||||
private String nickname;
|
||||
private List<String> usernameHashes;
|
||||
|
||||
ReserveUsernameRequest(String nickname) {
|
||||
this.nickname = nickname;
|
||||
ReserveUsernameRequest(List<String> usernameHashes) {
|
||||
this.usernameHashes = Collections.unmodifiableList(usernameHashes);
|
||||
}
|
||||
|
||||
String getNickname() {
|
||||
return nickname;
|
||||
List<String> getUsernameHashes() {
|
||||
return usernameHashes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,26 +4,18 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||
|
||||
public class ReserveUsernameResponse {
|
||||
@JsonProperty
|
||||
private String username;
|
||||
|
||||
@JsonProperty
|
||||
private String reservationToken;
|
||||
private String usernameHash;
|
||||
|
||||
ReserveUsernameResponse() {}
|
||||
|
||||
/**
|
||||
* Visible for testing.
|
||||
*/
|
||||
public ReserveUsernameResponse(String username, String reservationToken) {
|
||||
this.username = username;
|
||||
this.reservationToken = reservationToken;
|
||||
public ReserveUsernameResponse(String usernameHash) {
|
||||
this.usernameHash = usernameHash;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
String getReservationToken() {
|
||||
return reservationToken;
|
||||
public String getUsernameHash() {
|
||||
return usernameHash;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ public class WhoAmIResponse {
|
|||
public String number;
|
||||
|
||||
@JsonProperty
|
||||
public String username;
|
||||
public String usernameHash;
|
||||
|
||||
public String getAci() {
|
||||
return uuid;
|
||||
|
@ -27,7 +27,7 @@ public class WhoAmIResponse {
|
|||
return number;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
public String getUsernameHash() {
|
||||
return usernameHash;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -185,6 +185,7 @@ message AccountRecord {
|
|||
OptionalBool storyViewReceiptsEnabled = 30;
|
||||
bool hasReadOnboardingStory = 31;
|
||||
bool hasSeenGroupStoryEducationSheet = 32;
|
||||
string username = 33;
|
||||
}
|
||||
|
||||
message StoryDistributionListRecord {
|
||||
|
|
Loading…
Add table
Reference in a new issue