diff --git a/app/build.gradle b/app/build.gradle index e88d2de29..bec364b6f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jlleitschuh.gradle.ktlint' version "11.3.1" + id 'com.google.gms.google-services' } android { @@ -172,6 +173,10 @@ dependencies { // Google services model the translate text playImplementation 'com.google.mlkit:translate:17.0.1' + // PushNotifications + playImplementation platform('com.google.firebase:firebase-bom:32.0.0') + playImplementation 'com.google.firebase:firebase-messaging-ktx' + // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..4a9fa85f5 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,68 @@ +{ + "project_info": { + "project_number": "768341258853", + "project_id": "amethyst-3057a", + "storage_bucket": "amethyst-3057a.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst.debug" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 000000000..af98585d0 --- /dev/null +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,8 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.vitorpamplona.amethyst.AccountInfo + +class PushNotificationUtils { + fun init(accounts: List) { + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fc406f2c..4c7929cf2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,6 +70,7 @@ android:name="com.journeyapps.barcodescanner.CaptureActivity" android:screenOrientation="fullSensor" tools:replace="screenOrientation" /> + \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index eee89bf05..001c4ddde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -213,7 +213,13 @@ object LocalPreferences { } fun loadFromEncryptedStorage(): Account? { - encryptedPreferences(currentAccount()).apply { + val acc = loadFromEncryptedStorage(currentAccount()) + acc?.registerObservers() + return acc + } + + fun loadFromEncryptedStorage(npub: String?): Account? { + encryptedPreferences(npub).apply { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2796ed79a..9fa2c65f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1037,14 +1037,7 @@ class Account( saveable.invalidateData() } - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } - } - + fun registerObservers() { // Observes relays to restart connections userProfile().live().relays.observeForever { GlobalScope.launch(Dispatchers.IO) { @@ -1071,6 +1064,15 @@ class Account( } } } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + } } class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index dc7177f98..38f50ab20 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -347,7 +347,7 @@ object LocalCache { } } - fun consume(event: PrivateDmEvent, relay: Relay?) { + fun consume(event: PrivateDmEvent, relay: Relay?): Note { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -357,7 +357,7 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return note val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } @@ -374,6 +374,8 @@ object LocalCache { } refreshObservers(note) + + return note } fun consume(event: DeletionEvent) { @@ -911,6 +913,60 @@ object LocalCache { private fun refreshObservers(newNote: Note) { live.invalidateData(newNote) } + + fun consume(event: Event, relay: Relay?) { + if (!event.hasValidSignature()) return + + try { + when (event) { + is BadgeAwardEvent -> consume(event) + is BadgeDefinitionEvent -> consume(event) + is BadgeProfilesEvent -> consume(event) + is BookmarkListEvent -> consume(event) + is ChannelCreateEvent -> consume(event) + is ChannelHideMessageEvent -> consume(event) + is ChannelMessageEvent -> consume(event, relay) + is ChannelMetadataEvent -> consume(event) + is ChannelMuteUserEvent -> consume(event) + is ContactListEvent -> consume(event) + is DeletionEvent -> consume(event) + + is FileHeaderEvent -> consume(event, relay) + is FileStorageEvent -> consume(event, relay) + is FileStorageHeaderEvent -> consume(event, relay) + is HighlightEvent -> consume(event, relay) + is LnZapEvent -> { + event.zapRequest?.let { + consume(it, relay) + } + consume(event) + } + is LnZapRequestEvent -> consume(event) + is LnZapPaymentRequestEvent -> consume(event) + is LnZapPaymentResponseEvent -> consume(event) + is LongTextNoteEvent -> consume(event, relay) + is MetadataEvent -> consume(event) + is PrivateDmEvent -> consume(event, relay) + is PeopleListEvent -> consume(event) + is ReactionEvent -> consume(event) + is RecommendRelayEvent -> consume(event) + is ReportEvent -> consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { + consume(it, relay) + } + consume(event) + } + is TextNoteEvent -> consume(event, relay) + is PollNoteEvent -> consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } } class LocalCacheLiveData : LiveData>(setOf()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 5de8ef2bf..99db838b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent -import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent -import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent -import com.vitorpamplona.amethyst.service.model.BookmarkListEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent import com.vitorpamplona.amethyst.service.model.Event -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Subscription @@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) { private val clientListener = object : Client.Listener() { override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { if (subscriptionId in subscriptions.keys) { - if (!event.hasValidSignature()) return - val key = "$debugName $subscriptionId ${event.kind}" val keyValue = eventCounter.get(key) if (keyValue != null) { @@ -62,51 +39,7 @@ abstract class NostrDataSource(val debugName: String) { eventCounter = eventCounter + Pair(key, Counter(1)) } - try { - when (event) { - is BadgeAwardEvent -> LocalCache.consume(event) - is BadgeDefinitionEvent -> LocalCache.consume(event) - is BadgeProfilesEvent -> LocalCache.consume(event) - is BookmarkListEvent -> LocalCache.consume(event) - is ChannelCreateEvent -> LocalCache.consume(event) - is ChannelHideMessageEvent -> LocalCache.consume(event) - is ChannelMessageEvent -> LocalCache.consume(event, relay) - is ChannelMetadataEvent -> LocalCache.consume(event) - is ChannelMuteUserEvent -> LocalCache.consume(event) - is ContactListEvent -> LocalCache.consume(event) - is DeletionEvent -> LocalCache.consume(event) - - is FileHeaderEvent -> LocalCache.consume(event, relay) - is FileStorageEvent -> LocalCache.consume(event, relay) - is FileStorageHeaderEvent -> LocalCache.consume(event, relay) - is HighlightEvent -> LocalCache.consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is LnZapRequestEvent -> LocalCache.consume(event) - is LnZapPaymentRequestEvent -> LocalCache.consume(event) - is LnZapPaymentResponseEvent -> LocalCache.consume(event) - is LongTextNoteEvent -> LocalCache.consume(event, relay) - is MetadataEvent -> LocalCache.consume(event) - is PrivateDmEvent -> LocalCache.consume(event, relay) - is PeopleListEvent -> LocalCache.consume(event) - is ReactionEvent -> LocalCache.consume(event) - is RecommendRelayEvent -> LocalCache.consume(event) - is ReportEvent -> LocalCache.consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is TextNoteEvent -> LocalCache.consume(event, relay) - is PollNoteEvent -> LocalCache.consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } + LocalCache.consume(event, relay) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index c10837f3a..20e4bc696 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -21,7 +21,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * for initial messages. */ - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" } + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" } fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 460f1488e..6bfc27e69 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -20,7 +20,7 @@ object Nip19 { val hex: String, val relay: String? = null, val author: String? = null, - val kind: Long? = null, + val kind: Int? = null, val additionalChars: String = "" ) @@ -109,7 +109,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.EVENT, hex, relay, author, kind) } @@ -140,7 +140,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt new file mode 100644 index 000000000..e971ed0a6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -0,0 +1,97 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification +import com.vitorpamplona.amethyst.ui.note.showAmount + +class EventNotificationConsumer(private val applicationContext: Context) { + fun consume(event: Event) { + // adds to database + LocalCache.consume(event, null) + + when (event) { + is PrivateDmEvent -> notify(event) + is LnZapEvent -> notify(event) + } + } + + private fun notify(event: PrivateDmEvent) { + val note = LocalCache.notes[event.id] ?: return + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() + + val messagingWith = acc.userProfile().privateChatrooms.keys.filter { + ( + it.pubkeyHex in followingKeySet || acc.userProfile() + .hasSentMessagesTo(it) + ) && !acc.isHidden(it) + }.toSet() + + if (note.author in messagingWith) { + val content = acc.decryptContent(note) ?: "" + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext) + } + } + } + } + + private fun notify(event: LnZapEvent) { + val noteZapEvent = LocalCache.notes[event.id] ?: return + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } + val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let { + val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + Pair(author, decryptedContent.content) + } else if (!noteZapRequest.event?.content().isNullOrBlank()) { + Pair(noteZapRequest.author, noteZapRequest.event?.content()) + } else { + Pair(noteZapRequest.author, null) + } + } + + val zappedContent = noteZapped?.event?.content()?.split("\n")?.get(0)?.take(50) + val user = senderInfo?.first?.toBestDisplayName() ?: "" + var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo?.second?.let { + title += " ($it)" + } + var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) + zappedContent?.let { + content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) + } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext) + } + } + } + + private fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt new file mode 100644 index 000000000..d8d5d5d0b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -0,0 +1,185 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.core.app.NotificationCompat +import coil.ImageLoader +import coil.executeBlocking +import coil.request.ImageRequest +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity + +object NotificationUtils { + + // Notification ID. + private var notificationId = 0 + + private var dmChannel: NotificationChannel? = null + private var zapChannel: NotificationChannel? = null + + private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! + + dmChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_dms_channel_id), + applicationContext.getString(R.string.app_notification_dms_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_dms_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dmChannel!!) + + return dmChannel!! + } + + private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel { + if (zapChannel != null) return zapChannel!! + + zapChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_zaps_channel_id), + applicationContext.getString(R.string.app_notification_zaps_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_zaps_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(zapChannel!!) + + return zapChannel!! + } + + fun NotificationManager.sendZapNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val zapChannel = getOrCreateZapChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) + + sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendDMNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val dmChannel = getOrCreateDMChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) + + sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + channelId: String, + applicationContext: Context + ) { + if (pictureUrl != null) { + val request = ImageRequest.Builder(applicationContext) + .data(pictureUrl) + .build() + + val imageLoader = ImageLoader(applicationContext) + val imageResult = imageLoader.executeBlocking(request) + sendNotification( + messageBody = messageBody, + messageTitle = messageTitle, + picture = imageResult.drawable as? BitmapDrawable, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } else { + sendNotification( + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } + } + + private fun NotificationManager.sendNotification( + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + applicationContext: Context + ) { + val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { + data = Uri.parse(uri) + } + + val contentPendingIntent = PendingIntent.getActivity( + applicationContext, + notificationId, + contentIntent, + PendingIntent.FLAG_MUTABLE + ) + + // Build the notification + val builderPublic = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(applicationContext.getString(R.string.app_notification_private_message)) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + // Build the notification + val builder = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + notify(notificationId, builder.build()) + + notificationId++ + } + + /** + * Cancels all notifications. + */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt new file mode 100644 index 000000000..d3cb9d0c5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -0,0 +1,73 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class RegisterAccounts( + private val accounts: List +) { + + // creates proof that it controls all accounts + private fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String + ): List { + return accounts.mapNotNull { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null) { + val relayToUse = acc.activeRelays()?.firstOrNull { it.read } + if (relayToUse != null) { + acc.createAuthEvent(relayToUse, notificationToken) + } else { + null + } + } else { + null + } + } + } + + private fun postRegistrationEvent(events: List) { + try { + val jsonObject = """{ + "events": [ ${events.joinToString(", ") { it.toJson() }} ] + } + """ + + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = jsonObject.toRequestBody(mediaType) + + val request = Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url("https://push.amethyst.social/register") + .post(body) + .build() + + val client = OkHttpClient.Builder().build() + + client.newCall(request).execute() + } catch (e: java.lang.Exception) { + Log.e("FirebaseMsgService", "Unable to register with push server", e) + } + } + + fun go(notificationToken: String) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + postRegistrationEvent( + signEventsToProveControlOfAccounts(accounts, notificationToken) + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 808caf46f..f09b0735c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,7 +20,12 @@ import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.VideoCache +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.components.muted import com.vitorpamplona.amethyst.ui.navigation.Route @@ -39,15 +44,30 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val nip19 = Nip19.uriToRoute(intent?.data?.toString()) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null + val uri = intent?.data?.toString() + + val startingPage = if (uri.equals("nostr:Notifications", true)) { + Route.Notification.route.replace("{scrollToTop}", "true") + } else { + val nip19 = Nip19.uriToRoute(uri) + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> { + if (nip19.kind == PrivateDmEvent.kind) { + "Room/${nip19.author}" + } else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + } + + Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + else -> null + } } ?: try { - intent?.data?.toString()?.let { + uri?.let { Nip47.parse(it) val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) Route.Home.base + "?nip47=" + encodedUri @@ -100,6 +120,8 @@ class MainActivity : FragmentActivity() { GlobalScope.launch(Dispatchers.IO) { ServiceManager.start() } + + PushNotificationUtils().init(LocalPreferences.allSavedAccounts()) } override fun onPause() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 703bb8e12..cab4a7e44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,9 +357,21 @@ Files are hosted by your relays. New NIP: check if they support - Follow List All Follows Global + DefaultChannelID + New notification arrived + + PrivateMessagesID + Private Messages + Notifies you when a private message arrives + + ZapsID + Zaps Received + Notifies you when somebody zaps you + %1$s sats + from %1$s + for %1$s diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 000000000..f85be4db8 --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt new file mode 100644 index 000000000..a933abb52 --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -0,0 +1,22 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.model.Event + +class PushNotificationReceiverService : FirebaseMessagingService() { + + // this is called when a message is received + override fun onMessageReceived(remoteMessage: RemoteMessage) { + remoteMessage.data.let { + val eventStr = remoteMessage.data["event"] ?: return + val event = Event.fromJson(eventStr, true) + EventNotificationConsumer(applicationContext).consume(event) + } + } + + override fun onNewToken(token: String) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + } +} diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 000000000..9fc979cb9 --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.vitorpamplona.amethyst.AccountInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class PushNotificationUtils { + fun init(accounts: List) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + // get user notification token provided by firebase + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w("FirebaseMsgService", "Fetching FCM registration token failed", task.exception) + return@OnCompleteListener + } + // Get new FCM registration token + val notificationToken = task.result + + RegisterAccounts(accounts).go(notificationToken) + } + ) + } + } +} diff --git a/build.gradle b/build.gradle index d06865d09..09111b578 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,9 @@ buildscript { accompanist_version = '0.30.0' coil_version = '2.3.0' } + dependencies { + classpath 'com.google.gms:google-services:4.3.15' + } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.0.1' apply false