diff --git a/AppServicesUsageSamples/README.md b/AppServicesUsageSamples/README.md index 0142c2b..e386d00 100644 --- a/AppServicesUsageSamples/README.md +++ b/AppServicesUsageSamples/README.md @@ -5,6 +5,7 @@ Reference app that show cases different design patterns and examples of using th ## Samples ### [Property level encryption](apps/property-encryption/README.md) +### [Presence detection](apps/presence-detection/README.md) This demo shows the process of protecting users' sensitive data by employing end-to-end encryption techniques while guaranteeing access from any user's device. diff --git a/AppServicesUsageSamples/apps/presence-detection/README.md b/AppServicesUsageSamples/apps/presence-detection/README.md new file mode 100644 index 0000000..22c79bc --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/README.md @@ -0,0 +1,53 @@ +# Presence detection + +User presence detection allows to check for connectivity or activity of users or devices. It has many +applications from showing users' availability in a chat application to identify malfunctioning devices in an industrial process. + +This sample shows how to implement user presence detection effectively using Realm and Atlas resources. + +## Tracking local connectivity + +Clients could detect whether they are connected to Atlas via the `SyncSession.connectionState` +property. The values of the connection state are `DISCONNECTED`, `CONNECTING`, or `CONNECTED`. + +Realm supports listening to updates to the connection state via the function `connectionStateAsFlow`, and simplifies listening to +connectivity changes to: + +```kotlin +realm.syncSession + .connectionStateAsFlow() + .collect {connectionStateChange -> + // handle connection state + } +``` + +The local connectivity detection is used in this sample to display whether the current user is connected or not. + +## Tracking connectivity of external devices + +There are different approaches on how we can propagate to other parties the connectivity status of a +client. + +### Forwarding logs + +This approach exclusively use App services resources to perform the detection exclusively on the server. + +App services would log any user connectivity change in the logs as a `Sync` type event. App services support forwarding these logs to predefined functions. + +![logs sample](logs.png "Logs") + +In this sample we have configured a forward log that forward all `Sync` events to a [function](functions/logPresenceDetector.js) that detects connectivity changes. This function analyzes each log to detect if a user has started/ended a session, then it upserts the changes in a [collection](data_sources/mongodb-atlas/presence-detection/user_status/schema.json) that tracks the connectivity for that user. + +This collection is part of a Sync Realm, so any changes to it would be automatically propagated all its subscriptions. + +![Flow](presence-flow.svg "Flow") + +The response time for this solution is up to a minute, that is what it takes to the log forwarding feature to post any new updates. Logs can be processed individually or in batches, while it does not affect the response time, doing it in batches debounces changes and eliminates redundant notification changes. + +### Alternatives + +Another solution that we have not covered in this sample is the clients actively notifying if they have connectivity. + +The clients would send a heart beat to the server each X seconds, then the server could poll for the clients that have not send a beat in Y amount of time, deeming them as disconnected. + +While this approach would improve the response time, it would use more resources and wouldn't scale as well. diff --git a/AppServicesUsageSamples/apps/presence-detection/auth/custom_user_data.json b/AppServicesUsageSamples/apps/presence-detection/auth/custom_user_data.json new file mode 100644 index 0000000..a82d0fb --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/auth/custom_user_data.json @@ -0,0 +1,3 @@ +{ + "enabled": false +} diff --git a/AppServicesUsageSamples/apps/presence-detection/auth/providers.json b/AppServicesUsageSamples/apps/presence-detection/auth/providers.json new file mode 100644 index 0000000..dff103b --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/auth/providers.json @@ -0,0 +1,12 @@ +{ + "anon-user": { + "name": "anon-user", + "type": "anon-user", + "disabled": false + }, + "api-key": { + "name": "api-key", + "type": "api-key", + "disabled": true + } +} diff --git a/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/config.json b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/config.json new file mode 100644 index 0000000..9913676 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/config.json @@ -0,0 +1,10 @@ +{ + "name": "mongodb-atlas", + "type": "mongodb-atlas", + "config": { + "clusterName": "Cluster0", + "readPreference": "primary", + "wireProtocolEnabled": false + }, + "version": 1 +} diff --git a/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/default_rule.json b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/default_rule.json new file mode 100644 index 0000000..ea47d16 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/default_rule.json @@ -0,0 +1,17 @@ +{ + "roles": [ + { + "name": "readAll", + "apply_when": {}, + "document_filters": { + "write": false, + "read": true + }, + "read": true, + "write": false, + "insert": false, + "delete": false, + "search": true + } + ] +} diff --git a/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/relationships.json b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/relationships.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/relationships.json @@ -0,0 +1 @@ +{} diff --git a/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/schema.json b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/schema.json new file mode 100644 index 0000000..ec0a37a --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/data_sources/mongodb-atlas/presence-detection/user_status/schema.json @@ -0,0 +1,20 @@ +{ + "properties": { + "_id": { + "bsonType": "objectId" + }, + "owner_id": { + "bsonType": "string" + }, + "present": { + "bsonType": "bool" + } + }, + "required": [ + "_id", + "present", + "owner_id" + ], + "title": "user_status", + "type": "object" +} diff --git a/AppServicesUsageSamples/apps/presence-detection/environments/development.json b/AppServicesUsageSamples/apps/presence-detection/environments/development.json new file mode 100644 index 0000000..ad7e98e --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/environments/development.json @@ -0,0 +1,3 @@ +{ + "values": {} +} diff --git a/AppServicesUsageSamples/apps/presence-detection/environments/no-environment.json b/AppServicesUsageSamples/apps/presence-detection/environments/no-environment.json new file mode 100644 index 0000000..ad7e98e --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/environments/no-environment.json @@ -0,0 +1,3 @@ +{ + "values": {} +} diff --git a/AppServicesUsageSamples/apps/presence-detection/environments/production.json b/AppServicesUsageSamples/apps/presence-detection/environments/production.json new file mode 100644 index 0000000..ad7e98e --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/environments/production.json @@ -0,0 +1,3 @@ +{ + "values": {} +} diff --git a/AppServicesUsageSamples/apps/presence-detection/environments/qa.json b/AppServicesUsageSamples/apps/presence-detection/environments/qa.json new file mode 100644 index 0000000..ad7e98e --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/environments/qa.json @@ -0,0 +1,3 @@ +{ + "values": {} +} diff --git a/AppServicesUsageSamples/apps/presence-detection/environments/testing.json b/AppServicesUsageSamples/apps/presence-detection/environments/testing.json new file mode 100644 index 0000000..ad7e98e --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/environments/testing.json @@ -0,0 +1,3 @@ +{ + "values": {} +} diff --git a/AppServicesUsageSamples/apps/presence-detection/functions/config.json b/AppServicesUsageSamples/apps/presence-detection/functions/config.json new file mode 100644 index 0000000..fb3fa8a --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/functions/config.json @@ -0,0 +1,6 @@ +[ + { + "name": "logPresenceDetector", + "private": false + } +] diff --git a/AppServicesUsageSamples/apps/presence-detection/functions/logPresenceDetector.js b/AppServicesUsageSamples/apps/presence-detection/functions/logPresenceDetector.js new file mode 100644 index 0000000..6fb3ce5 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/functions/logPresenceDetector.js @@ -0,0 +1,43 @@ +exports = async function (logs) { + // logs appear in ascending order + for (let i = logs.length - 1; i >= 0; i--) { + extract_presence(logs[i]) + } +}; + +async function extract_presence(log) { + let type = log.type + if (type !== "SYNC_SESSION_START" && type !== "SYNC_SESSION_END") return; + + let user_id = log.user_id; + let present = type === "SYNC_SESSION_START"; + + console.log(`User ${user_id} present: ${present}`); + + update_presence(user_id, present); +} + +async function update_presence(user_id, present) { + const customUserDataCollection = context.services + .get("mongodb-atlas") + .db("presence-detection") + .collection("user_status"); + + try { + await customUserDataCollection.updateOne( + { + owner_id: user_id, + }, + { + owner_id: user_id, + present: present + }, + { + upsert: true + } + ); + } catch (e) { + console.error(`Failed to create custom user data document for user: ${user_id}`); + throw e + } +} diff --git a/AppServicesUsageSamples/apps/presence-detection/graphql/config.json b/AppServicesUsageSamples/apps/presence-detection/graphql/config.json new file mode 100644 index 0000000..c1d7285 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/graphql/config.json @@ -0,0 +1,3 @@ +{ + "use_natural_pluralization": true +} diff --git a/AppServicesUsageSamples/apps/presence-detection/http_endpoints/config.json b/AppServicesUsageSamples/apps/presence-detection/http_endpoints/config.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/http_endpoints/config.json @@ -0,0 +1 @@ +[] diff --git a/AppServicesUsageSamples/apps/presence-detection/log_forwarders/presenceDetector.json b/AppServicesUsageSamples/apps/presence-detection/log_forwarders/presenceDetector.json new file mode 100644 index 0000000..3199484 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/log_forwarders/presenceDetector.json @@ -0,0 +1,17 @@ +{ + "name": "presenceDetector", + "log_types": [ + "sync" + ], + "log_statuses": [ + "success" + ], + "policy": { + "type": "batch" + }, + "action": { + "type": "function", + "name": "logPresenceDetector" + }, + "disabled": false +} diff --git a/AppServicesUsageSamples/apps/presence-detection/logs.png b/AppServicesUsageSamples/apps/presence-detection/logs.png new file mode 100644 index 0000000..5573270 Binary files /dev/null and b/AppServicesUsageSamples/apps/presence-detection/logs.png differ diff --git a/AppServicesUsageSamples/apps/presence-detection/presence-flow.svg b/AppServicesUsageSamples/apps/presence-detection/presence-flow.svg new file mode 100644 index 0000000..dad6f10 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/presence-flow.svg @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AppServicesUsageSamples/apps/presence-detection/realm_config.json b/AppServicesUsageSamples/apps/presence-detection/realm_config.json new file mode 100644 index 0000000..1648fd3 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/realm_config.json @@ -0,0 +1,8 @@ +{ + "app_id": "presence-detection", + "config_version": 20210101, + "name": "presence-detection", + "location": "IE", + "provider_region": "aws-eu-west-1", + "deployment_model": "LOCAL" +} diff --git a/AppServicesUsageSamples/apps/presence-detection/sync/config.json b/AppServicesUsageSamples/apps/presence-detection/sync/config.json new file mode 100644 index 0000000..0ae7102 --- /dev/null +++ b/AppServicesUsageSamples/apps/presence-detection/sync/config.json @@ -0,0 +1,16 @@ +{ + "type": "flexible", + "state": "enabled", + "development_mode_enabled": false, + "service_name": "mongodb-atlas", + "database_name": "presence-detection", + "client_max_offline_days": 30, + "is_recovery_mode_disabled": false, + "permissions": { + "rules": {}, + "defaultRoles": [] + }, + "queryable_fields_names": [ + "owner_id" + ] +} diff --git a/AppServicesUsageSamples/demo/src/main/AndroidManifest.xml b/AppServicesUsageSamples/demo/src/main/AndroidManifest.xml index 4ec6abc..e1affc5 100644 --- a/AppServicesUsageSamples/demo/src/main/AndroidManifest.xml +++ b/AppServicesUsageSamples/demo/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/AppServicesUsageSamplesApp.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/AppServicesUsageSamplesApp.kt index f4212a2..1c187f3 100644 --- a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/AppServicesUsageSamplesApp.kt +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/AppServicesUsageSamplesApp.kt @@ -18,6 +18,7 @@ package io.realm.appservicesusagesamples import android.app.Application import io.realm.appservicesusagesamples.propertyencryption.propertyEncryptionModule +import io.realm.appservicesusagesamples.presence.presenceDetectionModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.GlobalContext.startKoin @@ -35,7 +36,11 @@ class AppServicesUsageSamplesApp: Application() { // Reference Android context androidContext(this@AppServicesUsageSamplesApp) // Load modules - modules(mainModule, propertyEncryptionModule) + modules( + mainModule, + propertyEncryptionModule, + presenceDetectionModule, + ) } } } diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/Constants.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/Constants.kt index 4715c49..245ed14 100644 --- a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/Constants.kt +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/Constants.kt @@ -16,12 +16,11 @@ */ package io.realm.appservicesusagesamples - // Replace any with the App Services app id sample. // It is not mandatory to have them all replaced, only the samples to evaluate. -const val PROPERTY_ENCRYPTION_APP_ID = "field-encryption-fjrvt" -const val USER_PRESENCE_APP_ID = "presence-detection-bcsii" +const val PROPERTY_ENCRYPTION_APP_ID = "" +const val USER_PRESENCE_APP_ID = "" const val OFFLINE_LOGIN_APP_ID = "" -const val ERROR_HANDLING_APP_ID = "error-handling-jltyk" +const val ERROR_HANDLING_APP_ID = "" const val BUSINESS_LOGIC_APP_ID = "" const val PURCHASE_VERIFICATION_APP_ID = "" diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/DependencyInjection.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/DependencyInjection.kt index f23e65c..2a3e9e8 100644 --- a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/DependencyInjection.kt +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/DependencyInjection.kt @@ -18,6 +18,7 @@ package io.realm.appservicesusagesamples import io.realm.appservicesusagesamples.propertyencryption.PropertyEncryptionActivity import io.realm.appservicesusagesamples.ui.SampleSelectorScreenViewModel +import io.realm.appservicesusagesamples.presence.PresenceDetectionActivity import io.realm.kotlin.mongodb.App import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.qualifier.named @@ -38,7 +39,7 @@ enum class Demos( ), USER_PRESENCE( "User presence", - SampleSelectorActivity::class.java, + PresenceDetectionActivity::class.java, USER_PRESENCE_APP_ID, ), OFFLINE_LOGIN( diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/DependencyInjection.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/DependencyInjection.kt new file mode 100644 index 0000000..cccca1e --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/DependencyInjection.kt @@ -0,0 +1,30 @@ +/* + * 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.appservicesusagesamples.presence + +import io.realm.appservicesusagesamples.Demos +import io.realm.appservicesusagesamples.presence.ui.UserStatusListViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val presenceDetectionModule = module { + viewModel { + UserStatusListViewModel( + app = get(qualifier = Demos.USER_PRESENCE.qualifier), + ) + } +} diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/PresenceDetectionActivity.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/PresenceDetectionActivity.kt new file mode 100644 index 0000000..05bde44 --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/PresenceDetectionActivity.kt @@ -0,0 +1,53 @@ +/* + * 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.appservicesusagesamples.presence + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import io.realm.appservicesusagesamples.presence.ui.UserStatusListScreen +import io.realm.appservicesusagesamples.presence.ui.UserStatusListViewModel +import io.realm.appservicesusagesamples.ui.theme.AppServicesUsageSamplesTheme +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.scope.activityRetainedScope +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.scope.Scope + +class PresenceDetectionActivity : ComponentActivity(), AndroidScopeComponent { + override val scope: Scope by activityRetainedScope() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val viewModel: UserStatusListViewModel by viewModel() + + setContent { + AppServicesUsageSamplesTheme { + UserStatusListScreen( + viewModel = viewModel, + modifier = Modifier + .fillMaxSize(), + onLogout = { + // close the app + finish() + } + ) + } + } + } +} diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/models/UserStatus.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/models/UserStatus.kt new file mode 100644 index 0000000..bc3ae96 --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/models/UserStatus.kt @@ -0,0 +1,16 @@ +package io.realm.appservicesusagesamples.presence.models + +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PersistedName +import io.realm.kotlin.types.annotations.PrimaryKey +import org.mongodb.kbson.ObjectId + +@PersistedName("user_status") +class UserStatus : RealmObject { + @PersistedName("_id") + @PrimaryKey + var id: ObjectId = ObjectId() + @PersistedName("owner_id") + var ownerId: String = "" + var present: Boolean = false +} \ No newline at end of file diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListScreen.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListScreen.kt new file mode 100644 index 0000000..4973342 --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListScreen.kt @@ -0,0 +1,258 @@ +/* + * 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.appservicesusagesamples.presence.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.realm.appservicesusagesamples.R +import io.realm.kotlin.mongodb.sync.ConnectionState +import org.koin.compose.koinInject + +@Composable +fun ControlsCard( + state: UserListUiStatus, + modifier: Modifier = Modifier, + onLogout: () -> Unit, + onConnect: () -> Unit, + onDisconnect: () -> Unit, +) { + ElevatedCard( + modifier = modifier.fillMaxWidth() + ) { + Box(Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + ElevatedButton( + colors = ButtonDefaults.buttonColors(), + enabled = state.connectionState != ConnectionState.CONNECTING, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + onClick = { + if (state.connectionState == ConnectionState.DISCONNECTED) onConnect() + else if (state.connectionState == ConnectionState.CONNECTED) onDisconnect() + } + ) { + Text( + text = when (state.connectionState) { + ConnectionState.DISCONNECTED -> "Connect" + ConnectionState.CONNECTING -> "Connecting..." + ConnectionState.CONNECTED -> "Disconnect" + } + ) + } + ElevatedButton( + colors = ButtonDefaults.buttonColors(), + enabled = !state.loading && !state.loggingOut, + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + onClick = onLogout + ) { + Text(text = "Logout") + } + } + } + } +} + +@Composable +fun StatusCard( + connected: Boolean, + message: String, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + if (connected) { + Icon( + painter = painterResource(R.drawable.baseline_check_circle_24), + contentDescription = "Connected", + tint = Color(0, 200, 0) + ) + } else { + Icon( + painter = painterResource(R.drawable.baseline_cancel_24), + contentDescription = "Disconnected", + tint = Color(200, 0, 0) + ) + } + Text( + modifier = Modifier + .padding(start = 4.dp) + .align(Alignment.CenterVertically), + text = message, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun UserStatusListScreen( + viewModel: UserStatusListViewModel, + modifier: Modifier = Modifier, + onLogout: () -> Unit, +) { + val users by viewModel.userStatus.observeAsState(emptyList()) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(uiState.loggedOut) { + if (uiState.loggedOut) { + onLogout() + } + } + LaunchedEffect(key1 = users) { + listState.scrollToItem(0) + } + Surface( + color = MaterialTheme.colorScheme.background, + modifier = modifier, + ) { + if(!uiState.loading) { + LazyColumn( + state = listState + ) { + val connected = uiState.connectionState == ConnectionState.CONNECTED + item { + StatusCard( + connected = connected, + message = "You id: ${viewModel.user.id}", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + ) + } + + if (connected) { + items( + users, + key = { + it.id.toHexString() + } + ) { userStatus -> + StatusCard( + connected = userStatus.present, + message = "User id: ${userStatus.ownerId}", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + ) + } + } + item { + Box( + modifier = Modifier + .padding(16.dp) + .height(72.dp) + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.BottomCenter + ) { + ControlsCard( + uiState, + onLogout = { viewModel.logout() }, + onConnect = { viewModel.connect() }, + onDisconnect = { viewModel.disconnect() }, + ) + } + } +} + +@Preview +@Composable +fun ConnectedUserStatusCardPreview() { + Surface( + color = MaterialTheme.colorScheme.background + ) { + StatusCard( + connected = true, + message = "hello world" + ) + } +} + + +@Preview +@Composable +fun DisconnectedUserStatusCardPreview() { + Surface( + color = MaterialTheme.colorScheme.background + ) { + StatusCard( + connected = false, + message = "hello world" + ) + } +} + +@Preview +@Composable +fun ControlsCardPreview() { + Surface( + color = MaterialTheme.colorScheme.background + ) { + ControlsCard( + state = UserListUiStatus( + loading = false + ), + onLogout = {}, + onDisconnect = {}, + onConnect = {}, + ) + } +} diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListViewModel.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListViewModel.kt new file mode 100644 index 0000000..77bff6e --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/presence/ui/UserStatusListViewModel.kt @@ -0,0 +1,144 @@ +/* + * 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.appservicesusagesamples.presence.ui + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.realm.appservicesusagesamples.presence.models.UserStatus +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.mongodb.App +import io.realm.kotlin.mongodb.Credentials +import io.realm.kotlin.mongodb.User +import io.realm.kotlin.mongodb.sync.ConnectionState +import io.realm.kotlin.mongodb.sync.SyncConfiguration +import io.realm.kotlin.mongodb.syncSession +import io.realm.kotlin.query.Sort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class UserListUiStatus( + val loading: Boolean = true, + val loggingOut: Boolean = false, + val loggedOut: Boolean = false, + val userId: String = "", + val connectionState: ConnectionState = ConnectionState.CONNECTING, +) + +class UserStatusListViewModel( + private val app: App, +) : ViewModel() { + private lateinit var realm: Realm + lateinit var user: User + + private val _uiState = MutableStateFlow(UserListUiStatus()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + app.login(Credentials.anonymous()) + .let { user -> + this@UserStatusListViewModel.user = user + + val syncConfig = SyncConfiguration + .Builder(app.currentUser!!, setOf(UserStatus::class)) + .initialSubscriptions { + // Subscribe to all user statuses + add(it.query("owner_id != $0", user.id)) + } + .waitForInitialRemoteData() + .build() + + realm = Realm.open(syncConfig) + + val job = async { + realm.query() + .sort("present", Sort.DESCENDING) + .asFlow() + .collect { + userStatus.postValue(it.list) + } + } + + val job2 = async { + realm.syncSession + .connectionStateAsFlow() + .collect {connectionStateChange -> + _uiState.update { + it.copy( + connectionState = connectionStateChange.newState + ) + } + } + } + + addCloseable { + job.cancel() + job2.cancel() + realm.close() + } + + _uiState.update { + it.copy( + userId = user.id, + loading = false, + connectionState = realm.syncSession.connectionState + ) + } + } + } + } + + val userStatus: MutableLiveData> by lazy { + MutableLiveData>() + } + + fun connect() { + _uiState.update { + it.copy(connectionState = ConnectionState.CONNECTING) + } + + realm.syncSession.resume() + } + + fun disconnect() { + _uiState.update { + it.copy(connectionState = ConnectionState.DISCONNECTED) + } + + realm.syncSession.pause() + } + + fun logout() { + _uiState.update { + it.copy(loggingOut = true) + } + viewModelScope.launch(Dispatchers.IO) { + app.currentUser?.logOut() + + _uiState.update { + it.copy(loggedOut = true) + } + } + } +} diff --git a/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/propertyencryption/models/CustomData.kt b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/propertyencryption/models/CustomData.kt new file mode 100644 index 0000000..7dd9bb5 --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/java/io/realm/appservicesusagesamples/propertyencryption/models/CustomData.kt @@ -0,0 +1,41 @@ +/* + * 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.appservicesusagesamples.propertyencryption.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * App services user custom data. It contains the resources required to achieve property level + * encryption: + * 1. Encryption key, stored and password-protected in [keyStore]. + * 2. Field encryption cipher spec, defined in [PLECipherSpec], + */ +@Serializable +class CustomData( + /** + * Defines the PLE algorithm. + */ + @SerialName("ple_cipher_spec") + val PLECipherSpec: SerializableCipherSpec?, + + /** + * BKS keystore containing the key. + */ + @SerialName("key_store") + val keyStore: ByteArray? +) diff --git a/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_cancel_24.xml b/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_cancel_24.xml new file mode 100644 index 0000000..a0a94e3 --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_cancel_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_check_circle_24.xml b/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_check_circle_24.xml new file mode 100644 index 0000000..b83d1bc --- /dev/null +++ b/AppServicesUsageSamples/demo/src/main/res/drawable/baseline_check_circle_24.xml @@ -0,0 +1,5 @@ + + +