• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Unleash / unleash-android / 11297355926

11 Oct 2024 06:19PM UTC coverage: 82.543% (-0.1%) from 82.638%
11297355926

push

github

web-flow
docs: use defaults for polling and metrics intervals (#92)

chore: use defaults for polling and metrics intervals

214 of 292 branches covered (73.29%)

Branch coverage included in aggregate %.

656 of 762 relevant lines covered (86.09%)

5.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.9
/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt
1
package io.getunleash.android
2

3
import android.content.Context
4
import android.util.Log
5
import androidx.lifecycle.Lifecycle
6
import androidx.lifecycle.LifecycleOwner
7
import androidx.lifecycle.ProcessLifecycleOwner
8
import io.getunleash.android.backup.LocalBackup
9
import io.getunleash.android.cache.CacheDirectoryProvider
10
import io.getunleash.android.cache.InMemoryToggleCache
11
import io.getunleash.android.cache.ObservableCache
12
import io.getunleash.android.cache.ObservableToggleCache
13
import io.getunleash.android.cache.ToggleCache
14
import io.getunleash.android.data.ImpressionEvent
15
import io.getunleash.android.data.Parser
16
import io.getunleash.android.data.Toggle
17
import io.getunleash.android.data.UnleashContext
18
import io.getunleash.android.data.UnleashState
19
import io.getunleash.android.data.Variant
20
import io.getunleash.android.events.UnleashFetcherHeartbeatListener
21
import io.getunleash.android.events.UnleashImpressionEventListener
22
import io.getunleash.android.events.UnleashListener
23
import io.getunleash.android.events.UnleashReadyListener
24
import io.getunleash.android.events.UnleashStateListener
25
import io.getunleash.android.http.ClientBuilder
26
import io.getunleash.android.http.NetworkStatusHelper
27
import io.getunleash.android.metrics.MetricsHandler
28
import io.getunleash.android.metrics.MetricsReporter
29
import io.getunleash.android.metrics.MetricsSender
30
import io.getunleash.android.metrics.NoOpMetrics
31
import io.getunleash.android.polling.UnleashFetcher
32
import io.getunleash.android.tasks.DataJob
33
import io.getunleash.android.tasks.LifecycleAwareTaskManager
34
import kotlinx.coroutines.CoroutineExceptionHandler
35
import kotlinx.coroutines.CoroutineScope
36
import kotlinx.coroutines.Dispatchers
37
import kotlinx.coroutines.SupervisorJob
38
import kotlinx.coroutines.cancel
39
import kotlinx.coroutines.channels.BufferOverflow
40
import kotlinx.coroutines.flow.MutableSharedFlow
41
import kotlinx.coroutines.flow.MutableStateFlow
42
import kotlinx.coroutines.flow.asSharedFlow
43
import kotlinx.coroutines.flow.asStateFlow
44
import kotlinx.coroutines.flow.first
45
import kotlinx.coroutines.flow.takeWhile
46
import kotlinx.coroutines.launch
47
import kotlinx.coroutines.runBlocking
48
import kotlinx.coroutines.withContext
49
import kotlinx.coroutines.withTimeout
50
import okhttp3.internal.toImmutableList
51
import java.io.File
52
import java.util.concurrent.TimeoutException
53
import java.util.concurrent.atomic.AtomicBoolean
54

55
val unleashExceptionHandler = CoroutineExceptionHandler { _, exception ->
3✔
56
    Log.e("UnleashHandler", "Caught unhandled exception: ${exception.message}", exception)
57
}
58

59
private val job = SupervisorJob()
5✔
60
val unleashScope = CoroutineScope(Dispatchers.Default + job + unleashExceptionHandler)
12✔
61

62
class DefaultUnleash(
27✔
63
    private val androidContext: Context,
3✔
64
    private val unleashConfig: UnleashConfig,
3✔
65
    unleashContext: UnleashContext = UnleashContext(),
10✔
66
    cacheImpl: ToggleCache = InMemoryToggleCache(),
5✔
67
    eventListeners: List<UnleashListener> = emptyList(),
2✔
68
    private val lifecycle: Lifecycle = getLifecycle(androidContext),
3✔
69
    private val coroutineScope: CoroutineScope = unleashScope
5✔
70
) : Unleash {
71
    companion object {
72
        private const val TAG = "Unleash"
73
        internal const val BACKUP_DIR_NAME = "unleash_backup"
74
    }
75

76
    private val unleashContextState = MutableStateFlow(unleashContext)
4✔
77
    private val metrics: MetricsHandler
78
    private val taskManager: LifecycleAwareTaskManager
79
    private val cache: ObservableToggleCache = ObservableCache(cacheImpl, coroutineScope)
9✔
80
    private var started = AtomicBoolean(false)
6✔
81
    private var ready = AtomicBoolean(false)
6✔
82
    private val fetcher: UnleashFetcher
83
    private val networkStatusHelper = NetworkStatusHelper(androidContext)
7✔
84
    private val impressionEventsFlow = MutableSharedFlow<ImpressionEvent>(
3✔
85
        replay = 1,
1✔
86
        extraBufferCapacity = 1000,
1✔
87
        onBufferOverflow = BufferOverflow.DROP_OLDEST
1✔
88
    )
89

90
    init {
1✔
91
        val httpClientBuilder = ClientBuilder(unleashConfig, androidContext)
8✔
92
        metrics =
2✔
93
            if (unleashConfig.metricsStrategy.enabled)
5✔
94
                MetricsSender(
8✔
95
                    unleashConfig,
2✔
96
                    httpClientBuilder.build("metrics", unleashConfig.metricsStrategy)
6✔
97
                )
98
            else NoOpMetrics()
4✔
99
        fetcher = UnleashFetcher(
5✔
100
                unleashConfig,
2✔
101
                httpClientBuilder.build("poller", unleashConfig.pollingStrategy),
6✔
102
                unleashContextState.asStateFlow()
3✔
103
            )
104
        taskManager = LifecycleAwareTaskManager(
8✔
105
            dataJobs = buildDataJobs(metrics, fetcher),
7✔
106
            networkAvailable = networkStatusHelper.isAvailable(),
3✔
107
            scope = coroutineScope
2✔
108
        )
109
        if (!unleashConfig.delayedInitialization) {
4✔
110
            start(eventListeners)
8✔
111
        } else if (eventListeners.isNotEmpty()) {
6!
112
            throw IllegalArgumentException("Event listeners are not supported as constructor arguments with delayed initialization")
×
113
        }
114
    }
1✔
115

116
    fun start(
15✔
117
        eventListeners: List<UnleashListener> = emptyList(),
2✔
118
        bootstrapFile: File? = null,
2✔
119
        bootstrap: List<Toggle> = emptyList()
2✔
120
    ) {
121
        if (!started.compareAndSet(false, true)) {
6✔
122
            Log.w(TAG, "Unleash already started, ignoring start call")
4✔
123
            return
1✔
124
        }
125
        eventListeners.forEach { addUnleashEventListener(it) }
8✔
126
        networkStatusHelper.registerNetworkListener(taskManager)
6✔
127
        if (unleashConfig.localStorageConfig.enabled) {
5✔
128
            val localBackup = getLocalBackup()
3✔
129
            localBackup.subscribeTo(cache.getUpdatesFlow())
5✔
130
        }
131
        if (unleashConfig.pollingStrategy.enabled) {
5✔
132
            fetcher.startWatchingContext()
3✔
133
        }
134
        cache.subscribeTo(fetcher.getFeaturesReceivedFlow())
7✔
135
        lifecycle.addObserver(taskManager)
6✔
136
        if (bootstrapFile != null && bootstrapFile.exists()) {
5!
137
            Log.i(TAG, "Using provided bootstrap file")
4✔
138
            Parser.proxyResponseAdapter.fromJson(bootstrapFile.readText())?.let { state ->
15!
139
                val toggles = state.toggles.groupBy { it.name }
9✔
140
                    .mapValues { (_, v) -> v.first() }
14✔
141
                cache.write(UnleashState(unleashContextState.value, toggles))
11✔
142
            }
1✔
143
        } else if (bootstrap.isNotEmpty()) {
8✔
144
            Log.i(TAG, "Using provided bootstrap toggles")
4✔
145
            cache.write(UnleashState(unleashContextState.value, bootstrap.associateBy { it.name }))
25✔
146
        }
147
    }
1✔
148

149
    private fun buildDataJobs(metricsSender: MetricsReporter, fetcher: UnleashFetcher) = buildList {
8✔
150
        if (unleashConfig.pollingStrategy.enabled) {
5✔
151
            add(
3✔
152
                DataJob(
3✔
153
                    "fetchToggles",
1✔
154
                    unleashConfig.pollingStrategy,
3✔
155
                    fetcher::refreshToggles
5✔
156
                )
157
            )
158
        }
159
        if (unleashConfig.metricsStrategy.enabled) {
5✔
160
            add(
3✔
161
                DataJob(
3✔
162
                    "sendMetrics",
1✔
163
                    unleashConfig.metricsStrategy,
3✔
164
                    metricsSender::sendMetrics
5✔
165
                )
166
            )
167
        }
168
    }.toImmutableList()
3✔
169

170
    private fun getLocalBackup(): LocalBackup {
171
        val backupDir = CacheDirectoryProvider(unleashConfig.localStorageConfig, androidContext)
12✔
172
            .getCacheDirectory(BACKUP_DIR_NAME)
5✔
173
        val localBackup = LocalBackup(backupDir)
8✔
174
        coroutineScope.launch {
15✔
175
            withContext(Dispatchers.IO) {
17✔
176
                unleashContextState.asStateFlow().takeWhile { !ready.get() }.collect { ctx ->
42!
177
                    Log.d(TAG, "Loading state from backup for $ctx")
11✔
178
                    localBackup.loadFromDisc(unleashContextState.value)?.let { state ->
19!
179
                        if (!ready.get()) {
4!
180
                            Log.i(TAG, "Loaded state from backup for $ctx")
11✔
181
                            cache.write(state)
5✔
182
                        } else {
183
                            Log.d(TAG, "Ignoring backup, Unleash is already ready")
×
184
                        }
185
                    }
1✔
186
                }
2✔
187
            }
×
188
        }
×
189
        return localBackup
2✔
190
    }
191

192
    override fun isEnabled(toggleName: String, defaultValue: Boolean): Boolean {
193
        val toggle = cache.get(toggleName)
5✔
194
        val enabled = toggle?.enabled ?: defaultValue
8✔
195
        val impressionData = unleashConfig.forceImpressionData || toggle?.impressionData ?: false
16✔
196
        if (impressionData) {
2✔
197
            emit(ImpressionEvent(toggleName, enabled, unleashContextState.value))
15✔
198
        }
199
        metrics.count(toggleName, enabled)
6✔
200
        return enabled
2✔
201
    }
202

203
    override fun getVariant(toggleName: String, defaultValue: Variant): Variant {
204
        val toggle = cache.get(toggleName)
5✔
205
        val enabled = isEnabled(toggleName)
8✔
206
        val variant = if (enabled) (toggle?.variant ?: defaultValue) else defaultValue
9!
207
        val impressionData = toggle?.impressionData ?: unleashConfig.forceImpressionData
6!
208
        if (impressionData) {
2!
209
            emit(ImpressionEvent(toggleName, enabled, unleashContextState.value, variant.name))
×
210
        }
211
        metrics.countVariant(toggleName, variant)
6✔
212
        return variant
2✔
213
    }
214

215
    private fun emit(impressionEvent: ImpressionEvent) {
216
        coroutineScope.launch {
15✔
217
            impressionEventsFlow.emit(impressionEvent)
11✔
218
        }
3✔
219
    }
1✔
220

221
    override fun refreshTogglesNow() {
222
        runBlocking {
11✔
223
            withContext(Dispatchers.IO) {
16✔
224
                fetcher.refreshToggles()
10✔
225
            }
226
        }
227
    }
1✔
228

229
    override fun refreshTogglesNowAsync() {
230
        coroutineScope.launch {
14✔
231
            withContext(Dispatchers.IO) {
15✔
232
                fetcher.refreshToggles()
9✔
233
            }
3✔
234
        }
3✔
235
    }
1✔
236

237
    override fun sendMetricsNow() {
238
        if (!unleashConfig.metricsStrategy.enabled) return
×
239
        runBlocking {
×
240
            withContext(Dispatchers.IO) {
×
241
                metrics.sendMetrics()
×
242
            }
×
243
        }
×
244
    }
×
245

246
    override fun sendMetricsNowAsync() {
247
        if (!unleashConfig.metricsStrategy.enabled) return
×
248
        coroutineScope.launch {
×
249
            withContext(Dispatchers.IO) {
×
250
                metrics.sendMetrics()
×
251
            }
×
252
        }
×
253
    }
×
254

255
    override fun isReady(): Boolean {
256
        return ready.get()
×
257
    }
258

259
    override fun setContext(context: UnleashContext) {
260
        unleashContextState.value = context
4✔
261
        if (started.get()) {
4✔
262
            refreshTogglesNow()
2✔
263
        }
264
    }
1✔
265

266
    @Throws(TimeoutException::class)
267
    override fun setContextWithTimeout(context: UnleashContext, timeout: Long) {
268
        unleashContextState.value = context
×
269
        if (started.get()) {
×
270
            runBlocking {
×
271
                withTimeout(timeout) {
×
272
                    fetcher.refreshToggles()
×
273
                }
274
            }
275
        }
276
    }
×
277

278
    override fun setContextAsync(context: UnleashContext) {
279
        unleashContextState.value = context
×
280
    }
×
281

282
    override fun addUnleashEventListener(listener: UnleashListener) {
283

284
        if (listener is UnleashReadyListener) coroutineScope.launch {
18✔
285
            cache.getUpdatesFlow().first{
21!
286
                true
3✔
287
            }
288
            if (ready.compareAndSet(false, true)) {
8✔
289
                Log.d(TAG, "Unleash state changed to ready")
4✔
290
            }
291
            Log.d(TAG, "Notifying UnleashReadyListener")
4✔
292
            listener.onReady()
4✔
293
        }
2✔
294

295
        if (listener is UnleashStateListener) coroutineScope.launch {
18✔
296
            cache.getUpdatesFlow().collect {
16✔
297
                listener.onStateChanged()
4✔
298
            }
2✔
299
        }
×
300

301
        if (listener is UnleashImpressionEventListener) coroutineScope.launch {
18✔
302
            impressionEventsFlow.asSharedFlow().collect { event ->
16✔
303
                listener.onImpression(event)
5✔
304
            }
2✔
305
        }
306

307
        if (listener is UnleashFetcherHeartbeatListener) coroutineScope.launch {
18✔
308
            fetcher.getHeartbeatFlow().collect { event ->
16✔
309
                if (event.status.isFailed()) {
4✔
310
                    listener.onError(event)
6✔
311
                } else if (event.status.isNotModified()) {
4✔
312
                    listener.togglesChecked()
5✔
313
                } else if (event.status.isSuccess()) {
4!
314
                    listener.togglesUpdated()
4✔
315
                }
316
            }
2✔
317
        }
318
    }
1✔
319

320
    override fun close() {
321
        networkStatusHelper.close()
×
322
        job.cancel("Unleash received closed signal")
×
323
    }
×
324
}
1✔
325

326
private fun getLifecycle(androidContext: Context) =
327
    if (androidContext is LifecycleOwner) {
×
328
        Log.d("Unleash", "Using lifecycle from Android context")
×
329
        androidContext.lifecycle
×
330
    } else {
331
        Log.d("Unleash", "Using lifecycle from ProcessLifecycleOwner")
×
332
        ProcessLifecycleOwner.get().lifecycle
×
333
    }
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc