From 252d43f8bd5da9b0da8c6aa3b606ba6f1d886a8e Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 8 Mar 2024 10:57:31 -0300 Subject: [PATCH] connect and sign_event of nip46 --- app/build.gradle | 6 +- app/google-services.json | 48 ++ app/src/main/AndroidManifest.xml | 15 +- .../greenart7c3/nostrsigner/MainActivity.kt | 57 ++ .../nostrsigner/MainThreadChecker.kt | 33 ++ .../greenart7c3/nostrsigner/models/Account.kt | 9 + .../nostrsigner/models/IntentData.kt | 5 +- .../greenart7c3/nostrsigner/relays/Client.kt | 329 +++++++++++ .../nostrsigner/relays/Constants.kt | 176 ++++++ .../greenart7c3/nostrsigner/relays/EOSE.kt | 58 ++ .../nostrsigner/relays/JsonFilter.kt | 87 +++ .../greenart7c3/nostrsigner/relays/Relay.kt | 532 ++++++++++++++++++ .../nostrsigner/relays/RelayPool.kt | 236 ++++++++ .../nostrsigner/relays/RelaySetupInfo.kt | 60 ++ .../nostrsigner/relays/Subscription.kt | 59 ++ .../nostrsigner/relays/TypedFilter.kt | 92 +++ .../service/EventNotificationConsumer.kt | 256 +++++++++ .../nostrsigner/service/HttpClientManager.kt | 115 ++++ .../nostrsigner/service/IntentUtils.kt | 152 +++-- .../nostrsigner/service/NotificationUtils.kt | 57 ++ .../nostrsigner/service/Notifications.kt | 77 +++ .../service/PushNotificationUtils.kt | 46 ++ .../nostrsigner/service/RegisterAccounts.kt | 123 ++++ .../greenart7c3/nostrsigner/ui/MainScreen.kt | 71 ++- .../nostrsigner/ui/SettingsScreen.kt | 3 +- .../ui/components/SingleEventHomeScreen.kt | 30 +- app/src/main/res/values/strings.xml | 3 +- build.gradle | 1 + 28 files changed, 2665 insertions(+), 71 deletions(-) create mode 100644 app/google-services.json create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/MainThreadChecker.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/Constants.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/EOSE.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/JsonFilter.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/RelaySetupInfo.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/relays/TypedFilter.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/HttpClientManager.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/Notifications.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/PushNotificationUtils.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/RegisterAccounts.kt diff --git a/app/build.gradle b/app/build.gradle index 3ca34b01..6eb406f0 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.5.0" + id 'com.google.gms.google-services' } android { @@ -69,7 +70,7 @@ android { } dependencies { - implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3') { + implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.3') { exclude group: 'net.java.dev.jna' } implementation 'net.java.dev.jna:jna:5.14.0@aar' @@ -122,4 +123,7 @@ dependencies { // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" + + implementation platform('com.google.firebase:firebase-bom:32.7.3') + implementation('com.google.firebase:firebase-messaging:23.4.1') } \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 00000000..1afad829 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "575711686692", + "project_id": "amber-c362b", + "storage_bucket": "amber-c362b.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:575711686692:android:11451e08365ea86f6ce8b1", + "android_client_info": { + "package_name": "com.greenart7c3.nostrsigner" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDB2tmjxGmFx_Oz25YhtKkheWbdb_NlnfU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:575711686692:android:fb6ef62b68245d696ce8b1", + "android_client_info": { + "package_name": "com.greenart7c3.nostrsigner.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDB2tmjxGmFx_Oz25YhtKkheWbdb_NlnfU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c89b29d2..85560559 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,11 +2,15 @@ + + + + + + + + + + >(mutableListOf()) class MainActivity : AppCompatActivity() { + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + // TODO: Inform user that that your app will show notifications. + } else { + // TODO: Inform user that that your app will not show notifications. + } + } + + private fun askNotificationPermission() { + // This is only necessary for API level >= 33 (TIRAMISU) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, "android.permission.POST_NOTIFICATIONS") == + PackageManager.PERMISSION_GRANTED + ) { + // FCM SDK (and your app) can post notifications. + } else if (shouldShowRequestPermissionRationale("android.permission.POST_NOTIFICATIONS")) { + // TODO: display an educational UI explaining to the user the features that will be enabled + // by them granting the POST_NOTIFICATION permission. This UI should provide the user + // "OK" and "No thanks" buttons. If the user selects "OK," directly request the permission. + // If the user selects "No thanks," allow the user to continue without notifications. + } else { + // Directly ask for the permission + requestPermissionLauncher.launch("android.permission.POST_NOTIFICATIONS") + } + } + } + private val mainViewModel = MainViewModel() + @OptIn(DelicateCoroutinesApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) installSplashScreen() + askNotificationPermission() + + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w("token", "Fetching FCM registration token failed", task.exception) + return@OnCompleteListener + } + + // Get new FCM registration token + GlobalScope.launch(Dispatchers.IO) { + PushNotificationUtils.init(LocalPreferences.allSavedAccounts()) + } + } + ) setContent { val packageName = callingPackage diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainThreadChecker.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainThreadChecker.kt new file mode 100644 index 00000000..bb9e442b --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainThreadChecker.kt @@ -0,0 +1,33 @@ +/** + * 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.greenart7c3.nostrsigner + +import android.os.Looper + +fun checkNotInMainThread() { + if (BuildConfig.DEBUG && isMainThread()) { + throw OnMainThreadException("It should not be in the MainThread") + } +} + +fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() + +class OnMainThreadException(str: String) : RuntimeException(str) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt index 1129aec4..c63719f2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSignerInternal @@ -15,6 +16,14 @@ class Account( var savedApps: MutableMap ) { val saveable: AccountLiveData = AccountLiveData(this) + + fun createAuthEvent( + relayUrl: String, + challenge: String, + onReady: (RelayAuthEvent) -> Unit + ) { + RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady) + } } class AccountLiveData(account: Account) : LiveData(AccountState(account)) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt index 76427551..fc864f4a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt @@ -1,6 +1,7 @@ package com.greenart7c3.nostrsigner.models import androidx.compose.runtime.MutableState +import com.greenart7c3.nostrsigner.service.BunkerRequest import com.vitorpamplona.quartz.encoders.HexKey data class Permission( @@ -132,10 +133,12 @@ class IntentData( val permissions: List?, val currentAccount: String, val checked: MutableState, - val rememberMyChoice: MutableState + val rememberMyChoice: MutableState, + val bunkerRequest: BunkerRequest? ) enum class SignerType { + CONNECT, SIGN_EVENT, NIP04_ENCRYPT, NIP04_DECRYPT, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt new file mode 100644 index 00000000..44bde4a9 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt @@ -0,0 +1,329 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import android.util.Log +import com.greenart7c3.nostrsigner.checkNotInMainThread +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventInterface +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * The Nostr Client manages multiple personae the user may switch between. Events are received and + * published through multiple relays. Events are stored with their respective persona. + */ +object Client : RelayPool.Listener { + private var listeners = setOf() + private var relays = emptyArray() + private var subscriptions = mapOf>() + + @Synchronized + fun reconnect( + relays: Array?, + onlyIfChanged: Boolean = false + ) { + Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays") + checkNotInMainThread() + + if (onlyIfChanged) { + if (!isSameRelaySetConfig(relays)) { + if (Client.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() + } + + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + Client.relays = relays + } + } + } else { + if (Client.relays.isNotEmpty()) { + RelayPool.disconnect() + RelayPool.unregister(this) + RelayPool.unloadRelays() + } + + if (relays != null) { + RelayPool.register(this) + RelayPool.loadRelays(relays.toList()) + RelayPool.requestAndWatch() + Client.relays = relays + } + } + } + + fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { + if (relays.size != newRelayConfig?.size) return false + + relays.forEach { oldRelayInfo -> + val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false + + if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false + } + + return true + } + + fun sendFilter( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf() + ) { + checkNotInMainThread() + + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.sendFilter(subscriptionId) + } + + fun sendFilterOnlyIfDisconnected( + subscriptionId: String = UUID.randomUUID().toString().substring(0..10), + filters: List = listOf() + ) { + checkNotInMainThread() + + subscriptions = subscriptions + Pair(subscriptionId, filters) + RelayPool.connectAndSendFiltersIfDisconnected() + } + + fun send( + signedEvent: EventInterface, + relay: String? = null, + feedTypes: Set? = null, + relayList: List? = null, + onDone: (() -> Unit)? = null + ) { + checkNotInMainThread() + + if (relayList != null) { + RelayPool.sendToSelectedRelays(relayList, signedEvent) + } else if (relay == null) { + RelayPool.send(signedEvent) + } else { + val useConnectedRelayIfPresent = RelayPool.getRelays(relay) + + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } + } else { + /** temporary connection */ + newSporadicRelay( + relay, + feedTypes, + onConnected = { relay -> relay.send(signedEvent) }, + onDone = onDone + ) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun newSporadicRelay( + url: String, + feedTypes: Set?, + onConnected: (Relay) -> Unit, + onDone: (() -> Unit)? + ) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) + + relay.connectAndRun( + onOk = { + GlobalScope.launch(Dispatchers.IO) { + relay.disconnect() + RelayPool.removeRelay(relay) + if (onDone != null) { + onDone() + } + } + } + ) { + allSubscriptions().forEach { relay.sendFilter(requestId = it) } + + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(10000) // waits for a reply + if (relay.isConnected()) { + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) { + onDone() + } + } + } + } + } + + fun close(subscriptionId: String) { + RelayPool.close(subscriptionId) + subscriptions = subscriptions.minus(subscriptionId) + } + + fun isActive(subscriptionId: String): Boolean { + return subscriptions.contains(subscriptionId) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + error: Error, + subscriptionId: String, + relay: Relay + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onError(error, subscriptionId, relay) } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String? + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: Relay, + challenge: String + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onNotify( + relay: Relay, + description: String + ) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onNotify(relay, description) } + } + } + + fun subscribe(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun isSubscribed(listener: Listener): Boolean { + return listeners.contains(listener) + } + + fun unsubscribe(listener: Listener) { + listeners = listeners.minus(listener) + } + + fun allSubscriptions(): Set { + return subscriptions.keys + } + + fun getSubscriptionFilters(subId: String): List { + return subscriptions[subId] ?: emptyList() + } + + abstract class Listener { + /** A new message was received */ + open fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean + ) = Unit + + /** A new or repeat message was received */ + open fun onError( + error: Error, + subscriptionId: String, + relay: Relay + ) = Unit + + /** Connected to or disconnected from a relay */ + open fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + subscriptionId: String? + ) = Unit + + /** When an relay saves or rejects a new event. */ + open fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay + ) = Unit + + open fun onAuth( + relay: Relay, + challenge: String + ) = Unit + + open fun onNotify( + relay: Relay, + description: String + ) = Unit + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Constants.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Constants.kt new file mode 100644 index 00000000..0420e724 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Constants.kt @@ -0,0 +1,176 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +object Constants { + val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) + val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) + val activeTypesGlobalChats = + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + val activeTypesSearch = setOf(FeedType.SEARCH) + + fun convertDefaultRelays(): Array { + return defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray() + } + + val defaultRelays = + arrayOf( + // Free relays for only DMs and Follows due to the amount of spam + RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), + // Chats + RelaySetupInfo( + "wss://nostr.bitcoiner.social", + read = true, + write = true, + feedTypes = activeTypesChats + ), + RelaySetupInfo( + "wss://relay.nostr.bg", + read = true, + write = true, + feedTypes = activeTypesChats + ), + RelaySetupInfo( + "wss://nostr.oxtr.dev", + read = true, + write = true, + feedTypes = activeTypesChats + ), + RelaySetupInfo( + "wss://nostr-pub.wellorder.net", + read = true, + write = true, + feedTypes = activeTypesChats + ), + RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats), + RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats), + // Less Reliable + // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, + // feedTypes = activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = + // activeTypes), + // NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes + // = activeTypes), + // NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = + // activeTypes), + // Paid relays + RelaySetupInfo( + "wss://relay.snort.social", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://relay.nostr.com.au", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://eden.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://nostr.milou.lol", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://puravida.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://nostr.wine", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://nostr.inosta.cc", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://atlas.nostr.land", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://relay.orangepill.dev", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + RelaySetupInfo( + "wss://relay.nostrati.com", + read = true, + write = false, + feedTypes = activeTypesGlobalChats + ), + // Supporting NIP-50 + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch + ) + ) + + val forcedRelayForSearch = + arrayOf( + RelaySetupInfo( + "wss://relay.nostr.band", + read = true, + write = false, + feedTypes = activeTypesSearch + ), + RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch), + RelaySetupInfo( + "wss://relay.noswhere.com", + read = true, + write = false, + feedTypes = activeTypesSearch + ) + ) + val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/EOSE.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/EOSE.kt new file mode 100644 index 00000000..5080f48d --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/EOSE.kt @@ -0,0 +1,58 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +class EOSETime(var time: Long) { + override fun toString(): String { + return time.toString() + } +} + +class EOSERelayList(var relayList: Map = emptyMap()) { + fun addOrUpdate( + relayUrl: String, + time: Long + ) { + val eose = relayList[relayUrl] + if (eose == null) { + relayList = relayList + Pair(relayUrl, EOSETime(time)) + } else { + eose.time = time + } + } +} + +class EOSEFollowList(var followList: Map = emptyMap()) { + fun addOrUpdate( + listCode: String, + relayUrl: String, + time: Long + ) { + val relayList = followList[listCode] + if (relayList == null) { + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + followList = followList + mapOf(listCode to newList) + } else { + relayList.addOrUpdate(relayUrl, time) + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/JsonFilter.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/JsonFilter.kt new file mode 100644 index 00000000..3289bbd4 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/JsonFilter.kt @@ -0,0 +1,87 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import com.vitorpamplona.quartz.events.Event + +class JsonFilter( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val tags: Map>? = null, + val since: Map? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null +) { + fun toJson(forRelay: String? = null): String { + val factory = Event.mapper.nodeFactory + val filter = + factory.objectNode().apply { + ids?.run { + put( + "ids", + factory.arrayNode(ids.size).apply { ids.forEach { add(it) } } + ) + } + authors?.run { + put( + "authors", + factory.arrayNode(authors.size).apply { authors.forEach { add(it) } } + ) + } + kinds?.run { + put( + "kinds", + factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } } + ) + } + tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } } + ) + } + } + since?.run { + if (!isEmpty()) { + if (forRelay != null) { + val relaySince = get(forRelay) + if (relaySince != null) { + put("since", relaySince.time) + } + } else { + val jsonObjectSince = factory.objectNode() + entries.forEach { sincePairs -> + jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") + } + put("since", jsonObjectSince) + } + } + } + until?.run { put("until", until) } + limit?.run { put("limit", limit) } + search?.run { put("search", search) } + } + return Event.mapper.writeValueAsString(filter) + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt new file mode 100644 index 00000000..688beba1 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt @@ -0,0 +1,532 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import android.util.Log +import com.greenart7c3.nostrsigner.BuildConfig +import com.greenart7c3.nostrsigner.checkNotInMainThread +import com.greenart7c3.nostrsigner.service.HttpClientManager +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventInterface +import com.vitorpamplona.quartz.events.RelayAuthEvent +import com.vitorpamplona.quartz.events.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.atomic.AtomicBoolean + +enum class FeedType { + FOLLOWS, + PUBLIC_CHATS, + PRIVATE_DMS, + GLOBAL, + SEARCH, + WALLET_CONNECT +} + +class Relay( + val url: String, + private val read: Boolean = true, + private val write: Boolean = true, + private val activeTypes: Set = FeedType.entries.toSet() +) { + companion object { + // waits 3 minutes to reconnect once things fail + const val RECONNECTING_IN_SECONDS = 60 * 3 + } + + private val httpClient = HttpClientManager.getHttpClient() + + private var listeners = setOf() + private var socket: WebSocket? = null + private var isReady: Boolean = false + private var usingCompression: Boolean = false + private var onOk: (() -> Unit)? = null + + var eventDownloadCounterInBytes = 0 + private var eventUploadCounterInBytes = 0 + + var errorCounter = 0 + private var pingInMs: Long? = null + + private var lastConnectTentative: Long = 0L + + private var afterEOSEPerSubscription = mutableMapOf() + + private val authResponse = mutableMapOf() + + fun register(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) + } + + fun isConnected(): Boolean { + return socket != null + } + + fun connect() { + connectAndRun { + checkNotInMainThread() + + // Sends everything. + renewFilters() + } + } + + private var connectingBlock = AtomicBoolean() + + fun connectAndRun(onOk: (() -> Unit)? = null, onConnected: (Relay) -> Unit) { + this.onOk = onOk + Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}") + // BRB is crashing OkHttp Deflater object :( + if (url.contains("brb.io")) return + + // If there is a connection, don't wait. + if (connectingBlock.getAndSet(true)) { + return + } + + checkNotInMainThread() + + if (socket != null) return + + lastConnectTentative = TimeUtils.now() + + try { + val request = + Request.Builder() + .header("User-Agent", "Amber/${BuildConfig.VERSION_NAME}") + .url(url.trim()) + .build() + + socket = httpClient.newWebSocket(request, RelayListener(onConnected)) + } catch (e: Exception) { + if (e is CancellationException) throw e + + errorCounter++ + markConnectionAsClosed() + Log.e("Relay", "Relay Invalid $url") + e.printStackTrace() + } finally { + connectingBlock.set(false) + } + } + + inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response + ) { + checkNotInMainThread() + Log.d("Relay", "Connect onOpen $url $socket") + + markConnectionAsReady( + pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis, + usingCompression = response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false + ) + + // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") + onConnected(this@Relay) + + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) } + } + + override fun onMessage( + webSocket: WebSocket, + text: String + ) { + checkNotInMainThread() + + eventDownloadCounterInBytes += text.bytesUsedInMemory() + + try { + processNewRelayMessage(text) + } catch (e: Throwable) { + if (e is CancellationException) throw e + e.printStackTrace() + text.chunked(2000) { chunked -> + listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) } + } + } + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String + ) { + checkNotInMainThread() + + Log.w("Relay", "Relay onClosing $url: $reason") + + listeners.forEach { + it.onRelayStateChange( + this@Relay, + StateType.DISCONNECTING, + null + ) + } + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String + ) { + checkNotInMainThread() + + markConnectionAsClosed() + + Log.w("Relay", "Relay onClosed $url: $reason") + + listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response? + ) { + checkNotInMainThread() + + errorCounter++ + + socket?.cancel() // 1000, "Normal close" + // Failures disconnect the relay. + markConnectionAsClosed() + + Log.w("Relay", "Relay onFailure $url, ${response?.message} $response") + t.printStackTrace() + listeners.forEach { + it.onError( + this@Relay, + "", + Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t) + ) + } + } + } + + fun markConnectionAsReady( + pingInMs: Long, + usingCompression: Boolean + ) { + this.resetEOSEStatuses() + this.isReady = true + this.pingInMs = pingInMs + this.usingCompression = usingCompression + } + + fun markConnectionAsClosed() { + this.socket = null + this.isReady = false + this.usingCompression = false + this.resetEOSEStatuses() + } + + fun processNewRelayMessage(newMessage: String) { + val msgArray = Event.mapper.readTree(newMessage) + + when (val type = msgArray.get(0).asText()) { + "EVENT" -> { + val subscriptionId = msgArray.get(1).asText() + val event = Event.fromJson(msgArray.get(2)) + + // Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}") + listeners.forEach { + it.onEvent( + this@Relay, + subscriptionId, + event, + afterEOSEPerSubscription[subscriptionId] == true + ) + } + } + "EOSE" -> + listeners.forEach { + val subscriptionId = msgArray.get(1).asText() + + afterEOSEPerSubscription[subscriptionId] = true + // Log.w("Relay", "Relay onEOSE $url $subscriptionId") + it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId) + } + "NOTICE" -> + listeners.forEach { + val message = msgArray.get(1).asText() + Log.w("Relay", "Relay onNotice $url, $message") + + it.onError(this@Relay, message, Error("Relay sent notice: $message")) + } + "OK" -> + listeners.forEach { + val eventId = msgArray[1].asText() + val success = msgArray[2].asBoolean() + val message = if (msgArray.size() > 2) msgArray[3].asText() else "" + + if (authResponse.containsKey(eventId)) { + val wasAlreadyAuthenticated = authResponse[eventId] + authResponse[eventId] = success + if (wasAlreadyAuthenticated != true && success) { + renewFilters() + } + } + + Log.w("Relay", "Relay on OK $url, $eventId, $success, $message") + it.onSendResponse(this@Relay, eventId, success, message) + if (success) { + onOk?.let { it() } + } + } + "AUTH" -> + listeners.forEach { + Log.w("Relay", "Relay onAuth $url, ${msgArray[1].asText()}") + it.onAuth(this@Relay, msgArray[1].asText()) + } + "NOTIFY" -> + listeners.forEach { + // Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}") + it.onNotify(this@Relay, msgArray[1].asText()) + } + "CLOSED" -> listeners.forEach { _ -> Log.w("Relay", "Relay onClosed $url, $newMessage") } + else -> + listeners.forEach { + Log.w("Relay", "Unsupported message: $newMessage") + it.onError( + this@Relay, + "", + Error("Unknown type $type on channel. Msg was $newMessage") + ) + } + } + } + + fun disconnect() { + Log.d("Relay", "Relay.disconnect $url") + checkNotInMainThread() + + lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. + socket?.cancel() + socket = null + isReady = false + usingCompression = false + resetEOSEStatuses() + } + + private fun resetEOSEStatuses() { + afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) + } + + fun sendFilter(requestId: String) { + checkNotInMainThread() + + if (read) { + if (isConnected()) { + if (isReady) { + val filters = + Client.getSubscriptionFilters(requestId).filter { filter -> + activeTypes.any { it in filter.types } + } + if (filters.isNotEmpty()) { + val request = + filters.joinToStringLimited( + separator = ",", + limit = 20, + prefix = """["REQ","$requestId",""", + postfix = "]" + ) { + it.filter.toJson(url) + } + + // Log.d("Relay", "onFilterSent $url $requestId $request") + + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + resetEOSEStatuses() + } + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) { + // sends all filters after connection is successful. + connect() + } + } + } + } + + private fun Iterable.joinToStringLimited( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + transform: ((T) -> CharSequence)? = null + ): String { + val buffer = StringBuilder() + buffer.append(prefix) + var count = 0 + for (element in this) { + if (limit < 0 || count <= limit) { + if (++count > 1) buffer.append(separator) + when { + transform != null -> buffer.append(transform(element)) + element is CharSequence? -> buffer.append(element) + element is Char -> buffer.append(element) + else -> buffer.append(element.toString()) + } + } else { + break + } + } + buffer.append(postfix) + return buffer.toString() + } + + fun connectAndSendFiltersIfDisconnected() { + checkNotInMainThread() + + if (socket == null) { + // waits 60 seconds to reconnect after disconnected. + if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) { + // println("sendfilter Only if Disconnected ${url} ") + connect() + } + } + } + + private fun renewFilters() { + // Force update all filters after AUTH. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } + + fun send(signedEvent: EventInterface) { + checkNotInMainThread() + + if (signedEvent is RelayAuthEvent) { + authResponse[signedEvent.id] = false + // specific protocol for this event. + val event = """["AUTH",${signedEvent.toJson()}]""" + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } else { + if (write) { + val event = """["EVENT",${signedEvent.toJson()}]""" + if (isConnected()) { + if (isReady) { + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } + } else { + // sends all filters after connection is successful. + connectAndRun { + checkNotInMainThread() + + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + + // Sends everything. + Client.allSubscriptions().forEach { sendFilter(requestId = it) } + } + } + } + } + } + + fun close(subscriptionId: String) { + checkNotInMainThread() + + val msg = """["CLOSE","$subscriptionId"]""" + // Log.d("Relay", "Close Subscription $url $msg") + socket?.send(msg) + } + + fun isSameRelayConfig(other: Relay): Boolean { + return url == other.url && + write == other.write && + read == other.read && + activeTypes == other.activeTypes + } + + enum class StateType { + // Websocket connected + CONNECT, + + // Websocket disconnecting + DISCONNECTING, + + // Websocket disconnected + DISCONNECT, + + // End Of Stored Events + EOSE + } + + interface Listener { + /** A new message was received */ + fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean + ) + + fun onError( + relay: Relay, + subscriptionId: String, + error: Error + ) + + fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String + ) + + fun onAuth( + relay: Relay, + challenge: String + ) + + /** + * Connected to or disconnected from a relay + * + * @param type is 0 for disconnect and 1 for connect + */ + fun onRelayStateChange( + relay: Relay, + type: StateType, + channel: String? + ) + + /** Relay sent an invoice */ + fun onNotify( + relay: Relay, + description: String + ) + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt new file mode 100644 index 00000000..54514876 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt @@ -0,0 +1,236 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import androidx.compose.runtime.Immutable +import com.greenart7c3.nostrsigner.checkNotInMainThread +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventInterface +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. + */ +object RelayPool : Relay.Listener { + private var relays = listOf() + private var listeners = setOf() + + // Backing property to avoid flow emissions from other classes + private var lastStatus = RelayPoolStatus(0, 0) + private val _statusFlow = + MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val statusFlow: SharedFlow = _statusFlow.asSharedFlow() + + fun availableRelays(): Int { + return relays.size + } + + fun connectedRelays(): Int { + return relays.count { it.isConnected() } + } + + fun getRelay(url: String): Relay? { + return relays.firstOrNull { it.url == url } + } + + fun getRelays(url: String): List { + return relays.filter { it.url == url } + } + + fun loadRelays(relayList: List) { + if (!relayList.isNullOrEmpty()) { + relayList.forEach { addRelay(it) } + } else { + Constants.convertDefaultRelays().forEach { addRelay(it) } + } + } + + fun unloadRelays() { + relays.forEach { it.unregister(this) } + relays = listOf() + } + + fun requestAndWatch() { + checkNotInMainThread() + + relays.forEach { it.connect() } + } + + fun sendFilter(subscriptionId: String) { + relays.forEach { it.sendFilter(subscriptionId) } + } + + fun connectAndSendFiltersIfDisconnected() { + relays.forEach { it.connectAndSendFiltersIfDisconnected() } + } + + fun hasListeners(): Boolean { + return listeners.isNotEmpty() + } + + fun sendToSelectedRelays( + list: List, + signedEvent: EventInterface + ) { + list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } } + } + + fun send(signedEvent: EventInterface) { + relays.forEach { it.send(signedEvent) } + } + + fun close(subscriptionId: String) { + relays.forEach { it.close(subscriptionId) } + } + + fun disconnect() { + relays.forEach { it.disconnect() } + } + + fun addRelay(relay: Relay) { + relay.register(this) + relays += relay + updateStatus() + } + + fun removeRelay(relay: Relay) { + relay.unregister(this) + relays = relays.minus(relay) + updateStatus() + } + + fun register(listener: Listener) { + listeners = listeners.plus(listener) + } + + fun unregister(listener: Listener) { + listeners = listeners.minus(listener) + } + + interface Listener { + fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean + ) + + fun onError( + error: Error, + subscriptionId: String, + relay: Relay + ) + + fun onRelayStateChange( + type: Relay.StateType, + relay: Relay, + channel: String? + ) + + fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay + ) + + fun onAuth( + relay: Relay, + challenge: String + ) + + fun onNotify( + relay: Relay, + description: String + ) + } + + override fun onEvent( + relay: Relay, + subscriptionId: String, + event: Event, + afterEOSE: Boolean + ) { + listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } + } + + override fun onError( + relay: Relay, + subscriptionId: String, + error: Error + ) { + listeners.forEach { it.onError(error, subscriptionId, relay) } + updateStatus() + } + + override fun onRelayStateChange( + relay: Relay, + type: Relay.StateType, + channel: String? + ) { + listeners.forEach { it.onRelayStateChange(type, relay, channel) } + if (type != Relay.StateType.EOSE) { + updateStatus() + } + } + + override fun onSendResponse( + relay: Relay, + eventId: String, + success: Boolean, + message: String + ) { + listeners.forEach { it.onSendResponse(eventId, success, message, relay) } + } + + override fun onAuth( + relay: Relay, + challenge: String + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + override fun onNotify( + relay: Relay, + description: String + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (lastStatus.connected != connected || lastStatus.available != available) { + lastStatus = RelayPoolStatus(connected, available) + _statusFlow.tryEmit(lastStatus) + } + } +} + +@Immutable +data class RelayPoolStatus( + val connected: Int, + val available: Int, + val isConnected: Boolean = connected > 0 +) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelaySetupInfo.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelaySetupInfo.kt new file mode 100644 index 00000000..387647e8 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelaySetupInfo.kt @@ -0,0 +1,60 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import android.util.LruCache +import androidx.compose.runtime.Immutable + +@Immutable +data class RelaySetupInfo( + val url: String, + val read: Boolean, + val write: Boolean, + val errorCount: Int = 0, + val downloadCountInBytes: Int = 0, + val uploadCountInBytes: Int = 0, + val spamCount: Int = 0, + val feedTypes: Set, + val paidRelay: Boolean = false +) { + val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) +} + +object RelayBriefInfoCache { + val cache = LruCache(50) + + @Immutable + data class RelayBriefInfo( + val url: String, + val displayUrl: String = + url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(), + val favIcon: String = "https://$displayUrl/favicon.ico".intern() + ) + + fun get(url: String): RelayBriefInfo { + val info = cache[url] + if (info != null) return info + + val newInfo = RelayBriefInfo(url) + cache.put(url, newInfo) + return newInfo + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt new file mode 100644 index 00000000..96d1c5e6 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt @@ -0,0 +1,59 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import com.fasterxml.jackson.databind.JsonNode +import com.vitorpamplona.quartz.events.Event +import java.util.UUID + +data class Subscription( + val id: String = UUID.randomUUID().toString().substring(0, 4), + val onEOSE: ((Long, String) -> Unit)? = null +) { + var typedFilters: List? = null // Inactive when null + + fun updateEOSE( + time: Long, + relay: String + ) { + onEOSE?.let { it(time, relay) } + } + + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) + } + + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory + + return factory.objectNode().apply { + put("id", id) + typedFilters?.also { filters -> + put( + "typedFilters", + factory.arrayNode(filters.size).apply { + filters.forEach { filter -> add(filter.toJsonObject()) } + } + ) + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/TypedFilter.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/TypedFilter.kt new file mode 100644 index 00000000..b467e396 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/TypedFilter.kt @@ -0,0 +1,92 @@ +/** + * 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.greenart7c3.nostrsigner.relays + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.vitorpamplona.quartz.events.Event + +class TypedFilter( + val types: Set, + val filter: JsonFilter +) { + fun toJson(): String { + return Event.mapper.writeValueAsString(toJsonObject()) + } + + fun toJsonObject(): JsonNode { + val factory = Event.mapper.nodeFactory + + return factory.objectNode().apply { + put("types", typesToJson(types)) + put("filter", filterToJson(filter)) + } + } + + fun typesToJson(types: Set): ArrayNode { + val factory = Event.mapper.nodeFactory + return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } } + } + + fun filterToJson(filter: JsonFilter): JsonNode { + val factory = Event.mapper.nodeFactory + return factory.objectNode().apply { + filter.ids?.run { + put( + "ids", + factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } } + ) + } + filter.authors?.run { + put( + "authors", + factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } } + ) + } + filter.kinds?.run { + put( + "kinds", + factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } } + ) + } + filter.tags?.run { + entries.forEach { kv -> + put( + "#${kv.key}", + factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } } + ) + } + } + /* + Does not include since in the json comparison + filter.since?.run { + val jsonObjectSince = JsonObject() + entries.forEach { sincePairs -> + jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}") + } + jsonObject.add("since", jsonObjectSince) + }*/ + filter.until?.run { put("until", filter.until) } + filter.limit?.run { put("limit", filter.limit) } + filter.search?.run { put("search", filter.search) } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt new file mode 100644 index 00000000..d4b0631c --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -0,0 +1,256 @@ +/** + * 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.greenart7c3.nostrsigner.service + +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 android.service.notification.StatusBarNotification +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.MainActivity +import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.models.Account +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.SealedGossipEvent + +data class BunkerRequest( + val id: String, + val method: String, + val params: Array, + var localKey: String +) { + fun toJson(): String { + return mapper.writeValueAsString(this) + } + + companion object { + val mapper: ObjectMapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule( + SimpleModule() + .addDeserializer(BunkerRequest::class.java, BunkerRequestDeserializer()) + ) + + fun fromJson(jsonObject: JsonNode): BunkerRequest { + return BunkerRequest( + id = jsonObject.get("id").asText().intern(), + method = jsonObject.get("method").asText().intern(), + params = jsonObject.get("params").asIterable().toList().map { + it.asText().intern() + }.toTypedArray(), + localKey = jsonObject.get("localKey")?.asText()?.intern() ?: "" + ) + } + + private class BunkerRequestDeserializer : StdDeserializer(BunkerRequest::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext + ): BunkerRequest { + return fromJson(jp.codec.readTree(jp)) + } + } + } +} + +class EventNotificationConsumer(private val applicationContext: Context) { + private val DM_GROUP_KEY = "com.greenart7c3.nostrsigner.DM_NOTIFICATION" + suspend fun consume(event: GiftWrapEvent) { + if (!notificationManager().areNotificationsEnabled()) return + + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + LocalPreferences.allSavedAccounts().forEach { + if (it.hasPrivKey) { + LocalPreferences.loadFromEncryptedStorage(it.npub)?.let { acc -> + consumeIfMatchesAccount(event, acc) + } + } + } + } + + private suspend fun consumeIfMatchesAccount( + pushWrappedEvent: GiftWrapEvent, + account: Account + ) { + pushWrappedEvent.cachedGift(account.signer) { notificationEvent -> + unwrapAndConsume(notificationEvent, account) { innerEvent -> + if (innerEvent.kind == 24133) { + notify(innerEvent, account) + } + } + } + } + + private fun unwrapAndConsume( + event: Event, + account: Account, + onReady: (Event) -> Unit + ) { + when (event) { + is GiftWrapEvent -> { + event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) } + } + is SealedGossipEvent -> { + event.cachedGossip(account.signer) { + onReady(it) + } + } + else -> { + onReady(event) + } + } + } + + private fun notify( + event: Event, + acc: Account + ) { + acc.signer.nip04Decrypt(event.content, event.pubKey) { + Log.d("bunker", event.toJson()) + Log.d("bunker", it) + + val bunkerRequest = BunkerRequest.mapper.readValue(it, BunkerRequest::class.java) + bunkerRequest.localKey = event.pubKey + + notificationManager() + .sendNotification( + event.id, + "${event.pubKey.toShortenHex()} wants you to sign an event", + "Bunker", + "nostrsigner:", + "PrivateMessagesID", + DM_GROUP_KEY, + applicationContext, + bunkerRequest + ) + } + } + + fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + bunkerRequest: BunkerRequest + ) { + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + notificationGroupKey, + applicationContext = applicationContext, + bunkerRequest + ) + } + + private fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + notificationGroupKey: String, + applicationContext: Context, + bunkerRequest: BunkerRequest + ) { + val notId = id.hashCode() + val notifications: Array = getActiveNotifications() + for (notification in notifications) { + if (notification.id == notId) { + return + } + } + + val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) } + contentIntent.putExtra("bunker", bunkerRequest.toJson()) + contentIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + val contentPendingIntent = PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_MUTABLE + ) + // Build the notification + val builderPublic = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.mipmap.ic_launcher_foreground) + .setColor(0xFFBF00) + .setContentTitle(messageTitle) + .setContentText("new event to sign") + .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) + + // Build the notification + val builder = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.mipmap.ic_launcher_foreground) + .setColor(0xFFBF00) + .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) + .setAutoCancel(true) + + notify(notId, builder.build()) + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/HttpClientManager.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/HttpClientManager.kt new file mode 100644 index 00000000..d5283be4 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/HttpClientManager.kt @@ -0,0 +1,115 @@ +/** + * 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.greenart7c3.nostrsigner.service + +import android.util.Log +import com.greenart7c3.nostrsigner.BuildConfig +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.time.Duration +import kotlin.properties.Delegates + +object HttpClientManager { + val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L) + val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L) + + var proxyChangeListeners = ArrayList<() -> Unit>() + private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI + private var defaultHttpClient: OkHttpClient? = null + + // fires off every time value of the property changes + private var internalProxy: Proxy? by + Delegates.observable(null) { _, oldValue, newValue -> + if (oldValue != newValue) { + proxyChangeListeners.forEach { it() } + } + } + + fun setDefaultProxy(proxy: Proxy?) { + if (internalProxy != proxy) { + Log.d("HttpClient", "Changing proxy to: ${proxy != null}") + this.internalProxy = proxy + + // recreates singleton + this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout) + } + } + + fun setDefaultTimeout(timeout: Duration) { + Log.d("HttpClient", "Changing timeout to: $timeout") + if (this.defaultTimeout.seconds != timeout.seconds) { + this.defaultTimeout = timeout + + // recreates singleton + this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout) + } + } + + private fun buildHttpClient( + proxy: Proxy?, + timeout: Duration + ): OkHttpClient { + val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds + val duration = Duration.ofSeconds(seconds) + return OkHttpClient.Builder() + .proxy(proxy) + .readTimeout(duration) + .connectTimeout(duration) + .writeTimeout(duration) + .addInterceptor(DefaultContentTypeInterceptor()) + .followRedirects(true) + .followSslRedirects(true) + .build() + } + + class DefaultContentTypeInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", "Amber/${BuildConfig.VERSION_NAME}") + .build() + return chain.proceed(requestWithUserAgent) + } + } + + fun getHttpClient(): OkHttpClient { + if (this.defaultHttpClient == null) { + this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout) + } + return defaultHttpClient!! + } + + fun initProxy( + useProxy: Boolean, + hostname: String, + port: Int + ): Proxy? { + return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 093d371f..3cfe705f 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -17,58 +17,114 @@ import com.vitorpamplona.quartz.events.Event import java.net.URLDecoder object IntentUtils { - fun getIntentData(intent: Intent, packageName: String?): IntentData? { - if (intent.data != null) { - val type = when (intent.extras?.getString("type")) { - "sign_event" -> SignerType.SIGN_EVENT - "nip04_encrypt" -> SignerType.NIP04_ENCRYPT - "nip04_decrypt" -> SignerType.NIP04_DECRYPT - "nip44_decrypt" -> SignerType.NIP44_DECRYPT - "nip44_encrypt" -> SignerType.NIP44_ENCRYPT - "get_public_key" -> SignerType.GET_PUBLIC_KEY - "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT - else -> SignerType.SIGN_EVENT - } + private fun getIntentDataFromBunkerRequest(intent: Intent, bunkerRequest: BunkerRequest): IntentData { + val type = when (bunkerRequest.method) { + "connect" -> SignerType.CONNECT + "sign_event" -> SignerType.SIGN_EVENT + else -> SignerType.SIGN_EVENT + } - val data = try { - if (packageName == null) { - URLDecoder.decode(intent.data?.toString()?.replace("+", "%2b") ?: "", "utf-8").replace("nostrsigner:", "") - } else { - intent.data?.toString()?.replace("nostrsigner:", "") ?: "" - } - } catch (e: Exception) { + val data = when (bunkerRequest.method) { + "connect" -> "ack" + "sign_event" -> { + val amberEvent = AmberEvent.fromJson(bunkerRequest.params.first()) + AmberEvent.toEvent(amberEvent).toJson() + } + else -> "" + } + + val pubKey = "" + val id = bunkerRequest.id + val compressionType = if (intent.extras?.getString("compression") == "gzip") CompressionType.GZIP else CompressionType.NONE + + return IntentData( + data, + "", + type, + pubKey, + id, + intent.extras?.getString("callbackUrl"), + compressionType, + ReturnType.EVENT, + listOf(), + intent.extras?.getString("current_user") ?: "", + mutableStateOf(true), + mutableStateOf(false), + bunkerRequest + ) + } + + private fun getIntentDataFromIntent(intent: Intent, packageName: String?): IntentData { + val type = when (intent.extras?.getString("type")) { + "sign_event" -> SignerType.SIGN_EVENT + "nip04_encrypt" -> SignerType.NIP04_ENCRYPT + "nip04_decrypt" -> SignerType.NIP04_DECRYPT + "nip44_decrypt" -> SignerType.NIP44_DECRYPT + "nip44_encrypt" -> SignerType.NIP44_ENCRYPT + "get_public_key" -> SignerType.GET_PUBLIC_KEY + "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT + else -> SignerType.SIGN_EVENT + } + + val data = try { + if (packageName == null) { + URLDecoder.decode(intent.data?.toString()?.replace("+", "%2b") ?: "", "utf-8").replace("nostrsigner:", "") + } else { intent.data?.toString()?.replace("nostrsigner:", "") ?: "" } - - val callbackUrl = intent.extras?.getString("callbackUrl") ?: "" - val name = if (callbackUrl.isNotBlank()) Uri.parse(callbackUrl).host ?: "" else "" - val pubKey = intent.extras?.getString("pubKey") ?: "" - val id = intent.extras?.getString("id") ?: "" - - val compressionType = if (intent.extras?.getString("compression") == "gzip") CompressionType.GZIP else CompressionType.NONE - val returnType = if (intent.extras?.getString("returnType") == "event") ReturnType.EVENT else ReturnType.SIGNATURE - val listType = object : TypeToken>() {}.type - val permissions = Gson().fromJson?>(intent.extras?.getString("permissions"), listType) - permissions?.forEach { - it.checked = true - } - - return IntentData( - data, - name, - type, - pubKey, - id, - intent.extras?.getString("callbackUrl"), - compressionType, - returnType, - permissions, - intent.extras?.getString("current_user") ?: "", - mutableStateOf(true), - mutableStateOf(false) - ) + } catch (e: Exception) { + intent.data?.toString()?.replace("nostrsigner:", "") ?: "" + } + + val callbackUrl = intent.extras?.getString("callbackUrl") ?: "" + val name = if (callbackUrl.isNotBlank()) Uri.parse(callbackUrl).host ?: "" else "" + val pubKey = intent.extras?.getString("pubKey") ?: "" + val id = intent.extras?.getString("id") ?: "" + + val compressionType = if (intent.extras?.getString("compression") == "gzip") CompressionType.GZIP else CompressionType.NONE + val returnType = if (intent.extras?.getString("returnType") == "event") ReturnType.EVENT else ReturnType.SIGNATURE + val listType = object : TypeToken>() {}.type + val permissions = Gson().fromJson?>(intent.extras?.getString("permissions"), listType) + permissions?.forEach { + it.checked = true + } + + return IntentData( + data, + name, + type, + pubKey, + id, + intent.extras?.getString("callbackUrl"), + compressionType, + returnType, + permissions, + intent.extras?.getString("current_user") ?: "", + mutableStateOf(true), + mutableStateOf(false), + null + ) + } + + fun getIntentData(intent: Intent, packageName: String?): IntentData? { + if (intent.data == null) { + return null + } + + val bunkerRequest = if (intent.getStringExtra("bunker") != null) { + BunkerRequest.mapper.readValue( + intent.getStringExtra("bunker"), + BunkerRequest::class.java + ) + } else { + null + } + + return if (bunkerRequest != null) { + getIntentDataFromBunkerRequest(intent, bunkerRequest) + } else { + getIntentDataFromIntent(intent, packageName) } - return null } fun getIntent(data: String, keyPair: KeyPair): Event { val event = AmberEvent.fromJson(data) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt new file mode 100644 index 00000000..21c1616c --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt @@ -0,0 +1,57 @@ +/** + * 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.greenart7c3.nostrsigner.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context + +object NotificationUtils { + private var dmChannel: NotificationChannel? = null + + fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! + + dmChannel = + NotificationChannel( + "PrivateMessagesID", + "Private Messages", + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = + "test" + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dmChannel!!) + + return dmChannel!! + } + + /** Cancels all notifications. */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/Notifications.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/Notifications.kt new file mode 100644 index 00000000..8ae2651d --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/Notifications.kt @@ -0,0 +1,77 @@ +package com.greenart7c3.nostrsigner.service + +import android.app.NotificationManager +import android.content.Intent +import android.util.Log +import android.util.LruCache +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.service.NotificationUtils.getOrCreateDMChannel +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.GiftWrapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.time.measureTimedValue + +class Notifications : FirebaseMessagingService() { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val eventCache = LruCache(100) + + override fun getStartCommandIntent(originalIntent: Intent?): Intent { + return super.getStartCommandIntent(originalIntent) + } + + override fun handleIntent(intent: Intent?) { + Log.d("Time", "Intent received $intent") + super.handleIntent(intent) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + scope.launch(Dispatchers.IO) { + val (value, elapsed) = + measureTimedValue { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } + Log.d("Time", "Notification processed in $elapsed") + } + super.onMessageReceived(remoteMessage) + } + + private suspend fun parseMessage(params: Map): GiftWrapEvent? { + params["encryptedEvent"]?.let { eventStr -> + (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { + return it + } + } + return null + } + + private suspend fun receiveIfNew(event: GiftWrapEvent) { + if (eventCache.get(event.id) == null) { + eventCache.put(event.id, event.id) + EventNotificationConsumer(applicationContext).consume(event) + notificationManager().getOrCreateDMChannel(applicationContext) + } + } + + fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager + } + + override fun onNewToken(token: String) { + scope.launch(Dispatchers.IO) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + } + super.onNewToken(token) + } + + override fun onDestroy() { + Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") + + scope.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/PushNotificationUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/PushNotificationUtils.kt new file mode 100644 index 00000000..e41070ca --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/PushNotificationUtils.kt @@ -0,0 +1,46 @@ +/** + * 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.greenart7c3.nostrsigner.service + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging +import com.greenart7c3.nostrsigner.AccountInfo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await + +object PushNotificationUtils { + var hasInit: Boolean = false + + suspend fun init(accounts: List) = + with(Dispatchers.IO) { + if (hasInit) { + return@with + } + // get user notification token provided by firebase + try { + RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Firebase token", "failed to get firebase token", e) + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/RegisterAccounts.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/RegisterAccounts.kt new file mode 100644 index 00000000..4944c79e --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/RegisterAccounts.kt @@ -0,0 +1,123 @@ +/** + * 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.greenart7c3.nostrsigner.service + +import android.util.Log +import com.greenart7c3.nostrsigner.AccountInfo +import com.greenart7c3.nostrsigner.BuildConfig +import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.models.Account +import com.vitorpamplona.quartz.events.RelayAuthEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class RegisterAccounts( + private val accounts: List +) { + private fun recursiveAuthCreation( + notificationToken: String, + remainingTos: List>, + output: MutableList, + onReady: (List) -> Unit + ) { + if (remainingTos.isEmpty()) { + onReady(output) + return + } + + val next = remainingTos.first() + + next.first.createAuthEvent(next.second, notificationToken) { + output.add(it) + recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady) + } + } + + // creates proof that it controls all accounts + private suspend fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String, + onReady: (List) -> Unit + ) { + val readyToSend = + accounts.mapNotNull { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null) { + val relayToUse = "wss://relay.nsec.app" // TODO: do not use a fixed relay + if (relayToUse != null) { + Pair(acc, relayToUse) + } else { + null + } + } else { + null + } + } + + val listOfAuthEvents = mutableListOf() + recursiveAuthCreation( + notificationToken, + readyToSend, + listOfAuthEvents, + onReady + ) + } + + 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", "Amber/${BuildConfig.VERSION_NAME}") + .url("http://10.0.2.2:3000/register") // TODO: change to production url + .post(body) + .build() + + val client = HttpClientManager.getHttpClient() + + val isSucess = client.newCall(request).execute().use { it.isSuccessful } + } catch (e: java.lang.Exception) { + if (e is CancellationException) throw e + val tag = "FirebaseMsgService" + + Log.e(tag, "Unable to register with push server", e) + } + } + + suspend fun go(notificationToken: String) = + withContext(Dispatchers.IO) { + signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) } + + PushNotificationUtils.hasInit = true + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt index ae0daf72..d6b8acc4 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -15,10 +15,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -41,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.intl.Locale @@ -52,6 +52,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.fasterxml.jackson.databind.ObjectMapper import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R @@ -61,12 +62,16 @@ import com.greenart7c3.nostrsigner.models.IntentData import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.ReturnType import com.greenart7c3.nostrsigner.models.SignerType +import com.greenart7c3.nostrsigner.models.TimeUtils +import com.greenart7c3.nostrsigner.relays.Client import com.greenart7c3.nostrsigner.service.getAppCompatActivity import com.greenart7c3.nostrsigner.service.toShortenHex import com.greenart7c3.nostrsigner.ui.actions.AccountsBottomSheet import com.greenart7c3.nostrsigner.ui.navigation.Route import com.greenart7c3.nostrsigner.ui.theme.ButtonBorder +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.Event import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -75,6 +80,12 @@ import java.io.ByteArrayOutputStream import java.util.Base64 import java.util.zip.GZIPOutputStream +data class BunkerResponse( + val id: String, + val result: String, + val error: String? +) + @OptIn(DelicateCoroutinesApi::class) fun sendResult( context: Context, @@ -89,7 +100,45 @@ fun sendResult( permissions: List? = null ) { val activity = context.getAppCompatActivity() - if (packageName != null) { + if (intentData.bunkerRequest != null) { + if (intentData.type == SignerType.GET_PUBLIC_KEY) { + val keysToClear = account.savedApps.filter { + it.key.startsWith(intentData.bunkerRequest.localKey) + }.map { + it.key + } + keysToClear.forEach { + account.savedApps.remove(it) + } + } + permissions?.filter { it.checked }?.forEach { + val type = it.type.toUpperCase(Locale.current) + val permissionKey = if (it.type == "sign_event") "${intentData.bunkerRequest.localKey}-$type-${it.kind}" else "${intentData.bunkerRequest.localKey}-$type" + account.savedApps[permissionKey] = true + } + if (rememberChoice) { + account.savedApps[key] = true + } + + account.signer.nip04Encrypt( + ObjectMapper().writeValueAsString(BunkerResponse(intentData.bunkerRequest.id, event, null)), + intentData.bunkerRequest.localKey + ) { encryptedContent -> + account.signer.sign( + TimeUtils.now(), + 24133, + arrayOf(arrayOf("p", intentData.bunkerRequest.localKey)), + encryptedContent + ) { + LocalPreferences.saveToEncryptedStorage(account) + GlobalScope.launch(Dispatchers.IO) { + Client.send(it, relay = "wss://relay.nsec.app", onDone = { + activity?.finish() + }) + } + } + } + } else if (packageName != null) { if (intentData.type == SignerType.GET_PUBLIC_KEY) { val keysToClear = account.savedApps.filter { it.key.startsWith(packageName) @@ -119,6 +168,7 @@ fun sendResult( intent.putExtra("package", BuildConfig.APPLICATION_ID) } activity?.setResult(RESULT_OK, intent) + activity?.finish() } else if (intentData.callBackUrl != null) { if (intentData.returnType == ReturnType.SIGNATURE) { val intent = Intent(Intent.ACTION_VIEW) @@ -144,6 +194,7 @@ fun sendResult( context.startActivity(intent) } } + activity?.finish() } else { val result = if (intentData.returnType == ReturnType.SIGNATURE) { value @@ -165,6 +216,7 @@ fun sendResult( Toast.LENGTH_SHORT ).show() } + activity?.finish() } activity?.intent = null activity?.finish() @@ -177,8 +229,8 @@ fun GoToTop(goToTop: () -> Unit) { shape = CircleShape ) { Icon( - Icons.Default.ArrowUpward, - contentDescription = stringResource(R.string.go_to_top) + Icons.Default.Add, + contentDescription = stringResource(R.string.connect_app) ) } } @@ -203,16 +255,15 @@ fun MainScreen( skipPartiallyExpanded = true ) val scope = rememberCoroutineScope() - val listState = rememberLazyListState() val interactionSource = remember { MutableInteractionSource() } + val clipboardManager = LocalClipboardManager.current Scaffold( floatingActionButton = { - if (navBackStackEntry?.destination?.route?.contains("History") == true) { + if (navBackStackEntry?.destination?.route?.contains("Permission") == true) { GoToTop { - scope.launch { - listState.scrollToItem(0) - } + val bunkerUrl = "bunker://${account.keyPair.pubKey.toHexKey()}?relay=wss://relay.nsec.app" + clipboardManager.setText(AnnotatedString(bunkerUrl)) } } }, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt index e8e1e260..47f91e91 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt @@ -120,9 +120,8 @@ fun SettingsScreen( Modifier .fillMaxWidth() .padding(vertical = 24.dp), - fullText = "v${BuildConfig.VERSION_NAME}\n\n${context.getString(R.string.support_development)}\n\n${context.getString(R.string.source_code)}\n\n${context.getString(R.string.made_possible_by)} ${context.getString(R.string.opensats)}", + fullText = "v${BuildConfig.VERSION_NAME}\n\n${context.getString(R.string.support_development)}\n\n${context.getString(R.string.source_code)}", hyperLinks = mutableMapOf( - stringResource(R.string.opensats) to stringResource(R.string.opensats_uri), stringResource(R.string.source_code) to stringResource(R.string.amber_github_uri), stringResource(R.string.support_development) to stringResource(R.string.support_development_uri) ), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt index 760dc80d..7d5583bb 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt @@ -30,14 +30,18 @@ fun SingleEventHomeScreen( intentData: IntentData, account: Account ) { - var key = "$packageName-${intentData.type}" + var key = if (intentData.bunkerRequest != null) { + "${intentData.bunkerRequest.localKey}-${intentData.type}" + } else { + "$packageName-${intentData.type}" + } val appName = packageName ?: intentData.name val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val clipboardManager = LocalClipboardManager.current when (intentData.type) { - SignerType.GET_PUBLIC_KEY -> { + SignerType.GET_PUBLIC_KEY, SignerType.CONNECT -> { val remember = remember { mutableStateOf(account.savedApps[key] != null) } @@ -46,7 +50,11 @@ fun SingleEventHomeScreen( applicationName, intentData.permissions, { permissions -> - val sig = account.keyPair.pubKey.toNpub() + val sig = if (intentData.type == SignerType.CONNECT) { + "ack" + } else { + account.keyPair.pubKey.toNpub() + } coroutineScope.launch { sendResult( context, @@ -80,11 +88,16 @@ fun SingleEventHomeScreen( mutableStateOf(account.savedApps[key] != null) } val shouldRunOnAccept = account.savedApps[key] ?: false + val localPackageName = if (intentData.bunkerRequest != null) { + intentData.bunkerRequest.localKey + } else { + packageName + } EncryptDecryptData( intentData.data, shouldRunOnAccept, remember, - packageName, + localPackageName, applicationName, appName, intentData.type, @@ -197,15 +210,20 @@ fun SingleEventHomeScreen( else -> { val event = IntentUtils.getIntent(intentData.data, account.keyPair) - key = "$packageName-${intentData.type}-${event.kind}" + key = "$key-${event.kind}" val remember = remember { mutableStateOf(account.savedApps[key] != null) } val shouldRunOnAccept = account.savedApps[key] ?: false + val localPackageName = if (intentData.bunkerRequest != null) { + intentData.bunkerRequest.localKey + } else { + packageName + } EventData( shouldRunOnAccept, remember, - packageName, + localPackageName, appName, applicationName, event, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6500861f..1e2a4131 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,7 +63,7 @@ Encrypted/Decrypted data Data copied to the clipboard Message already encrypted - go to top + Connect app Event copied to the clipboard Select/Deselect all Decrypt content @@ -73,7 +73,6 @@ Source code https://opensats.org https://github.com/greenart7c3/amber - Made possible by Show QR Code Shows QR Code with you private key diff --git a/build.gradle b/build.gradle index a955c6d6..31f4c25b 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ plugins { id 'com.android.library' version '8.3.0' apply false id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false + id 'com.google.gms.google-services' version '4.4.1' apply false } tasks.register('installGitHook', Copy) {