add option to use relays directly

This commit is contained in:
greenart7c3
2024-04-22 14:12:47 -03:00
parent 4e4d041e74
commit 1d9bbdda75
30 changed files with 1569 additions and 63 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "3267256afb5b9a388fb15f267466e995",
"identityHash": "f43524ba310997fbd2fb8e145d477ef9",
"entities": [
{
"tableName": "application",
@@ -173,12 +173,44 @@
]
}
]
},
{
"tableName": "notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3267256afb5b9a388fb15f267466e995')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f43524ba310997fbd2fb8e145d477ef9')"
]
}
}
@@ -0,0 +1,216 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "f43524ba310997fbd2fb8e145d477ef9",
"entities": [
{
"tableName": "application",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `relays` TEXT NOT NULL, `url` TEXT NOT NULL, `icon` TEXT NOT NULL, `description` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `isConnected` INTEGER NOT NULL, `secret` TEXT NOT NULL, `useSecret` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "relays",
"columnName": "relays",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isConnected",
"columnName": "isConnected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useSecret",
"columnName": "useSecret",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [
{
"name": "index_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_key` ON `${TABLE_NAME}` (`key`)"
},
{
"name": "index_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "applicationPermission",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `acceptable` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "acceptable",
"columnName": "acceptable",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "permissions_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `permissions_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "permissions_unique",
"unique": true,
"columnNames": [
"pkKey",
"type",
"kind"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `${TABLE_NAME}` (`pkKey`, `type`, `kind`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
},
{
"tableName": "notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "eventId",
"columnName": "eventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f43524ba310997fbd2fb8e145d477ef9')"
]
}
}
+9
View File
@@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<queries>
<package android:name="org.torproject.android"/>
@@ -11,6 +14,12 @@
<application
android:name=".nostrsigner">
<service
android:name=".service.ConnectivityService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false"></service>
<receiver
android:exported="true"
android:enabled="true"
@@ -10,6 +10,8 @@ import com.greenart7c3.nostrsigner.database.ApplicationEntity
import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.service.HttpClientManager
import com.greenart7c3.nostrsigner.ui.NotificationType
import com.greenart7c3.nostrsigner.ui.parseNotificationType
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -37,6 +39,7 @@ private object PrefKeys {
const val RATIONALE = "rationale"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val NOTIFICATION_TYPE = "notification_type"
}
@Immutable
@@ -244,6 +247,18 @@ object LocalPreferences {
HttpClientManager.setDefaultProxy(proxy)
}
fun updateNotificationType(notificationType: NotificationType) {
encryptedPreferences().edit().apply {
putInt(PrefKeys.NOTIFICATION_TYPE, notificationType.screenCode)
}.apply()
}
fun getNotificationType(): NotificationType {
encryptedPreferences().apply {
return parseNotificationType(getInt(PrefKeys.NOTIFICATION_TYPE, 0))
}
}
private suspend fun convertToDatabase(map: MutableMap<String, Boolean>, pubKey: String, database: AppDatabase) = withContext(Dispatchers.IO) {
map.forEach {
val splitData = it.key.split("-")
@@ -1,6 +1,9 @@
package com.greenart7c3.nostrsigner
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle
import android.util.Log
@@ -9,22 +12,33 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.service.HttpClientManager
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.greenart7c3.nostrsigner.ui.AccountScreen
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.navigation.Route
import com.greenart7c3.nostrsigner.ui.theme.NostrSignerTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
fun Intent.isLaunchFromHistory(): Boolean = this.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
class MainActivity : AppCompatActivity() {
private val intents = MutableStateFlow<List<IntentData>>(listOf())
val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -76,6 +90,16 @@ class MainActivity : AppCompatActivity() {
}
}
IntentUtils.bunkerRequests.clear()
if (BuildConfig.FLAVOR != "offline") {
val connectivityManager =
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
connectivityManager.registerDefaultNetworkCallback(networkCallback)
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
updateNetworkCapabilities(it)
}
}
super.onResume()
}
@@ -98,4 +122,76 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false
if (isOnMobileDataState.value != isOnMobileData) {
isOnMobileDataState.value = isOnMobileData
changedNetwork = true
}
if (isOnWifiDataState.value != isOnWifi) {
isOnWifiDataState.value = isOnWifi
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
var lastNetwork: Network? = null
override fun onAvailable(network: Network) {
super.onAvailable(network)
if (BuildConfig.FLAVOR == "offline") return
if (lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) {
NotificationDataSource.stopSync()
delay(1000)
NotificationDataSource.start()
}
}
lastNetwork = network
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
if (BuildConfig.FLAVOR == "offline") return
GlobalScope.launch(Dispatchers.IO) {
Log.d(
"ServiceManager NetworkCallback",
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}"
)
if (updateNetworkCapabilities(networkCapabilities)) {
NotificationDataSource.stopSync()
delay(1000)
NotificationDataSource.start()
}
}
}
}
}
@@ -5,8 +5,19 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [ApplicationEntity::class, ApplicationPermissionsEntity::class], version = 1)
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `notification` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `eventId` TEXT NOT NULL, `time` INTEGER NOT NULL)")
}
}
@Database(
entities = [ApplicationEntity::class, ApplicationPermissionsEntity::class, NotificationEntity::class],
version = 2
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun applicationDao(): ApplicationDao
@@ -18,7 +29,9 @@ abstract class AppDatabase : RoomDatabase() {
context,
AppDatabase::class.java,
"amber_db_$npub"
).build()
)
.addMigrations(MIGRATION_1_2)
.build()
instance
}
}
@@ -9,6 +9,15 @@ import androidx.room.Transaction
@Dao
interface ApplicationDao {
@Query("SELECT MAX(time) FROM notification")
fun getLatestNotification(): Long?
@Query("SELECT * FROM notification WHERE eventId = :eventId")
fun getNotification(eventId: String): NotificationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertNotification(notificationEntity: NotificationEntity): Long?
@Query("SELECT * FROM application where pubKey = :pubKey")
fun getAll(pubKey: String): List<ApplicationEntity>
@@ -0,0 +1,14 @@
package com.greenart7c3.nostrsigner.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "notification"
)
data class NotificationEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val eventId: String,
val time: Long
)
@@ -122,9 +122,9 @@ object Client : RelayPool.Listener {
checkNotInMainThread()
if (relayList != null) {
RelayPool.sendToSelectedRelays(relayList, signedEvent)
RelayPool.sendToSelectedRelays(relayList, signedEvent, onLoading, onDone)
} else if (relay == null) {
RelayPool.send(signedEvent)
RelayPool.send(signedEvent, onLoading, onDone)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
@@ -53,8 +53,7 @@ class Relay(
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
const val RECONNECTING_IN_SECONDS = 60
}
private val httpClient = HttpClientManager.getHttpClient()
@@ -63,10 +63,8 @@ object RelayPool : Relay.Listener {
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
if (relayList.isNotEmpty()) {
relayList.forEach { addRelay(it) }
} else {
Constants.convertDefaultRelays().forEach { addRelay(it) }
}
}
@@ -95,13 +93,23 @@ object RelayPool : Relay.Listener {
fun sendToSelectedRelays(
list: List<Relay>,
signedEvent: EventInterface
signedEvent: EventInterface,
onLoading: (Boolean) -> Unit,
onDone: (() -> Unit)? = null
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent, null) } }
list.forEach { relay ->
relays.filter { it.url == relay.url }.forEach {
it.onLoading = onLoading
it.send(signedEvent, onDone)
}
}
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent, null) }
fun send(signedEvent: EventInterface, onLoading: (Boolean) -> Unit, onDone: (() -> Unit)? = null) {
relays.forEach {
it.onLoading = onLoading
it.send(signedEvent, onDone)
}
}
fun close(subscriptionId: String) {
@@ -56,4 +56,36 @@ data class Subscription(
}
}
}
fun hasChangedFiltersFrom(otherFilters: List<TypedFilter>?): Boolean {
if (typedFilters == null && otherFilters == null) return false
if (typedFilters?.size != otherFilters?.size) return true
typedFilters?.forEachIndexed { index, typedFilter ->
val otherFilter = otherFilters?.getOrNull(index) ?: return true
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size ||
typedFilter.filter.ids?.size != otherFilter.filter.ids?.size ||
typedFilter.filter.tags?.size != otherFilter.filter.tags?.size ||
typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size ||
typedFilter.filter.limit != otherFilter.filter.limit ||
typedFilter.filter.search?.length != otherFilter.filter.search?.length ||
typedFilter.filter.until != otherFilter.filter.until
) {
return true
}
// deep check
if (typedFilter.filter.ids != otherFilter.filter.ids ||
typedFilter.filter.authors != otherFilter.filter.authors ||
typedFilter.filter.tags != otherFilter.filter.tags ||
typedFilter.filter.kinds != otherFilter.filter.kinds ||
typedFilter.filter.search != otherFilter.filter.search
) {
return true
}
}
return false
}
}
@@ -9,6 +9,7 @@ import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.ui.BunkerResponse
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.HexKey
@@ -111,10 +112,10 @@ object AmberUtils {
fun sendBunkerError(
account: Account,
bunkerRequest: BunkerRequest,
relays: List<Relay>,
context: Context,
onLoading: (Boolean) -> Unit
) {
val relays = bunkerRequest.relays.ifEmpty { listOf("wss://relay.nsec.app") }
IntentUtils.sendBunkerResponse(
account,
bunkerRequest.localKey,
@@ -0,0 +1,126 @@
/**
* 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 androidx.compose.runtime.Stable
import com.greenart7c3.nostrsigner.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
fun invalidate(
ignoreIfDoing: Boolean = false,
onUpdate: suspend () -> Unit
) {
if (onlyOneInBlock.getAndSet(true)) {
if (!ignoreIfDoing) {
invalidatesAgain = true
}
return
}
scope.launch(dispatcher) {
try {
onUpdate()
delay(delay)
if (invalidatesAgain) {
onUpdate()
}
} finally {
withContext(NonCancellable) {
invalidatesAgain = false
onlyOneInBlock.set(false)
}
}
}
}
fun cancel() {
scope.cancel()
}
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var queue = LinkedBlockingQueue<T>()
fun invalidateList(
newObject: T,
onUpdate: suspend (Set<T>) -> Unit
) {
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
return
}
scope.launch(dispatcher) {
try {
val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
if (mySet.isNotEmpty()) {
onUpdate(mySet)
}
delay(delay)
val mySet2 = mutableSetOf<T>()
queue.drainTo(mySet2)
if (mySet2.isNotEmpty()) {
onUpdate(mySet2)
}
} finally {
withContext(NonCancellable) { onlyOneInBlock.set(false) }
}
}
}
fun cancel() {
scope.cancel()
}
}
@@ -0,0 +1,77 @@
package com.greenart7c3.nostrsigner.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.ui.NotificationType
import java.util.Timer
import java.util.TimerTask
class ConnectivityService : Service() {
private val timer = Timer()
private val binder = MyBinder()
override fun onBind(intent: Intent): IBinder {
return null!!
}
inner class MyBinder : Binder() {
fun getService(): ConnectivityService = this@ConnectivityService
}
private fun createNotification(): Notification {
val channelId = "ServiceChannel"
val channel = NotificationChannel(channelId, "Checking Connectivity", NotificationManager.IMPORTANCE_DEFAULT)
channel.setSound(null, null)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle("Amber is running in background")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.setColor(0xFFBF00)
return notificationBuilder.build()
}
override fun onCreate() {
startForeground(1, createNotification())
timer.scheduleAtFixedRate(
object : TimerTask() {
override fun run() {
if (LocalPreferences.getNotificationType() == NotificationType.PUSH) return
Log.d("ConnectivityService", "Checking connectivity...")
RelayPool.getAll().forEach {
if (!it.isConnected()) {
Log.d(
"ConnectivityService",
"Relay ${it.url} is not connected, reconnecting..."
)
it.connectAndSendFiltersIfDisconnected()
}
}
}
},
0,
61000
)
super.onCreate()
}
override fun onDestroy() {
timer.cancel()
super.onDestroy()
}
}
@@ -38,13 +38,16 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.greenart7c3.nostrsigner.BuildConfig
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.database.NotificationEntity
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.nostrsigner
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.service.NotificationUtils.sendNotification
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.BunkerResponse
import com.greenart7c3.nostrsigner.ui.NotificationType
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
@@ -107,7 +110,7 @@ data class BunkerRequest(
localKey = jsonObject.get("localKey")?.asText()?.intern() ?: "",
relays = jsonObject.get("relays")?.asIterable()?.toList()?.map {
it.asText().intern()
} ?: listOf("wss://relay.nsec.app"),
} ?: listOf(),
secret = jsonObject.get("secret")?.asText()?.intern() ?: ""
)
}
@@ -125,6 +128,22 @@ data class BunkerRequest(
class EventNotificationConsumer(private val applicationContext: Context) {
private val groupKey = "com.greenart7c3.nostrsigner.DM_NOTIFICATION"
fun consume(event: Event) {
if (!notificationManager().areNotificationsEnabled()) return
if (event.kind() != 24133) 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 ->
notify(event, acc)
}
}
}
}
suspend fun consume(event: GiftWrapEvent) {
if (!notificationManager().areNotificationsEnabled()) return
@@ -133,7 +152,9 @@ class EventNotificationConsumer(private val applicationContext: Context) {
LocalPreferences.allSavedAccounts().forEach {
if (it.hasPrivKey) {
LocalPreferences.loadFromEncryptedStorage(it.npub)?.let { acc ->
consumeIfMatchesAccount(event, acc)
if (LocalPreferences.getNotificationType() == NotificationType.PUSH) {
consumeIfMatchesAccount(event, acc)
}
}
}
}
@@ -176,6 +197,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event: Event,
acc: Account
) {
val dao = nostrsigner.instance.getDatabase(acc.keyPair.pubKey.toNpub()).applicationDao()
val notification = dao.getNotification(event.id)
if (notification != null) return
dao.insertNotification(NotificationEntity(0, event.id(), event.createdAt))
acc.signer.nip04Decrypt(event.content, event.pubKey) {
Log.d("bunker", event.toJson())
Log.d("bunker", it)
@@ -200,14 +226,12 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val database = nostrsigner.instance.getDatabase(acc.keyPair.pubKey.toNpub())
val permission = database.applicationDao().getByKey(bunkerRequest.localKey)
val relays = permission?.application?.relays?.ifEmpty { listOf("wss://relay.nsec.app") } ?: bunkerRequest.relays
if (permission != null && permission.application.isConnected && type == SignerType.CONNECT) {
IntentUtils.sendBunkerResponse(
acc,
bunkerRequest.localKey,
BunkerResponse(bunkerRequest.id, "ack", null),
relays,
permission.application.relays.map { url -> Relay(url) },
onLoading = {}
) { }
return@nip04Decrypt
@@ -229,7 +253,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
acc,
bunkerRequest.localKey,
BunkerResponse(bunkerRequest.id, "", "invalid secret"),
relays,
applicationWithSecret?.application?.relays?.map { url -> Relay(url) } ?: listOf(),
onLoading = { }
) { }
return@nip04Decrypt
@@ -240,6 +264,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
bunkerRequest.secret = applicationWithSecret?.application?.secret ?: ""
bunkerRequest.relays = applicationWithSecret?.application?.relays ?: bunkerRequest.relays
}
}
@@ -248,7 +273,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
acc,
bunkerRequest.localKey,
BunkerResponse(bunkerRequest.id, "", "no permission"),
relays,
listOf(),
onLoading = { }
) { }
return@nip04Decrypt
@@ -277,6 +302,8 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if (type == SignerType.CONNECT) {
message = "$name $bunkerPermission"
}
val relaysUrl = permission?.application?.relays ?: applicationWithSecret?.application?.relays ?: listOf()
val relays = relaysUrl.map { url -> Relay(url) }
cursor.use { localCursor ->
if (localCursor == null) {
@@ -17,15 +17,20 @@ 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.nostrsigner
import com.greenart7c3.nostrsigner.relays.Client
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.BunkerResponse
import com.vitorpamplona.quartz.crypto.KeyPair
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.util.UUID
@@ -135,7 +140,7 @@ object IntentUtils {
account: Account,
localKey: String,
bunkerResponse: BunkerResponse,
relays: List<String>,
relays: List<Relay>,
onLoading: (Boolean) -> Unit,
onSign: (() -> Unit)? = null,
onDone: () -> Unit
@@ -153,10 +158,25 @@ object IntentUtils {
if (onSign != null) {
onSign()
}
GlobalScope.launch(Dispatchers.IO) {
relays.forEach { relay ->
Client.send(it, relay = relay, onDone = onDone, onLoading = onLoading)
if (RelayPool.getAll().isEmpty()) {
val database = nostrsigner.instance.getDatabase(account.keyPair.pubKey.toNpub())
val savedRelays = mutableListOf<Relay>()
database.applicationDao().getAllApplications().forEach {
it.application.relays.forEach { url ->
if (url.isNotBlank()) {
if (!savedRelays.any { it.url == url }) {
savedRelays.add(Relay(url))
}
}
}
}
Client.reconnect(savedRelays.toTypedArray())
delay(1000)
}
Client.send(it, relayList = relays, onDone = onDone, onLoading = onLoading)
}
}
}
@@ -0,0 +1,316 @@
/**
* 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.checkNotInMainThread
import com.greenart7c3.nostrsigner.relays.Client
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.relays.Subscription
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
private var active: Boolean = false
fun printCounter() {
eventCounter.forEach {
Log.d(
"STATE DUMP ${this.javaClass.simpleName}",
"Received Events ${it.key}: ${it.value.counter}"
)
}
}
fun isActive(): Boolean {
return active
}
private val clientListener =
object : Client.Listener() {
override fun onEvent(
event: Event,
subscriptionId: String,
relay: Relay,
afterEOSE: Boolean
) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
subscriptionId: String?
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url} ${subscriptionId}
// ${type.name}")
// }
if (
type == Relay.StateType.EOSE &&
subscriptionId != null &&
subscriptions.containsKey(subscriptionId)
) {
markAsEOSE(subscriptionId, relay)
}
}
override fun onSendResponse(
eventId: String,
success: Boolean,
message: String,
relay: Relay
) {
}
override fun onAuth(
relay: Relay,
challenge: String
) {
auth(relay, challenge)
}
override fun onNotify(
relay: Relay,
description: String
) {
notify(relay, description)
}
}
init {
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d(this.javaClass.simpleName, "${this.javaClass.simpleName} Unsubscribe")
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
active = true
resetFilters()
}
@OptIn(DelicateCoroutinesApi::class)
open fun stop() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
GlobalScope.launch(Dispatchers.IO) {
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
}
open fun stopSync() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
fun resetFilters() {
scope.launch(Dispatchers.IO) { resetFiltersSuspend() }
}
fun resetFiltersSuspend() {
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters }
changingFilters.getAndSet(true)
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
val isActive = Client.isActive(updatedSubscription.id)
if (!isActive && updatedSubscriptionNewFilters != null) {
// Filter was removed from the active list
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
if (currentFilters.containsKey(updatedSubscription.id)) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
Client.close(updatedSubscription.id)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters
)
}
}
}
} else {
if (updatedSubscriptionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 3 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}"
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
}
}
}
changingFilters.getAndSet(false)
}
open fun consume(
event: Event,
relay: Relay
) {
// LocalCache.verifyAndConsume(event, relay)
}
open fun markAsEOSE(
subscriptionId: String,
relay: Relay
) {
subscriptions[subscriptionId]?.updateEOSE(
// in case people's clock is slighly off.
TimeUtils.oneMinuteAgo(),
relay.url
)
}
abstract fun updateChannelFilters()
open fun auth(
relay: Relay,
challenge: String
) = Unit
open fun notify(
relay: Relay,
description: String
) = Unit
}
@@ -0,0 +1,105 @@
/**
* 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 com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.checkNotInMainThread
import com.greenart7c3.nostrsigner.models.TimeUtils
import com.greenart7c3.nostrsigner.nostrsigner
import com.greenart7c3.nostrsigner.relays.Client
import com.greenart7c3.nostrsigner.relays.EOSETime
import com.greenart7c3.nostrsigner.relays.FeedType
import com.greenart7c3.nostrsigner.relays.JsonFilter
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
object NotificationDataSource : NostrDataSource("AccountData") {
private val eventNotificationConsumer = EventNotificationConsumer(nostrsigner.instance)
fun createNotificationsFilter(): TypedFilter {
var since = TimeUtils.now()
val accounts = LocalPreferences.allSavedAccounts()
accounts.forEach {
val latest = nostrsigner.instance.getDatabase(it.npub).applicationDao().getLatestNotification()
if (latest != null) {
since = latest
}
}
val pubKeys = accounts.mapNotNull {
LocalPreferences.loadFromEncryptedStorage(it.npub)?.keyPair?.pubKey?.toHexKey()
}
val eoses = RelayPool.getAll().associate {
Pair(it.url, EOSETime(since))
}
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
JsonFilter(
kinds = listOf(24133),
tags = mapOf("p" to pubKeys),
limit = 1,
since = eoses
)
)
}
override fun consume(
event: Event,
relay: Relay
) {
checkNotInMainThread()
eventNotificationConsumer.consume(event)
}
private val accountChannel =
requestNewChannel { _, _ ->
invalidateFilters()
}
override fun updateChannelFilters() {
accountChannel.typedFilters = listOf(createNotificationsFilter())
}
override fun auth(
relay: Relay,
challenge: String
) {
super.auth(relay, challenge)
LocalPreferences.allSavedAccounts().forEach {
val account = LocalPreferences.loadFromEncryptedStorage(it.npub) ?: return@forEach
account.createAuthEvent(relay.url, challenge) { authEvent ->
Client.send(
authEvent,
{ },
relay.url
)
}
}
}
}
@@ -27,6 +27,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import com.greenart7c3.nostrsigner.MainActivity
@@ -129,6 +130,11 @@ object NotificationUtils {
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setExtras(
Bundle().apply {
putString("route", Route.Home.route)
}
)
// Build the notification
val builder = NotificationCompat.Builder(
@@ -147,6 +153,11 @@ object NotificationUtils {
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setExtras(
Bundle().apply {
putString("route", Route.Home.route)
}
)
notify(notId, builder.build())
}
@@ -11,13 +11,18 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import com.greenart7c3.nostrsigner.BuildConfig
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.nostrsigner
import com.greenart7c3.nostrsigner.relays.Client
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.service.ConnectivityService
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.vitorpamplona.quartz.encoders.toNpub
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -64,15 +69,22 @@ fun AccountScreen(
SideEffect {
scope.launch(Dispatchers.IO) {
val relays = mutableListOf<Relay>()
database.applicationDao().getAllApplications().forEach {
it.application.relays.forEach { url ->
if (url.isNotBlank()) {
if (RelayPool.getRelays(url).isEmpty()) {
RelayPool.addRelay(Relay(url))
if (!relays.any { it.url == url }) {
relays.add(Relay(url))
}
}
}
}
delay(1000)
if (LocalPreferences.getNotificationType() == NotificationType.DIRECT && BuildConfig.FLAVOR != "offline") {
nostrsigner.instance.applicationContext.startService(Intent(nostrsigner.instance.applicationContext, ConnectivityService::class.java))
Client.reconnect(relays.toTypedArray())
NotificationDataSource.start()
}
}
}
@@ -64,6 +64,8 @@ import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity
import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.relays.Client
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.ui.actions.QrCodeDialog
import com.greenart7c3.nostrsigner.ui.components.CloseButton
import com.greenart7c3.nostrsigner.ui.components.PostButton
@@ -255,6 +257,18 @@ fun EditPermission(
database
.applicationDao()
.delete(applicationData)
val relays = mutableListOf<Relay>()
database.applicationDao().getAllApplications().forEach {
it.application.relays.forEach { url ->
if (url.isNotBlank()) {
if (!relays.any { it.url == url }) {
relays.add(Relay(url))
}
}
}
}
Client.reconnect(relays.toTypedArray())
}
scope.launch(Dispatchers.Main) {
@@ -477,6 +491,17 @@ fun EditPermission(
permissions
)
)
val relays = mutableListOf<Relay>()
database.applicationDao().getAllApplications().forEach {
it.application.relays.forEach { url ->
if (url.isNotBlank()) {
if (!relays.any { it.url == url }) {
relays.add(Relay(url))
}
}
}
}
Client.reconnect(relays.toTypedArray())
scope.launch(Dispatchers.Main) {
navController.popBackStack()
@@ -87,9 +87,11 @@ 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.relays.Relay
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.service.EventNotificationConsumer
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.greenart7c3.nostrsigner.service.PushNotificationUtils
import com.greenart7c3.nostrsigner.service.getAppCompatActivity
import com.greenart7c3.nostrsigner.service.toShortenHex
@@ -137,10 +139,12 @@ fun sendResult(
RelayPool.getAll().forEach { relay ->
if (!relay.isConnected()) {
relay.connectAndRun {
GlobalScope.launch(Dispatchers.IO) {
delay(60000)
if (relay.isConnected()) {
relay.disconnect()
if (!NotificationDataSource.isActive()) {
GlobalScope.launch(Dispatchers.IO) {
delay(60000)
if (relay.isConnected()) {
relay.disconnect()
}
}
}
}
@@ -165,7 +169,7 @@ fun sendResult(
database.applicationDao().deletePermissions(key)
}
val relays = intentData.bunkerRequest?.relays?.ifEmpty { listOf("wss://relay.nsec.app") } ?: listOf("wss://relay.nsec.app")
val relays = intentData.bunkerRequest?.relays ?: listOf()
val savedApplication = database.applicationDao().getByKey(key)
val application = savedApplication ?: ApplicationWithPermissions(
@@ -225,7 +229,7 @@ fun sendResult(
account,
intentData.bunkerRequest.localKey,
BunkerResponse(intentData.bunkerRequest.id, event, null),
relays,
application.application.relays.map { url -> Relay(url) },
onLoading,
onSign = {
database.applicationDao().insertApplicationWithPermissions(application)
@@ -1,11 +1,16 @@
package com.greenart7c3.nostrsigner.ui
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Person
@@ -22,25 +27,37 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.greenart7c3.nostrsigner.BuildConfig
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.nostrsigner
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.service.ConnectivityService
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.greenart7c3.nostrsigner.ui.actions.AccountBackupDialog
import com.greenart7c3.nostrsigner.ui.actions.AccountsBottomSheet
import com.greenart7c3.nostrsigner.ui.actions.ConnectOrbotDialog
import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog
import com.greenart7c3.nostrsigner.ui.components.HyperlinkText
import com.greenart7c3.nostrsigner.ui.components.IconRow
import com.greenart7c3.nostrsigner.ui.components.TextSpinner
import com.greenart7c3.nostrsigner.ui.components.TitleExplainer
import com.vitorpamplona.quartz.encoders.toNpub
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -64,6 +81,11 @@ fun SettingsScreen(
skipPartiallyExpanded = true
)
val scope = rememberCoroutineScope()
val notificationItems = persistentListOf(
TitleExplainer(stringResource(NotificationType.PUSH.resourceId)),
TitleExplainer(stringResource(NotificationType.DIRECT.resourceId))
)
val notificationItemsIndex = LocalPreferences.getNotificationType().screenCode
if (shouldShowBottomSheet) {
AccountsBottomSheet(
@@ -123,31 +145,66 @@ fun SettingsScreen(
}
)
}
Box(
Modifier
.padding(16.dp)
) {
IconRow(
title =
if (checked) {
stringResource(R.string.disconnect_from_your_orbot_setup)
} else {
stringResource(R.string.connect_via_tor_short)
},
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
onLongClick = {
conectOrbotDialogOpen = true
},
onClick = {
if (BuildConfig.FLAVOR != "offline") {
Box(
Modifier
.padding(16.dp)
) {
IconRow(
title =
if (checked) {
disconnectTorDialog = true
stringResource(R.string.disconnect_from_your_orbot_setup)
} else {
stringResource(R.string.connect_via_tor_short)
},
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
onLongClick = {
conectOrbotDialogOpen = true
},
onClick = {
if (checked) {
disconnectTorDialog = true
} else {
conectOrbotDialogOpen = true
}
}
)
}
Box(
Modifier
.padding(16.dp)
) {
SettingsRow(
R.string.notification_type,
R.string.select_the_type_of_notification_you_want_to_receive,
notificationItems,
notificationItemsIndex
) {
scope.launch(Dispatchers.IO) {
LocalPreferences.updateNotificationType(parseNotificationType(it))
if (it == 0) {
nostrsigner.instance.applicationContext.stopService(
Intent(
nostrsigner.instance.applicationContext,
ConnectivityService::class.java
)
)
NotificationDataSource.stopSync()
RelayPool.disconnect()
} else {
nostrsigner.instance.applicationContext.startService(
Intent(
nostrsigner.instance.applicationContext,
ConnectivityService::class.java
)
)
NotificationDataSource.start()
}
}
}
)
}
}
Spacer(modifier = Modifier.weight(1f))
@@ -223,3 +280,60 @@ fun SettingsScreen(
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
}
}
@Composable
fun SettingsRow(
name: Int,
description: Int,
selectedItens: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = stringResource(name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stringResource(description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
TextSpinner(
label = "",
placeholder = selectedItens[selectedIndex].title,
options = selectedItens,
onSelect = onSelect,
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f)
)
}
}
enum class NotificationType(val screenCode: Int, val resourceId: Int) {
PUSH(0, R.string.push_notifications),
DIRECT(1, R.string.direct_connection)
}
fun parseNotificationType(screenCode: Int): NotificationType {
return when (screenCode) {
NotificationType.PUSH.screenCode -> NotificationType.PUSH
NotificationType.DIRECT.screenCode -> NotificationType.DIRECT
else -> {
NotificationType.PUSH
}
}
}
@@ -47,11 +47,13 @@ import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.relays.RelayPool
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.ApplicationNameCache
import com.greenart7c3.nostrsigner.service.EventNotificationConsumer
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.NotificationDataSource
import com.greenart7c3.nostrsigner.service.getAppCompatActivity
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
@@ -141,10 +143,12 @@ fun MultiEventHomeScreen(
RelayPool.getAll().forEach { relay ->
if (!relay.isConnected()) {
relay.connectAndRun {
GlobalScope.launch(Dispatchers.IO) {
delay(60000)
if (relay.isConnected()) {
relay.disconnect()
if (!NotificationDataSource.isActive()) {
GlobalScope.launch(Dispatchers.IO) {
delay(60000)
if (relay.isConnected()) {
relay.disconnect()
}
}
}
}
@@ -205,7 +209,7 @@ fun MultiEventHomeScreen(
localAccount,
intentData.bunkerRequest.localKey,
BunkerResponse(intentData.bunkerRequest.id, signedEvent.toJson(), null),
applicationEntity?.application?.relays ?: listOf("wss://relay.nsec.app"),
applicationEntity?.application?.relays?.map { url -> Relay(url) } ?: emptyList(),
onLoading = {}
) {}
} else {
@@ -242,7 +246,7 @@ fun MultiEventHomeScreen(
localAccount,
intentData.bunkerRequest.localKey,
BunkerResponse(intentData.bunkerRequest.id, signature, null),
applicationEntity?.application?.relays ?: listOf("wss://relay.nsec.app"),
applicationEntity?.application?.relays?.map { url -> Relay(url) } ?: emptyList(),
onLoading = {}
) { }
} else {
@@ -31,6 +31,7 @@ import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.relays.Relay
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.getAppCompatActivity
@@ -125,7 +126,8 @@ fun SingleEventHomeScreen(
}
if (intentData.bunkerRequest != null) {
AmberUtils.sendBunkerError(account, intentData.bunkerRequest, context, onLoading = onLoading)
val relays = applicationEntity?.application?.relays?.map { url -> Relay(url) } ?: emptyList()
AmberUtils.sendBunkerError(account, intentData.bunkerRequest, relays, context, onLoading = onLoading)
} else {
context.getAppCompatActivity()?.intent = null
context.getAppCompatActivity()?.finish()
@@ -257,7 +259,7 @@ fun SingleEventHomeScreen(
}
if (intentData.bunkerRequest != null) {
AmberUtils.sendBunkerError(account, intentData.bunkerRequest, context, onLoading)
AmberUtils.sendBunkerError(account, intentData.bunkerRequest, applicationEntity?.application?.relays?.map { url -> Relay(url) } ?: emptyList(), context, onLoading)
} else {
context.getAppCompatActivity()?.intent = null
context.getAppCompatActivity()?.finish()
@@ -412,6 +414,7 @@ fun SingleEventHomeScreen(
AmberUtils.sendBunkerError(
account,
intentData.bunkerRequest,
applicationEntity?.application?.relays?.map { url -> Relay(url) } ?: emptyList(),
context,
onLoading
)
@@ -0,0 +1,202 @@
/**
* 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.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TextSpinner(
label: String?,
placeholder: String,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
TextSpinner(
placeholder,
options,
onSelect,
modifier
) { currentOption, modifier ->
OutlinedTextField(
value = currentOption,
onValueChange = {},
readOnly = true,
label = { label?.let { Text(it) } },
modifier = modifier
)
}
}
@Composable
fun TextSpinner(
placeholder: String,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
mainElement(
currentText,
remember { Modifier.fillMaxWidth().focusRequester(focusRequester) }
)
Box(
modifier =
Modifier.matchParentSize().clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
focusRequester.requestFocus()
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun SpinnerSelectionDialog(
title: String? = null,
options: ImmutableList<TitleExplainer>,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
SpinnerSelectionDialog(
title = title,
options = options,
onSelect = onSelect,
onDismiss = onDismiss
) { item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = item.title, color = MaterialTheme.colorScheme.onSurface)
}
item.explainer?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = 14.sp)
}
}
}
}
@Composable
fun <T> SpinnerSelectionDialog(
title: String? = null,
options: ImmutableList<T>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (T) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp)
) {
LazyColumn {
title?.let {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp),
horizontalArrangement = Arrangement.Center
) {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold
)
}
HorizontalDivider(color = Color.LightGray, thickness = 0.25.dp)
}
}
itemsIndexed(options) { index, item ->
Row(
modifier = Modifier.fillMaxWidth().clickable { onSelect(index) }.padding(16.dp, 16.dp)
) {
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
HorizontalDivider(color = Color.LightGray, thickness = 0.25.dp)
}
}
}
}
}
}
@Immutable data class TitleExplainer(val title: String, val explainer: String? = null)
+4
View File
@@ -115,4 +115,8 @@
<string name="no">No</string>
<string name="could_not_connect_to_tor">Could not connect to Tor</string>
<string name="relays">Relays</string>
<string name="push_notifications">Push notifications</string>
<string name="direct_connection">Direct Connection</string>
<string name="notification_type">Notification Type</string>
<string name="select_the_type_of_notification_you_want_to_receive">Select the type of notification you want to receive</string>
</resources>
+10
View File
@@ -3,6 +3,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<queries>
<package android:name="org.torproject.android"/>
@@ -11,6 +15,12 @@
<application
android:name=".nostrsigner">
<service
android:name=".service.ConnectivityService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false"></service>
<service
android:name=".service.Notifications"
android:enabled="true"
+2
View File
@@ -24,6 +24,7 @@ roomKtx = "2.6.1"
securityCryptoKtx = "1.1.0-alpha06"
zxingAndroidEmbedded = "4.3.0"
okhttp = "5.0.0-alpha.12"
kotlin = "1.9.23"
[libraries]
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
@@ -64,3 +65,4 @@ zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", ve
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }