capacitor-launchdarkly 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CapacitorLaunchdarkly.podspec +20 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +32 -0
  4. package/README.md +747 -2
  5. package/android/build.gradle +68 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/Json.kt +67 -0
  8. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyClient.kt +250 -0
  9. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyPlugin.kt +148 -0
  10. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LdConfigFactory.kt +34 -0
  11. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StartOptions.kt +76 -0
  12. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StoredContext.kt +47 -0
  13. package/android/src/main/res/.gitkeep +0 -0
  14. package/dist/docs.json +1174 -0
  15. package/dist/esm/definitions.d.ts +520 -0
  16. package/dist/esm/definitions.js +2 -0
  17. package/dist/esm/definitions.js.map +1 -0
  18. package/dist/esm/index.d.ts +4 -0
  19. package/dist/esm/index.js +7 -0
  20. package/dist/esm/index.js.map +1 -0
  21. package/dist/esm/web.d.ts +46 -0
  22. package/dist/esm/web.js +204 -0
  23. package/dist/esm/web.js.map +1 -0
  24. package/dist/plugin.cjs.js +221 -0
  25. package/dist/plugin.cjs.js.map +1 -0
  26. package/dist/plugin.js +224 -0
  27. package/dist/plugin.js.map +1 -0
  28. package/ios/Sources/LaunchDarklyPlugin/ConfigFactory.swift +29 -0
  29. package/ios/Sources/LaunchDarklyPlugin/JSONValues.swift +68 -0
  30. package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyClient.swift +165 -0
  31. package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyPlugin.swift +169 -0
  32. package/ios/Sources/LaunchDarklyPlugin/PrivacyInfo.xcprivacy +23 -0
  33. package/ios/Sources/LaunchDarklyPlugin/StartOptions.swift +79 -0
  34. package/ios/Sources/LaunchDarklyPlugin/StoredContext.swift +43 -0
  35. package/ios/Tests/LaunchDarklyPluginTests/BillingSafetyTests.swift +53 -0
  36. package/ios/Tests/LaunchDarklyPluginTests/JSONValuesTests.swift +67 -0
  37. package/ios/Tests/LaunchDarklyPluginTests/LaunchDarklyPluginTests.swift +37 -0
  38. package/ios/Tests/LaunchDarklyPluginTests/PluginConfigurationTests.swift +52 -0
  39. package/ios/Tests/LaunchDarklyPluginTests/StartContextTests.swift +36 -0
  40. package/package.json +105 -4
