diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a9ed1357..5c36151559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Default log level has been changed from `LogLevel.WARN` to `LogLevel.INFO`. * Avoid tracking unreferenced realm versions through the garbage collector. (Issue [#1234](https://github.com/realm/realm-kotlin/issues/1234)) * [Sync] All tokens, passwords and custom function arguments are now obfuscated by default, even if `LogLevel` is set to DEBUG, TRACE or ALL. (Issue [#410](https://github.com/realm/realm-kotlin/issues/410)) +* [Sync] Add support for `App.authenticationChangeAsFlow()` which make it possible to listen to authentication changes like "LoggedIn", "LoggedOut" and "Removed" across all users of the app. (Issue [#749](https://github.com/realm/realm-kotlin/issues/749)). * [Sync] `@PersistedName` is now also supported on model classes. (Issue [#1138](https://github.com/realm/realm-kotlin/issues/1138)) ### Fixed diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt index b284e50f40..9acb2eb1a6 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt @@ -23,6 +23,7 @@ import io.realm.kotlin.mongodb.exceptions.AuthException import io.realm.kotlin.mongodb.exceptions.InvalidCredentialsException import io.realm.kotlin.mongodb.internal.AppConfigurationImpl import io.realm.kotlin.mongodb.internal.AppImpl +import kotlinx.coroutines.flow.Flow /** * An **App** is the main client-side entry point for interacting with an **Atlas App Services @@ -101,6 +102,14 @@ public interface App { */ public suspend fun login(credentials: Credentials): User + /** + * Create a [Flow] of [AuthenticationChange]-events to receive notifications of updates to all + * app user authentication states: login, logout and removal. + * + * @return a [Flow] of authentication events for users associated with this app. + */ + public fun authenticationChangeAsFlow(): Flow + /** * Close the app instance and release all underlying resources. * diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/AuthenticationChange.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/AuthenticationChange.kt new file mode 100644 index 0000000000..6b89fc69f1 --- /dev/null +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/AuthenticationChange.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Realm Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.realm.kotlin.mongodb + +import kotlinx.coroutines.flow.Flow + +/** + * This sealed class describe the possible events that can be observed on the [Flow] created by + * calling [App.authenticationChangeAsFlow]. + * + * The specific states are represented by these subclasses [LoggedIn], [LoggedOut] and + * [Removed]. + * + * Changes can thus be consumed this way: + * + * ``` + * app.authenticationChangeAsFlow().collect { change: AuthenticationChange -> + * when(change) { + * is LoggedIn -> handleLogin(change.user) + * is LoggedOut -> handleLogOut(change.user) + * is Removed -> handleRemove(change.user) + * } + * } + * ``` + */ +public sealed interface AuthenticationChange { + /** + * A reference to the [User] this event happened to. + * + * *Warning:* This is the live user object, so the [User.state] might have diverged from the + * event it is associated with, i.e. if a users logs out and back in while the event is + * propagating, the state of the user might be [User.State.LOGGED_IN], even though it was + * reported as a [LoggedOut] event. + */ + public val user: User +} + +/** + * Event emitted when a user logs into the app. + */ +public interface LoggedIn : AuthenticationChange + +/** + * Event emitted when a user is logged out. + */ +public interface LoggedOut : AuthenticationChange + +/** + * Event emitted when a user is removed, which also logs them out. + */ +public interface Removed : AuthenticationChange diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/User.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/User.kt index 690e63dc9a..fa4d7e3742 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/User.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/User.kt @@ -128,6 +128,8 @@ public interface User { * * @throws io.realm.kotlin.mongodb.exceptions.ServiceException if a failure occurred when * communicating with App Services. See [AppException] for details. + * @throws IllegalStateException if a consumer listening to [App.authenticationChangeAsFlow] + * is too slow consuming events. */ // FIXME add references to allUsers and remove when ready // * All other users will be marked as [User.State.LOGGED_OUT] @@ -147,6 +149,8 @@ public interface User { * @throws IllegalStateException if the user was already removed. * @throws io.realm.kotlin.mongodb.exceptions.ServiceException if a failure occurred when * communicating with App Services. See [AppException] for details. + * @throws IllegalStateException if a consumer listening to [App.authenticationChangeAsFlow] + * is too slow consuming events. */ // TODO Document how this method behave if offline public suspend fun remove(): User @@ -163,6 +167,8 @@ public interface User { * @throws IllegalStateException if the user was already removed or not logged in. * @throws io.realm.kotlin.mongodb.exceptions.ServiceException if a failure occurred when * communicating with App Services. See [AppException] for details. + * @throws IllegalStateException if a consumer listening to [App.authenticationChangeAsFlow] + * is too slow consuming events. */ public suspend fun delete() diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt index 02c999d992..abb9ac900d 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt @@ -25,10 +25,14 @@ import io.realm.kotlin.internal.interop.sync.NetworkTransport import io.realm.kotlin.internal.util.Validation import io.realm.kotlin.internal.util.use import io.realm.kotlin.mongodb.App +import io.realm.kotlin.mongodb.AuthenticationChange import io.realm.kotlin.mongodb.Credentials import io.realm.kotlin.mongodb.User import io.realm.kotlin.mongodb.auth.EmailPasswordAuth +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow // TODO Public due to being a transitive dependency to UserImpl public class AppImpl( @@ -38,6 +42,17 @@ public class AppImpl( internal val nativePointer: RealmAppPointer private val networkTransport: NetworkTransport + // Allow some delay between events being reported and them being consumed. + // When the (somewhat arbitrary) limit is hit, we will throw an exception, since we assume the + // consumer is doing something wrong. This is also needed because we don't + // want to block user events like logout, delete and remove. + @Suppress("MagicNumber") + private val authenticationChangeFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.SUSPEND + ) + init { val appResources: Pair> = configuration.createNativeApp() networkTransport = appResources.first @@ -73,8 +88,29 @@ public class AppImpl( } ) return channel.receive() - .getOrThrow() + .getOrThrow().also { user: User -> + reportAuthenticationChange(user, User.State.LOGGED_IN) + } + } + } + + internal fun reportAuthenticationChange(user: User, change: User.State) { + val event: AuthenticationChange = when (change) { + User.State.LOGGED_OUT -> LoggedOutImpl(user) + User.State.LOGGED_IN -> LoggedInImpl(user) + User.State.REMOVED -> RemovedImpl(user) } + if (!authenticationChangeFlow.tryEmit(event)) { + throw IllegalStateException( + "It wasn't possible to emit authentication changes " + + "because a consuming flow was blocked. Increase dispatcher processing resources " + + "or buffer `App.authenticationChangeAsFlow()` with buffer(...)." + ) + } + } + + override fun authenticationChangeAsFlow(): Flow { + return authenticationChangeFlow } override fun close() { diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AuthenticationChangeImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AuthenticationChangeImpl.kt new file mode 100644 index 0000000000..4f2e880d5e --- /dev/null +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AuthenticationChangeImpl.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Realm Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.realm.kotlin.mongodb.internal + +import io.realm.kotlin.mongodb.LoggedIn +import io.realm.kotlin.mongodb.LoggedOut +import io.realm.kotlin.mongodb.Removed +import io.realm.kotlin.mongodb.User + +internal class LoggedInImpl(override val user: User) : LoggedIn +internal class LoggedOutImpl(override val user: User) : LoggedOut +internal class RemovedImpl(override val user: User) : Removed diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/UserImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/UserImpl.kt index ad7139a387..97bb9e143a 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/UserImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/UserImpl.kt @@ -93,30 +93,48 @@ public class UserImpl( } override suspend fun logOut() { - Channel>(1).use { channel -> + Channel>(1).use { channel -> + val reportLoggedOut = loggedIn RealmInterop.realm_app_log_out( app.nativePointer, nativePointer, - channelResultCallback(channel) { - // No-op + channelResultCallback(channel) { + if (reportLoggedOut) { + User.State.LOGGED_OUT + } else { + null + } } ) return@use channel.receive() - .getOrThrow() + .getOrThrow().also { state: User.State? -> + if (state != null) { + app.reportAuthenticationChange(this, state) + } + } } } override suspend fun remove(): User { - Channel>(1).use { channel -> + Channel>(1).use { channel -> + val reportRemoved = loggedIn RealmInterop.realm_app_remove_user( app.nativePointer, nativePointer, - channelResultCallback(channel) { - // No-op + channelResultCallback(channel) { + if (reportRemoved) { + User.State.REMOVED + } else { + null + } } ) return@use channel.receive() - .getOrThrow() + .getOrThrow().also { state: User.State? -> + if (state != null) { + app.reportAuthenticationChange(this, state) + } + } } return this } @@ -125,16 +143,18 @@ public class UserImpl( if (state != User.State.LOGGED_IN) { throw IllegalStateException("User must be logged in, in order to be deleted.") } - Channel>(1).use { channel -> + Channel>(1).use { channel -> RealmInterop.realm_app_delete_user( app.nativePointer, nativePointer, - channelResultCallback(channel) { - // No-op + channelResultCallback(channel) { + User.State.REMOVED } ) return@use channel.receive() - .getOrThrow() + .getOrThrow().also { state: User.State -> + app.reportAuthenticationChange(this, state) + } } } diff --git a/packages/test-sync/src/androidAndroidTest/kotlin/io/realm/kotlin/test/mongodb/shared/AppTests.kt b/packages/test-sync/src/androidAndroidTest/kotlin/io/realm/kotlin/test/mongodb/shared/AppTests.kt index 6522db2aa9..4018eca13c 100644 --- a/packages/test-sync/src/androidAndroidTest/kotlin/io/realm/kotlin/test/mongodb/shared/AppTests.kt +++ b/packages/test-sync/src/androidAndroidTest/kotlin/io/realm/kotlin/test/mongodb/shared/AppTests.kt @@ -22,10 +22,15 @@ import io.realm.kotlin.entities.sync.ChildPk import io.realm.kotlin.entities.sync.ParentPk import io.realm.kotlin.internal.platform.appFilesDirectory import io.realm.kotlin.internal.platform.fileExists +import io.realm.kotlin.internal.platform.runBlocking import io.realm.kotlin.mongodb.App import io.realm.kotlin.mongodb.AppConfiguration +import io.realm.kotlin.mongodb.AuthenticationChange import io.realm.kotlin.mongodb.AuthenticationProvider import io.realm.kotlin.mongodb.Credentials +import io.realm.kotlin.mongodb.LoggedIn +import io.realm.kotlin.mongodb.LoggedOut +import io.realm.kotlin.mongodb.Removed import io.realm.kotlin.mongodb.User import io.realm.kotlin.mongodb.exceptions.InvalidCredentialsException import io.realm.kotlin.mongodb.sync.SyncConfiguration @@ -36,15 +41,21 @@ import io.realm.kotlin.test.mongodb.asTestApp import io.realm.kotlin.test.mongodb.createUserAndLogIn import io.realm.kotlin.test.util.TestHelper import io.realm.kotlin.test.util.TestHelper.randomEmail -import kotlinx.coroutines.runBlocking +import io.realm.kotlin.test.util.receiveOrFail +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.test.AfterTest import kotlin.test.BeforeTest +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.test.fail @@ -234,6 +245,16 @@ class AppTests { assertNull(app.currentUser) } + @Test + @Ignore // Waiting for https://github.com/realm/realm-core/issues/6514 + fun currentUser_clearedAfterUserIsRemoved() = runBlocking { + assertNull(app.currentUser) + val user1 = app.login(Credentials.anonymous()) + assertEquals(user1, app.currentUser) + user1.remove() + assertNull(app.currentUser) + } + // @Test // fun switchUser_nullThrows() { // try { @@ -249,63 +270,103 @@ class AppTests { // TODO("FIXME") // } // -// @Test -// fun authListener() { -// val userRef = AtomicReference(null) -// looperThread.runBlocking { -// val authenticationListener = object : AuthenticationListener { -// override fun loggedIn(user: User) { -// userRef.set(user) -// user.logOutAsync { /* Ignore */ } -// } -// -// override fun loggedOut(user: User) { -// assertEquals(userRef.get(), user) -// looperThread.testComplete() -// } -// } -// app.addAuthenticationListener(authenticationListener) -// app.login(Credentials.anonymous()) -// } -// } -// -// @Test -// fun authListener_nullThrows() { -// assertFailsWith { app.addAuthenticationListener(TestHelper.getNull()) } -// } -// -// @Test -// fun authListener_remove() = looperThread.runBlocking { -// val failListener = object : AuthenticationListener { -// override fun loggedIn(user: User) { fail() } -// override fun loggedOut(user: User) { fail() } -// } -// val successListener = object : AuthenticationListener { -// override fun loggedOut(user: User) { fail() } -// override fun loggedIn(user: User) { looperThread.testComplete() } -// } -// // This test depends on listeners being executed in order which is an -// // implementation detail, but there isn't a sure fire way to do this -// // without depending on implementation details or assume a specific timing. -// app.addAuthenticationListener(failListener) -// app.addAuthenticationListener(successListener) -// app.removeAuthenticationListener(failListener) -// app.login(Credentials.anonymous()) -// } -// -// @Test -// fun functions_defaultCodecRegistry() { -// var user = app.login(Credentials.anonymous()) -// assertEquals(app.configuration.defaultCodecRegistry, app.getFunctions(user).defaultCodecRegistry) -// } -// -// @Test -// fun functions_customCodecRegistry() { -// var user = app.login(Credentials.anonymous()) -// val registry = CodecRegistries.fromCodecs(StringCodec()) -// assertEquals(registry, app.getFunctions(user, registry).defaultCodecRegistry) -// } -// + @Test + fun authenticationChangeAsFlow() = runBlocking { + val c = Channel(1) + val job = async { + app.authenticationChangeAsFlow().collect { + c.send(it) + } + } + + val user1 = app.login(Credentials.anonymous()) + val loggedInEvent = c.receiveOrFail() + assertTrue(loggedInEvent is LoggedIn) + assertSame(user1, loggedInEvent.user) + + user1.logOut() + val loggedOutEvent = c.receiveOrFail() + assertTrue(loggedOutEvent is LoggedOut) + assertSame(user1, loggedOutEvent.user) + + // Repeating logout does not trigger a new event + user1.logOut() + val user2 = app.login(Credentials.anonymous()) + val reloginEvent = c.receiveOrFail() + assertEquals(user2, reloginEvent.user) + assertTrue(reloginEvent is LoggedIn) + + job.cancel() + c.close() + } + + @Test + fun authenticationChangeAsFlow_removeUser() = runBlocking { + val c = Channel(1) + val job = async { + app.authenticationChangeAsFlow().collect { + c.send(it) + } + } + val user1 = app.login(Credentials.anonymous(reuseExisting = true)) + val loggedInEvent = c.receiveOrFail() + assertTrue(loggedInEvent is LoggedIn) + + user1.remove() + val loggedOutEvent = c.receiveOrFail() + assertTrue(loggedOutEvent is Removed) + assertSame(user1, loggedOutEvent.user) + + job.cancel() + c.close() + + // Work-around for https://github.com/realm/realm-core/issues/6514 + // By logging the user back in, the TestApp teardown can correctly remove it. + app.login(Credentials.anonymous(reuseExisting = true)).logOut() + } + + @Test + fun authenticationChangeAsFlow_deleteUser() = runBlocking { + val c = Channel(1) + val job = async { + app.authenticationChangeAsFlow().collect { + c.send(it) + } + } + val user = app.login(Credentials.anonymous(reuseExisting = true)) + val loggedInEvent = c.receiveOrFail() + assertTrue(loggedInEvent is LoggedIn) + + user.delete() + val loggedOutEvent = c.receiveOrFail() + assertTrue(loggedOutEvent is Removed) + assertSame(user, loggedOutEvent.user) + + job.cancel() + c.close() + } + + @Test + fun authenticationChangeAsFlow_throwsWhenExceedCapacity() = runBlocking { + val latch = Mutex(locked = true) + val job = async { + app.authenticationChangeAsFlow().collect { + // Block `flow` from collecting any more events beside the first. + latch.withLock { + // Allow flow to continue + } + } + } + // Logging in 9 users should hit the capacity of the flow, causing the next + // login to fail. + repeat(9) { + app.createUserAndLogIn() + } + assertFailsWith { + app.createUserAndLogIn() + } + job.cancel() + } @Test fun encryptedMetadataRealm() {