diff --git a/.gitignore b/.gitignore index d3b53df..99bb228 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ android/keystores/debug.keystore # generated by bob lib/ + +# Documentation and iOS source +cbl-reactnative-docs/ +couchbase-lite-ios/ +ios-swift-quickstart/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index beb5f31..bfb597f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,19 @@ [submodule "src/cblite-js"] path = src/cblite-js url = git@github.com:Couchbase-Ecosystem/cblite-js.git + branch = RN-v1 + [submodule "ios/cbl-js-swift"] path = ios/cbl-js-swift url = git@github.com:Couchbase-Ecosystem/cbl-js-swift.git + branch = RN-v1 + [submodule "android/src/main/java/com/cblreactnative/cbl-js-kotlin"] path = android/src/main/java/com/cblreactnative/cbl-js-kotlin url = git@github.com:Couchbase-Ecosystem/cbl-js-kotlin.git + branch = RN-v1 + [submodule "expo-example/cblite-js-tests"] path = expo-example/cblite-js-tests url = git@github.com:Couchbase-Ecosystem/cblite-js-tests.git + branch = RN-v1 \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 6e42986..1bece57 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -107,7 +107,7 @@ dependencies { implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.json:json:20240303" - implementation "com.couchbase.lite:couchbase-lite-android-ee-ktx:3.2.1" + implementation "com.couchbase.lite:couchbase-lite-android-ee-ktx:3.3.0" implementation 'com.eclipsesource.j2v8:j2v8:6.2.1@aar' } diff --git a/android/src/main/java/com/cblreactnative/CblReactnativeModule.kt b/android/src/main/java/com/cblreactnative/CblReactnativeModule.kt index 24aac31..54cb2b7 100644 --- a/android/src/main/java/com/cblreactnative/CblReactnativeModule.kt +++ b/android/src/main/java/com/cblreactnative/CblReactnativeModule.kt @@ -4,6 +4,7 @@ import cbl.js.kotiln.DatabaseManager import cbl.js.kotiln.CollectionManager import cbl.js.kotiln.FileSystemHelper import cbl.js.kotiln.LoggingManager +import cbl.js.kotiln.LogSinksManager import cbl.js.kotiln.ReplicatorManager import cbl.js.kotiln.ReplicatorHelper import com.couchbase.lite.* @@ -22,6 +23,35 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.json.JSONObject +/** + * Enum representing the type of listener. + * + * This allows us to identify what kind of listener a token represents, + * useful for debugging and filtering. + */ +enum class ChangeListenerType { + COLLECTION, + COLLECTION_DOCUMENT, + QUERY, + REPLICATOR, + REPLICATOR_DOCUMENT +} + +/** + * Metadata for storing listener information in unified dictionary. + * + * This data class wraps the native ListenerToken along with its type. + * When adding a listener, we store both the token and its type. + * When removing a listener, we look up by UUID and get both back. + * + * This eliminates the need to pass the type when removing - it's + * already stored in the metadata! + */ +data class ChangeListenerRecord( + val nativeListenerToken: ListenerToken, + val listenerType: ChangeListenerType +) + @OptIn(DelicateCoroutinesApi::class) @Suppress("FunctionName") class CblReactnativeModule(reactContext: ReactApplicationContext) : @@ -29,10 +59,15 @@ class CblReactnativeModule(reactContext: ReactApplicationContext) : // Property to hold the context private val context: ReactApplicationContext = reactContext - private val queryChangeListeners: MutableMap = mutableMapOf() - private val replicatorChangeListeners: MutableMap = mutableMapOf() - private val replicatorDocumentListeners: MutableMap = mutableMapOf() - private val collectionChangeListeners: MutableMap = mutableMapOf() + + /** + * Unified storage for all listener tokens. + * Maps UUID token string to ChangeListenerRecord (which contains native token + type) + */ + private val allChangeListenerTokenByUuid: MutableMap = mutableMapOf() + + // Track whether JavaScript is listening for events + private var listenerCount = 0 init { CouchbaseLite.init(context, true) @@ -42,6 +77,18 @@ class CblReactnativeModule(reactContext: ReactApplicationContext) : return NAME } + // Required for NativeEventEmitter - these methods are called by React Native + // when JS adds/removes listeners, but may not be reliable with Expo + @ReactMethod + fun addListener(eventName: String) { + listenerCount++ + } + + @ReactMethod + fun removeListeners(count: Int) { + listenerCount -= count + if (listenerCount < 0) listenerCount = 0 + } private fun sendEvent( reactContext: ReactContext, @@ -334,6 +381,32 @@ class CblReactnativeModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun collection_GetFullName( + collectionName: String, + name: String, + scopeName: String, + promise: Promise + ) { + GlobalScope.launch(Dispatchers.IO) { + try { + if (!DataValidation.validateCollection(collectionName, scopeName, name, promise)) { + return@launch + } + val fullName = CollectionManager.fullName(collectionName, scopeName, name) + val map = Arguments.createMap() + map.putString("fullName", fullName) + context.runOnUiQueueThread { + promise.resolve(map) + } + } catch (e: Throwable) { + context.runOnUiQueueThread { + promise.reject("DATABASE_ERROR", e.message) + } + } + } + } + @ReactMethod fun collection_GetDefault( name: String, @@ -625,7 +698,14 @@ fun collection_AddChangeListener( sendEvent(context, "collectionChange", resultMap) } } - collectionChangeListeners[changeListenerToken] = listener + + + // Store in unified dictionary with type + allChangeListenerTokenByUuid[changeListenerToken] = ChangeListenerRecord( + nativeListenerToken = listener, + listenerType = ChangeListenerType.COLLECTION + ) + context.runOnUiQueueThread { promise.resolve(null) } @@ -641,24 +721,54 @@ fun collection_AddChangeListener( fun collection_RemoveChangeListener( changeListenerToken: String, promise: Promise +) { + // Delegate to unified listener removal + listenerToken_Remove(changeListenerToken, promise) + +} + +/** + * Generic method to remove any listener by its UUID token. + * + * This is the unified removal method that works for all listener types: + * - Collection change listeners + * - Collection document change listeners + * - Query change listeners + * - Replicator status change listeners + * - Replicator document change listeners + * + * The method looks up the listener by UUID in the unified storage, + * retrieves both the native token and its type, and removes it. + */ +@ReactMethod +fun listenerToken_Remove( + changeListenerToken: String, + promise: Promise ) { GlobalScope.launch(Dispatchers.IO) { try { - if (collectionChangeListeners.containsKey(changeListenerToken)) { - val listener = collectionChangeListeners[changeListenerToken] - listener?.remove() - collectionChangeListeners.remove(changeListenerToken) + val listenerRecord = allChangeListenerTokenByUuid[changeListenerToken] + + if (listenerRecord != null) { + // Remove the listener using the native token + listenerRecord.nativeListenerToken.remove() + + // Remove from our unified storage + allChangeListenerTokenByUuid.remove(changeListenerToken) + context.runOnUiQueueThread { promise.resolve(null) } } else { + val errorMsg = "No listener found for token $changeListenerToken" + android.util.Log.e("CblReactnative", "::KOTLIN DEBUG:: listenerToken_Remove: $errorMsg") context.runOnUiQueueThread { - promise.reject("COLLECTION_ERROR", "No such listener found with token $changeListenerToken") + promise.reject("LISTENER_ERROR", errorMsg) } } } catch (e: Throwable) { context.runOnUiQueueThread { - promise.reject("COLLECTION_ERROR", e.message) + promise.reject("LISTENER_ERROR", e.message) } } } @@ -699,8 +809,14 @@ fun collection_AddDocumentChangeListener( sendEvent(context, "collectionDocumentChange", resultMap) } } - - collectionChangeListeners[changeListenerToken] = listener + + // Store in unified dictionary with type + allChangeListenerTokenByUuid[changeListenerToken] = ChangeListenerRecord( + nativeListenerToken = listener, + listenerType = ChangeListenerType.COLLECTION_DOCUMENT + ) + + context.runOnUiQueueThread { promise.resolve(null) } @@ -1120,7 +1236,13 @@ fun collection_AddDocumentChangeListener( sendEvent(context, "queryChange", resultMap) } } - queryChangeListeners[changeListenerToken] = listener + + // Store in unified dictionary with type + allChangeListenerTokenByUuid[changeListenerToken] = ChangeListenerRecord( + nativeListenerToken = listener, + listenerType = ChangeListenerType.QUERY + ) + context.runOnUiQueueThread { promise.resolve(null) } @@ -1137,26 +1259,9 @@ fun query_RemoveChangeListener( changeListenerToken: String, promise: Promise ) { - GlobalScope.launch(Dispatchers.IO) { - try { - if (queryChangeListeners.containsKey(changeListenerToken)) { - val listener = queryChangeListeners[changeListenerToken] - listener?.remove() - queryChangeListeners.remove(changeListenerToken) - context.runOnUiQueueThread { - promise.resolve(null) - } - } else { - context.runOnUiQueueThread { - promise.reject("QUERY_ERROR", "No query listener found for token $changeListenerToken") - } - } - } catch (e: Throwable) { - context.runOnUiQueueThread { - promise.reject("QUERY_ERROR", e.message) - } - } - } + // Delegate to unified listener removal + listenerToken_Remove(changeListenerToken, promise) + } // Replicator Functions @@ -1181,7 +1286,11 @@ fun replicator_AddChangeListener( } } listener?.let { - replicatorChangeListeners[changeListenerToken] = it + // Store in unified dictionary with type + allChangeListenerTokenByUuid[changeListenerToken] = ChangeListenerRecord( + nativeListenerToken = it, + listenerType = ChangeListenerType.REPLICATOR + ) } context.runOnUiQueueThread { promise.resolve(null) @@ -1215,7 +1324,12 @@ fun replicator_AddDocumentChangeListener( } } listener?.let { - replicatorDocumentListeners[changeListenerToken] = it + + // Store in unified dictionary with type + allChangeListenerTokenByUuid[changeListenerToken] = ChangeListenerRecord( + nativeListenerToken = it, + listenerType = ChangeListenerType.REPLICATOR_DOCUMENT + ) } context.runOnUiQueueThread { promise.resolve(null) @@ -1355,44 +1469,10 @@ fun replicator_RemoveChangeListener( changeListenerToken: String, replicatorId: String, promise: Promise) { - GlobalScope.launch(Dispatchers.IO) { - try { - if (!DataValidation.validateReplicatorId(replicatorId, promise)){ - return@launch - } - - // Check for replicator change listeners - if (replicatorChangeListeners.containsKey(changeListenerToken)) { - val listener = replicatorChangeListeners[changeListenerToken] - listener?.remove() - replicatorChangeListeners.remove(changeListenerToken) - context.runOnUiQueueThread { - promise.resolve(null) - } - return@launch - } - - // Check for document change listeners - if (replicatorDocumentListeners.containsKey(changeListenerToken)) { - val listener = replicatorDocumentListeners[changeListenerToken] - listener?.remove() - replicatorDocumentListeners.remove(changeListenerToken) - context.runOnUiQueueThread { - promise.resolve(null) - } - return@launch - } - - // If no listener found - context.runOnUiQueueThread { - promise.reject("REPLICATOR_ERROR", "No such listener found with token $changeListenerToken") - } - } catch (e: Throwable) { - context.runOnUiQueueThread { - promise.reject("REPLICATOR_ERROR", e.message) - } - } - } + // Delegate to unified listener removal + // Note: replicatorId parameter is not used anymore but must remain in signature for compatibility + listenerToken_Remove(changeListenerToken, promise) + } @ReactMethod @@ -1548,6 +1628,110 @@ fun replicator_RemoveChangeListener( } } + // ============================================================ + // LOG SINKS FUNCTIONS + // ============================================================ + + @ReactMethod + fun logsinks_SetConsole( + level: Double?, + domains: ReadableArray?, + promise: Promise + ) { + GlobalScope.launch(Dispatchers.IO) { + try { + // Convert Double? to Int? + val intLevel = level?.toInt() + + // Convert ReadableArray? to List? + val domainList = domains?.toArrayList()?.filterIsInstance() + + LogSinksManager.setConsoleSink(intLevel, domainList) + + context.runOnUiQueueThread { + promise.resolve(null) + } + } catch (e: Throwable) { + context.runOnUiQueueThread { + promise.reject("LOGSINKS_ERROR", e.message) + } + } + } + } + + @ReactMethod + fun logsinks_SetFile( + level: Double?, + config: ReadableMap?, + promise: Promise + ) { + GlobalScope.launch(Dispatchers.IO) { + try { + // Convert Double? to Int? + val intLevel = level?.toInt() + + // Convert ReadableMap? to Map? + val configMap = config?.toHashMap()?.mapValues { it.value as Any } + + LogSinksManager.setFileSink(intLevel, configMap) + + context.runOnUiQueueThread { + promise.resolve(null) + } + } catch (e: Throwable) { + context.runOnUiQueueThread { + promise.reject("LOGSINKS_ERROR", e.message) + } + } + } + } + + @ReactMethod + fun logsinks_SetCustom( + level: Double?, + domains: ReadableArray?, + token: String?, + promise: Promise + ) { + GlobalScope.launch(Dispatchers.IO) { + try { + // Convert Double? to Int? + val intLevel = level?.toInt() + + // Convert ReadableArray? to List? + val domainList = domains?.toArrayList()?.filterIsInstance() + + // Create callback only if enabling (not disabling) + val callback: ((LogLevel, LogDomain, String) -> Unit)? = + if (intLevel != null && token != null) { + { logLevel, logDomain, message -> + val eventData = Arguments.createMap() + eventData.putString("token", token) + eventData.putInt("level", logLevel.ordinal) + eventData.putString("domain", LogSinksManager.logDomainToString(logDomain)) + eventData.putString("message", message) + + // Note: React Native may show warnings about no listeners if events arrive before + // JS listener is fully initialized, but these warnings are harmless + context.runOnUiQueueThread { + sendEvent(context, "customLogMessage", eventData) + } + } + } else null + + LogSinksManager.setCustomSink(intLevel, domainList, callback) + + context.runOnUiQueueThread { + promise.resolve(null) + } + } catch (e: Throwable) { + context.runOnUiQueueThread { + promise.reject("LOGSINKS_ERROR", e.message) + } + } + } + } + companion object { const val NAME = "CblReactnative" } diff --git a/android/src/main/java/com/cblreactnative/cbl-js-kotlin b/android/src/main/java/com/cblreactnative/cbl-js-kotlin index 2756957..a418cf8 160000 --- a/android/src/main/java/com/cblreactnative/cbl-js-kotlin +++ b/android/src/main/java/com/cblreactnative/cbl-js-kotlin @@ -1 +1 @@ -Subproject commit 27569575b375fc67965a80d69868587f199e1928 +Subproject commit a418cf8edf9ea0c91daa9a9347d3483bf4a22ec3 diff --git a/cbl-reactnative.podspec b/cbl-reactnative.podspec index 739acf4..53ab0bb 100644 --- a/cbl-reactnative.podspec +++ b/cbl-reactnative.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/Couchbase-Ecosystem/cbl-reactnative", :tag => "#{s.version}" } s.swift_version = '5.5' - s.dependency 'CouchbaseLite-Swift-Enterprise', '3.2.1' + s.dependency 'CouchbaseLite-Swift-Enterprise', '3.3.0' s.source_files = "ios/**/*.{h,m,mm,swift}" # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. diff --git a/expo-example/app/collection/default.tsx b/expo-example/app/collection/default.tsx index cd8eaec..9c738c2 100644 --- a/expo-example/app/collection/default.tsx +++ b/expo-example/app/collection/default.tsx @@ -9,8 +9,9 @@ export default function CollectionGetDefaultScreen() { async function update(database: Database) { try { const collection = await defaultCollection(database); + const fullName = await collection.fullName(); return [ - `Found Collection: <${collection.fullName()}> in Database: <${collection.database.getName()}>`, + `Found Collection: <${fullName}> in Database: <${collection.database.getName()}>`, ]; } catch (error) { // @ts-ignore diff --git a/expo-example/app/collection/deleteCollection.tsx b/expo-example/app/collection/deleteCollection.tsx index 7596152..8fdf75d 100644 --- a/expo-example/app/collection/deleteCollection.tsx +++ b/expo-example/app/collection/deleteCollection.tsx @@ -9,8 +9,9 @@ export default function CollectionDeleteScreen() { async function update(collection: Collection) { try { await deleteCollection(collection); + const fullName = await collection.fullName(); return [ - `Deleted Collection: <${collection.fullName()} in Database: <${collection.database.getName()}>`, + `Deleted Collection: <${fullName}> in Database: <${collection.database.getName()}>`, ]; } catch (error) { // @ts-ignore diff --git a/expo-example/app/collection/fullNameTest.tsx b/expo-example/app/collection/fullNameTest.tsx new file mode 100644 index 0000000..9189823 --- /dev/null +++ b/expo-example/app/collection/fullNameTest.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Collection } from 'cbl-reactnative'; +import CBLCollectionActionContainer from '@/components/CBLCollectionActionContainer/CBLCollectionActionContainer'; + +export default function CollectionFullNameTestScreen() { + function reset() {} + + async function update(collection: Collection): Promise { + const results: string[] = []; + + try { + // Test 1: Get fullName from existing collection + results.push('=== TEST 1: Get Full Name ==='); + const fullName = await collection.fullName(); + results.push(`✅ SUCCESS: Full Name = "${fullName}"`); + results.push(` Collection Name: ${collection.name}`); + results.push(` Scope Name: ${collection.scope.name}`); + results.push(` Database: ${collection.database.getName()}`); + + // Test 2: Verify format is correct + results.push(''); + results.push('=== TEST 2: Verify Format ==='); + const expectedFormat = `${collection.scope.name}.${collection.name}`; + if (fullName === expectedFormat) { + results.push(`✅ Format matches: "${fullName}"`); + } else { + results.push(`❌ Format mismatch!`); + results.push(` Expected: "${expectedFormat}"`); + results.push(` Got: "${fullName}"`); + } + + // Test 3: Test error handling with a fake collection + results.push(''); + results.push('=== TEST 3: Error Handling ==='); + results.push('Testing with valid collection (should succeed)'); + try { + const testFullName = await collection.fullName(); + results.push(`✅ No error thrown for valid collection`); + results.push(` Got: "${testFullName}"`); + } catch (error: any) { + results.push(`❌ Unexpected error: ${error.message}`); + results.push(` Code: ${error.code || 'N/A'}`); + } + + // Test 4: Compare with old computed method + results.push(''); + results.push('=== TEST 4: Compare Old vs New ==='); + const fullNameNew = await collection.fullName(); + const fullNameOld = `${collection.scope.name}.${collection.name}`; + if (fullNameNew === fullNameOld) { + results.push(`✅ Both methods return same result`); + results.push(` New (native): "${fullNameNew}"`); + results.push(` Old (computed): "${fullNameOld}"`); + } else { + results.push(`⚠️ Results differ!`); + results.push(` New (native): "${fullNameNew}"`); + results.push(` Old (computed): "${fullNameOld}"`); + } + + } catch (error: any) { + results.push(''); + results.push('=== ❌ ERROR OCCURRED ==='); + results.push(`Message: ${error.message || 'Unknown error'}`); + results.push(`Code: ${error.code || 'N/A'}`); + results.push(`Type: ${error.constructor.name}`); + + // Check if error has expected properties + if (error.message) { + results.push('✅ Error has .message property'); + } else { + results.push('❌ Error missing .message property'); + } + + // Log full error for debugging + results.push(''); + results.push('Full error object:'); + results.push(JSON.stringify(error, null, 2)); + } + + return results; + } + + return ( + + ); +} + diff --git a/expo-example/app/collection/get.tsx b/expo-example/app/collection/get.tsx index 32ed907..1be1f33 100644 --- a/expo-example/app/collection/get.tsx +++ b/expo-example/app/collection/get.tsx @@ -6,8 +6,9 @@ export default function CollectionGetScreen() { function reset() {} async function update(collection: Collection): Promise { + const fullName = await collection.fullName(); return [ - `Collection: <${collection.fullName()}> was retrieved from database <${collection.database.getName()}>`, + `Collection: <${fullName}> was retrieved from database <${collection.database.getName()}>`, ]; } diff --git a/expo-example/app/collection/list.tsx b/expo-example/app/collection/list.tsx index 7a9f48e..ec6e35d 100644 --- a/expo-example/app/collection/list.tsx +++ b/expo-example/app/collection/list.tsx @@ -20,11 +20,12 @@ export default function CollectionListScreen() { const results: string[] = []; const collections = await listCollections(database, scopeName); if (collections.length > 0) { - collections.forEach((collection) => { + for (const collection of collections) { + const fullName = await collection.fullName(); results.push( - `Found Collection: <${collection.fullName()}> in Database ${database.getName()}` + `Found Collection: <${fullName}> in Database ${database.getName()}` ); - }); + } } else { results.push( 'Error: No collections found. Collections should have at least 1 collection defined in a given scope.' diff --git a/expo-example/app/document/blob/setBlob.tsx b/expo-example/app/document/blob/setBlob.tsx index 36ea096..1cc6d03 100644 --- a/expo-example/app/document/blob/setBlob.tsx +++ b/expo-example/app/document/blob/setBlob.tsx @@ -27,8 +27,9 @@ export default function DocumentSetBlobScreen() { mutDoc !== null && mutDoc.getId() === documentId ) { + const fullName = await collection.fullName(); return [ - `Blob with key <${key}> set on Document <${documentId}> in Collection <${collection.fullName()}> Database <${collection.database.getName()}>`, + `Blob with key <${key}> set on Document <${documentId}> in Collection <${fullName}> Database <${collection.database.getName()}>`, ]; } else { return ['Error: Blob could not be set']; diff --git a/expo-example/app/document/delete.tsx b/expo-example/app/document/delete.tsx index 0ec024f..2b6c590 100644 --- a/expo-example/app/document/delete.tsx +++ b/expo-example/app/document/delete.tsx @@ -12,8 +12,9 @@ export default function DeleteDocumentScreen() { ): Promise { try { await deleteDocument(collection, documentId); + const fullName = await collection.fullName(); return [ - `Document <${documentId}> deleted successfully from Collection <${collection.fullName()}> - Database <${collection.database.getName()}>`, + `Document <${documentId}> deleted successfully from Collection <${fullName}> - Database <${collection.database.getName()}>`, ]; } catch (error) { // @ts-ignore diff --git a/expo-example/app/document/edit.tsx b/expo-example/app/document/edit.tsx index cc19404..f855212 100644 --- a/expo-example/app/document/edit.tsx +++ b/expo-example/app/document/edit.tsx @@ -24,8 +24,9 @@ export default function DocumentEditorScreen() { document !== null && doc.getId() === documentId ) { + const fullName = await collection.fullName(); return [ - `Document <${documentId}> saved in Collection <${collection.fullName()}> Database <${collection.database.getName()}>`, + `Document <${documentId}> saved in Collection <${fullName}> Database <${collection.database.getName()}>`, ]; } else { return ['Error: Document could not be saved']; diff --git a/expo-example/app/document/expiration.tsx b/expo-example/app/document/expiration.tsx index 4fe995b..70362d5 100644 --- a/expo-example/app/document/expiration.tsx +++ b/expo-example/app/document/expiration.tsx @@ -21,8 +21,9 @@ export default function GetDocumentExpirationScreen() { ): Promise { try { await setExpirationDate(collection, documentId, expiration); + const fullName = await collection.fullName(); return [ - `Successfully set expiration to <${expiration}> for Document with ID: <${documentId}> in Collection <${collection.fullName()}> in Database <${collection.database.getName()}>`, + `Successfully set expiration to <${expiration}> for Document with ID: <${documentId}> in Collection <${fullName}> in Database <${collection.database.getName()}>`, ]; } catch (error) { // @ts-ignore diff --git a/expo-example/app/logsinks/console.tsx b/expo-example/app/logsinks/console.tsx new file mode 100644 index 0000000..d58349b --- /dev/null +++ b/expo-example/app/logsinks/console.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { SafeAreaView, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyleScheme } from '@/components/Themed/Themed'; +import ResultListView from '@/components/ResultsListView/ResultsListView'; +import useNavigationBarTitleResetOption from '@/hooks/useNavigationBarTitleResetOption'; +import { useLogDomainAsValues } from '@/hooks/useLogDomain'; +import HeaderRunActionView from '@/components/HeaderRunActionView/HeaderRunActionView'; +import SelectKeyValue from '@/components/SelectKeyValue/SelectKeyValue'; +import { useLogLevelAsValues } from '@/hooks/useLogLevel'; +import { Divider, Button, ButtonText } from '@gluestack-ui/themed'; +import setConsoleSink from '@/service/logsinks/setConsoleSink'; +import disableConsoleSink from '@/service/logsinks/disableConsoleSink'; + +export default function LogSinksConsoleScreen() { + const [selectedLogLevel, setSelectedLogLevel] = useState(''); + const [selectedDomains, setSelectedDomains] = useState([]); + const [resultMessage, setResultsMessage] = useState([]); + const navigation = useNavigation(); + const styles = useStyleScheme(); + useNavigationBarTitleResetOption('LogSinks Console', navigation, reset); + + const logDomains = useLogDomainAsValues(); + const logLevels = useLogLevelAsValues(); + + function reset() { + setSelectedLogLevel(''); + setSelectedDomains([]); + setResultsMessage([]); + } + + const enable = async () => { + if (selectedLogLevel === '') { + setResultsMessage((prev) => [...prev, '❌ Please select a log level']); + return; + } + const result = await setConsoleSink(selectedLogLevel, selectedDomains); + setResultsMessage((prev) => [...prev, result]); + }; + + const disable = async () => { + const result = await disableConsoleSink(); + setResultsMessage((prev) => [...prev, result]); + }; + + return ( + + + + + + { + if (value && !selectedDomains.includes(value)) { + setSelectedDomains([...selectedDomains, value]); + } + }} + placeholder="Add Domain" + items={logDomains} + /> + {selectedDomains.length > 0 && ( + + {selectedDomains.map((domain, index) => ( + + ))} + + )} + + + + + + ); +} + diff --git a/expo-example/app/logsinks/custom.tsx b/expo-example/app/logsinks/custom.tsx new file mode 100644 index 0000000..ccd3638 --- /dev/null +++ b/expo-example/app/logsinks/custom.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from 'react'; +import { SafeAreaView, View, ScrollView } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyleScheme, Text } from '@/components/Themed/Themed'; +import ResultListView from '@/components/ResultsListView/ResultsListView'; +import useNavigationBarTitleResetOption from '@/hooks/useNavigationBarTitleResetOption'; +import { useLogDomainAsValues } from '@/hooks/useLogDomain'; +import HeaderRunActionView from '@/components/HeaderRunActionView/HeaderRunActionView'; +import SelectKeyValue from '@/components/SelectKeyValue/SelectKeyValue'; +import { useLogLevelAsValues } from '@/hooks/useLogLevel'; +import { Divider, Button, ButtonText } from '@gluestack-ui/themed'; +import setCustomSink from '@/service/logsinks/setCustomSink'; +import disableCustomSink from '@/service/logsinks/disableCustomSink'; + +export default function LogSinksCustomScreen() { + const [selectedLogLevel, setSelectedLogLevel] = useState(''); + const [selectedDomains, setSelectedDomains] = useState([]); + const [resultMessage, setResultsMessage] = useState([]); + const [isListening, setIsListening] = useState(false); + const [logMessages, setLogMessages] = useState([]); + const navigation = useNavigation(); + const styles = useStyleScheme(); + useNavigationBarTitleResetOption('LogSinks Custom', navigation, reset); + + const logDomains = useLogDomainAsValues(); + const logLevels = useLogLevelAsValues(); + + function reset() { + setSelectedLogLevel(''); + setSelectedDomains([]); + setResultsMessage([]); + setLogMessages([]); + setIsListening(false); + } + + const enable = async () => { + if (selectedLogLevel === '') { + setResultsMessage((prev) => [...prev, '❌ Please select a log level']); + return; + } + + // Callback that receives logs from native + const callback = (level: number, domain: string, message: string) => { + const timestamp = new Date().toLocaleTimeString(); + const logEntry = `[${timestamp}] [${domain}] ${message}`; + setLogMessages((prev) => [...prev, logEntry]); + }; + + const result = await setCustomSink(selectedLogLevel, selectedDomains, callback); + setResultsMessage((prev) => [...prev, result]); + setIsListening(true); + }; + + const disable = async () => { + const result = await disableCustomSink(); + setResultsMessage((prev) => [...prev, result]); + setIsListening(false); + setLogMessages([]); + }; + + const clearLogs = () => { + setLogMessages([]); + }; + + return ( + + + + + + + { + if (value && !selectedDomains.includes(value)) { + setSelectedDomains([...selectedDomains, value]); + } + }} + placeholder="Add Domain" + items={logDomains} + /> + {selectedDomains.length > 0 && ( + + {selectedDomains.map((domain, index) => ( + + ))} + + )} + + + + + + + + + + {isListening && ( + + + ✅ Listening for logs... ({logMessages.length} received) + + + )} + + + + Status Messages: + + + + {logMessages.length > 0 && ( + + Received Logs: + + + )} + + + ); +} + diff --git a/expo-example/app/logsinks/file.tsx b/expo-example/app/logsinks/file.tsx new file mode 100644 index 0000000..f577a54 --- /dev/null +++ b/expo-example/app/logsinks/file.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react'; +import { + TextInput, + useColorScheme, + View, + ScrollView, + StyleSheet, + SafeAreaView, +} from 'react-native'; +import { Switch, Divider, Button, ButtonText } from '@gluestack-ui/themed'; +import { useNavigation } from '@react-navigation/native'; +import { + useStyleScheme, + useThemeColor, + Text, +} from '@/components/Themed/Themed'; +import ResultListView from '@/components/ResultsListView/ResultsListView'; +import useNavigationBarTitleResetOption from '@/hooks/useNavigationBarTitleResetOption'; +import SelectKeyValue from '@/components/SelectKeyValue/SelectKeyValue'; +import { useLogLevelAsValues } from '@/hooks/useLogLevel'; +import { usePlaceholderTextColor } from '@/hooks/usePlaceholderTextColor'; +import getFileDefaultPath from '@/service/file/getFileDefaultPath'; +import HeaderToolbarView from '@/components/HeaderToolbarView/HeaderToolbarView'; +import setFileSink from '@/service/logsinks/setFileSink'; +import disableFileSink from '@/service/logsinks/disableFileSink'; + +export default function LogSinksFileScreen() { + const [selectedLogLevel, setSelectedLogLevel] = useState(''); + const [directory, setDirectory] = useState(''); + const [maxRotateCount, setMaxRotateCount] = useState('5'); + const [maxSize, setMaxSize] = useState('10485760'); + const [usePlainText, setUsePlainText] = useState(false); + const [resultMessage, setResultsMessage] = useState([]); + + const navigation = useNavigation(); + useNavigationBarTitleResetOption('LogSinks File', navigation, reset); + + const scheme = useColorScheme(); + const styles = useStyleScheme(); + const textColor = useThemeColor({ light: 'black', dark: 'white' }, 'text'); + const placeholderTextColor = usePlaceholderTextColor(scheme); + const logLevels = useLogLevelAsValues(); + + function reset() { + setDirectory(''); + setMaxRotateCount('5'); + setMaxSize('10485760'); + setUsePlainText(false); + setSelectedLogLevel(''); + setResultsMessage([]); + } + + const enable = async () => { + const numericRegex = /^[0-9]*$/; + let maxRotateCountInt = 5; + let maxSizeInt = 10485760; + + if (numericRegex.test(maxRotateCount) && numericRegex.test(maxSize)) { + maxRotateCountInt = parseInt(maxRotateCount, 10); + maxSizeInt = parseInt(maxSize, 10); + } else { + setResultsMessage((prev) => [ + ...prev, + '❌ Max Rotate Count and Max Size must be numeric values', + ]); + return; + } + if (selectedLogLevel === '') { + setResultsMessage((prev) => [...prev, '❌ Please select a log level']); + return; + } + if (directory === '') { + setResultsMessage((prev) => [...prev, '❌ Please enter a directory path']); + return; + } + + const result = await setFileSink( + selectedLogLevel, + directory, + maxRotateCountInt, + maxSizeInt, + usePlainText + ); + setResultsMessage((prev) => [...prev, result]); + }; + + const disable = async () => { + const result = await disableFileSink(); + setResultsMessage((prev) => [...prev, result]); + }; + + const handleLocationPress = async () => { + try { + const defaultPath = await getFileDefaultPath(); + setDirectory(defaultPath); + } catch (error: any) { + setResultsMessage((prev) => [...prev, `❌ ${error.message}`]); + } + }; + + const icons = [ + { + iconName: 'folder-open', + onPress: handleLocationPress, + }, + { + iconName: 'play', + onPress: enable, + }, + ]; + + return ( + + + + + setDirectory(newText)} + value={directory} + multiline={true} + /> + + + + + + + Max Rotate Count + + + + + Max Size (in bytes) + + + + + + Use Plain Text + + + + + + + + + + + ); +} + +const fileStyles = StyleSheet.create({ + divider: { + marginTop: 12, + marginBottom: 12, + }, + logDirectory: { + height: undefined, + minHeight: 80, + }, + maxSizeInput: { + height: undefined, + minHeight: 30, + }, + switch: { + paddingRight: 16, + }, +}); + diff --git a/expo-example/app/tests/collection-change-listener.tsx b/expo-example/app/tests/collection-change-listener.tsx new file mode 100644 index 0000000..0b14a96 --- /dev/null +++ b/expo-example/app/tests/collection-change-listener.tsx @@ -0,0 +1,262 @@ +import React, { useState } from 'react'; +import TestRunnerContainer from '@/components/TestRunnerContainer/TestRunnerContainer'; +import { SafeAreaView, Text, Button, ScrollView} from 'react-native'; +import { View } from '@/components/Themed/Themed'; +import { + Database, + DatabaseConfiguration, + Replicator, + ReplicatorConfiguration, + URLEndpoint, + BasicAuthenticator, + SessionAuthenticator, + ReplicatorType, + ListenerToken, + Collection, + MutableDocument, + Document, + ConcurrencyControl + } from 'cbl-reactnative';import getFileDefaultPath from '@/service/file/getFileDefaultPath'; + +export default function CollectionChangeListenerScreen() { + + // open a database + // create a collection + // add a collecion change listener + + + + const [listOfLogs, setListOfLogs] = useState([]); + const [errorLogs, setErrorLogs] = useState([]); + + const [database, setDatabase] = useState(null); + const [collection, setCollection] = useState(null); + const [replicator, setReplicator] = useState(null); + + const [listOfDocuments, setListOfDocuments] = useState([]); + + const [token, setToken] = useState(null); + + + + const openDatabase = async () => { + try { + setListOfLogs(prev => [...prev, 'Opening Database']); // ✅ Use prev + const databaseName = 'databse_name_random'; + const directory = await getFileDefaultPath(); + const dbConfig = new DatabaseConfiguration(); + const database = new Database(databaseName, dbConfig); + await database.open(); + setListOfLogs(prev => [...prev, `Database opened with name: ${database.getName()} at default directory`]); // ✅ Use prev + setDatabase(database); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error opening database: ${error.message}`]); // ✅ Use prev + } + } + + const createCollection = async () => { + try { + setListOfLogs(prev => [...prev, 'Creating Collection']); // ✅ Use prev + const collection = await database?.createCollection('test_collection'); + if (collection) { + setCollection(collection); + setListOfLogs(prev => [...prev, `Collection created`]); // ✅ Use prev + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating collection: ${error.message}`]); + } + } + + const startCollectionChangeListener = async () => { + try{ + if (collection) { + setListOfLogs(prev => [...prev, `Starting collection change listener`]); + const cblToken = await collection.addChangeListener((change) => { + setListOfLogs(prev => [...prev, `Collection changed:`, JSON.stringify(change)]); + }); + setToken(cblToken); + + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error starting collection change listener: ${error.message}`]); + } + } + + const stopCollectionChangeListenerOldAPI = async () => { + try{ + if (collection) { + if (token) { + // old api + await collection.removeChangeListener(token); + setToken(null); + setListOfLogs(prev => [...prev, `✅ OLD API: Collection change listener stopped via collection.removeChangeListener()`]); + } else { + setErrorLogs(prev => [...prev, `No token found to stop collection change listener`]); + } + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error stopping collection change listener: ${error.message}`]); + } + } + + const stopCollectionChangeListenerNewAPI = async () => { + try { + if(token){ + await token.remove() + setToken(null); + setListOfLogs(prev => [...prev, `✅ NEW API: Collection change listener stopped via token.remove()`]); + } else { + setErrorLogs(prev => [...prev, `No token found to stop collection change listener`]); + } + } + catch(error){ + setErrorLogs(prev => [...prev, `Error stopping collection change listener (NEW API): ${error}`]); + } + } + + // const connectToSyncGateway = async () => { + // setListOfLogs(prev => [...prev, 'Connecting to Sync Gateway']); // ✅ Use prev + // const defaultCollection = await database?.defaultCollection(); + + // const syncGatewayUrl = "wss://nasm0fvdr-jnehnb.apps.cloud.couchbase.com:4984/testendpoint" + // const endpoint = new URLEndpoint(syncGatewayUrl); + // const username = "jayantdhingra" + // const password = "f9yu5QT4B5jpZep@" + + // const replicatorConfig = new ReplicatorConfiguration(endpoint) + // replicatorConfig.setAuthenticator(new BasicAuthenticator(username, password)) + // // replicatorConfig.setContinuous(true) + // replicatorConfig.setAcceptOnlySelfSignedCerts(false); + + + // if (defaultCollection) { + // replicatorConfig.addCollection(defaultCollection) + // } + + // const replicator = await Replicator.create(replicatorConfig) + + // replicator.addChangeListener((change) => { + // const status = change.status; + + // setListOfLogs(prev => [...prev, `Replicator changed:, ${status}`]); + + // if (status.getError()) { + // setErrorLogs(prev => [...prev, `Replication error: ${status.getError()}`]); + // } + + // }) + + // await replicator.start(false) + // setReplicator(replicator); + + // setListOfLogs(prev => [...prev, `Replicator created`]); // ✅ Use prev + // } + + + const createDocument = async () => { + setListOfLogs(prev => [...prev, 'Creating Document']); // ✅ Use prev + try { + const doc = new MutableDocument() + // const defaultCollection = await database?.defaultCollection(); + + + doc.setString('name', 'Jayanthealthy') + doc.setString('email', 'john@example.com') + doc.setString('phone', '1234567890') + doc.setString('address', '123 Main St, Anytown, USA') + doc.setString('city', 'Anytown') + doc.setString('state', 'CA') + doc.setString('zip', '12345') + doc.setNumber('age', 30) + doc.setBoolean('isActive', true) + doc.setDate('createdAt', new Date()) + doc.setArray('tags', ['tag1', 'tag2', 'tag3']) + doc.setDictionary('address', {street: '123 Main St', city: 'Anytown', state: 'CA', zip: '12345'}) + + + await collection?.save(doc) + setListOfLogs(prev => [...prev, `Document created`]); // ✅ Use prev + setListOfDocuments(prev => [...prev, doc.getId()]); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating document: ${error.message}`]); // ✅ Use prev + } + + } + + + // const deleteDocument = async (docId: string) => { + // setListOfLogs(prev => [...prev, 'Deleting Document']); // ✅ Use prev + // try { + // const defaultCollection = await database?.defaultCollection(); + // const doc = await defaultCollection?.document(docId) + // if(doc){ + // await defaultCollection?.deleteDocument(doc) + // setListOfDocuments(prev => prev.filter(id => id !== docId)); + // setListOfLogs(prev => [...prev, `Document deleted`]); // ✅ Use prev + // } else { + // setErrorLogs(prev => [...prev, `Document not found`]); // ✅ Use prev + // } + + // } catch (error) { + // // @ts-ignore + // setErrorLogs(prev => [...prev, `Error deleting document: ${error.message}`]); // ✅ Use prev + // } + // } + + + + return ( + + + + + + + + + + + Enable with Specific Domains: + + + + + + + + + + + + + + 2. Trigger Log Events + + + Database Operations: + + + + + + + + + + Document & Query Operations: + + + + + + + + + + + Activity Logs + + + {listOfLogs.length === 0 ? ( + No activity yet. Enable custom logging and trigger operations above. + ) : ( + listOfLogs.map((log, index) => ( + + {log} + + )) + )} + + + {capturedLogs.length > 0 && ( + <> + + 🎯 Captured Logs ({capturedLogs.length}) + + + {capturedLogs.map((log, index) => ( + + {log} + + ))} + + + )} + + {errorLogs.length > 0 && ( + <> + Errors + + {errorLogs.map((log, index) => ( + + {log} + + ))} + + + )} + + + + ); +} + +const localStyles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + content: { + padding: 10, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 15, + textAlign: 'center', + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 15, + marginBottom: 10, + }, + warningBox: { + backgroundColor: '#e8f5e9', + padding: 15, + borderRadius: 5, + marginBottom: 15, + borderWidth: 1, + borderColor: '#4caf50', + }, + warningText: { + fontSize: 16, + fontWeight: 'bold', + color: '#2e7d32', + marginBottom: 5, + }, + statusText: { + fontSize: 11, + color: '#2e7d32', + fontStyle: 'italic', + marginBottom: 3, + }, + listeningText: { + fontSize: 13, + color: '#2e7d32', + fontWeight: 'bold', + marginTop: 5, + }, + buttonGroup: { + marginBottom: 15, + }, + groupLabel: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + color: '#666', + }, + buttonRow: { + flexDirection: 'row', + marginBottom: 5, + }, + buttonSpacer: { + width: 10, + }, + separator: { + height: 20, + }, + logContainer: { + padding: 10, + backgroundColor: '#f5f5f5', + borderRadius: 5, + minHeight: 100, + marginBottom: 15, + }, + logText: { + fontSize: 12, + marginBottom: 5, + lineHeight: 18, + }, + emptyLog: { + fontSize: 12, + fontStyle: 'italic', + color: '#999', + }, + capturedTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 10, + marginBottom: 10, + color: '#4caf50', + }, + capturedContainer: { + padding: 10, + backgroundColor: '#e8f5e9', + borderRadius: 5, + marginBottom: 15, + maxHeight: 300, + }, + capturedText: { + fontSize: 11, + marginBottom: 3, + color: '#2e7d32', + }, + errorTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 10, + marginBottom: 10, + color: 'red', + }, + errorContainer: { + padding: 10, + backgroundColor: '#ffe6e6', + borderRadius: 5, + marginBottom: 15, + }, + errorText: { + color: 'red', + fontSize: 12, + marginBottom: 5, + }, +}); \ No newline at end of file diff --git a/expo-example/app/tests/document-change-listener.tsx b/expo-example/app/tests/document-change-listener.tsx new file mode 100644 index 0000000..addf52b --- /dev/null +++ b/expo-example/app/tests/document-change-listener.tsx @@ -0,0 +1,224 @@ +import React, { useState } from 'react'; +import TestRunnerContainer from '@/components/TestRunnerContainer/TestRunnerContainer'; +import { SafeAreaView, Text, Button, ScrollView} from 'react-native'; +import { View } from '@/components/Themed/Themed'; +import { + Database, + DatabaseConfiguration, + Replicator, + ReplicatorConfiguration, + URLEndpoint, + BasicAuthenticator, + SessionAuthenticator, + ReplicatorType, + Collection, + MutableDocument, + Document, + ConcurrencyControl, + ListenerToken + } from 'cbl-reactnative';import getFileDefaultPath from '@/service/file/getFileDefaultPath'; + +export default function DocumentChangeListenerScreen() { + + // open a database + // create a collection + // add a collecion change listener + + + + const [listOfLogs, setListOfLogs] = useState([]); + const [errorLogs, setErrorLogs] = useState([]); + + const [database, setDatabase] = useState(null); + const [collection, setCollection] = useState(null); + const [document, setDocument] = useState(null); + const [replicator, setReplicator] = useState(null); + + const [listOfDocuments, setListOfDocuments] = useState([]); + + const [token, setToken] = useState(null); + + + + const openDatabase = async () => { + try { + setListOfLogs(prev => [...prev, 'Opening Database']); // ✅ Use prev + const databaseName = 'databse_name_random'; + const directory = await getFileDefaultPath(); + const dbConfig = new DatabaseConfiguration(); + const database = new Database(databaseName, dbConfig); + await database.open(); + setListOfLogs(prev => [...prev, `Database opened with name: ${database.getName()} at default directory`]); // ✅ Use prev + setDatabase(database); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error opening database: ${error.message}`]); // ✅ Use prev + } + } + + const createCollection = async () => { + try { + setListOfLogs(prev => [...prev, 'Creating Collection']); // ✅ Use prev + const collection = await database?.createCollection('test_collection'); + if (collection) { + setCollection(collection); + setListOfLogs(prev => [...prev, `Collection created`]); // ✅ Use prev + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating collection: ${error.message}`]); + } + } + + const startDocumentChangeListener = async () => { + try{ + if (collection) { + setListOfLogs(prev => [...prev, `Starting document change listener`]); + const cblToken = await collection.addDocumentChangeListener(document?.getId() || '', (change) => { + const dateTime = new Date().toISOString(); + setListOfLogs(prev => [...prev, + `${dateTime} - Document changed:`, + ` Document ID: ${change.documentId}`, + ` Collection: ${change.collection.name}`, + ` Database: ${change.database.getName()}` + ]); + }); + setToken(cblToken); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error starting document change listener: ${error.message}`]); + } + } + + const stopDocumentChangeListenerOldApi = async () => { + try{ + if (collection) { + if (token) { + await collection.removeDocumentChangeListener(token); + setToken(null); + setListOfLogs(prev => [...prev, `✅ OLD API: Document change listener stopped`]); + } else { + setErrorLogs(prev => [...prev, `No token found to stop document change listener`]); + } + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error stopping document change listener: ${error.message}`]); + } + } + + const stopDocumentChangeListenerNewApi = async () => { + try{ + if (token) { + await token.remove(); + setToken(null); + setListOfLogs(prev => [...prev, `✅ NEW API: Document change listener stopped`]); + } else { + setErrorLogs(prev => [...prev, `No token found to stop document change listener`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error stopping document change listener: ${error.message}`]); + } + } + + const createDocument = async () => { + setListOfLogs(prev => [...prev, 'Creating Document']); // ✅ Use prev + try { + const doc = new MutableDocument() + // const defaultCollection = await database?.defaultCollection(); + + + doc.setString('name', 'Jayanthealthy') + doc.setString('email', 'john@example.com') + doc.setString('phone', '1234567890') + doc.setString('address', '123 Main St, Anytown, USA') + doc.setString('city', 'Anytown') + doc.setString('state', 'CA') + doc.setString('zip', '12345') + doc.setNumber('age', 30) + doc.setBoolean('isActive', true) + doc.setDate('createdAt', new Date()) + doc.setArray('tags', ['tag1', 'tag2', 'tag3']) + doc.setDictionary('address', {street: '123 Main St', city: 'Anytown', state: 'CA', zip: '12345'}) + + + await collection?.save(doc) + setListOfLogs(prev => [...prev, `Document created`]); // ✅ Use prev + setDocument(doc); + setListOfDocuments(prev => [...prev, doc.getId()]); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating document: ${error.message}`]); // ✅ Use prev + } + + } + + const updateDocument = async () => { + try{ + if (document) { + const mutableDoc = MutableDocument.fromDocument(document as Document); + mutableDoc.setString('name', 'Jayanthealthy updated'); + await collection?.save(mutableDoc); + setListOfLogs(prev => [...prev, `Document updated`]); // ✅ Use prev + setDocument(mutableDoc); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error updating document: ${error.message}`]); // ✅ Use prev + } + } + + + + return ( + + + + + + + + + + + + Plaintext Format: + + + + + + + + 2. Trigger Log Events + + + Database Operations: + + + + + + + + + + Document & Query Operations: + + + + + + + + + + + Activity Logs + + + {listOfLogs.length === 0 ? ( + No activity yet. Start by enabling file logging above. + ) : ( + listOfLogs.map((log, index) => ( + + {log} + + )) + )} + + + {errorLogs.length > 0 && ( + <> + Errors + + {errorLogs.map((log, index) => ( + + {log} + + ))} + + + )} + + + + ); +} + +const localStyles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + content: { + padding: 10, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 15, + textAlign: 'center', + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 15, + marginBottom: 10, + }, + warningBox: { + backgroundColor: '#fff3cd', + padding: 15, + borderRadius: 5, + marginBottom: 15, + borderWidth: 1, + borderColor: '#ffc107', + }, + warningText: { + fontSize: 16, + fontWeight: 'bold', + color: '#856404', + marginBottom: 5, + }, + statusText: { + fontSize: 11, + color: '#856404', + fontStyle: 'italic', + }, + buttonGroup: { + marginBottom: 15, + }, + groupLabel: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + color: '#666', + }, + buttonRow: { + flexDirection: 'row', + marginBottom: 5, + }, + buttonSpacer: { + width: 10, + }, + separator: { + height: 20, + }, + logContainer: { + padding: 10, + backgroundColor: '#f5f5f5', + borderRadius: 5, + minHeight: 100, + marginBottom: 15, + }, + logText: { + fontSize: 12, + marginBottom: 5, + lineHeight: 18, + }, + emptyLog: { + fontSize: 12, + fontStyle: 'italic', + color: '#999', + }, + errorTitle: { + fontSize: 16, + fontWeight: 'bold', + marginTop: 10, + marginBottom: 10, + color: 'red', + }, + errorContainer: { + padding: 10, + backgroundColor: '#ffe6e6', + borderRadius: 5, + marginBottom: 15, + }, + errorText: { + color: 'red', + fontSize: 12, + marginBottom: 5, + }, +}); \ No newline at end of file diff --git a/expo-example/app/tests/listener.tsx b/expo-example/app/tests/listener.tsx new file mode 100644 index 0000000..735a7e4 --- /dev/null +++ b/expo-example/app/tests/listener.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import TestRunnerContainer from '@/components/TestRunnerContainer/TestRunnerContainer'; + +import { ListenerTests } from "../../cblite-js-tests/cblite-tests/e2e/listener-test"; + +export default function TestsListenerScreen() { + function reset() {} + + async function update(): Promise { + try { + return ['']; + } catch (e) { + // @ts-ignore + return [e.message]; + } + } + + return ( + + ); +} \ No newline at end of file diff --git a/expo-example/app/tests/live-query-listeners.tsx b/expo-example/app/tests/live-query-listeners.tsx new file mode 100644 index 0000000..c88efaf --- /dev/null +++ b/expo-example/app/tests/live-query-listeners.tsx @@ -0,0 +1,299 @@ +import React, { useState } from 'react'; +import { SafeAreaView, Text, Button, ScrollView } from 'react-native'; +import { View } from '@/components/Themed/Themed'; +import { + Database, + DatabaseConfiguration, + Collection, + MutableDocument, + Query, + ListenerToken +} from 'cbl-reactnative'; +import getFileDefaultPath from '@/service/file/getFileDefaultPath'; + +export default function LiveQueryScreen() { + const [listOfLogs, setListOfLogs] = useState([]); + const [errorLogs, setErrorLogs] = useState([]); + + const [database, setDatabase] = useState(null); + const [collection, setCollection] = useState(null); + const [query, setQuery] = useState(null); + const [token, setToken] = useState(null); + const [listOfDocuments, setListOfDocuments] = useState([]); + + const openDatabase = async () => { + try { + setListOfLogs(prev => [...prev, 'Opening Database']); + const databaseName = 'live_query_test_db'; + const directory = await getFileDefaultPath(); + const dbConfig = new DatabaseConfiguration(); + const database = new Database(databaseName, dbConfig); + await database.open(); + setListOfLogs(prev => [...prev, `Database opened with name: ${database.getName()}`]); + setDatabase(database); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error opening database: ${error.message}`]); + } + } + + const createCollection = async () => { + try { + setListOfLogs(prev => [...prev, 'Creating Collection']); + const collection = await database?.createCollection('live_query_collection'); + if (collection) { + setCollection(collection); + setListOfLogs(prev => [...prev, `Collection created`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating collection: ${error.message}`]); + } + } + + const startLiveQuery = async () => { + try { + if (database && collection) { + setListOfLogs(prev => [...prev, `Starting live query`]); + + // Create a query that selects all documents where type = 'test' + const queryString = `SELECT * FROM _default.live_query_collection WHERE type = 'test'`; + const query = database.createQuery(queryString); + + const listenerToken = await query.addChangeListener((change) => { + const date = new Date().toISOString(); + + if (change.error) { + setErrorLogs(prev => [...prev, `${date} Query error: ${change.error}`]); + return; + } + + if (change.results && change.results.length > 0) { + const results = change.results.map((doc) => JSON.stringify(doc)); + setListOfLogs(prev => [ + ...prev, + `${date} Query results updated:`, + ` Found ${change.results.length} document(s)`, + ...results.map(r => ` ${r}`) + ]); + } else { + setListOfLogs(prev => [...prev, `${date} Query results: No matching documents`]); + } + }); + + setQuery(query); + setToken(listenerToken); + setListOfLogs(prev => [...prev, `Live query started successfully with token: ${listenerToken.getUuidToken()}`]); + } else { + setErrorLogs(prev => [...prev, `Database or Collection not initialized`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error starting live query: ${error.message}`]); + } + } + + const stopLiveQueryOldAPI = async () => { + try { + if (query && token) { + await query.removeChangeListener(token); + setToken(null); + setQuery(null); + setListOfLogs(prev => [...prev, `✅ OLD API: Live query stopped via query.removeChangeListener()`]); + } else { + setErrorLogs(prev => [...prev, `No active query to stop`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error stopping live query (OLD API): ${error.message}`]); + } + } + + const stopLiveQueryNewAPI = async () => { + try { + if (token) { + await token.remove(); + setToken(null); + setQuery(null); + setListOfLogs(prev => [...prev, `✅ NEW API: Live query stopped via token.remove()`]); + } else { + setErrorLogs(prev => [...prev, `No active query to stop`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error stopping live query (NEW API): ${error.message}`]); + } + } + + const createDocument = async () => { + setListOfLogs(prev => [...prev, 'Creating Document']); + try { + const doc = new MutableDocument(); + doc.setString('type', 'test'); + doc.setString('name', 'Test Document'); + doc.setString('description', 'This is a test document for live query'); + doc.setNumber('value', Math.floor(Math.random() * 100)); + doc.setDate('createdAt', new Date()); + + await collection?.save(doc); + setListOfLogs(prev => [...prev, `Document created with ID: ${doc.getId()}`]); + setListOfDocuments(prev => [...prev, doc.getId()]); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating document: ${error.message}`]); + } + } + + const createNonMatchingDocument = async () => { + setListOfLogs(prev => [...prev, 'Creating Non-Matching Document']); + try { + const doc = new MutableDocument(); + doc.setString('type', 'other'); + doc.setString('name', 'Non-Matching Document'); + doc.setString('description', 'This document should NOT trigger the live query'); + + await collection?.save(doc); + setListOfLogs(prev => [...prev, `Non-matching document created with ID: ${doc.getId()}`]); + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error creating non-matching document: ${error.message}`]); + } + } + + const updateDocument = async () => { + try { + if (listOfDocuments.length === 0) { + setErrorLogs(prev => [...prev, 'No documents to update']); + return; + } + + const docId = listOfDocuments[listOfDocuments.length - 1]; + const doc = await collection?.document(docId); + + if (doc) { + const mutableDoc = new MutableDocument(doc.getId(), doc.toDictionary()); + mutableDoc.setNumber('value', Math.floor(Math.random() * 100)); + mutableDoc.setDate('updatedAt', new Date()); + + await collection?.save(mutableDoc); + setListOfLogs(prev => [...prev, `Document ${docId} updated`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error updating document: ${error.message}`]); + } + } + + const deleteDocument = async () => { + try { + if (listOfDocuments.length === 0) { + setErrorLogs(prev => [...prev, 'No documents to delete']); + return; + } + + const docId = listOfDocuments[listOfDocuments.length - 1]; + const doc = await collection?.document(docId); + + if (doc) { + await collection?.deleteDocument(doc); + setListOfDocuments(prev => prev.filter(id => id !== docId)); + setListOfLogs(prev => [...prev, `Document ${docId} deleted`]); + } + } catch (error) { + // @ts-ignore + setErrorLogs(prev => [...prev, `Error deleting document: ${error.message}`]); + } + } + + return ( + + + + + Live Query Test (Query Change Listener) + + + + Tests both OLD and NEW APIs for removing query change listeners + + + + Setup Steps: + +