@@ -0,0 +1,68 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+ // The LaunchDarkly Android client SDK, pinned. An app can move it with `launchDarklyAndroidVersion` in its variables.gradle.
7
+ launchDarklyAndroidVersion = project.hasProperty('launchDarklyAndroidVersion') ? rootProject.ext.launchDarklyAndroidVersion : '5.18.0'
8
+ }
9
+
10
+ buildscript {
11
+ ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '2.4.21'
12
+ repositories {
13
+ google()
14
+ mavenCentral()
15
+ }
16
+ dependencies {
17
+ // The version Capacitor 8's own Android library builds with: an app loads one Android Gradle plugin for every module.
18
+ classpath 'com.android.tools.build:gradle:8.13.2'
19
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
20
+ }
21
+ }
22
+
23
+ apply plugin: 'com.android.library'
24
+ apply plugin: 'kotlin-android'
25
+
26
+ android {
27
+ namespace = "io.github.maximbelov.launchdarkly"
28
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
29
+ defaultConfig {
30
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
31
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
32
+ versionCode 1
33
+ versionName "1.0"
34
+ }
35
+ buildTypes {
36
+ release {
37
+ minifyEnabled false
38
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
39
+ }
40
+ }
41
+ lintOptions {
42
+ abortOnError = false
43
+ }
44
+ compileOptions {
45
+ sourceCompatibility JavaVersion.VERSION_21
46
+ targetCompatibility JavaVersion.VERSION_21
47
+ }
48
+ }
49
+
50
+ kotlin {
51
+ jvmToolchain(21)
52
+ }
53
+
54
+ repositories {
55
+ google()
56
+ mavenCentral()
57
+ }
58
+
59
+
60
+ dependencies {
61
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
62
+ implementation project(':capacitor-android')
63
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
64
+ implementation "com.launchdarkly:launchdarkly-android-client-sdk:$launchDarklyAndroidVersion"
65
+ testImplementation "junit:junit:$junitVersion"
66
+ // JSObject is an org.json.JSONObject, which android.jar only stubs on the JVM; the real one lets the tests read it back.
67
+ testImplementation "org.json:json:20260814"
68
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,67 @@
1
+ package io.github.maximbelov.launchdarkly
2
+
3
+ import com.getcapacitor.JSObject
4
+ import com.launchdarkly.sdk.LDContext
5
+ import com.launchdarkly.sdk.LDValue
6
+ import com.launchdarkly.sdk.json.JsonSerialization
7
+ import org.json.JSONArray
8
+ import org.json.JSONObject
9
+ import org.json.JSONTokener
10
+
11
+ /**
12
+ * Contexts in the JSON shape LaunchDarkly's SDKs use, read by the SDK's own parser, so custom attributes of any JSON type,
13
+ * nested objects, `anonymous`, `name`, multi-contexts and `_meta.privateAttributes` mean what they mean to LaunchDarkly. The key
14
+ * is taken as given: nothing here generates or replaces one.
15
+ */
16
+ object Contexts {
17
+ /** The context [json] describes. Throws [IllegalArgumentException] for one the SDK does not accept. */
18
+ fun parse(json: String): LDContext {
19
+ val context = try {
20
+ JsonSerialization.deserialize(json, LDContext::class.java)
21
+ } catch (e: Exception) {
22
+ throw IllegalArgumentException("Invalid context: ${e.message}", e)
23
+ }
24
+ require(context != null && context.isValid) { "Invalid context: ${context?.error ?: "empty"}" }
25
+ return context
26
+ }
27
+
28
+ /** [parse], or null for a context that is absent or not valid. */
29
+ fun parseOrNull(json: String?): LDContext? = json?.let {
30
+ try {
31
+ parse(it)
32
+ } catch (e: IllegalArgumentException) {
33
+ null
34
+ }
35
+ }
36
+ }
37
+
38
+ /** Flag values between JavaScript (org.json values, as Capacitor hands them over) and the SDK's [LDValue], through JSON text. */
39
+ object JsValues {
40
+ /** An org.json value (JSONObject, JSONArray, String, Number, Boolean, JSONObject.NULL or null) as an [LDValue]. */
41
+ fun toLDValue(value: Any?): LDValue {
42
+ if (value == null || value == JSONObject.NULL) return LDValue.ofNull()
43
+ return LDValue.parse(jsonText(value))
44
+ }
45
+
46
+ /**
47
+ * The JSON text of one org.json value. Android's org.json has no `valueToString`, so the value is written as the only item
48
+ * of an array and the brackets are dropped.
49
+ */
50
+ fun jsonText(value: Any?): String {
51
+ val array = JSONArray().put(value ?: JSONObject.NULL).toString()
52
+ return array.substring(1, array.length - 1)
53
+ }
54
+
55
+ /** An [LDValue] as the org.json value Capacitor sends to JavaScript; JSON null is [JSONObject.NULL]. */
56
+ fun toJs(value: LDValue?): Any {
57
+ if (value == null || value.isNull) return JSONObject.NULL
58
+ return JSONTokener(value.toJsonString()).nextValue()
59
+ }
60
+
61
+ /** Every flag as one JavaScript object. */
62
+ fun flags(values: Map<String, LDValue>): JSObject {
63
+ val flags = JSObject()
64
+ for ((key, value) in values) flags.put(key, toJs(value))
65
+ return flags
66
+ }
67
+ }
@@ -0,0 +1,250 @@
1
+ package io.github.maximbelov.launchdarkly
2
+
3
+ import android.app.Application
4
+ import com.launchdarkly.sdk.LDValue
5
+ import com.launchdarkly.sdk.android.ConnectionInformation
6
+ import com.launchdarkly.sdk.android.LDAllFlagsListener
7
+ import com.launchdarkly.sdk.android.LDClient
8
+ import com.launchdarkly.sdk.android.LDFailure
9
+ import com.launchdarkly.sdk.android.LDStatusListener
10
+ import java.util.concurrent.CountDownLatch
11
+ import java.util.concurrent.ExecutionException
12
+ import java.util.concurrent.ExecutorService
13
+ import java.util.concurrent.Executors
14
+ import java.util.concurrent.TimeUnit
15
+ import java.util.concurrent.TimeoutException
16
+
17
+ /** Where the client's events go: the plugin instance of the current bridge. */
18
+ interface ClientEvents {
19
+ fun flagChanged(key: String, value: LDValue?, previous: LDValue?)
20
+ fun connectionModeChanged(mode: String)
21
+ }
22
+
23
+ /**
24
+ * The one LaunchDarkly client of the process. It outlives a plugin instance, since an activity that is recreated creates a new
25
+ * bridge and a new plugin, while the SDK keeps one client per process.
26
+ *
27
+ * Starting, restarting, identifying and closing run one after another on [lifecycle], in the order they were asked for. What
28
+ * only waits (for the start to finish, for the first flags) runs on [waiters], so a wait never holds up Capacitor's plugin
29
+ * thread or the lifecycle.
30
+ */
31
+ object LaunchDarklyClient {
32
+ private val lifecycle: ExecutorService = Executors.newSingleThreadExecutor { Thread(it, "LaunchDarklyLifecycle") }
33
+ private val waiters: ExecutorService = Executors.newCachedThreadPool { Thread(it, "LaunchDarklyWait").apply { isDaemon = true } }
34
+
35
+ @Volatile var events: ClientEvents? = null
36
+
37
+ private val lock = Any()
38
+ private var client: LDClient? = null
39
+ private var options: StartOptions? = null
40
+ private var contextJson: String? = null
41
+ private var started: CountDownLatch? = null
42
+ private val lastValues = HashMap<String, LDValue>()
43
+
44
+ private val flagsListener = LDAllFlagsListener { keys -> onFlagsChanged(keys) }
45
+ private val statusListener = object : LDStatusListener {
46
+ override fun onConnectionModeChanged(connectionInformation: ConnectionInformation) {
47
+ events?.connectionModeChanged(connectionInformation.connectionMode.name)
48
+ }
49
+
50
+ override fun onInternalFailure(ldFailure: LDFailure?) = Unit
51
+ }
52
+
53
+ /** The mobile key of the running client, or of the one being started. */
54
+ val mobileKey: String? get() = synchronized(lock) { options?.mobileKey }
55
+
56
+ /**
57
+ * Starts the client unless one with the same mobile key is running or starting; one with another key is closed first.
58
+ * [done] runs on a waiter thread once the start has finished: the first flags are in or the wait is over.
59
+ */
60
+ fun start(application: Application, options: StartOptions, context: ContextChoice, done: () -> Unit = {}) {
61
+ val latch = synchronized(lock) {
62
+ if (this.options?.mobileKey == options.mobileKey && started != null) {
63
+ started!!
64
+ } else {
65
+ val latch = CountDownLatch(1)
66
+ val previous = this.options
67
+ this.options = options
68
+ this.contextJson = context.json
69
+ this.started = latch
70
+ lifecycle.execute { startNow(application, options, context, latch, closeFirst = previous != null) }
71
+ latch
72
+ }
73
+ }
74
+ waiters.execute {
75
+ awaitStart(latch, options)
76
+ done()
77
+ }
78
+ }
79
+
80
+ private fun startNow(application: Application, options: StartOptions, context: ContextChoice, latch: CountDownLatch, closeFirst: Boolean) {
81
+ try {
82
+ if (closeFirst) closeNow()
83
+ // Blocks this lifecycle thread, not the caller, for at most startWaitSeconds; the client is usable either way.
84
+ val newClient = LDClient.init(application, LdConfigFactory.build(options), context.context, options.startWaitWholeSeconds)
85
+ newClient.registerAllFlagsListener(flagsListener)
86
+ newClient.registerStatusListener(statusListener)
87
+ synchronized(lock) {
88
+ client = newClient
89
+ lastValues.clear()
90
+ lastValues.putAll(newClient.allFlags())
91
+ }
92
+ } finally {
93
+ latch.countDown()
94
+ }
95
+ }
96
+
97
+ /** Restarts the running client with [change] applied to its options, keeping the current context. */
98
+ fun restart(application: Application, change: (StartOptions) -> StartOptions, done: (Exception?) -> Unit) {
99
+ val (current, json) = synchronized(lock) { options to contextJson }
100
+ if (current == null || json == null) {
101
+ done(IllegalStateException("The client is not started."))
102
+ return
103
+ }
104
+ val context = try {
105
+ ContextChoice(Contexts.parse(json), json)
106
+ } catch (e: IllegalArgumentException) {
107
+ done(e)
108
+ return
109
+ }
110
+ val next = change(current)
111
+ val latch = CountDownLatch(1)
112
+ synchronized(lock) {
113
+ options = next
114
+ started = latch
115
+ }
116
+ lifecycle.execute { startNow(application, next, context, latch, closeFirst = true) }
117
+ waiters.execute {
118
+ awaitStart(latch, next)
119
+ done(null)
120
+ }
121
+ }
122
+
123
+ /** Switches the context; [done] gets whether the wait for its flags timed out, or the error. */
124
+ fun identify(context: ContextChoice, timeoutSeconds: Double, store: ContextStore, done: (Boolean?, Exception?) -> Unit) {
125
+ lifecycle.execute {
126
+ val current = synchronized(lock) { client }
127
+ if (current == null) {
128
+ done(null, IllegalStateException("The client is not started."))
129
+ return@execute
130
+ }
131
+ try {
132
+ current.identify(context.context).get((timeoutSeconds * 1000).toLong(), TimeUnit.MILLISECONDS)
133
+ remember(context, store)
134
+ done(false, null)
135
+ } catch (e: TimeoutException) {
136
+ // The SDK has switched to the context and keeps loading its flags; only the wait is over.
137
+ remember(context, store)
138
+ done(true, null)
139
+ } catch (e: ExecutionException) {
140
+ done(null, (e.cause as? Exception) ?: e)
141
+ } catch (e: InterruptedException) {
142
+ Thread.currentThread().interrupt()
143
+ done(null, e)
144
+ }
145
+ }
146
+ }
147
+
148
+ private fun remember(context: ContextChoice, store: ContextStore) {
149
+ synchronized(lock) { contextJson = context.json }
150
+ store.write(context.json)
151
+ }
152
+
153
+ /** Waits on a waiter thread until the start has finished, then answers. */
154
+ fun afterStart(answer: () -> Unit) {
155
+ val (latch, current) = synchronized(lock) { started to options }
156
+ waiters.execute {
157
+ if (latch != null && current != null) awaitStart(latch, current)
158
+ answer()
159
+ }
160
+ }
161
+
162
+ /** Answers on a waiter thread whether the client has its first flags, waiting up to [timeoutSeconds] for them. */
163
+ fun waitForInitialization(timeoutSeconds: Double, answer: (Boolean) -> Unit) {
164
+ val latch = synchronized(lock) { started }
165
+ if (latch == null) {
166
+ answer(false)
167
+ return
168
+ }
169
+ waiters.execute {
170
+ val deadline = System.nanoTime() + (timeoutSeconds * 1_000_000_000).toLong()
171
+ latch.await(remainingNanos(deadline), TimeUnit.NANOSECONDS)
172
+ while (true) {
173
+ if (synchronized(lock) { client }?.isInitialized == true) return@execute answer(true)
174
+ if (remainingNanos(deadline) <= 0) return@execute answer(false)
175
+ Thread.sleep(POLL_MILLIS)
176
+ }
177
+ }
178
+ }
179
+
180
+ val isInitialized: Boolean get() = synchronized(lock) { client }?.isInitialized == true
181
+
182
+ val context: String? get() = synchronized(lock) { contextJson }
183
+
184
+ /** Every flag from the SDK's store; reading them this way records no evaluation event. */
185
+ fun allFlags(): Map<String, LDValue> = synchronized(lock) { client }?.allFlags() ?: emptyMap()
186
+
187
+ fun variation(key: String, defaultValue: LDValue): LDValue =
188
+ synchronized(lock) { client }?.jsonValueVariation(key, defaultValue) ?: defaultValue
189
+
190
+ fun setOnline(online: Boolean) {
191
+ val current = synchronized(lock) { client } ?: return
192
+ if (online) current.setOnline() else current.setOffline()
193
+ }
194
+
195
+ fun flush() {
196
+ synchronized(lock) { client }?.flush()
197
+ }
198
+
199
+ fun close(done: () -> Unit) {
200
+ synchronized(lock) {
201
+ options = null
202
+ started = null
203
+ contextJson = null
204
+ }
205
+ lifecycle.execute {
206
+ closeNow()
207
+ done()
208
+ }
209
+ }
210
+
211
+ private fun closeNow() {
212
+ val current = synchronized(lock) {
213
+ val c = client
214
+ client = null
215
+ lastValues.clear()
216
+ c
217
+ } ?: return
218
+ current.unregisterAllFlagsListener(flagsListener)
219
+ current.unregisterStatusListener(statusListener)
220
+ try {
221
+ current.close()
222
+ } catch (e: Exception) {
223
+ // close() declares IOException and never throws it; nothing to do either way.
224
+ }
225
+ }
226
+
227
+ private fun onFlagsChanged(keys: List<String>) {
228
+ val current = synchronized(lock) { client } ?: return
229
+ val values = current.allFlags()
230
+ for (key in keys) {
231
+ val value = values[key]
232
+ val previous = synchronized(lock) {
233
+ val before = lastValues[key]
234
+ if (value == null) lastValues.remove(key) else lastValues[key] = value
235
+ before
236
+ }
237
+ events?.flagChanged(key, value, previous)
238
+ }
239
+ }
240
+
241
+ private fun awaitStart(latch: CountDownLatch, options: StartOptions) {
242
+ // The SDK's own wait ends the start; the margin only guards against a start that never returns.
243
+ latch.await((options.startWaitWholeSeconds + START_MARGIN_SECONDS).toLong(), TimeUnit.SECONDS)
244
+ }
245
+
246
+ private fun remainingNanos(deadline: Long) = deadline - System.nanoTime()
247
+
248
+ private const val POLL_MILLIS = 50L
249
+ private const val START_MARGIN_SECONDS = 10
250
+ }
@@ -0,0 +1,148 @@
1
+ package io.github.maximbelov.launchdarkly
2
+
3
+ import android.app.Application
4
+ import android.content.Context
5
+ import com.getcapacitor.JSObject
6
+ import com.getcapacitor.Plugin
7
+ import com.getcapacitor.PluginCall
8
+ import com.getcapacitor.PluginMethod
9
+ import com.getcapacitor.annotation.CapacitorPlugin
10
+ import com.launchdarkly.sdk.LDValue
11
+ import org.json.JSONObject
12
+
13
+ @CapacitorPlugin(name = "LaunchDarkly")
14
+ class LaunchDarklyPlugin : Plugin(), ClientEvents {
15
+
16
+ private val store by lazy {
17
+ SharedPreferencesContextStore(
18
+ context.getSharedPreferences(SharedPreferencesContextStore.PREFERENCES, Context.MODE_PRIVATE),
19
+ )
20
+ }
21
+
22
+ private val application: Application get() = context.applicationContext as Application
23
+
24
+ /**
25
+ * Runs as the bridge is created, before the web view loads the app. With a `mobileKey` in `plugins.LaunchDarkly` of
26
+ * capacitor.config the client starts here, with the stored context or else `initialContext`, so the flags are on their way
27
+ * before any app code asks for them. Without one nothing starts, and `initialize()` does it.
28
+ */
29
+ override fun load() {
30
+ LaunchDarklyClient.events = this
31
+ val autoStart = PluginConfig.autoStart(config.configJSON) ?: return
32
+ val context = StartContext.choose(store.read(), autoStart.initialContext)
33
+ LaunchDarklyClient.start(application, autoStart.options, context)
34
+ }
35
+
36
+ @PluginMethod
37
+ fun initialize(call: PluginCall) {
38
+ val mobileKey = call.getString("mobileKey")?.takeIf { it.isNotBlank() }
39
+ ?: return call.reject("initialize needs a mobileKey on Android.")
40
+ if (LaunchDarklyClient.mobileKey == mobileKey) {
41
+ // One client per process: the same key changes nothing and answers the state once the start is done.
42
+ return LaunchDarklyClient.afterStart { call.resolve(state()) }
43
+ }
44
+ val given = readContext(call) ?: return
45
+ val options = StartOptions.parse(mobileKey, call.getObject("options"))
46
+ // The given context plays the part of initialContext: a context an earlier identify() stored wins over it.
47
+ val context = StartContext.choose(store.read(), given.json)
48
+ LaunchDarklyClient.start(application, options, context) { call.resolve(state()) }
49
+ }
50
+
51
+ @PluginMethod
52
+ fun waitForInitialization(call: PluginCall) {
53
+ val timeout = StartOptions.seconds(call.getDouble("timeoutSeconds") ?: StartOptions.DEFAULT_WAIT_SECONDS)
54
+ LaunchDarklyClient.waitForInitialization(timeout) { initialized ->
55
+ call.resolve(JSObject().put("initialized", initialized))
56
+ }
57
+ }
58
+
59
+ @PluginMethod
60
+ fun getState(call: PluginCall) {
61
+ LaunchDarklyClient.afterStart { call.resolve(state()) }
62
+ }
63
+
64
+ @PluginMethod
65
+ fun identify(call: PluginCall) {
66
+ val context = readContext(call) ?: return
67
+ val timeout = StartOptions.seconds(call.getDouble("timeoutSeconds") ?: StartOptions.DEFAULT_WAIT_SECONDS)
68
+ LaunchDarklyClient.identify(context, timeout, store) { timedOut, error ->
69
+ if (error != null) call.reject(error.message ?: "identify failed", error) else call.resolve(JSObject().put("timedOut", timedOut == true))
70
+ }
71
+ }
72
+
73
+ @PluginMethod
74
+ fun variation(call: PluginCall) {
75
+ val key = call.getString("key") ?: return call.reject("variation needs a key.")
76
+ val defaultValue = JsValues.toLDValue(call.data.opt("defaultValue"))
77
+ val value = LaunchDarklyClient.variation(key, defaultValue)
78
+ call.resolve(JSObject().put("value", JsValues.toJs(value)))
79
+ }
80
+
81
+ @PluginMethod
82
+ fun allFlags(call: PluginCall) {
83
+ call.resolve(JSObject().put("flags", JsValues.flags(LaunchDarklyClient.allFlags())))
84
+ }
85
+
86
+ @PluginMethod
87
+ fun setStreaming(call: PluginCall) {
88
+ val enabled = call.getBoolean("enabled") ?: return call.reject("setStreaming needs enabled.")
89
+ LaunchDarklyClient.restart(application, { it.copy(streaming = enabled) }) { error ->
90
+ if (error != null) call.reject(error.message, error) else call.resolve()
91
+ }
92
+ }
93
+
94
+ @PluginMethod
95
+ fun setOnline(call: PluginCall) {
96
+ val online = call.getBoolean("online") ?: return call.reject("setOnline needs online.")
97
+ LaunchDarklyClient.setOnline(online)
98
+ call.resolve()
99
+ }
100
+
101
+ @PluginMethod
102
+ fun flush(call: PluginCall) {
103
+ LaunchDarklyClient.flush()
104
+ call.resolve()
105
+ }
106
+
107
+ @PluginMethod
108
+ fun close(call: PluginCall) {
109
+ LaunchDarklyClient.close { call.resolve() }
110
+ }
111
+
112
+ @PluginMethod
113
+ fun clearStoredContext(call: PluginCall) {
114
+ store.clear()
115
+ call.resolve()
116
+ }
117
+
118
+ override fun flagChanged(key: String, value: LDValue?, previous: LDValue?) {
119
+ val change = JSObject().put("key", key).put("value", JsValues.toJs(value))
120
+ if (previous != null) change.put("previous", JsValues.toJs(previous))
121
+ notifyListeners("flagChange", change)
122
+ }
123
+
124
+ override fun connectionModeChanged(mode: String) {
125
+ notifyListeners("connectionModeChange", JSObject().put("mode", mode))
126
+ }
127
+
128
+ private fun readContext(call: PluginCall): ContextChoice? {
129
+ val json = call.getObject("context")?.toString() ?: run {
130
+ call.reject("A context is needed.")
131
+ return null
132
+ }
133
+ return try {
134
+ ContextChoice(Contexts.parse(json), json)
135
+ } catch (e: IllegalArgumentException) {
136
+ call.reject(e.message, e)
137
+ null
138
+ }
139
+ }
140
+
141
+ private fun state(): JSObject {
142
+ val state = JSObject()
143
+ .put("initialized", LaunchDarklyClient.isInitialized)
144
+ .put("flags", JsValues.flags(LaunchDarklyClient.allFlags()))
145
+ LaunchDarklyClient.context?.let { state.put("context", JSONObject(it)) }
146
+ return state
147
+ }
148
+ }
@@ -0,0 +1,34 @@
1
+ package io.github.maximbelov.launchdarkly
2
+
3
+ import com.launchdarkly.sdk.android.Components
4
+ import com.launchdarkly.sdk.android.LDConfig
5
+
6
+ /**
7
+ * Builds the SDK configuration. Two settings here are not options, because LaunchDarkly bills client-side SDKs by the number
8
+ * of unique context keys (of the kind that has the most):
9
+ *
10
+ * - Automatic environment attributes are disabled. Enabled, the SDK adds `ld_application` and `ld_device` contexts to every
11
+ * evaluation, and the `ld_device` key is generated per install, which counts every device as a context of its own.
12
+ * - Anonymous keys are not generated. Enabled, the SDK replaces the key of every anonymous context with a key generated per
13
+ * install (`AnonymousKeyContextModifier`), so an app that shares one anonymous key across all its installs would again count
14
+ * one context per device. The SDK's default is already false; it is set anyway, so a change of default cannot move it.
15
+ */
16
+ object LdConfigFactory {
17
+ fun build(options: StartOptions): LDConfig {
18
+ val builder = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled)
19
+ .mobileKey(options.mobileKey)
20
+ .generateAnonymousKeys(false)
21
+ .evaluationReasons(options.evaluationReasons)
22
+ .offline(options.offline)
23
+ .dataSource(if (options.streaming) Components.streamingDataSource() else Components.pollingDataSource())
24
+ options.application?.let { app ->
25
+ val info = Components.applicationInfo()
26
+ app.id?.let { info.applicationId(it) }
27
+ app.name?.let { info.applicationName(it) }
28
+ app.version?.let { info.applicationVersion(it) }
29
+ app.versionName?.let { info.applicationVersionName(it) }
30
+ builder.applicationInfo(info)
31
+ }
32
+ return builder.build()
33
+ }
34
+ }
@@ -0,0 +1,76 @@
1
+ package io.github.maximbelov.launchdarkly
2
+
3
+ import org.json.JSONObject
4
+ import kotlin.math.ceil
5
+
6
+ /** Application metadata sent with every request. It describes the app; it is not a context. */
7
+ data class ApplicationMetadata(
8
+ val id: String? = null,
9
+ val name: String? = null,
10
+ val version: String? = null,
11
+ val versionName: String? = null,
12
+ )
13
+
14
+ /** How the client is started: from `plugins.LaunchDarkly` in capacitor.config, or from `initialize()`. */
15
+ data class StartOptions(
16
+ val mobileKey: String,
17
+ val streaming: Boolean = true,
18
+ val evaluationReasons: Boolean = false,
19
+ val offline: Boolean = false,
20
+ val startWaitSeconds: Double = DEFAULT_WAIT_SECONDS,
21
+ val application: ApplicationMetadata? = null,
22
+ ) {
23
+ /** [startWaitSeconds] in whole seconds for the SDK's `init`, rounded up so that a fraction still waits. */
24
+ val startWaitWholeSeconds: Int get() = ceil(startWaitSeconds).toInt()
25
+
26
+ companion object {
27
+ const val DEFAULT_WAIT_SECONDS = 5.0
28
+
29
+ /** The options of `initialize()` or of the config block, everything but the key and the context. */
30
+ fun parse(mobileKey: String, json: JSONObject?): StartOptions {
31
+ val options = json ?: JSONObject()
32
+ return StartOptions(
33
+ mobileKey = mobileKey,
34
+ streaming = options.optBoolean("streaming", true),
35
+ evaluationReasons = options.optBoolean("evaluationReasons", false),
36
+ offline = options.optBoolean("offline", false),
37
+ startWaitSeconds = seconds(options.optDouble("startWaitSeconds", DEFAULT_WAIT_SECONDS)),
38
+ application = options.optJSONObject("application")?.let(::parseApplication),
39
+ )
40
+ }
41
+
42
+ /** A wait in seconds as given, or the default when it is not a usable number. */
43
+ fun seconds(value: Double, default: Double = DEFAULT_WAIT_SECONDS): Double =
44
+ if (value.isNaN() || value.isInfinite() || value < 0) default else value
45
+
46
+ private fun parseApplication(json: JSONObject) = ApplicationMetadata(
47
+ id = json.optStringOrNull("id"),
48
+ name = json.optStringOrNull("name"),
49
+ version = json.optStringOrNull("version"),
50
+ versionName = json.optStringOrNull("versionName"),
51
+ )
52
+ }
53
+ }
54
+
55
+ /** What the native start-up does when the app launches: start with [options] and [initialContext] unless a context was stored. */
56
+ data class AutoStart(val options: StartOptions, val initialContext: String)
57
+
58
+ object PluginConfig {
59
+ /** The context of a native start-up that has neither a stored context nor an `initialContext`. */
60
+ const val DEFAULT_INITIAL_CONTEXT = """{"kind":"user","key":"anonymous_user","anonymous":true}"""
61
+
62
+ /**
63
+ * Reads the `plugins.LaunchDarkly` block of capacitor.config. Without a `mobileKey` there is no native start-up, and the
64
+ * answer is null: the app starts the client with `initialize()` instead.
65
+ */
66
+ fun autoStart(config: JSONObject?): AutoStart? {
67
+ if (config == null) return null
68
+ val mobileKey = config.optStringOrNull("mobileKey") ?: return null
69
+ val initialContext = config.optJSONObject("initialContext")?.toString() ?: DEFAULT_INITIAL_CONTEXT
70
+ return AutoStart(StartOptions.parse(mobileKey, config), initialContext)
71
+ }
72
+ }
73
+
74
+ /** A string value, or null when it is absent, not a string, or blank. */
75
+ internal fun JSONObject.optStringOrNull(name: String): String? =
76
+ (opt(name) as? String)?.takeIf { it.isNotBlank() }