diff --git a/app/src/main/java/org/session/libsession/utilities/CommunityUrlParser.kt b/app/src/main/java/org/session/libsession/utilities/CommunityUrlParser.kt new file mode 100644 index 0000000000..030578f7cb --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/CommunityUrlParser.kt @@ -0,0 +1,53 @@ +package org.session.libsession.utilities + +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsignal.utilities.toHexString +import org.session.libsession.messaging.open_groups.migrateLegacyServerUrl + +object CommunityUrlParser { + + private const val publicKeyQuery = "public_key" + private const val hexPubkeyLength = 64 + + sealed class Error(message: String) : IllegalArgumentException(message) { + data object InvalidUrl : Error("Invalid community URL.") + data object InvalidPublicKey : Error("Invalid public key provided.") + } + + data class CommunityUrlInfo( + val baseUrl: String, + val room: String, + val pubKeyHex: String, + ) + + fun trimQueryParameter(url: String): String = url.substringBefore("?$publicKeyQuery") + + fun parse(url: String): CommunityUrlInfo = + try { + val (baseUrl, room, publicKey) = requireNotNull(BaseCommunityInfo.parseFullUrl(url)) { + "Invalid community URL" + } + CommunityUrlInfo( + baseUrl = baseUrl.migrateLegacyServerUrl(), + room = room, + pubKeyHex = publicKey.toHexString(), + ) + } catch (_: Exception) { + throw classifyError(url) + } + + private fun classifyError(url: String): Error { + val parsedUrl = url.toHttpUrlOrNull() ?: return Error.InvalidUrl + val pathSegments = parsedUrl.pathSegments.filter { it.isNotEmpty() } + val room = when { + pathSegments.firstOrNull() == "r" -> pathSegments.getOrNull(1) + else -> pathSegments.firstOrNull() + } ?: return Error.InvalidUrl + + val encodedPubkey = parsedUrl.queryParameter(publicKeyQuery) ?: return Error.InvalidPublicKey + if (encodedPubkey.length != hexPubkeyLength) return Error.InvalidPublicKey + + return Error.InvalidUrl + } +} diff --git a/app/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt b/app/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt deleted file mode 100644 index 5a90d0258e..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.session.libsession.utilities - -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.open_groups.migrateLegacyServerUrl - -object OpenGroupUrlParser { - - sealed class Error(val description: String) : Exception(description) { - object MalformedURL : Error("Malformed URL.") - object NoRoom : Error("No room specified in the URL.") - object NoPublicKey : Error("No public key specified in the URL.") - object InvalidPublicKey : Error("Invalid public key provided.") - } - - private const val suffix = "/" - private const val queryPrefix = "public_key" - - fun parseUrl(string: String): V2OpenGroupInfo { - // URL has to start with 'http://' - val urlWithPrefix = if (!string.startsWith("http")) "http://$string" else string - // If the URL is malformed, throw an exception - val url = urlWithPrefix.toHttpUrlOrNull() ?: throw Error.MalformedURL - // Parse components - val server = HttpUrl.Builder().scheme(url.scheme).host(url.host).port(url.port).build().toString().removeSuffix(suffix).migrateLegacyServerUrl() - val room = url.pathSegments.firstOrNull { !it.isNullOrEmpty() } ?: throw Error.NoRoom - val publicKey = url.queryParameter(queryPrefix) ?: throw Error.NoPublicKey - if (publicKey.length != 64) throw Error.InvalidPublicKey - // Return - return V2OpenGroupInfo(server, room, publicKey) - } - - fun trimQueryParameter(string: String): String { - return string.substringBefore("?$queryPrefix") - } -} - -class V2OpenGroupInfo(val server: String, val room: String, val serverPublicKey: String) { - fun joinUrl() = "$server/$room?public_key=$serverPublicKey" -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index ef952120aa..e2837ca3eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -66,9 +66,9 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME -import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY @@ -1248,9 +1248,9 @@ class ConversationViewModel @AssistedInject constructor( } private fun openOrJoinCommunity(url: String){ - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { Toast.makeText(application, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) .show() return @@ -1261,14 +1261,14 @@ class ConversationViewModel @AssistedInject constructor( viewModelScope.launch { try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) // after joining or if already joined, open the conversation _uiEvents.tryEmit(ConversationUiEvent.NavigateToConversation( - address = Address.Community(openGroup.server, openGroup.room), + address = Address.Community(communityInfo.baseUrl, communityInfo.room), )) } catch (e: Exception) { Log.e("", "Error joining community", e) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt index 2a71511a7e..71fe3c21ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.databinding.ViewOpenGroupInvitationBinding import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.getAccentColor import javax.inject.Inject @@ -42,7 +42,7 @@ class OpenGroupInvitationView : LinearLayout { openGroupInvitationIconImageView.setImageResource(iconID) openGroupInvitationIconBackground.backgroundTintList = ColorStateList.valueOf(backgroundColor) openGroupTitleTextView.text = data.groupName - openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl) + openGroupURLTextView.text = CommunityUrlParser.trimQueryParameter(data.groupUrl) openGroupTitleTextView.setTextColor(textColor) openGroupJoinMessageTextView.setTextColor(textColor) openGroupURLTextView.setTextColor(textColor) @@ -53,4 +53,4 @@ class OpenGroupInvitationView : LinearLayout { val data = data ?: return null return (data.groupName to data.groupUrl) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt index c5698a1326..40ea73911d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt @@ -49,8 +49,8 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.utilities.Address +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.ExpirationUtil -import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData @@ -393,9 +393,9 @@ class ConversationV3ViewModel @AssistedInject constructor( } private fun openOrJoinCommunity(url: String) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { Toast.makeText(context, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) .show() return @@ -406,13 +406,13 @@ class ConversationV3ViewModel @AssistedInject constructor( viewModelScope.launch { try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) // after joining or if already joined, open the conversation - val communityAddress = Address.Community(openGroup.server, openGroup.room) + val communityAddress = Address.Community(communityInfo.baseUrl, communityInfo.room) navigateTo( destination = ConversationV3Destination.RouteConversation(communityAddress), ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt index 1478989ac9..30e6f9352c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -92,7 +92,7 @@ fun CommunityInviteMessage( ) Text( - text = OpenGroupUrlParser.trimQueryParameter(url), + text = CommunityUrlParser.trimQueryParameter(url), style = LocalType.current.small, color = getTextColor(outgoing), maxLines = 2, @@ -143,4 +143,4 @@ fun CommunityInvitePreview( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 9427d1fb78..44540a993e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -32,7 +32,7 @@ import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.displayName @@ -467,9 +467,9 @@ class HomeViewModel @Inject constructor( } private fun openOrJoinCommunity(url: String) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { Toast.makeText(context, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) .show() return @@ -481,13 +481,13 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) // after joining or if already joined, open the conversation - val communityAddress = Address.Community(openGroup.server, openGroup.room) + val communityAddress = Address.Community(communityInfo.baseUrl, communityInfo.room) _uiEvents.emit(UiEvent.OpenConversation(communityAddress)) } catch (e: Exception) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index f28afa76ad..76553251f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.Address -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -111,12 +111,12 @@ class GlobalSearchViewModel @Inject constructor( // otherwise a dialog is handled by the query event flow if(communityUrl.joined){ // community is already joined: add it to the result list - val openGroup = OpenGroupUrlParser.parseUrl(communityUrl.url) + val communityInfo = CommunityUrlParser.parse(communityUrl.url) results = results.copy( threads = results.threads + recipientRepository.getRecipientSync( Address.Community( - serverUrl = openGroup.server, - room = openGroup.room + serverUrl = communityInfo.baseUrl, + room = communityInfo.room ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt index dab8348113..3dc620c03d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt @@ -20,7 +20,7 @@ import network.loki.messenger.R import org.session.libsession.messaging.open_groups.OfficialCommunityRepository import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log @@ -74,33 +74,23 @@ class JoinCommunityViewModel @Inject constructor( viewModelScope.launch(Dispatchers.Default) { _state.update { it.copy(loading = true) } - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (e: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (e: CommunityUrlParser.Error) { _state.update { it.copy(loading = false) } - when (e) { - is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { - withContext(Dispatchers.Main) { - Toast.makeText( - appContext, - appContext.getString(R.string.communityJoinError), - Toast.LENGTH_SHORT - ).show() - } - return@launch - } - - is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { - withContext(Dispatchers.Main) { - Toast.makeText( - appContext, - appContext.getString(R.string.communityEnterUrlErrorInvalidDescription), - Toast.LENGTH_SHORT - ).show() - } - return@launch - } + withContext(Dispatchers.Main) { + Toast.makeText( + appContext, + appContext.getString( + when (e) { + CommunityUrlParser.Error.InvalidUrl -> R.string.communityJoinError + CommunityUrlParser.Error.InvalidPublicKey -> R.string.communityEnterUrlErrorInvalidDescription + } + ), + Toast.LENGTH_SHORT + ).show() } + return@launch } // Check if we've already joined this community @@ -113,13 +103,13 @@ class JoinCommunityViewModel @Inject constructor( try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) _uiEvents.emit(UiEvent.NavigateToConversation( - address = Address.Community(openGroup.server, openGroup.room), + address = Address.Community(communityInfo.baseUrl, communityInfo.room), )) } catch (e: Exception) { Log.e("Loki", "Couldn't join community.", e) @@ -181,4 +171,4 @@ class JoinCommunityViewModel @Inject constructor( sealed interface UiEvent { data class NavigateToConversation(val address: Address.Conversable) : UiEvent } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt index 58b1612983..32cbd4840b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt @@ -19,7 +19,7 @@ import network.loki.messenger.R import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.Address.Companion.toConversableAddress import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log @@ -171,9 +171,9 @@ class CreateGroupViewModel @AssistedInject constructor( } fun openOrJoinCommunity(url: String) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { Toast.makeText(appContext, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) .show() return @@ -184,13 +184,13 @@ class CreateGroupViewModel @AssistedInject constructor( viewModelScope.launch { try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) // after joining or if already joined, open the conversation - val communityAddress = Address.Community(openGroup.server, openGroup.room) + val communityAddress = Address.Community(communityInfo.baseUrl, communityInfo.room) mutableEvents.emit(CreateGroupEvent.NavigateToConversation(communityAddress)) } catch (e: Exception) { @@ -211,4 +211,4 @@ sealed interface CreateGroupEvent { data class NavigateToConversation(val address: Address.Conversable): CreateGroupEvent data class Error(val message: String): CreateGroupEvent -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 4cdc8139cf..41c4e8fe98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.withTimeoutOrNull import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix @@ -192,9 +192,9 @@ class NewMessageViewModel @AssistedInject constructor( } private fun openOrJoinCommunity(url: String){ - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { Toast.makeText(application, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) .show() return @@ -203,13 +203,13 @@ class NewMessageViewModel @AssistedInject constructor( viewModelScope.launch { try { openGroupManager.add( - server = openGroup.server, - room = openGroup.room, - publicKey = openGroup.serverPublicKey, + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, ) // after joining or if already joined, open the conversation - _success.emit(Success(Address.Community(openGroup.server, openGroup.room))) + _success.emit(Success(Address.Community(communityInfo.baseUrl, communityInfo.room))) } catch (e: Exception) { Log.e("", "Error joining community", e) withContext(Dispatchers.Main) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/links/CommunityLinkRule.kt b/app/src/main/java/org/thoughtcrime/securesms/links/CommunityLinkRule.kt index c062b8d813..2353b28120 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/links/CommunityLinkRule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/links/CommunityLinkRule.kt @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.links import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.session.libsession.utilities.Address +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.withUserConfigs import org.thoughtcrime.securesms.database.CommunityDatabase import javax.inject.Inject @@ -15,19 +15,19 @@ class CommunityLinkRule @Inject constructor( ) : LinkRule { override suspend fun classify(url: String): LinkType? = withContext(Dispatchers.IO) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (_: OpenGroupUrlParser.Error) { + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { return@withContext null } val joinedCommunity = configFactory.withUserConfigs { - it.userGroups.getCommunityInfo(openGroup.server, openGroup.room) + it.userGroups.getCommunityInfo(communityInfo.baseUrl, communityInfo.room) } - val roomInfo = communityDatabase.getRoomInfo(Address.Community(openGroup.server, openGroup.room)) + val roomInfo = communityDatabase.getRoomInfo(Address.Community(communityInfo.baseUrl, communityInfo.room)) val name = roomInfo?.details?.name ?.takeIf { it.isNotBlank() } - ?: openGroup.room + ?: communityInfo.room return@withContext LinkType.CommunityLink( url = url, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 24d112b2f8..ce2f25be3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.preferences import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -17,40 +18,63 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.min +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.Address +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.startconversation.newmessage.Success +import org.thoughtcrime.securesms.links.LinkChecker +import org.thoughtcrime.securesms.links.LinkType +import org.thoughtcrime.securesms.links.LinkType.CommunityLink.DisplayType.SCANNED import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.dialog.LinkAlertDialog import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import javax.inject.Inject private val TITLES = listOf(R.string.view, R.string.scan) @AndroidEntryPoint class QRCodeActivity : ScreenLockActionBarActivity() { + @Inject lateinit var linkChecker: LinkChecker + @Inject lateinit var openGroupManager: OpenGroupManager + + private var urlDialog: LinkType? by mutableStateOf(null) + override val applyDefaultWindowInsets: Boolean get() = false @@ -77,21 +101,77 @@ class QRCodeActivity : ScreenLockActionBarActivity() { errors.asSharedFlow(), onScan = ::onScan ) + + if (urlDialog != null) { + LinkAlertDialog( + data = urlDialog!!, + onDismissRequest = { + urlDialog = null + }, + openOrJoinCommunity = ::openOrJoinCommunity + ) + } } } - private fun onScan(string: String) { - val accountId = AccountId.fromStringOrNull(string) - if (accountId?.prefix != IdPrefix.STANDARD) { - errors.tryEmit(getString(R.string.qrNotAccountId)) - } else if (!isFinishing) { - startActivity( - ConversationActivityV2.createIntent(this, address = Address.Standard(accountId)) - .setDataAndType(intent.data, intent.type) - .addFlags(FLAG_ACTIVITY_SINGLE_TOP) - ) + private fun onScan(value: String) { + lifecycleScope.launch { + val accountId = AccountId.fromStringOrNull(value) + + // check if we have a community URL + val communityLink = linkChecker.check(value) as? LinkType.CommunityLink + + if (communityLink != null) { + onCommunityUrlDetected(communityLink.copy(displayType = SCANNED)) + } else if (accountId?.prefix != IdPrefix.STANDARD) { + errors.tryEmit(getString(R.string.qrNotAccountId)) + } else if (!isFinishing) { + openConversation(Address.Standard(accountId)) + } + } + } - finish() + private fun openConversation(address: Address.Conversable){ + startActivity( + ConversationActivityV2.createIntent(this, address = address) + .setDataAndType(intent.data, intent.type) + .addFlags(FLAG_ACTIVITY_SINGLE_TOP) + ) + + finish() + } + + private fun onCommunityUrlDetected(communityLink: LinkType.CommunityLink){ + // show confirmation dialog for community + urlDialog = communityLink + } + + private fun openOrJoinCommunity(url: String){ + val communityInfo = try { + CommunityUrlParser.parse(url) + } catch (_: CommunityUrlParser.Error) { + Toast.makeText(application, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT) + .show() + return + } + + lifecycleScope.launch { + try { + openGroupManager.add( + server = communityInfo.baseUrl, + room = communityInfo.room, + publicKey = communityInfo.pubKeyHex, + ) + + // after joining or if already joined, open the conversation + openConversation(Address.Community(communityInfo.baseUrl, communityInfo.room)) + } catch (e: Exception) { + Log.e("", "Error joining community", e) + withContext(Dispatchers.Main) { + Toast.makeText(application, R.string.communityErrorDescription, Toast.LENGTH_SHORT) + .show() + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt index a592524637..5a04da42c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt @@ -4,9 +4,11 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -31,6 +33,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.sp import com.squareup.phrase.Phrase import kotlinx.coroutines.launch import network.loki.messenger.R @@ -38,7 +41,9 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v3.settings.ConversationSettingsViewModel.Commands.ShowProBadgeCTA import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon import org.thoughtcrime.securesms.ui.components.SlimAccentOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -88,20 +93,24 @@ fun UserProfileModal( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) // title - ProBadgeText( + AnnotatedTextWithIcon( + modifier = Modifier.qaTag(stringResource(R.string.qa_pro_badge_text)) + .fillMaxWidth() + .safeContentWidth(), text = data.name, - showBadge = data.showProBadge, - onBadgeClick = if (!data.currentUserPro) { - { - sendCommand(UserProfileModalCommands.ShowProCTA) - } - } else null + iconRes = if(data.showProBadge ) R.drawable.ic_pro_badge else null, + onIconClick = { + sendCommand(UserProfileModalCommands.ShowProCTA) + }, + iconSize = 58.sp to 24.sp, + style = LocalType.current.h4, ) if (!data.subtitle.isNullOrEmpty()) { Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( text = data.subtitle, + textAlign = TextAlign.Center, style = LocalType.current.small.copy(color = LocalColors.current.textSecondary) ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/links/LinkCheckerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/links/LinkCheckerTest.kt index ef360720f5..c17794bf63 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/links/LinkCheckerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/links/LinkCheckerTest.kt @@ -3,17 +3,27 @@ package org.thoughtcrime.securesms.links import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.test.runTest import network.loki.messenger.libsession_util.ReadableUserGroupsConfig import network.loki.messenger.libsession_util.util.GroupInfo +import org.junit.After import org.junit.Test import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.utilities.CommunityUrlParser import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.UserConfigs +import org.session.libsignal.utilities.Base64 import org.thoughtcrime.securesms.database.CommunityDatabase class LinkCheckerTest { + @After + fun tearDown() { + unmockkObject(CommunityUrlParser) + } + @Test fun `returns generic link when no rule matches`() = runTest { val checker = LinkChecker(rules = emptyList()) @@ -69,10 +79,58 @@ class LinkCheckerTest { ) } + @Test + fun `detects r path community links with base64 pubkeys`() = runTest { + val checker = checker( + joinedCommunity = null, + roomInfo = null, + ) + + assertThat(checker.check(communityUrl(pathPrefix = "/r/", publicKey = base64PublicKey()))).isEqualTo( + LinkType.CommunityLink( + url = communityUrl(pathPrefix = "/r/", publicKey = base64PublicKey()), + name = ROOM, + joined = false, + displayType = LinkType.CommunityLink.DisplayType.CONVERSATION, + ) + ) + } + + @Test + fun `detects community links with base32z pubkeys`() = runTest { + val checker = checker( + joinedCommunity = null, + roomInfo = null, + ) + + assertThat(checker.check(communityUrl(publicKey = base32zPublicKey()))).isEqualTo( + LinkType.CommunityLink( + url = communityUrl(publicKey = base32zPublicKey()), + name = ROOM, + joined = false, + displayType = LinkType.CommunityLink.DisplayType.CONVERSATION, + ) + ) + } + private fun checker( joinedCommunity: GroupInfo.CommunityGroupInfo?, roomInfo: OpenGroupApi.RoomInfo?, ): LinkChecker { + mockkObject(CommunityUrlParser) + every { CommunityUrlParser.parse(any()) } answers { + when (invocation.args[0] as String) { + communityUrl(), + communityUrl(pathPrefix = "/r/", publicKey = base64PublicKey()), + communityUrl(publicKey = base32zPublicKey()) -> CommunityUrlParser.CommunityUrlInfo( + baseUrl = SERVER, + room = ROOM, + pubKeyHex = PUBLIC_KEY, + ) + else -> throw CommunityUrlParser.Error.InvalidUrl + } + } + val configFactory = mockk(relaxed = true) val userConfigs = mockk() val userGroups = mockk() @@ -107,10 +165,40 @@ class LinkCheckerTest { ) } - private fun communityUrl(publicKey: String = PUBLIC_KEY): String { - return "$SERVER/$ROOM?public_key=$publicKey" + private fun communityUrl( + pathPrefix: String = "/", + publicKey: String = PUBLIC_KEY, + ): String { + return "$SERVER$pathPrefix$ROOM?public_key=$publicKey" + } + + private fun base64PublicKey(): String = Base64.encodeBytesWithoutPadding(publicKeyBytes()) + + private fun base32zPublicKey(): String { + val alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" + val bytes = publicKeyBytes() + val output = StringBuilder((bytes.size * 8 + 4) / 5) + var buffer = 0 + var bits = 0 + + for (byte in bytes) { + buffer = (buffer shl 8) or (byte.toInt() and 0xff) + bits += 8 + while (bits >= 5) { + bits -= 5 + output.append(alphabet[(buffer shr bits) and 0x1f]) + } + } + + if (bits > 0) { + output.append(alphabet[(buffer shl (5 - bits)) and 0x1f]) + } + + return output.toString() } + private fun publicKeyBytes(): ByteArray = ByteArray(32) { 0xaa.toByte() } + private companion object { const val SERVER = "https://session.example" const val ROOM = "session-room"