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 @@ + +