diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt new file mode 100644 index 000000000..a8c751dbd --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/URIParserTest.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.ui.navigation.findParameterValue +import junit.framework.TestCase.assertEquals +import org.junit.Test +import java.net.URI + +class URIParserTest { + @Test + fun testNEventWithAccount() { + val test = "nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val testUri = URI(test) + + // assertEquals("nostr", testUri.scheme) + assertEquals(null, testUri.authority) + assertEquals(null, testUri.host) + assertEquals(-1, testUri.port) + assertEquals(null, testUri.userInfo) + assertEquals("nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku", testUri.path) + assertEquals(null, testUri.fragment) + assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery) + + assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account")) + } + + @Test + fun testNotificationsWithAccount() { + val test = "notifications?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + val testUri = URI(test) + + // assertEquals("nostr", testUri.scheme) + assertEquals(null, testUri.authority) + assertEquals(null, testUri.host) + assertEquals(-1, testUri.port) + assertEquals(null, testUri.userInfo) + assertEquals("notifications", testUri.path) + assertEquals(null, testUri.fragment) + assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery) + + assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account")) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8988d1664..aeb11db81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDM import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event @@ -176,7 +177,7 @@ class EventNotificationConsumer( val content = chatNote.event?.content() ?: "" val user = chatNote.author?.toBestDisplayName() ?: "" val userPicture = chatNote.author?.profilePicture() - val noteUri = chatNote.toNEvent() + val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() notificationManager() .sendDMNotification( event.id, @@ -216,7 +217,7 @@ class EventNotificationConsumer( decryptContent(note, signer) { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() - val noteUri = note.toNEvent() + val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() notificationManager() .sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext) } @@ -322,7 +323,7 @@ class EventNotificationConsumer( ) } val userPicture = senderInfo.first.profilePicture() - val noteUri = "nostr:Notifications" + val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub() Log.d(TAG, "Notify ${event.id} $content $title $noteUri") @@ -353,9 +354,9 @@ class EventNotificationConsumer( ) val userPicture = senderInfo.first.profilePicture() - val noteUri = "nostr:Notifications" + val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub() - Log.d(TAG, "Notify ${event.id} $content $title $noteUri") + Log.d(TAG, "Notify ${event.id} $title $noteUri") notificationManager() .sendZapNotification( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index bfde8a1da..016ca7817 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -221,9 +221,6 @@ object NotificationUtils { .setContentTitle(messageTitle) .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this .setContentIntent(contentPendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) @@ -239,9 +236,6 @@ object NotificationUtils { .setContentTitle(messageTitle) .setContentText(messageBody) .setLargeIcon(picture?.bitmap) - // .setGroup(messageTitle) - // .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we - // activate this .setContentIntent(contentPendingIntent) .setPublicVersion(builderPublic.build()) .setPriority(NotificationCompat.PRIORITY_HIGH) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index d5b6f8fed..80d2427d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -256,11 +256,11 @@ class MainActivity : AppCompatActivity() { } fun uriToRoute(uri: String?): String? = - if (uri.equals("nostr:Notifications", true)) { + if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { Route.Notification.route.replace("{scrollToTop}", "true") } else { - if (uri?.startsWith("nostr:Hashtag?id=") == true) { - Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) + if (uri?.startsWith("hashtag?id=") == true || uri?.startsWith("nostr:hashtag?id=") == true) { + Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:").removePrefix("hashtag?id=")) } else { val nip19 = Nip19Bech32.uriToRoute(uri)?.entity when (nip19) { @@ -280,6 +280,7 @@ fun uriToRoute(uri: String?): String? = "Event/${nip19.hex}" } } + is Nip19Bech32.NAddress -> { if (nip19.kind == CommunityDefinitionEvent.KIND) { "Community/${nip19.atag}" @@ -289,12 +290,14 @@ fun uriToRoute(uri: String?): String? = "Event/${nip19.atag}" } } + is Nip19Bech32.NEmbed -> { if (LocalCache.getNoteIfExists(nip19.event.id) == null) { LocalCache.verifyAndConsume(nip19.event, null) } "Event/${nip19.event.id}" } + else -> null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 308fa3a07..b35f2e91d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -183,7 +183,7 @@ class MarkdownMediaRenderer( richTextStringBuilder: RichTextString.Builder, ) { val tagWithoutHash = tag.removePrefix("#") - renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash", richTextStringBuilder) + renderAsCompleteLink(tag, "nostr:nashtag?id=$tagWithoutHash", richTextStringBuilder) val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash) if (hashtagIcon != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7ffd49270..565d4961c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.encoders.Nip19Bech32 import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.net.URI import java.net.URLDecoder fun NavBackStackEntry.id(): String? = arguments?.getString("id") @@ -349,6 +350,14 @@ private fun NavigateIfIntentRequested( accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, ) { + accountViewModel.firstRoute?.let { + accountViewModel.firstRoute = null + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, it)) { + nav.newStack(it) + } + } + val activity = LocalContext.current.getActivity() if (activity.intent.action == Intent.ACTION_SEND) { @@ -394,12 +403,17 @@ private fun NavigateIfIntentRequested( LaunchedEffect(intentNextPage) { if (actionableNextPage != null) { - actionableNextPage?.let { - val currentRoute = getRouteWithArguments(nav.controller) - if (!isSameRoute(currentRoute, it)) { - nav.newStack(it) + actionableNextPage?.let { nextRoute -> + val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() + if (npub != null && accountStateViewModel.currentAccount() != npub) { + accountStateViewModel.switchUserSync(npub, nextRoute) + } else { + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, nextRoute)) { + nav.newStack(nextRoute) + } + actionableNextPage = null } - actionableNextPage = null } } else if (intentNextPage.contains("ncryptsec1")) { // login functions @@ -439,14 +453,22 @@ private fun NavigateIfIntentRequested( } } else { val uri = intent.data?.toString() + if (!uri.isNullOrBlank()) { // navigation functions val newPage = uriToRoute(uri) if (newPage != null) { - val currentRoute = getRouteWithArguments(nav.controller) - if (!isSameRoute(currentRoute, newPage)) { - nav.newStack(newPage) + scope.launch { + val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() + if (npub != null && accountStateViewModel.currentAccount() != npub) { + accountStateViewModel.switchUserSync(npub, newPage) + } else { + val currentRoute = getRouteWithArguments(nav.controller) + if (!isSameRoute(currentRoute, newPage)) { + nav.newStack(newPage) + } + } } } else if (uri.contains("ncryptsec")) { // login functions @@ -508,3 +530,14 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) + +fun URI.findParameterValue(parameterName: String): String? = + rawQuery + ?.split('&') + ?.map { + val parts = it.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.drop(1).firstOrNull() ?: "" + Pair(name, value) + }?.firstOrNull { it.first == parameterName } + ?.second diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 7693e2d7c..b3ac53388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -97,23 +97,7 @@ fun AccountScreen( ) { LoggedInPage( state.accountSettings, - accountStateViewModel, - sharedPreferencesViewModel, - ) - } - - DisposableEffect(key1 = accountState) { - onDispose { - state.currentViewModelStore.viewModelStore.clear() - } - } - } - is AccountState.LoggedInViewOnly -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore, - ) { - LoggedInPage( - state.accountSettings, + state.route, accountStateViewModel, sharedPreferencesViewModel, ) @@ -132,6 +116,7 @@ fun AccountScreen( @Composable fun LoggedInPage( accountSettings: AccountSettings, + route: String?, accountStateViewModel: AccountStateViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, ) { @@ -145,6 +130,8 @@ fun LoggedInPage( ), ) + accountViewModel.firstRoute = route + LaunchedEffect(key1 = accountViewModel) { accountViewModel.restartServices() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 105630729..e7d4f4746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.AccountSettings sealed class AccountState { @@ -27,14 +28,10 @@ sealed class AccountState { object LoggedOff : AccountState() - class LoggedInViewOnly( - val accountSettings: AccountSettings, - ) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(accountSettings) - } - + @Stable class LoggedIn( val accountSettings: AccountSettings, + var route: String? = null, ) : AccountState() { val currentViewModelStore = AccountCentricViewModelStore(accountSettings) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 12a7fbcd3..2c32d8eac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.LocalPreferences.currentAccount import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList @@ -81,10 +80,10 @@ class AccountStateViewModel : ViewModel() { viewModelScope.launch { tryLoginExistingAccount() } } - private suspend fun tryLoginExistingAccount() = + private suspend fun tryLoginExistingAccount(route: String? = null) = withContext(Dispatchers.IO) { LocalPreferences.loadCurrentAccountFromEncryptedStorage() - }?.let { startUI(it) } ?: run { requestLoginUI() } + }?.let { startUI(it, route) } ?: run { requestLoginUI() } private suspend fun requestLoginUI() { _accountContent.update { AccountState.LoggedOff } @@ -162,22 +161,20 @@ class AccountStateViewModel : ViewModel() { } @OptIn(FlowPreview::class) - suspend fun startUI(accountSettings: AccountSettings) = - withContext(Dispatchers.Main) { - if (accountSettings.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(accountSettings) } - } else { - _accountContent.update { AccountState.LoggedInViewOnly(accountSettings) } - } + suspend fun startUI( + accountSettings: AccountSettings, + route: String? = null, + ) = withContext(Dispatchers.Main) { + _accountContent.update { AccountState.LoggedIn(accountSettings, route) } - collectorJob?.cancel() - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - accountSettings.saveable.debounce(1000).collect { - LocalPreferences.saveToEncryptedStorage(it.accountSettings) - } + collectorJob?.cancel() + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + accountSettings.saveable.debounce(1000).collect { + LocalPreferences.saveToEncryptedStorage(it.accountSettings) } - } + } + } private fun prepareLogoutOrSwitch() = when (val state = _accountContent.value) { @@ -186,11 +183,6 @@ class AccountStateViewModel : ViewModel() { state.currentViewModelStore.viewModelStore.clear() } - is AccountState.LoggedInViewOnly -> { - collectorJob?.cancel() - state.currentViewModelStore.viewModelStore.clear() - } - else -> {} } @@ -316,22 +308,39 @@ class AccountStateViewModel : ViewModel() { fun switchUser(accountInfo: AccountInfo) { viewModelScope.launch(Dispatchers.IO) { - prepareLogoutOrSwitch() - LocalPreferences.switchToAccount(accountInfo) - tryLoginExistingAccount() + switchUserSync(accountInfo) } } + suspend fun switchUserSync( + npub: String, + route: String, + ): Boolean { + if (npub != LocalPreferences.currentAccount()) { + val account = LocalPreferences.allSavedAccounts().firstOrNull { it.npub == npub } + if (account != null) { + switchUserSync(account, route) + return true + } + } + return false + } + + suspend fun switchUserSync( + accountInfo: AccountInfo, + route: String? = null, + ) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(accountInfo) + tryLoginExistingAccount(route) + } + fun currentAccount() = when (val state = _accountContent.value) { is AccountState.LoggedIn -> state.accountSettings.keyPair.pubKey .toNpub() - is AccountState.LoggedInViewOnly -> - state.accountSettings.keyPair.pubKey - .toNpub() - else -> null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7b8e3741f..1085374db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -153,6 +153,8 @@ class AccountViewModel( Dao { val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) + var firstRoute: String? = null + // TODO: contact lists are not notes yet // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 26cd53136..8783872cb 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -6,7 +6,8 @@ + android:exported="true" + >