capacitor-launchdarkly 0.0.0-stage → 0.1.0
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.
- package/CapacitorLaunchdarkly.podspec +20 -0
- package/LICENSE +21 -0
- package/Package.swift +32 -0
- package/README.md +747 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/Json.kt +67 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyClient.kt +250 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyPlugin.kt +148 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LdConfigFactory.kt +34 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StartOptions.kt +76 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StoredContext.kt +47 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1174 -0
- package/dist/esm/definitions.d.ts +520 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +46 -0
- package/dist/esm/web.js +204 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +221 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +224 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/LaunchDarklyPlugin/ConfigFactory.swift +29 -0
- package/ios/Sources/LaunchDarklyPlugin/JSONValues.swift +68 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyClient.swift +165 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyPlugin.swift +169 -0
- package/ios/Sources/LaunchDarklyPlugin/PrivacyInfo.xcprivacy +23 -0
- package/ios/Sources/LaunchDarklyPlugin/StartOptions.swift +79 -0
- package/ios/Sources/LaunchDarklyPlugin/StoredContext.swift +43 -0
- package/ios/Tests/LaunchDarklyPluginTests/BillingSafetyTests.swift +53 -0
- package/ios/Tests/LaunchDarklyPluginTests/JSONValuesTests.swift +67 -0
- package/ios/Tests/LaunchDarklyPluginTests/LaunchDarklyPluginTests.swift +37 -0
- package/ios/Tests/LaunchDarklyPluginTests/PluginConfigurationTests.swift +52 -0
- package/ios/Tests/LaunchDarklyPluginTests/StartContextTests.swift +36 -0
- 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,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() }
|