diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/CallLinks.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/CallLinks.kt index 9f86e5c2de..020b594124 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/CallLinks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/CallLinks.kt @@ -58,7 +58,7 @@ object CallLinks { return false } - if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) { + if (!url.startsWith(HTTPS_LINK_PREFIX) || !url.startsWith(SNGL_LINK_PREFIX)) { return false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 23b92dc2b9..2c1b2d7bd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -25,6 +25,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.signal.libsignal.usernames.Username; import org.signal.ringrtc.CallLinkRootKey; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.WebRtcCallActivity; @@ -46,6 +48,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.whispersystems.signalservice.api.push.ServiceId; +import org.whispersystems.signalservice.api.push.UsernameLinkComponents; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import java.io.IOException; import java.util.Objects; @@ -291,46 +295,16 @@ public class CommunicationActions { * If the url is a signal.me link it will handle it. */ public static void handlePotentialSignalMeUrl(@NonNull FragmentActivity activity, @NonNull String potentialUrl) { - String e164 = SignalMeUtil.parseE164FromLink(activity, potentialUrl); - String username = SignalMeUtil.parseUsernameFromLink(potentialUrl); + String e164 = SignalMeUtil.parseE164FromLink(activity, potentialUrl); + UsernameLinkComponents username = SignalMeUtil.parseUsernameComponentsFromLink(potentialUrl); + + if (e164 != null) { + handleE164Link(activity, e164); + } else if (username != null) { + handleUsernameLink(activity, username); + } if (e164 != null || username != null) { - SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(activity, 500, 500); - - SimpleTask.run(() -> { - Recipient recipient = Recipient.UNKNOWN; - if (e164 != null) { - recipient = Recipient.external(activity, e164); - - if (!recipient.isRegistered() || !recipient.hasServiceId()) { - try { - ContactDiscovery.refresh(activity, recipient, false, TimeUnit.SECONDS.toMillis(10)); - recipient = Recipient.resolved(recipient.getId()); - } catch (IOException e) { - Log.w(TAG, "[handlePotentialSignalMeUrl] Failed to refresh directory for new contact."); - } - } - } else { - Optional serviceId = UsernameUtil.fetchAciForUsernameHash(username); - if (serviceId.isPresent()) { - recipient = Recipient.externalUsername(serviceId.get(), username); - } - } - - return recipient; - }, recipient -> { - dialog.dismiss(); - - if (recipient != Recipient.UNKNOWN) { - startConversation(activity, recipient, null); - } else if (username != null) { - new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.ContactSelectionListFragment_username_not_found) - .setMessage(activity.getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, username)) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - }); } } @@ -456,6 +430,71 @@ public class CommunicationActions { .execute(); } + private static void handleE164Link(Activity activity, String e164) { + SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(activity, 500, 500); + + SimpleTask.run(() -> { + Recipient recipient = Recipient.external(activity, e164); + + if (!recipient.isRegistered() || !recipient.hasServiceId()) { + try { + ContactDiscovery.refresh(activity, recipient, false, TimeUnit.SECONDS.toMillis(10)); + recipient = Recipient.resolved(recipient.getId()); + } catch (IOException e) { + Log.w(TAG, "[handlePotentialSignalMeUrl] Failed to refresh directory for new contact."); + } + } + + return recipient; + }, recipient -> { + dialog.dismiss(); + + if (recipient.isRegistered() && recipient.hasServiceId()) { + startConversation(activity, recipient, null); + } else { + new MaterialAlertDialogBuilder(activity) + .setMessage(activity.getString(R.string.NewConversationActivity__s_is_not_a_signal_user, e164)) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + }); + } + + private static void handleUsernameLink(Activity activity, UsernameLinkComponents link) { + SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(activity, 500, 500); + + SimpleTask.run(() -> { + try { + byte[] encryptedUsername = ApplicationDependencies.getSignalServiceAccountManager().getEncryptedUsernameFromLinkServerId(link.getServerId()); + Username username = Username.fromLink(new Username.UsernameLink(link.getEntropy(), encryptedUsername)); + Optional serviceId = UsernameUtil.fetchAciForUsername(username.getUsername()); + + if (serviceId.isPresent()) { + return Recipient.externalUsername(serviceId.get(), username.getUsername()); + } else { + return null; + } + } catch (IOException e) { + Log.w(TAG, "Failed to fetch encrypted username", e); + return null; + } catch (BaseUsernameException e) { + Log.w(TAG, "Invalid username", e); + return null; + } + }, recipient -> { + dialog.dismiss(); + + if (recipient != null && recipient.isRegistered() && recipient.hasServiceId()) { + startConversation(activity, recipient, null); + } else { + new MaterialAlertDialogBuilder(activity) + .setMessage(activity.getString(R.string.UsernameLinkSettings_qr_result_not_found_no_username)) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + }); + } + private interface CallContext { @NonNull Permissions.PermissionsBuilder getPermissionsBuilder(); void startActivity(@NonNull Intent intent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.java deleted file mode 100644 index 2892f46872..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.i18n.phonenumbers.PhoneNumberUtil; - -import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; - -import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -class SignalMeUtil { - private static final String HOST = "^(https|sgnl)://" + "signal\\.me"; - private static final Pattern E164_PATTERN = Pattern.compile(HOST + "/#p/(\\+[0-9]+)$"); - private static final Pattern USERNAME_PATTERN = Pattern.compile(HOST + "/#u/(.+)$"); - - /** - * If this is a valid signal.me link and has a valid e164, it will return the e164. Otherwise, it will return null. - */ - public static @Nullable String parseE164FromLink(@NonNull Context context, @Nullable String link) { - if (Util.isEmpty(link)) { - return null; - } - - Matcher matcher = E164_PATTERN.matcher(link); - - if (matcher.matches()) { - String e164 = matcher.group(2); - - if (PhoneNumberUtil.getInstance().isPossibleNumber(e164, Locale.getDefault().getCountry())) { - return PhoneNumberFormatter.get(context).format(e164); - } else { - return null; - } - } else { - return null; - } - } - - /** - * If this is a valid signal.me link and has a valid username, it will return the username. Otherwise, it will return null. - */ - public static @Nullable String parseUsernameFromLink(@Nullable String link) { - if (Util.isEmpty(link)) { - return null; - } - - Matcher matcher = USERNAME_PATTERN.matcher(link); - - if (matcher.matches()) { - String username = matcher.group(2); - try { - return username == null || username.isEmpty() ? null : URLDecoder.decode(username, StandardCharsets.UTF_8.toString()); - } catch (UnsupportedEncodingException e) { - return null; - } - } else { - return null; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.kt new file mode 100644 index 0000000000..f774cfb200 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalMeUtil.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import com.google.i18n.phonenumbers.PhoneNumberUtil +import org.signal.core.util.Base64 +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.util.UuidUtil +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.util.Locale + +internal object SignalMeUtil { + private val E164_REGEX = """^(https|sgnl)://signal\.me/#p/(\+[0-9]+)$""".toRegex() + private val USERNAME_REGEX = """^(https|sgnl)://signal\.me/#eu/(.+)$""".toRegex() + + /** + * If this is a valid signal.me link and has a valid e164, it will return the e164. Otherwise, it will return null. + */ + @JvmStatic + fun parseE164FromLink(context: Context, link: String?): String? { + if (link.isNullOrBlank()) { + return null + } + + return E164_REGEX.find(link)?.let { match -> + val e164: String = match.groups[2]?.value ?: return@let null + + if (PhoneNumberUtil.getInstance().isPossibleNumber(e164, Locale.getDefault().country)) { + PhoneNumberFormatter.get(context).format(e164) + } else { + null + } + } + } + + /** + * If this is a valid signal.me link and has valid username link components, it will return those components. Otherwise, it will return null. + */ + @JvmStatic + fun parseUsernameComponentsFromLink(link: String?): UsernameLinkComponents? { + if (link.isNullOrBlank()) { + return null + } + + return USERNAME_REGEX.find(link)?.let { match -> + val usernameLinkBase64: String = match.groups[2]?.value ?: return@let null + + try { + val usernameLinkData: ByteArray = Base64.decode(usernameLinkBase64).takeIf { it.size == 48 } ?: return@let null + val entropy: ByteArray = usernameLinkData.sliceArray(0 until 32) + val uuidBytes: ByteArray = usernameLinkData.sliceArray(32 until usernameLinkData.size) + val uuid = UuidUtil.parseOrNull(uuidBytes) + + UsernameLinkComponents(entropy, uuid) + } catch (e: UnsupportedEncodingException) { + null + } catch (e: IOException) { + null + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt index 7400a20444..094bf9fb77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt @@ -85,7 +85,7 @@ object UsernameUtil { Log.d(TAG, "No local user with this username. Searching remotely.") return try { - fetchAciForUsernameHash(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))) + fetchAciForUsernameHash(Base64.encodeUrlSafeWithoutPadding(Username(username).hash)) } catch (e: BaseUsernameException) { Optional.empty() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0d2405f39..5922f3c531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4579,6 +4579,8 @@ View contact %1$s is not a Signal user + + %1$s is not a Signal user Search name or number