connect and sign_event of nip46

This commit is contained in:
greenart7c3
2024-03-08 13:09:09 -03:00
parent 226179f091
commit 252d43f8bd
28 changed files with 2665 additions and 71 deletions
+5 -1
View File
@@ -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')
}
+48
View File
@@ -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"
}
+14 -1
View File
@@ -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)
),
@@ -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,
+1 -2
View File
@@ -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">
+1
View File
@@ -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) {