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