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

Unleash / unleash-android / 10075269522

24 Jul 2024 10:50AM UTC coverage: 69.676% (+4.4%) from 65.26%
10075269522

Pull #64

github

web-flow
Merge edfc3794f into 4fb5c6315
Pull Request #64: tests: test metrics sender

178 of 280 branches covered (63.57%)

Branch coverage included in aggregate %.

532 of 739 relevant lines covered (71.99%)

4.59 hits per line

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

62.28
/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.polling.ProxyResponse
17
import io.getunleash.android.data.Toggle
18
import io.getunleash.android.data.UnleashContext
19
import io.getunleash.android.data.UnleashState
20
import io.getunleash.android.data.Variant
21
import io.getunleash.android.events.UnleashFetcherHeartbeatListener
22
import io.getunleash.android.events.UnleashImpressionEventListener
23
import io.getunleash.android.events.UnleashListener
24
import io.getunleash.android.events.UnleashReadyListener
25
import io.getunleash.android.events.UnleashStateListener
26
import io.getunleash.android.http.ClientBuilder
27
import io.getunleash.android.http.NetworkStatusHelper
28
import io.getunleash.android.metrics.MetricsHandler
29
import io.getunleash.android.metrics.MetricsReporter
30
import io.getunleash.android.metrics.MetricsSender
31
import io.getunleash.android.metrics.NoOpMetrics
32
import io.getunleash.android.polling.UnleashFetcher
33
import io.getunleash.android.tasks.DataJob
34
import io.getunleash.android.tasks.LifecycleAwareTaskManager
35
import kotlinx.coroutines.CoroutineExceptionHandler
36
import kotlinx.coroutines.CoroutineScope
37
import kotlinx.coroutines.Dispatchers
38
import kotlinx.coroutines.SupervisorJob
39
import kotlinx.coroutines.cancel
40
import kotlinx.coroutines.channels.BufferOverflow
41
import kotlinx.coroutines.flow.MutableSharedFlow
42
import kotlinx.coroutines.flow.MutableStateFlow
43
import kotlinx.coroutines.flow.asSharedFlow
44
import kotlinx.coroutines.flow.asStateFlow
45
import kotlinx.coroutines.flow.first
46
import kotlinx.coroutines.flow.takeWhile
47
import kotlinx.coroutines.launch
48
import kotlinx.coroutines.runBlocking
49
import kotlinx.coroutines.withContext
50
import kotlinx.coroutines.withTimeout
51
import okhttp3.internal.toImmutableList
52
import java.io.File
53
import java.util.concurrent.TimeoutException
54
import java.util.concurrent.atomic.AtomicBoolean
55

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

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

63
class DefaultUnleash(
27✔
64
    private val androidContext: Context,
3✔
65
    private val unleashConfig: UnleashConfig,
3✔
66
    unleashContext: UnleashContext = UnleashContext(),
10✔
67
    cacheImpl: ToggleCache = InMemoryToggleCache(),
5✔
68
    eventListeners: List<UnleashListener> = emptyList(),
2✔
69
    private val lifecycle: Lifecycle = getLifecycle(androidContext),
3✔
70
    private val coroutineScope: CoroutineScope = unleashScope
5✔
71
) : Unleash {
72
    companion object {
73
        private const val TAG = "Unleash"
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(
×
95
                    unleashConfig,
×
96
                    httpClientBuilder.build("metrics", unleashConfig.metricsStrategy)
×
97
                )
98
            else NoOpMetrics()
4✔
99
        fetcher = if (unleashConfig.pollingStrategy.enabled)
7✔
100
            UnleashFetcher(
4✔
101
                unleashConfig,
2✔
102
                httpClientBuilder.build("poller", unleashConfig.pollingStrategy),
6✔
103
                unleashContextState.asStateFlow()
3✔
104
            ) else null
1✔
105
        taskManager = LifecycleAwareTaskManager(
8✔
106
            dataJobs = buildDataJobs(metrics, fetcher),
7✔
107
            networkAvailable = networkStatusHelper.isAvailable(),
3✔
108
            scope = coroutineScope
2✔
109
        )
110
        if (!unleashConfig.delayedInitialization) {
4!
111
            start(eventListeners)
×
112
        } else if (eventListeners.isNotEmpty()) {
6!
113
            throw IllegalArgumentException("Event listeners are not supported as constructor arguments with delayed initialization")
×
114
        }
115
    }
1✔
116

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

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

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

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

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

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

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

230
    override fun refreshTogglesNowAsync() {
231
        coroutineScope.launch {
×
232
            withContext(Dispatchers.IO) {
×
233
                fetcher?.refreshToggles()
×
234
            }
×
235
        }
×
236
    }
×
237

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

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

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

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

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

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

283
    override fun addUnleashEventListener(listener: UnleashListener) {
284

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

296
        if (listener is UnleashStateListener) coroutineScope.launch {
3!
297
            cache.getUpdatesFlow().collect {
×
298
                listener.onStateChanged()
×
299
            }
×
300
        }
×
301

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

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

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

327
private fun getLifecycle(androidContext: Context) =
328
    if (androidContext is LifecycleOwner) {
×
329
        Log.d("Unleash", "Using lifecycle from Android context")
×
330
        androidContext.lifecycle
×
331
    } else {
332
        Log.d("Unleash", "Using lifecycle from ProcessLifecycleOwner")
×
333
        ProcessLifecycleOwner.get().lifecycle
×
334
    }
×
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