Implement username is out of sync banner.
This commit is contained in:
parent
4954be109c
commit
a398745740
9 changed files with 302 additions and 3 deletions
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -108,6 +108,7 @@ import org.thoughtcrime.securesms.components.reminder.Reminder;
|
|||
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;
|
||||
|
@ -153,6 +154,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
|||
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;
|
||||
|
@ -784,6 +786,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1043,6 +1047,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
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();
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -4,8 +4,11 @@ import android.text.TextUtils;
|
|||
|
||||
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;
|
||||
|
@ -23,6 +26,7 @@ import org.thoughtcrime.securesms.profiles.ProfileName;
|
|||
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;
|
||||
|
@ -34,8 +38,12 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
|||
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;
|
||||
|
@ -142,6 +150,10 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));
|
||||
|
||||
StoryOnboardingDownloadJob.Companion.enqueueIfNeeded();
|
||||
|
||||
if (FeatureFlags.usernames()) {
|
||||
checkUsernameIsInSync();
|
||||
}
|
||||
}
|
||||
|
||||
private void setExpiringProfileKeyCredential(@NonNull Recipient recipient,
|
||||
|
@ -241,6 +253,42 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
|
|
@ -11,9 +11,10 @@ import java.util.List;
|
|||
|
||||
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);
|
||||
|
@ -68,6 +69,18 @@ public final class PhoneNumberPrivacyValues extends SignalStoreValues {
|
|||
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.
|
||||
|
|
|
@ -9,6 +9,7 @@ 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.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
|
@ -87,6 +88,7 @@ class UsernameEditRepository {
|
|||
try {
|
||||
accountManager.confirmUsername(reserved.getUsername(), reserved.getReserveUsernameResponse());
|
||||
SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserved.getUsername());
|
||||
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync();
|
||||
Log.i(TAG, "[confirmUsername] Successfully reserved username.");
|
||||
return UsernameSetResult.SUCCESS;
|
||||
} catch (UsernameTakenException e) {
|
||||
|
@ -106,6 +108,7 @@ class UsernameEditRepository {
|
|||
try {
|
||||
accountManager.deleteUsername();
|
||||
SignalDatabase.recipients().setUsername(Recipient.self().getId(), null);
|
||||
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync();
|
||||
Log.i(TAG, "[deleteUsername] Successfully deleted the username.");
|
||||
return UsernameDeleteResult.SUCCESS;
|
||||
} catch (IOException e) {
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
<item name="reminder_action_cds_temporary_error_learn_more" type="id" />
|
||||
<item name="reminder_action_cds_permanent_error_learn_more" type="id" />
|
||||
|
||||
<item name="reminder_action_fix_username" type="id" />
|
||||
|
||||
<item name="status_bar_guideline" type="id" />
|
||||
<item name="navigation_bar_guideline" type="id" />
|
||||
|
||||
|
|
|
@ -959,6 +959,12 @@
|
|||
<!-- Snackbar message after successful deletion of username -->
|
||||
<string name="ManageProfileFragment__username_deleted">Username deleted</string>
|
||||
|
||||
<!-- UsernameOutOfSyncReminder -->
|
||||
<!-- Displayed above the conversation list when a user needs to address an issue with their username -->
|
||||
<string name="UsernameOutOfSyncReminder__something_went_wrong">Something went wrong with your username, it\'s no longer assigned to your account. You can try and set it again or choose a new one.</string>
|
||||
<!-- Action text to navigate user to manually fix the issue with their username -->
|
||||
<string name="UsernameOutOfSyncReminder__fix_now">Fix now</string>
|
||||
|
||||
|
||||
<!-- ManageRecipientActivity -->
|
||||
<string name="ManageRecipientActivity_no_groups_in_common">No groups in common</string>
|
||||
|
|
Loading…
Add table
Reference in a new issue