diff --git a/.github/ISSUE_TEMPLATE/BUG.md b/.github/ISSUE_TEMPLATE/BUG.md new file mode 100644 index 0000000..0d737bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG.md @@ -0,0 +1,47 @@ +--- +name: Bug Report +about: Create a report to help us improve the SDK. +labels: bug +--- + + + +### Bug Report + +- [ ] I'm using the latest version of the SDK. +- [ ] I've seen [the docs](https://docs.walletbeacon.io) and [the demo code](https://github.com/airgap-it/beacon-android-sdk/tree/master/demo). +- [ ] I'm using the SDK directly (not via a 3rd party library or application). + + - [ ] I've been able to confirm that the issue comes from this SDK, not the 3rd party software on top. + +#### Current Behavior + + + +#### Expected Behavior + + + +#### How to Reproduce? + + + +#### Environment + +- Device: **PLACEHOLDER** +- OS version: **PLACEHOLDER** + +#### Additional Context + + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/FEATURE.md b/.github/ISSUE_TEMPLATE/FEATURE.md new file mode 100644 index 0000000..e5c2450 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE.md @@ -0,0 +1,30 @@ +--- +name: Feature Request +about: Suggest an idea for this project. +labels: enhancement +--- + + + +### Feature Request + +- [ ] I'm using the latest version of the SDK. + +#### Summary + + + +#### Expected Behaviour + + + +#### Use Case + + + +#### Additional Context + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index c1c15c4..3192d8a 100644 --- a/build.gradle +++ b/build.gradle @@ -5,10 +5,10 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Version.kotlin}" classpath "org.jetbrains.kotlin:kotlin-serialization:${Version.kotlin}" - classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.10" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.21" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/buildSrc/src/main/java/GradleConfig.kt b/buildSrc/src/main/java/GradleConfig.kt index 4d8976a..a4aec97 100644 --- a/buildSrc/src/main/java/GradleConfig.kt +++ b/buildSrc/src/main/java/GradleConfig.kt @@ -1,14 +1,14 @@ object Android { - const val compileSdk = 32 + const val compileSdk = 33 const val minSdk = 21 - const val targetSdk = 32 + const val targetSdk = 33 - const val versionCode = 27 - const val versionName = "3.2.2" + const val versionCode = 31 + const val versionName = "3.2.3" } object Version { - const val kotlin = "1.5.30" + const val kotlin = "1.7.20" const val kotlinSerialization = "1.3.1" diff --git a/core/src/main/java/it/airgap/beaconsdk/core/internal/migration/v3_2_0/SerializersFromV3_2_0.kt b/core/src/main/java/it/airgap/beaconsdk/core/internal/migration/v3_2_0/SerializersFromV3_2_0.kt index b3f0932..68ec657 100644 --- a/core/src/main/java/it/airgap/beaconsdk/core/internal/migration/v3_2_0/SerializersFromV3_2_0.kt +++ b/core/src/main/java/it/airgap/beaconsdk/core/internal/migration/v3_2_0/SerializersFromV3_2_0.kt @@ -46,7 +46,7 @@ private data class PeerSurrogate( val isPaired: Boolean = false, ) { fun toTarget(): Peer = when (type) { - Type.P2P -> P2pPeer(id, name, publicKey, relayServer, version, icon, appUrl) + Type.P2P -> P2pPeer(id, name, publicKey, relayServer, version, icon, appUrl, isPaired) } @Serializable diff --git a/core/src/main/java/it/airgap/beaconsdk/core/internal/storage/decorator/DecoratedStorage.kt b/core/src/main/java/it/airgap/beaconsdk/core/internal/storage/decorator/DecoratedStorage.kt index 6197e4b..08f67e2 100644 --- a/core/src/main/java/it/airgap/beaconsdk/core/internal/storage/decorator/DecoratedStorage.kt +++ b/core/src/main/java/it/airgap/beaconsdk/core/internal/storage/decorator/DecoratedStorage.kt @@ -15,51 +15,49 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.reflect.KClass -private typealias StorageSelectCollection = suspend Storage.() -> List -private typealias StorageInsertCollection = suspend Storage.(List) -> Unit - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class DecoratedStorage( private val storage: Storage, private val beaconConfiguration: BeaconConfiguration, ) : ExtendedStorage, Storage by storage { - private val _appMetadata: MutableSharedFlow by lazy { resourceFlow() } - override val appMetadata: Flow get() = _appMetadata.onSubscription { emitAll(getAppMetadata(beaconConfiguration).asFlow()) } + override val peers: Flow + get() = with(ResourceCollection.PeerList) { + resourceFlow.onSubscription { emitAll(getPeers(beaconConfiguration).asFlow()) } + } - private val _permissions: MutableSharedFlow by lazy { resourceFlow() } - override val permissions: Flow get() = _permissions.onSubscription { emitAll(getPermissions(beaconConfiguration).asFlow()) } + override val appMetadata: Flow + get() = with(ResourceCollection.AppMetadataList) { + resourceFlow.onSubscription { emitAll(getAppMetadata(beaconConfiguration).asFlow()) } + } - private val _peers: MutableSharedFlow by lazy { resourceFlow() } - override val peers: Flow get() = _peers.onSubscription { emitAll(getPeers(beaconConfiguration).asFlow()) } + override val permissions: Flow + get() = with(ResourceCollection.PermissionList) { + resourceFlow.onSubscription { emitAll(getPermissions(beaconConfiguration).asFlow()) } + } override suspend fun addPeers( peers: List, overwrite: Boolean, selector: Peer.() -> List?, ) { - add( - { getPeers(beaconConfiguration) }, - Storage::setPeers, - _peers, - peers, - overwrite, - selector, - ) + add(ResourceCollection.PeerList, peers, overwrite, selector) } override suspend fun findPeer(predicate: (Peer) -> Boolean): Peer? = - selectFirst({ getPeers(beaconConfiguration) }, predicate) + selectFirst(ResourceCollection.PeerList, predicate) override suspend fun findPeer( instanceClass: KClass, predicate: (T) -> Boolean, - ): T? = selectFirstInstance({ getPeers(beaconConfiguration) }, instanceClass, predicate) + ): T? = selectFirstInstance(ResourceCollection.PeerList, instanceClass, predicate) override suspend fun removePeers(predicate: ((Peer) -> Boolean)?) { - if (predicate != null) remove({ getPeers(beaconConfiguration) }, Storage::setPeers, predicate) - else removeAll(Storage::setPeers) + if (predicate != null) remove(ResourceCollection.PeerList, predicate) + else removeAll(ResourceCollection.PeerList) } override suspend fun addAppMetadata( @@ -67,27 +65,20 @@ public class DecoratedStorage( overwrite: Boolean, selector: AppMetadata.() -> List?, ) { - add( - { getAppMetadata(beaconConfiguration) }, - Storage::setAppMetadata, - _appMetadata, - appsMetadata, - overwrite, - selector, - ) + add(ResourceCollection.AppMetadataList, appsMetadata, overwrite, selector) } override suspend fun findAppMetadata(predicate: (AppMetadata) -> Boolean): AppMetadata? = - selectFirst({ getAppMetadata(beaconConfiguration) }, predicate) + selectFirst(ResourceCollection.AppMetadataList, predicate) override suspend fun findAppMetadata( instanceClass: KClass, predicate: (T) -> Boolean, - ): T? = selectFirstInstance({ getAppMetadata(beaconConfiguration) }, instanceClass, predicate) + ): T? = selectFirstInstance(ResourceCollection.AppMetadataList, instanceClass, predicate) override suspend fun removeAppMetadata(predicate: ((AppMetadata) -> Boolean)?) { - if (predicate != null) remove({ getAppMetadata(beaconConfiguration) }, Storage::setAppMetadata, predicate) - else removeAll(Storage::setAppMetadata) + if (predicate != null) remove(ResourceCollection.AppMetadataList, predicate) + else removeAll(ResourceCollection.AppMetadataList) } override suspend fun addPermissions( @@ -95,81 +86,72 @@ public class DecoratedStorage( overwrite: Boolean, selector: Permission.() -> List?, ) { - add( - { getPermissions(beaconConfiguration) }, - Storage::setPermissions, - _permissions, - permissions, - overwrite, - selector, - ) + add(ResourceCollection.PermissionList, permissions, overwrite, selector) } override suspend fun findPermission(predicate: (Permission) -> Boolean): Permission? = - selectFirst({ getPermissions(beaconConfiguration) }, predicate) + selectFirst(ResourceCollection.PermissionList, predicate) override suspend fun findPermission( instanceClass: KClass, predicate: (T) -> Boolean, - ): T? = selectFirstInstance({ getPermissions(beaconConfiguration) }, instanceClass, predicate) + ): T? = selectFirstInstance(ResourceCollection.PermissionList, instanceClass, predicate) override suspend fun removePermissions(predicate: ((Permission) -> Boolean)?) { - if (predicate != null) remove({ getPermissions(beaconConfiguration) }, Storage::setPermissions, predicate) - else removeAll(Storage::setPermissions) + if (predicate != null) remove(ResourceCollection.PermissionList, predicate) + else removeAll(ResourceCollection.PermissionList) } override suspend fun addMigrations(migrations: Set) { - val storageMigrations = getMigrations() - .toMutableSet() - .also { it.addAll(migrations) } + ResourceCollection.MigrationSet.runAtomic { + val storageMigrations = getMigrations() + .toMutableSet() + .also { it.addAll(migrations) } - setMigrations(storageMigrations) + setMigrations(storageMigrations) + } } override fun scoped(beaconScope: BeaconScope): ExtendedStorage = DecoratedStorage(storage.scoped(beaconScope), beaconConfiguration) override fun extend(beaconConfiguration: BeaconConfiguration): ExtendedStorage = this - private suspend fun selectFirst( - select: StorageSelectCollection, + private suspend fun > selectFirst( + resourceCollection: ResourceCollection, predicate: (T) -> Boolean, - ): T? = select(this).find(predicate) + ): T? = resourceCollection.runAtomic { select(beaconConfiguration).find(predicate) } - private suspend fun selectFirstInstance( - select: StorageSelectCollection, + private suspend fun , Instance : T> selectFirstInstance( + resourceCollection: ResourceCollection, instanceClass: KClass, predicate: (Instance) -> Boolean, - ): Instance? = select(this).filterIsInstance(instanceClass.java).find(predicate) + ): Instance? = resourceCollection.runAtomic { select(beaconConfiguration).filterIsInstance(instanceClass.java).find(predicate) } private suspend fun add( - select: StorageSelectCollection, - insert: StorageInsertCollection, - subscribeFlow: MutableSharedFlow, + resourceCollection: ResourceCollection>, elements: List, overwrite: Boolean, selector: T.() -> List?, ) { - val stored = select(this).distinctByKeepLast(selector) + resourceCollection.runAtomic { + val stored = select(beaconConfiguration).distinctByKeepLast(selector) - val mappedIndices = createMappedIndices(stored, elements, selector) - val (toInsert, updatedIndices) = stored.updatedWith(elements, mappedIndices, overwrite) + val mappedIndices = createMappedIndices(stored, elements, selector) + val (toInsert, updatedIndices) = stored.updatedWith(elements, mappedIndices, overwrite) - insert(this, toInsert) + insert(toInsert, beaconConfiguration) - CoroutineScope(Dispatchers.Default).launch { - toInsert.filterIndexed { index, _ -> updatedIndices.contains(index) }.forEach { subscribeFlow.tryEmit(it) } + CoroutineScope(Dispatchers.Default).launch { + toInsert.filterIndexed { index, _ -> updatedIndices.contains(index) }.forEach { resourceFlow.emit(it) } + } } } - private suspend fun remove( - select: StorageSelectCollection, - insert: StorageInsertCollection, - predicate: (T) -> Boolean, - ) { - insert(this, select(this).filterNot(predicate)) + private suspend fun remove(resourceCollection: ResourceCollection>, predicate: (T) -> Boolean) { + resourceCollection.runAtomic { insert(select(beaconConfiguration).filterNot(predicate), beaconConfiguration) } } - private suspend fun removeAll(insert: StorageInsertCollection) { - insert(this, emptyList()) + private suspend fun removeAll(resourceCollection: ResourceCollection>) { + resourceCollection.runAtomic { insert(emptyList(), beaconConfiguration) } } private fun createMappedIndices(first: List, second: List, selector: T.() -> List?): Map { @@ -181,9 +163,6 @@ public class DecoratedStorage( return indices.values.filter { it.size == 2 }.associate { it[0] to it[1] } } - private fun resourceFlow(bufferCapacity: Int = 64): MutableSharedFlow = - MutableSharedFlow(extraBufferCapacity = bufferCapacity) - private inline fun List.distinctByKeepLast(selector: T.() -> List?): List { val (withSelector, withoutSelector) = map { selector(it)?.sumHashCodes() to it } .partition { it.first != null } @@ -221,4 +200,62 @@ public class DecoratedStorage( private fun List.sumHashCodes(): Int = fold(0) { acc, next -> acc + next.hashCode() } private fun Pair.mapFirst(transform: (A) -> R): Pair = Pair(transform(first), second) -} \ No newline at end of file + + private sealed class ResourceCollection> { + abstract val Storage.resourceFlow: MutableSharedFlow + + abstract suspend fun Storage.select(beaconConfiguration: BeaconConfiguration): C + abstract suspend fun Storage.insert(collection: C, beaconConfiguration: BeaconConfiguration) + + private val mutex: Mutex = Mutex() + suspend inline fun runAtomic(action: ResourceCollection.() -> R): R = + mutex.withLock { action(this) } + + protected fun resourceFlow(bufferCapacity: Int = 64): MutableSharedFlow = + MutableSharedFlow(extraBufferCapacity = bufferCapacity) + + object PeerList : ResourceCollection>() { + override val Storage.resourceFlow: MutableSharedFlow by lazy { resourceFlow() } + + override suspend fun Storage.select(beaconConfiguration: BeaconConfiguration): List = + getPeers(beaconConfiguration) + + override suspend fun Storage.insert(collection: List, beaconConfiguration: BeaconConfiguration) { + setPeers(collection) + } + } + + object AppMetadataList : ResourceCollection>() { + override val Storage.resourceFlow: MutableSharedFlow by lazy { resourceFlow() } + + override suspend fun Storage.select(beaconConfiguration: BeaconConfiguration): List = + getAppMetadata(beaconConfiguration) + + override suspend fun Storage.insert(collection: List, beaconConfiguration: BeaconConfiguration) { + setAppMetadata(collection) + } + } + + object PermissionList : ResourceCollection>() { + override val Storage.resourceFlow: MutableSharedFlow by lazy { resourceFlow() } + + override suspend fun Storage.select(beaconConfiguration: BeaconConfiguration): List = + getPermissions(beaconConfiguration) + + override suspend fun Storage.insert(collection: List, beaconConfiguration: BeaconConfiguration) { + setPermissions(collection) + } + } + + object MigrationSet : ResourceCollection>() { + override val Storage.resourceFlow: MutableSharedFlow by lazy { resourceFlow() } + + override suspend fun Storage.select(beaconConfiguration: BeaconConfiguration): Set = + getMigrations() + + override suspend fun Storage.insert(collection: Set, beaconConfiguration: BeaconConfiguration) { + setMigrations(collection) + } + } + } +} diff --git a/core/src/main/java/it/airgap/beaconsdk/core/internal/transport/p2p/P2pTransport.kt b/core/src/main/java/it/airgap/beaconsdk/core/internal/transport/p2p/P2pTransport.kt index 86428db..3cd40a3 100644 --- a/core/src/main/java/it/airgap/beaconsdk/core/internal/transport/p2p/P2pTransport.kt +++ b/core/src/main/java/it/airgap/beaconsdk/core/internal/transport/p2p/P2pTransport.kt @@ -11,13 +11,14 @@ import it.airgap.beaconsdk.core.internal.transport.p2p.store.DiscardPairingData import it.airgap.beaconsdk.core.internal.transport.p2p.store.OnPairingCompleted import it.airgap.beaconsdk.core.internal.transport.p2p.store.OnPairingRequested import it.airgap.beaconsdk.core.internal.transport.p2p.store.P2pTransportStore +import it.airgap.beaconsdk.core.internal.utils.CoroutineScopeRegistry import it.airgap.beaconsdk.core.internal.utils.flatMap import it.airgap.beaconsdk.core.internal.utils.runCatchingFlat import it.airgap.beaconsdk.core.internal.utils.success import it.airgap.beaconsdk.core.storage.findPeer import it.airgap.beaconsdk.core.transport.data.* import it.airgap.beaconsdk.core.transport.p2p.P2pClient -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @OptIn(FlowPreview::class) @@ -31,13 +32,15 @@ internal class P2pTransport( override val incomingConnectionMessages: Flow> by lazy { storageManager.updatedPeers .filterIsInstance() - .onEach { onUpdatedP2pPeer(it) } + .onEach { it.onUpdated() } .filterNot { it.isRemoved || client.isSubscribed(it) } .mapNotNull { client.subscribeTo(it) } .flattenMerge() .map { IncomingConnectionMessage.fromResult(it) } } + private val peerScopes: CoroutineScopeRegistry = CoroutineScopeRegistry("peers") + override suspend fun pair(): Flow> = flow { val pairingRequest = client.createPairingRequest().also { emit(it) @@ -87,19 +90,33 @@ internal class P2pTransport( } } - private suspend fun onUpdatedP2pPeer(peer: P2pPeer) { - if (!peer.isPaired && !peer.isRemoved) pairP2pPeer(peer) - if (peer.isRemoved) client.unsubscribeFrom(peer) + private suspend fun P2pPeer.onUpdated() { + when { + isRemoved -> unpair() + !isPaired -> pair() + } } - private suspend fun pairP2pPeer(peer: P2pPeer) { - val result = client.sendPairingResponse(peer) + private suspend fun P2pPeer.pair() { + peerScopes.get(scopeId).launch { + val result = client.sendPairingResponse(this@pair) + + if (result.isSuccess) { + storageManager.updatePeers(listOf(selfPaired())) { listOfNotNull(id, name, publicKey, relayServer, icon, appUrl) } + } - if (result.isSuccess) { - storageManager.updatePeers(listOf(peer.selfPaired())) { listOfNotNull(id, name, publicKey, relayServer, icon, appUrl) } + peerScopes.cancel(scopeId) } } + private suspend fun P2pPeer.unpair() { + peerScopes.cancel(scopeId) + client.unsubscribeFrom(this) + } + + private val P2pPeer.scopeId: String + get() = id ?: publicKey + private fun failWithUnknownPeer(publicKey: String?): Nothing = throw IllegalStateException("P2P peer with public key $publicKey is not recognized.") diff --git a/core/src/main/java/it/airgap/beaconsdk/core/internal/utils/CoroutineScopeRegistry.kt b/core/src/main/java/it/airgap/beaconsdk/core/internal/utils/CoroutineScopeRegistry.kt new file mode 100644 index 0000000..29bbda0 --- /dev/null +++ b/core/src/main/java/it/airgap/beaconsdk/core/internal/utils/CoroutineScopeRegistry.kt @@ -0,0 +1,54 @@ +package it.airgap.beaconsdk.core.internal.utils + +import androidx.annotation.RestrictTo +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class CoroutineScopeRegistry( + private val name: String, + private val context: CoroutineContext = Dispatchers.Default, +) { + private val mutex: Mutex = Mutex() + private val scopes: MutableMap = mutableMapOf() + + public suspend fun get(id: String): CoroutineScope = mutex.withLock { + scopes.getIfActiveOrPut(id) { DisposableCoroutineScope(id) } + } + + public suspend fun cancel(id: String) { + mutex.withLock { + scopes[id]?.cancel(message = "Scope ${scopeName(id)} canceled.") + scopes.remove(id) + } + } + + public suspend fun cancelAll() { + mutex.withLock { + scopes.forEach { it.value.cancel(message = "Scope ${scopeName(it.key)} canceled.") } + scopes.clear() + } + } + + public suspend fun isEmpty(): Boolean = mutex.withLock { scopes.isEmpty() } + + private fun scopeName(id: String): String = "$name@$id" + + @Suppress("FunctionName") + private fun DisposableCoroutineScope(id: String): CoroutineScope = + CoroutineScope(CoroutineName(scopeName(id)) + context).also { it.removeOnCompletion(id) } + + private fun CoroutineScope.removeOnCompletion(id: String) { + coroutineContext.job.invokeOnCompletion { + CoroutineScope(Dispatchers.Default).launch { + mutex.withLock { scopes.remove(id) } + } + } + } + + private fun MutableMap.getIfActiveOrPut(key: String, defaultValue: () -> CoroutineScope): CoroutineScope = + get(key)?.takeIf { it.isActive } ?: defaultValue().also { put(key, it) } +} \ No newline at end of file diff --git a/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockSecureStorage.kt b/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockSecureStorage.kt index 13befbd..a08aaf0 100644 --- a/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockSecureStorage.kt +++ b/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockSecureStorage.kt @@ -2,13 +2,19 @@ package it.airgap.beaconsdk.core.internal.storage import it.airgap.beaconsdk.core.scope.BeaconScope import it.airgap.beaconsdk.core.storage.SecureStorage +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock public class MockSecureStorage : SecureStorage { + private val mutex: Mutex = Mutex() + private var sdkSecretSeed: String? = null - override suspend fun getSdkSecretSeed(): String? = sdkSecretSeed + override suspend fun getSdkSecretSeed(): String? = mutex.withLock { sdkSecretSeed } override suspend fun setSdkSecretSeed(sdkSecretSeed: String) { - this.sdkSecretSeed = sdkSecretSeed + mutex.withLock { + this.sdkSecretSeed = sdkSecretSeed + } } override fun scoped(beaconScope: BeaconScope): SecureStorage = this diff --git a/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockStorage.kt b/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockStorage.kt index 6bf6126..34ae578 100644 --- a/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockStorage.kt +++ b/core/src/mock/java/it/airgap/beaconsdk/core/internal/storage/MockStorage.kt @@ -6,37 +6,51 @@ import it.airgap.beaconsdk.core.data.Peer import it.airgap.beaconsdk.core.data.Permission import it.airgap.beaconsdk.core.scope.BeaconScope import it.airgap.beaconsdk.core.storage.Storage +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock public class MockStorage : Storage { + private val mutex: Mutex = Mutex() + private var p2pPeers: List = emptyList() private var appsMetadata: List = emptyList() private var permissions: List = emptyList() private var sdkVersion: String? = null private var migrations: Set = emptySet() - override suspend fun getMaybePeers(): List> = p2pPeers.map { Maybe.Some(it) } + override suspend fun getMaybePeers(): List> = mutex.withLock { p2pPeers.map { Maybe.Some(it) } } override suspend fun setPeers(p2pPeers: List) { - this.p2pPeers = p2pPeers + mutex.withLock { + this.p2pPeers = p2pPeers + } } - override suspend fun getMaybeAppMetadata(): List> = appsMetadata.map { Maybe.Some(it) } + override suspend fun getMaybeAppMetadata(): List> = mutex.withLock { appsMetadata.map { Maybe.Some(it) } } override suspend fun setAppMetadata(appMetadata: List) { - this.appsMetadata = appMetadata + mutex.withLock { + this.appsMetadata = appMetadata + } } - override suspend fun getMaybePermissions(): List> = permissions.map { Maybe.Some(it) } + override suspend fun getMaybePermissions(): List> = mutex.withLock { permissions.map { Maybe.Some(it) } } override suspend fun setPermissions(permissions: List) { - this.permissions = permissions + mutex.withLock { + this.permissions = permissions + } } - override suspend fun getSdkVersion(): String? = sdkVersion + override suspend fun getSdkVersion(): String? = mutex.withLock { sdkVersion } override suspend fun setSdkVersion(sdkVersion: String) { - this.sdkVersion = sdkVersion + mutex.withLock { + this.sdkVersion = sdkVersion + } } - override suspend fun getMigrations(): Set = migrations + override suspend fun getMigrations(): Set = mutex.withLock { migrations } override suspend fun setMigrations(migrations: Set) { - this.migrations = migrations + mutex.withLock { + this.migrations = migrations + } } override fun scoped(beaconScope: BeaconScope): Storage = this diff --git a/demo/build.gradle b/demo/build.gradle index 517ad71..bc20658 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -68,12 +68,13 @@ dependencies { implementation project(path: ':blockchain-substrate') implementation project(path: ':transport-p2p-matrix') - // // Published + // Published // def beaconVersion = Android.versionName // def withoutJna = { exclude group: "net.java.dev.jna" } // // implementation "com.github.airgap-it.beacon-android-sdk:core:$beaconVersion", withoutJna // +// implementation "com.github.airgap-it.beacon-android-sdk:client-dapp:$beaconVersion", withoutJna // implementation "com.github.airgap-it.beacon-android-sdk:client-wallet:$beaconVersion", withoutJna // implementation "com.github.airgap-it.beacon-android-sdk:blockchain-tezos:$beaconVersion", withoutJna // implementation "com.github.airgap-it.beacon-android-sdk:blockchain-substrate:$beaconVersion", withoutJna diff --git a/demo/src/main/java/it/airgap/beaconsdkdemo/dapp/DAppFragmentViewModel.kt b/demo/src/main/java/it/airgap/beaconsdkdemo/dapp/DAppFragmentViewModel.kt index 4ba7df8..9f3e684 100644 --- a/demo/src/main/java/it/airgap/beaconsdkdemo/dapp/DAppFragmentViewModel.kt +++ b/demo/src/main/java/it/airgap/beaconsdkdemo/dapp/DAppFragmentViewModel.kt @@ -57,7 +57,11 @@ class DAppFragmentViewModel : ViewModel() { fun requestPermission() { viewModelScope.launch { - beaconClient?.requestTezosPermission() + try { + beaconClient?.requestTezosPermission() + } catch (e: Exception) { + onError(e) + } } } diff --git a/demo/src/main/java/it/airgap/beaconsdkdemo/wallet/WalletFragmentViewModel.kt b/demo/src/main/java/it/airgap/beaconsdkdemo/wallet/WalletFragmentViewModel.kt index cbbe025..7df3e3d 100644 --- a/demo/src/main/java/it/airgap/beaconsdkdemo/wallet/WalletFragmentViewModel.kt +++ b/demo/src/main/java/it/airgap/beaconsdkdemo/wallet/WalletFragmentViewModel.kt @@ -88,8 +88,12 @@ class WalletFragmentViewModel : ViewModel() { fun pair(pairingRequest: String) { viewModelScope.launch { - beaconClient?.pair(pairingRequest) - checkForPeers() + try { + beaconClient?.pair(pairingRequest) + checkForPeers() + } catch (e: Throwable) { + onError(e) + } } } diff --git a/transport-p2p-matrix/src/main/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClient.kt b/transport-p2p-matrix/src/main/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClient.kt index 7dee9e8..739908c 100644 --- a/transport-p2p-matrix/src/main/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClient.kt +++ b/transport-p2p-matrix/src/main/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClient.kt @@ -28,7 +28,7 @@ internal class MatrixClient( val events: Flow get() = store.events - private val syncScopes: MutableMap = mutableMapOf() + private val syncScopes: CoroutineScopeRegistry = CoroutineScopeRegistry("sync") suspend fun joinedRooms(): List = store.state().getOrNull()?.rooms?.values?.filterIsInstance() ?: emptyList() @@ -51,18 +51,12 @@ internal class MatrixClient( ?: failWith("Login failed", loginResponse.exceptionOrNull()) store.intent(Init(userId, deviceId, accessToken)) - syncScope(node) { syncPoll(it, node).collect() } + syncPoll(node) } suspend fun stop(node: String? = null) { with(syncScopes) { - if (node != null) { - get(node)?.cancel("Sync for node $node canceled.") - remove(node) - } else { - forEach { it.value.cancel("Sync for node ${it.key} canceled.") } - clear() - } + node?.let { cancel(it) } ?: cancelAll() if (isEmpty()) { store.intent(Reset) @@ -156,7 +150,7 @@ internal class MatrixClient( } } - fun syncPoll(scope: CoroutineScope, node: String, interval: Long = 0): Flow> { + fun syncPollFlow(scope: CoroutineScope, node: String, interval: Long = 0): Flow> { val syncMutex = Mutex() return poller.poll(Dispatchers.IO, interval) { @@ -171,6 +165,13 @@ internal class MatrixClient( } } + private suspend fun syncPoll(node: String) { + syncScopes.get(node).launch { + syncPollFlow(this, node).collect() + syncScopes.cancel(node) + } + } + private suspend inline fun withAccessToken( name: String, block: (accessToken: String) -> T, @@ -179,17 +180,6 @@ internal class MatrixClient( return block(accessToken) } - private suspend fun syncScope(node: String, block: suspend (CoroutineScope) -> Unit) { - syncScopes - .getOrPut(node) { CoroutineScope(CoroutineName(syncScopeName(node)) + Dispatchers.Default) } - .launch { - block(this) - syncScopes.remove(node) - } - } - - private fun syncScopeName(node: String): String = "sync@$node" - private suspend fun onSyncSuccess(sync: MatrixSync) { store.intent( OnSyncSuccess( diff --git a/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/P2pMatrixTest.kt b/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/P2pMatrixTest.kt index 421d3b9..4d04633 100644 --- a/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/P2pMatrixTest.kt +++ b/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/P2pMatrixTest.kt @@ -495,8 +495,6 @@ internal class P2pMatrixTest { p2pMatrix.unsubscribeFrom(peer) p2pMatrix.subscribeTo(peer) - println(unsubscribed.await()) - assertTrue(p2pMatrix.isSubscribed(peer), "Expected peer to be recognized as subscribed") assertTrue(unsubscribed.await()?.isEmpty() == true, diff --git a/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClientTest.kt b/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClientTest.kt index a7430ff..c2410c5 100644 --- a/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClientTest.kt +++ b/transport-p2p-matrix/src/test/java/it/airgap/beaconsdk/transport/p2p/matrix/internal/matrix/MatrixClientTest.kt @@ -218,7 +218,7 @@ internal class MatrixClientTest { coVerify(exactly = 1) { store.intent(Init(userId, deviceId, accessToken)) } coVerify(exactly = 1) { store.intent(OnSyncSuccess(nextSyncToken, pollingTimeout = 30000, null, null)) } - verify(exactly = 1) { client.syncPoll(any(), node) } + verify(exactly = 1) { client.syncPollFlow(any(), node) } verify(exactly = 1) { poller.poll(any(), 0, any()) } confirmVerified(userService, poller) @@ -667,7 +667,7 @@ internal class MatrixClientTest { coEvery { store.state() } returns Result.success(MatrixStoreState(accessToken = accessToken)) runBlocking { - client.syncPoll(CoroutineScope(TestCoroutineDispatcher()), node).take(1).collect() + client.syncPollFlow(this, node).take(1).collect() coVerify { store.intent( @@ -684,6 +684,7 @@ internal class MatrixClientTest { @Test fun `updates retry counter on error and continues polling if max not exceeded`() { + val node = "node" val accessToken = "accessToken" coEvery { eventService.sync(any(), any(), any()) } returns Result.failure() @@ -691,7 +692,7 @@ internal class MatrixClientTest { runBlocking { val scope = CoroutineScope(TestCoroutineDispatcher()) - client.syncPoll(scope, "node").take(1).collect() + client.syncPollFlow(scope, node).take(1).collect() assertTrue(scope.isActive, "Expected scope to be active") coVerify { store.intent(OnSyncError) } @@ -700,6 +701,7 @@ internal class MatrixClientTest { @Test fun `cancels polling on max retries exceeded`() { + val node = "node" val accessToken = "accessToken" coEvery { eventService.sync(any(), any(), any()) } returns Result.failure() @@ -707,7 +709,7 @@ internal class MatrixClientTest { runBlocking { val scope = CoroutineScope(TestCoroutineDispatcher()) - client.syncPoll(scope, "node").take(1).collect() + client.syncPollFlow(scope, node).take(1).collect() assertFalse(scope.isActive, "Expected scope to be canceled") coVerify { store.intent(OnSyncError) }