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

Unleash / unleash-android / 10095366255

25 Jul 2024 01:48PM CUT coverage: 82.415% (+0.1%) from 82.319%
10095366255

push

github

web-flow
fix(deps): update dependency androidx.activity:activity-compose to v1.9.1 (#67)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Gastón Fournier <gaston@getunleash.io>

209 of 284 branches covered (73.59%)

Branch coverage included in aggregate %.

644 of 751 relevant lines covered (85.75%)

5.45 hits per line

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

75.09
/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
        internal const val BACKUP_DIR_NAME = "unleash_backup"
75
    }
76

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

284
    override fun addUnleashEventListener(listener: UnleashListener) {
285

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

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

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

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

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

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