diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java
index 2560cb959a..b375c80d10 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/OnboardingValues.java
@@ -9,10 +9,11 @@ import java.util.List;
public final class OnboardingValues extends SignalStoreValues {
- private static final String SHOW_NEW_GROUP = "onboarding.new_group";
- private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends";
- private static final String SHOW_APPEARANCE = "onboarding.appearance";
- private static final String SHOW_ADD_PHOTO = "onboarding.add_photo";
+ private static final String SHOW_NEW_GROUP = "onboarding.new_group";
+ private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends";
+ private static final String SHOW_APPEARANCE = "onboarding.appearance";
+ private static final String SHOW_ADD_PHOTO = "onboarding.add_photo";
+ private static final String SHOW_SET_UP_USERNAME = "onboarding.set_up_username";
OnboardingValues(@NonNull KeyValueStore store) {
super(store);
@@ -24,6 +25,7 @@ public final class OnboardingValues extends SignalStoreValues {
putBoolean(SHOW_INVITE_FRIENDS, true);
putBoolean(SHOW_APPEARANCE, true);
putBoolean(SHOW_ADD_PHOTO, true);
+ putBoolean(SHOW_SET_UP_USERNAME, true);
}
@Override
@@ -36,13 +38,15 @@ public final class OnboardingValues extends SignalStoreValues {
setShowInviteFriends(false);
setShowAppearance(false);
setShowAddPhoto(false);
+ setShowSetUpUsername(false);
}
public boolean hasOnboarding(@NonNull Context context) {
- return shouldShowNewGroup() ||
- shouldShowInviteFriends() ||
- shouldShowAppearance() ||
- shouldShowAddPhoto();
+ return shouldShowNewGroup() ||
+ shouldShowInviteFriends() ||
+ shouldShowAppearance() ||
+ shouldShowAddPhoto() ||
+ shouldShowSetUpUsername();
}
public void setShowNewGroup(boolean value) {
@@ -76,4 +80,12 @@ public final class OnboardingValues extends SignalStoreValues {
public boolean shouldShowAddPhoto() {
return getBoolean(SHOW_ADD_PHOTO, false);
}
+
+ public void setShowSetUpUsername(boolean value) {
+ putBoolean(SHOW_SET_UP_USERNAME, value);
+ }
+
+ public boolean shouldShowSetUpUsername() {
+ return getBoolean(SHOW_SET_UP_USERNAME, false);
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt
index 47efb43841..801d227051 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt
@@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.EmptyMegaphoneActionController
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
+import org.thoughtcrime.securesms.profiles.username.ConnectWithUsernamesDialogFragment
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
import org.signal.core.ui.R as CoreUiR
@@ -226,6 +227,11 @@ enum class OnboardingListItem(
@DrawableRes val icon: Int,
@ColorRes val cardColor: Int
) {
+ SET_UP_USERNAME(
+ title = R.string.Megaphones_set_up_username,
+ icon = CoreUiR.drawable.symbol_at_24,
+ cardColor = R.color.onboarding_background_5
+ ),
GROUP(
title = R.string.Megaphones_new_group,
icon = R.drawable.symbol_group_24,
@@ -291,6 +297,7 @@ abstract class OnboardingState private constructor(
*/
private object Preview : OnboardingState(
initialState = DisplayState(
+ shouldShowSetUpUsername = true,
shouldShowNewGroup = true,
shouldShowInviteFriends = true,
shouldShowAddPhoto = true,
@@ -300,6 +307,7 @@ abstract class OnboardingState private constructor(
) {
override fun onItemCloseClick(onboardingListItem: OnboardingListItem) {
displayState = when (onboardingListItem) {
+ OnboardingListItem.SET_UP_USERNAME -> displayState.copy(shouldShowSetUpUsername = false)
OnboardingListItem.GROUP -> displayState.copy(shouldShowNewGroup = false)
OnboardingListItem.INVITE -> displayState.copy(shouldShowInviteFriends = false)
OnboardingListItem.ADD_PHOTO -> displayState.copy(shouldShowAddPhoto = false)
@@ -316,6 +324,7 @@ abstract class OnboardingState private constructor(
private class Real(megaphoneActionController: MegaphoneActionController) : OnboardingState(megaphoneActionController = megaphoneActionController) {
override fun onItemCloseClick(onboardingListItem: OnboardingListItem) {
when (onboardingListItem) {
+ OnboardingListItem.SET_UP_USERNAME -> SignalStore.onboarding.setShowSetUpUsername(false)
OnboardingListItem.GROUP -> SignalStore.onboarding.setShowNewGroup(false)
OnboardingListItem.INVITE -> SignalStore.onboarding.setShowInviteFriends(false)
OnboardingListItem.ADD_PHOTO -> SignalStore.onboarding.setShowAddPhoto(false)
@@ -331,6 +340,7 @@ abstract class OnboardingState private constructor(
override fun onItemActionClick(onboardingListItem: OnboardingListItem) {
when (onboardingListItem) {
+ OnboardingListItem.SET_UP_USERNAME -> megaphoneActionController.onMegaphoneDialogFragmentRequested(ConnectWithUsernamesDialogFragment())
OnboardingListItem.GROUP -> megaphoneActionController.onMegaphoneNavigationRequested(CreateGroupActivity.createIntent(megaphoneActionController.megaphoneActivity))
OnboardingListItem.INVITE -> megaphoneActionController.onMegaphoneNavigationRequested(AppSettingsActivity.invite(megaphoneActionController.megaphoneActivity))
OnboardingListItem.ADD_PHOTO -> {
@@ -355,15 +365,17 @@ abstract class OnboardingState private constructor(
* Simple display state, driven by [SignalStore] by default.
*/
data class DisplayState(
+ private val shouldShowSetUpUsername: Boolean = SignalStore.onboarding.shouldShowSetUpUsername() && SignalStore.account.isPhoneNumberless && SignalStore.account.username == null,
private val shouldShowNewGroup: Boolean = SignalStore.onboarding.shouldShowNewGroup(),
private val shouldShowInviteFriends: Boolean = SignalStore.onboarding.shouldShowInviteFriends(),
private val shouldShowAddPhoto: Boolean = SignalStore.onboarding.shouldShowAddPhoto() && !SignalStore.misc.hasEverHadAnAvatar,
private val shouldShowAppearance: Boolean = SignalStore.onboarding.shouldShowAppearance()
) {
- fun hasNoVisibleContent(): Boolean = !(shouldShowNewGroup || shouldShowInviteFriends || shouldShowAddPhoto || shouldShowAppearance)
+ fun hasNoVisibleContent(): Boolean = !(shouldShowSetUpUsername || shouldShowNewGroup || shouldShowInviteFriends || shouldShowAddPhoto || shouldShowAppearance)
fun shouldDisplayListItem(onboardingListItem: OnboardingListItem): Boolean {
return when (onboardingListItem) {
+ OnboardingListItem.SET_UP_USERNAME -> shouldShowSetUpUsername
OnboardingListItem.GROUP -> shouldShowNewGroup
OnboardingListItem.INVITE -> shouldShowInviteFriends
OnboardingListItem.ADD_PHOTO -> shouldShowAddPhoto
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt
new file mode 100644
index 0000000000..41b1c09b52
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/ConnectWithUsernamesDialogFragment.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.profiles.username
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.ComposeDialogFragment
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.Scaffolds
+import org.signal.core.ui.compose.SignalIcons
+import org.signal.core.ui.enableEdgeToEdge
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
+import org.signal.core.ui.R as CoreUiR
+
+/**
+ * Explains usernames to accounts registered without a phone number and gives them the
+ * opportunity to set one up now.
+ */
+class ConnectWithUsernamesDialogFragment : ComposeDialogFragment() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return super.onCreateDialog(savedInstanceState).apply {
+ window?.enableEdgeToEdge()
+ }
+ }
+
+ @Composable
+ override fun DialogContent() {
+ ConnectWithUsernamesDialogContent(
+ onSetUpUsernameClick = {
+ startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
+ dismissAllowingStateLoss()
+ },
+ onNotNowClick = { dismissAllowingStateLoss() }
+ )
+ }
+}
+
+@Composable
+private fun ConnectWithUsernamesDialogContent(
+ onSetUpUsernameClick: () -> Unit,
+ onNotNowClick: () -> Unit
+) {
+ Scaffolds.Settings(
+ title = "",
+ onNavigationClick = onNotNowClick,
+ navigationIcon = SignalIcons.X.imageVector
+ ) {
+ Column(modifier = Modifier.padding(it)) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(R.string.ConnectWithUsernamesDialogFragment__connect_with_usernames),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
+ .padding(top = 4.dp, bottom = 36.dp)
+ )
+
+ ConnectWithUsernamesRowItem(
+ title = stringResource(R.string.ConnectWithUsernamesDialogFragment__usernames),
+ description = stringResource(R.string.ConnectWithUsernamesDialogFragment__people_can_message_you_using_your_optional_username),
+ image = painterResource(R.drawable.usernames_48_color)
+ )
+
+ ConnectWithUsernamesRowItem(
+ title = stringResource(R.string.ConnectWithUsernamesDialogFragment__qr_codes_and_links),
+ description = stringResource(R.string.ConnectWithUsernamesDialogFragment__usernames_have_a_unique_qr_code),
+ image = painterResource(R.drawable.qr_codes_48_color)
+ )
+
+ ConnectWithUsernamesRowItem(
+ title = stringResource(R.string.ConnectWithUsernamesDialogFragment__stay_discoverable),
+ description = stringResource(R.string.ConnectWithUsernamesDialogFragment__without_a_phone_number_usernames_and_links),
+ image = painterResource(R.drawable.discoverable_48_color)
+ )
+ }
+
+ Buttons.LargeTonal(
+ onClick = onSetUpUsernameClick,
+ modifier = Modifier
+ .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
+ .padding(top = 16.dp)
+ .defaultMinSize(minWidth = 221.dp)
+ .align(Alignment.CenterHorizontally)
+ ) {
+ Text(text = stringResource(R.string.ConnectWithUsernamesDialogFragment__set_up_username))
+ }
+
+ TextButton(
+ onClick = onNotNowClick,
+ modifier = Modifier
+ .padding(
+ start = dimensionResource(CoreUiR.dimen.gutter),
+ end = dimensionResource(CoreUiR.dimen.gutter),
+ top = 8.dp,
+ bottom = 16.dp
+ )
+ .defaultMinSize(minWidth = 221.dp)
+ .align(Alignment.CenterHorizontally)
+ ) {
+ Text(text = stringResource(R.string.ConnectWithUsernamesDialogFragment__not_now))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConnectWithUsernamesRowItem(
+ title: String,
+ description: String,
+ image: Painter,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
+ .padding(bottom = 40.dp)
+ ) {
+ Image(
+ painter = image,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(start = 12.dp, top = 4.dp, end = 24.dp)
+ .size(48.dp)
+ )
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp, end = 8.dp)
+ )
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun ConnectWithUsernamesDialogContentPreview() {
+ Previews.Preview {
+ ConnectWithUsernamesDialogContent(
+ onSetUpUsernameClick = {},
+ onNotNowClick = {}
+ )
+ }
+}
diff --git a/app/src/main/res/drawable/discoverable_48_color.xml b/app/src/main/res/drawable/discoverable_48_color.xml
new file mode 100644
index 0000000000..6cfbaf1de0
--- /dev/null
+++ b/app/src/main/res/drawable/discoverable_48_color.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/onboarding_card_colors.xml b/app/src/main/res/values/onboarding_card_colors.xml
index 406643db21..5c3a8a51fd 100644
--- a/app/src/main/res/values/onboarding_card_colors.xml
+++ b/app/src/main/res/values/onboarding_card_colors.xml
@@ -4,4 +4,5 @@
#FFDEE5D6
#FFD6E5E5
#FFE5DBE7
+ #FFE5E8F8
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4ca3fc0347..3f4395e1ad 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1838,6 +1838,8 @@
Invite friends
Chat colors
Add a profile photo
+
+ Set up username
A new device was linked to your account %1$s.
@@ -3491,6 +3493,26 @@
Set up username
+
+
+ Connect with usernames
+
+ Usernames
+
+ People can message you using your optional username instead of a phone number. Usernames aren\'t visible on your profile.
+
+ QR codes and links
+
+ Usernames have a unique QR code and link you can share with friends to quickly start a chat with you.
+
+ Stay discoverable
+
+ Without a phone number, usernames and links are the only way others can connect with you.
+
+ Set up username
+
+ Not now
+
Play video