Skip to content

Commit

Permalink
Implement username is out of sync banner.
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-signal authored and greyson-signal committed Feb 21, 2023
1 parent 4954be1 commit a398745
Show file tree
Hide file tree
Showing 9 changed files with 302 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package org.thoughtcrime.securesms.jobs

import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.failure
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.util.Base64UrlSafe

@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {

@get:Rule
val harness = SignalActivityRule()

@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
}

@Test
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
}

@Test
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
val serverUsername = "hello.3232"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)

// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()

// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}

@Test
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(WhoAmIResponse())
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)

// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()

// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}

@Test
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)

// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()

// THEN
assertFalse(didReserve)
assertFalse(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}

@Test
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().failure(418)
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)

// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()

// THEN
assertTrue(didReserve)
assertFalse(didConfirm)
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.components.reminder

import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags

/**
* Displays a reminder message when the local username gets out of sync with
* what the server thinks our username is.
*/
class UsernameOutOfSyncReminder(context: Context) : Reminder(
null,
context.getString(R.string.UsernameOutOfSyncReminder__something_went_wrong)
) {

init {
addAction(
Action(
context.getString(R.string.UsernameOutOfSyncReminder__fix_now),
R.id.reminder_action_fix_username
)
)
}

override fun isDismissable(): Boolean {
return false
}

companion object {
@JvmStatic
fun isEligible(): Boolean {
return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
Expand Down Expand Up @@ -153,6 +154,7 @@
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
Expand Down Expand Up @@ -784,6 +786,8 @@ private void onReminderAction(@IdRes int reminderActionId) {
CdsTemporaryErrorBottomSheet.show(getChildFragmentManager());
} else if (reminderActionId == R.id.reminder_action_cds_permanent_error_learn_more) {
CdsPermanentErrorBottomSheet.show(getChildFragmentManager());
} else if (reminderActionId == R.id.reminder_action_fix_username) {
startActivity(ManageProfileActivity.getIntentForUsernameEdit(requireContext()));
}
}

Expand Down Expand Up @@ -1043,6 +1047,8 @@ private void updateReminders() {
return Optional.of(new CdsTemporyErrorReminder(context));
} else if (CdsPermanentErrorReminder.isEligible()) {
return Optional.of(new CdsPermanentErrorReminder(context));
} else if (UsernameOutOfSyncReminder.isEligible()) {
return Optional.of(new UsernameOutOfSyncReminder(context));
} else {
return Optional.<Reminder>empty();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2085,6 +2085,16 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}

fun getUsername(id: RecipientId): String? {
return writableDatabase.query(TABLE_NAME, arrayOf(USERNAME), "$ID = ?", SqlUtil.buildArgs(id), null, null, null).use {
if (it.moveToFirst()) {
it.requireString(USERNAME)
} else {
null
}
}
}

fun setUsername(id: RecipientId, username: String?) {
writableDatabase.withinTransaction {
if (username != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.signal.core.util.logging.Log;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.badges.BadgeRepository;
Expand All @@ -23,6 +26,7 @@
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.subscription.Subscriber;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ProfileUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
Expand All @@ -34,8 +38,12 @@
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
import org.whispersystems.signalservice.internal.push.WhoAmIResponse;
import org.whispersystems.util.Base64UrlSafe;

import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -142,6 +150,10 @@ protected void onRun() throws Exception {
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));

StoryOnboardingDownloadJob.Companion.enqueueIfNeeded();

if (FeatureFlags.usernames()) {
checkUsernameIsInSync();
}
}

private void setExpiringProfileKeyCredential(@NonNull Recipient recipient,
Expand Down Expand Up @@ -241,6 +253,42 @@ private void ensureUnidentifiedAccessCorrect(@Nullable String unidentifiedAccess
}
}

@VisibleForTesting
static void checkUsernameIsInSync() {
try {
String localUsername = SignalDatabase.recipients().getUsername(Recipient.self().getId());
boolean hasLocalUsername = !TextUtils.isEmpty(localUsername);

if (!hasLocalUsername) {
return;
}

WhoAmIResponse whoAmIResponse = ApplicationDependencies.getSignalServiceAccountManager().getWhoAmI();
boolean hasServerUsername = !TextUtils.isEmpty(whoAmIResponse.getUsernameHash());
String serverUsernameHash = whoAmIResponse.getUsernameHash();
String localUsernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(localUsername));

if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) {
tryToReserveAndConfirmLocalUsername(localUsername, localUsernameHash);
}
} catch (IOException | BaseUsernameException e) {
Log.w(TAG, "Failed perform synchronization check", e);
}
}

private static void tryToReserveAndConfirmLocalUsername(@NonNull String localUsername, @NonNull String localUsernameHash) {
try {
ReserveUsernameResponse response = ApplicationDependencies.getSignalServiceAccountManager()
.reserveUsername(Collections.singletonList(localUsernameHash));

ApplicationDependencies.getSignalServiceAccountManager()
.confirmUsername(localUsername, response);
} catch (IOException e) {
Log.d(TAG, "Failed to synchronize username.", e);
SignalStore.phoneNumberPrivacy().markUsernameOutOfSync();
}
}

private void setProfileBadges(@Nullable List<SignalServiceProfile.Badge> badges) throws IOException {
if (badges == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@

public final class PhoneNumberPrivacyValues extends SignalStoreValues {

public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode";
public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode";
public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp";
public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode";
public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode";
public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp";
public static final String USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync";

private static final Collection<CertificateType> REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164);
private static final Collection<CertificateType> PRIVACY_CERTIFICATE = Collections.singletonList(CertificateType.UUID_ONLY);
Expand Down Expand Up @@ -68,6 +69,18 @@ public long getPhoneNumberListingModeTimestamp() {
return getLong(LISTING_TIMESTAMP, 0);
}

public void markUsernameOutOfSync() {
putBoolean(USERNAME_OUT_OF_SYNC, true);
}

public void clearUsernameOutOfSync() {
putBoolean(USERNAME_OUT_OF_SYNC, false);
}

public boolean isUsernameOutOfSync() {
return getBoolean(USERNAME_OUT_OF_SYNC, false);
}

/**
* If you respect {@link #getPhoneNumberSharingMode}, then you will only ever need to fetch and store
* these certificates types.
Expand Down

0 comments on commit a398745

Please sign in to comment.