- adds an account switcher to the notification click

- removes the use of nostr: for notifications
- enables a new screen to be routed when new logins happen
This commit is contained in:
Vitor Pamplona
2024-12-10 18:43:16 -05:00
parent 83e942a7be
commit 8809593bbc
11 changed files with 165 additions and 76 deletions
@@ -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"))
}
}
@@ -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(
@@ -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)
@@ -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
}
}
@@ -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) {
@@ -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
@@ -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()
}
@@ -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)
}
@@ -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
}
@@ -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<ContactListEvent?> = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey)
+2 -1
View File
@@ -6,7 +6,8 @@
<service
android:name=".service.notifications.PushNotificationReceiverService"
android:exported="false">
android:exported="true"
>
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>