diff --git a/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/1.json b/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/1.json
index cb4964c5..2e3ebdd3 100644
--- a/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/1.json
+++ b/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/1.json
@@ -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')"
]
}
}
\ No newline at end of file
diff --git a/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/2.json b/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/2.json
new file mode 100644
index 00000000..f84bc970
--- /dev/null
+++ b/app/schemas/com.greenart7c3.nostrsigner.database.AppDatabase/2.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/free/AndroidManifest.xml b/app/src/free/AndroidManifest.xml
index 16e8864f..be9b94a1 100644
--- a/app/src/free/AndroidManifest.xml
+++ b/app/src/free/AndroidManifest.xml
@@ -3,6 +3,9 @@
+
+
+
@@ -11,6 +14,12 @@
+
+
, pubKey: String, database: AppDatabase) = withContext(Dispatchers.IO) {
map.forEach {
val splitData = it.key.split("-")
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
index 37eb7ddc..94211d34 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
@@ -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>(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()
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/AppDatabase.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/AppDatabase.kt
index 2d2f22b7..86cc3754 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/database/AppDatabase.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/AppDatabase.kt
@@ -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
}
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt
index 8c3a4319..356d95c6 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt
@@ -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
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/NotificationEntity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/NotificationEntity.kt
new file mode 100644
index 00000000..031d2f45
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/NotificationEntity.kt
@@ -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
+)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt
index e0a34c2c..640463a9 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Client.kt
@@ -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)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt
index 7fca5253..fc4f5588 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Relay.kt
@@ -53,8 +53,7 @@ class Relay(
private val activeTypes: Set = 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()
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt
index 14387cb9..6edf8b91 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayPool.kt
@@ -63,10 +63,8 @@ object RelayPool : Relay.Listener {
}
fun loadRelays(relayList: List) {
- 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,
- 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) {
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt
index 96d1c5e6..daef9463 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/Subscription.kt
@@ -56,4 +56,36 @@ data class Subscription(
}
}
}
+ fun hasChangedFiltersFrom(otherFilters: List?): 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
+ }
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt
index 4ee65d3d..96a78abd 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt
@@ -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,
context: Context,
onLoading: (Boolean) -> Unit
) {
- val relays = bunkerRequest.relays.ifEmpty { listOf("wss://relay.nsec.app") }
IntentUtils.sendBunkerResponse(
account,
bunkerRequest.localKey,
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BundledUpdate.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BundledUpdate.kt
new file mode 100644
index 00000000..15a423c7
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BundledUpdate.kt
@@ -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(
+ val delay: Long,
+ val dispatcher: CoroutineDispatcher = Dispatchers.Default
+) {
+ val scope = CoroutineScope(dispatcher + SupervisorJob())
+
+ private var onlyOneInBlock = AtomicBoolean()
+ private var queue = LinkedBlockingQueue()
+
+ fun invalidateList(
+ newObject: T,
+ onUpdate: suspend (Set) -> Unit
+ ) {
+ checkNotInMainThread()
+
+ queue.put(newObject)
+ if (onlyOneInBlock.getAndSet(true)) {
+ return
+ }
+
+ scope.launch(dispatcher) {
+ try {
+ val mySet = mutableSetOf()
+ queue.drainTo(mySet)
+ if (mySet.isNotEmpty()) {
+ onUpdate(mySet)
+ }
+
+ delay(delay)
+
+ val mySet2 = mutableSetOf()
+ queue.drainTo(mySet2)
+ if (mySet2.isNotEmpty()) {
+ onUpdate(mySet2)
+ }
+ } finally {
+ withContext(NonCancellable) { onlyOneInBlock.set(false) }
+ }
+ }
+ }
+
+ fun cancel() {
+ scope.cancel()
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt
new file mode 100644
index 00000000..4feac6c5
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt
index c317b7e0..9d85f5cb 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt
@@ -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) {
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
index 24af9b0e..ff83ee25 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
@@ -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,
+ relays: List,
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()
+ 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)
}
}
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrDataSource.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrDataSource.kt
new file mode 100644
index 00000000..ab2467e9
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NostrDataSource.kt
@@ -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()
+
+ data class Counter(var counter: Int)
+
+ private var eventCounter = mapOf()
+ 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
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationDataSource.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationDataSource.kt
new file mode 100644
index 00000000..edfce4ea
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationDataSource.kt
@@ -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
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt
index bd8d1c8c..0bc3dde6 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationUtils.kt
@@ -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())
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
index f19f684b..ccd580aa 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
@@ -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()
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()
+ }
}
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt
index 18093d41..4d70e9c4 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/EditPermission.kt
@@ -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()
+ 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()
+ 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()
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt
index 97eacde7..80b64ae9 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt
@@ -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)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
index dc94e8fb..feb4e946 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
@@ -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,
+ 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
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/MultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/MultiEventHomeScreen.kt
index e5ce8de3..90063b6c 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/MultiEventHomeScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/MultiEventHomeScreen.kt
@@ -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 {
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt
index b9b24d65..78ef8f36 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SingleEventHomeScreen.kt
@@ -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
)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/TextSpinner.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/TextSpinner.kt
new file mode 100644
index 00000000..4213dc9c
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/TextSpinner.kt
@@ -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,
+ 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,
+ 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,
+ 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 SpinnerSelectionDialog(
+ title: String? = null,
+ options: ImmutableList,
+ 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)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 57cd27a3..0c88a33b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -115,4 +115,8 @@
No
Could not connect to Tor
Relays
+ Push notifications
+ Direct Connection
+ Notification Type
+ Select the type of notification you want to receive
\ No newline at end of file
diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml
index 323058e8..e9f2de05 100644
--- a/app/src/play/AndroidManifest.xml
+++ b/app/src/play/AndroidManifest.xml
@@ -3,6 +3,10 @@
+
+
+
+
@@ -11,6 +15,12 @@
+
+