mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
connect and sign_event of nip46
This commit is contained in:
+5
-1
@@ -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')
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -2,11 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".nostrsigner"
|
||||
android:allowBackup="false"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -14,9 +18,18 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SignerSplash"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:targetApi="33">
|
||||
|
||||
<service
|
||||
android:name=".service.Notifications"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name=".SignerProvider"
|
||||
android:authorities="${applicationId}.SIGN_EVENT;${applicationId}.NIP04_ENCRYPT;${applicationId}.NIP04_DECRYPT;${applicationId}.NIP44_ENCRYPT;${applicationId}.NIP44_DECRYPT;${applicationId}.GET_PUBLIC_KEY;${applicationId}.DECRYPT_ZAP_EVENT"
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
package com.greenart7c3.nostrsigner
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.android.gms.tasks.OnCompleteListener
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.greenart7c3.nostrsigner.models.IntentData
|
||||
import com.greenart7c3.nostrsigner.service.IntentUtils
|
||||
import com.greenart7c3.nostrsigner.service.PushNotificationUtils
|
||||
import com.greenart7c3.nostrsigner.ui.AccountScreen
|
||||
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
|
||||
import com.greenart7c3.nostrsigner.ui.theme.NostrSignerTheme
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
var intents = IntentLiveData()
|
||||
@@ -26,12 +38,57 @@ class MainViewModel : ViewModel() {
|
||||
class IntentLiveData : MutableLiveData<MutableList<IntentData>>(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
|
||||
|
||||
@@ -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)
|
||||
@@ -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<String, Boolean>
|
||||
) {
|
||||
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>(AccountState(account))
|
||||
|
||||
@@ -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<Permission>?,
|
||||
val currentAccount: String,
|
||||
val checked: MutableState<Boolean>,
|
||||
val rememberMyChoice: MutableState<Boolean>
|
||||
val rememberMyChoice: MutableState<Boolean>,
|
||||
val bunkerRequest: BunkerRequest?
|
||||
)
|
||||
|
||||
enum class SignerType {
|
||||
CONNECT,
|
||||
SIGN_EVENT,
|
||||
NIP04_ENCRYPT,
|
||||
NIP04_DECRYPT,
|
||||
|
||||
@@ -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<Listener>()
|
||||
private var relays = emptyArray<Relay>()
|
||||
private var subscriptions = mapOf<String, List<TypedFilter>>()
|
||||
|
||||
@Synchronized
|
||||
fun reconnect(
|
||||
relays: Array<Relay>?,
|
||||
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<Relay>?): 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<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilter(subscriptionId)
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.connectAndSendFiltersIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<Relay>? = 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<FeedType>?,
|
||||
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<String> {
|
||||
return subscriptions.keys
|
||||
}
|
||||
|
||||
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Relay> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<String, EOSETime> = 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<String, EOSERelayList> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val tags: Map<String, List<String>>? = null,
|
||||
val since: Map<String, EOSETime>? = 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)
|
||||
}
|
||||
}
|
||||
@@ -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> = 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<Listener>()
|
||||
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<String, Boolean>()
|
||||
|
||||
private val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
|
||||
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 <T> Iterable<T>.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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Relay>()
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
// Backing property to avoid flow emissions from other classes
|
||||
private var lastStatus = RelayPoolStatus(0, 0)
|
||||
private val _statusFlow =
|
||||
MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val statusFlow: SharedFlow<RelayPoolStatus> = _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<Relay> {
|
||||
return relays.filter { it.url == url }
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
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<Relay>,
|
||||
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
|
||||
)
|
||||
@@ -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<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
object RelayBriefInfoCache {
|
||||
val cache = LruCache<String, RelayBriefInfo?>(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
|
||||
}
|
||||
}
|
||||
@@ -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<TypedFilter>? = 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()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FeedType>,
|
||||
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<FeedType>): 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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>(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<StatusBarNotification> = 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<List<Permission>>() {}.type
|
||||
val permissions = Gson().fromJson<List<Permission>?>(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<List<Permission>>() {}.type
|
||||
val permissions = Gson().fromJson<List<Permission>?>(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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>(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<String, String>): 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()
|
||||
}
|
||||
}
|
||||
@@ -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<AccountInfo>) =
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AccountInfo>
|
||||
) {
|
||||
private fun recursiveAuthCreation(
|
||||
notificationToken: String,
|
||||
remainingTos: List<Pair<Account, String>>,
|
||||
output: MutableList<RelayAuthEvent>,
|
||||
onReady: (List<RelayAuthEvent>) -> 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<AccountInfo>,
|
||||
notificationToken: String,
|
||||
onReady: (List<RelayAuthEvent>) -> 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<RelayAuthEvent>()
|
||||
recursiveAuthCreation(
|
||||
notificationToken,
|
||||
readyToSend,
|
||||
listOfAuthEvents,
|
||||
onReady
|
||||
)
|
||||
}
|
||||
|
||||
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Permission>? = 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<Event>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
+24
-6
@@ -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,
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<string name="encrypted_decrypted_data">Encrypted/Decrypted data</string>
|
||||
<string name="data_copied_to_the_clipboard">Data copied to the clipboard</string>
|
||||
<string name="message_already_encrypted">Message already encrypted</string>
|
||||
<string name="go_to_top">go to top</string>
|
||||
<string name="connect_app">Connect app</string>
|
||||
<string name="event_copied_to_the_clipboard">Event copied to the clipboard</string>
|
||||
<string name="select_deselect_all">Select/Deselect all</string>
|
||||
<string name="decrypt_content">Decrypt content</string>
|
||||
@@ -73,7 +73,6 @@
|
||||
<string name="source_code">Source code</string>
|
||||
<string name="opensats_uri">https://opensats.org</string>
|
||||
<string name="amber_github_uri">https://github.com/greenart7c3/amber</string>
|
||||
<string name="made_possible_by">Made possible by</string>
|
||||
<string name="show_qr_code">Show QR Code</string>
|
||||
<string name="shows_qr_code_with_you_private_key">Shows QR Code with you private key</string>
|
||||
<string name="account_backup_tips3_md" tools:ignore="Typos">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user