Description
Describe the bug
Unleash Android SDK Version = "1.2.1"
URL - https://unleash-stage.penpencil.co/projects
Code
package com.penpencil.core.unleash
import android.content.Context
import android.os.Looper
import android.util.Log
import androidx.annotation.WorkerThread
import com.penpencil.core.BuildConfig
import com.penpencil.core.logging.PwLog
import io.getunleash.android.DefaultUnleash
import io.getunleash.android.UnleashConfig
import io.getunleash.android.data.Toggle
import io.getunleash.android.data.UnleashContext
import io.getunleash.android.data.Variant
import io.getunleash.android.events.UnleashReadyListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.lang.ref.WeakReference
/**
-
@Property config Configuration object containing Unleash setup parameters
*/
class UnleashFeatureManager private constructor() {private var applicationContext: WeakReference? = null
private var unleash: DefaultUnleash? = null
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val contextUpdateMutex = Mutex()
companion object {
@volatile
private var instance: UnleashFeatureManager? = nullfun getInstance(): UnleashFeatureManager { return instance ?: synchronized(this) { instance ?: UnleashFeatureManager().also { instance = it } } }
}
/**
-
Initializes the Unleash SDK with the provided configuration.
-
Should be called from Application class or a dependency injection module.
-
@param context Application context
-
@param config Configuration data for Unleash initialization
*/
fun initialize(
context: Context, config: FeatureConfig
) {
if (unleash != null) returnapplicationContext = WeakReference(context.applicationContext)
val unleashConfig =
UnleashConfig.newBuilder(appName = config.appName)
.proxyUrl(config.proxyUrl)
.clientKey(config.clientKey).apply {
pollingStrategy.interval(config.pollingInterval)
metricsStrategy.interval(config.metricsInterval)
}.build()val initialContext =
UnleashContext.newBuilder().userId(config.userId).build()applicationContext?.get()?.let { appContext ->
unleash = DefaultUnleash(
androidContext = appContext, unleashConfig = unleashConfig
).apply {
setContext(initialContext)
}
}
}
/**
- Starts the Unleash client with optional bootstrap data.
- Uses coroutines for async initialization and timeout handling.
- @param bootstrapToggles Optional list of initial toggle states
- @param maxWaitTimeMs Maximum time to wait for initialization in milliseconds
- @return Boolean indicating whether initialization was successful
*/
suspend fun start(
bootstrapToggles: List? = null, onReady: (() -> Unit)
) = withContext(Dispatchers.Main) {
try {
Log.e("TAG", "start: ${System.currentTimeMillis()}")
// Use Dispatchers.Main for main thread
unleash?.let { client ->
// Start Unleash on the main thread
bootstrapToggles?.let { toggles ->
client.start(bootstrap = toggles)
} ?: client.start(
eventListeners = listOf(object : UnleashReadyListener {
override fun onReady() {
// Handle readiness if needed
onReady?.invoke()
}
})
)
}
} catch (e: Exception) {
Log.e("UnleashFeatureManager", "Failed to start Unleash client", e)
}
}
/**
- Checks if a feature flag is enabled asynchronously.
- @param flagName Name of the feature flag
- @param defaultValue Default value if the flag is not found or Unleash is not ready
- @return Boolean indicating if the feature is enabled
*/
suspend fun isFeatureEnabledAsync(
flagName: String, defaultValue: Boolean = false
): Boolean = executeWhenReady {
unleash?.isEnabled(flagName) ?: defaultValue
} ?: defaultValue
/**
-
Checks if a feature flag is enabled with timeout protection.
-
Should be called from a background thread or coroutine.
*/
suspend fun isFeatureEnabled(
flagName: String,
defaultValue: Boolean = false,
maxWaitTimeMs: Long = 1000
): Boolean = withContext(Dispatchers.IO) {
if (unleash == null) {
Log.w("UnleashFeatureManager", "Unleash client is not initialized.")
return@withContext defaultValue
}try {
// Use coroutine timeout instead of blocking wait
withTimeoutOrNull(maxWaitTimeMs) {
while (!isReady()) {
delay(100) // Non-blocking delay
}
unleash?.isEnabled(flagName) ?: defaultValue
} ?: run {
Log.w("UnleashFeatureManager", "Unleash client is not ready within the timeout.")
defaultValue
}
} catch (e: Exception) {
Log.e("UnleashFeatureManager", "Error checking feature flag: ${e.message}")
defaultValue
}
}
-
// If you absolutely need a sync version (not recommended), make it explicit:
/**
* WARNING: This method blocks the calling thread and should NOT be called from the main thread.
*/
@workerthread
fun isFeatureEnabledBlocking(
flagName: String,
defaultValue: Boolean = false,
maxWaitTimeMs: Long = 1000
): Boolean {
if (Looper.getMainLooper().isCurrentThread) {
if (BuildConfig.DEBUG) {
throw IllegalStateException("isFeatureEnabledBlocking called on main thread!")
} else {
Log.e("UnleashFeatureManager", "isFeatureEnabledBlocking called on main thread!")
return defaultValue
}
}
return runBlocking {
isFeatureEnabled(flagName, defaultValue, maxWaitTimeMs)
}
}
/**
* Gets the variant for a feature flag asynchronously.
*
* @param flagName Name of the feature flag
* @return Variant object containing the feature variant information
*/
suspend fun getFeatureVariantAsync(flagName: String): Variant? = executeWhenReady {
unleash?.getVariant(flagName)
}
/**
* Gets the variant for a feature flag with timeout protection.
* Uses coroutines for non-blocking operations.
*
* @param flagName Name of the feature flag
* @param maxWaitTimeMs Maximum time to wait for readiness in milliseconds
* @return Variant object containing the feature variant information
*/
suspend fun getFeatureVariant(
flagName: String,
maxWaitTimeMs: Long = 1000
): Variant? = withContext(Dispatchers.IO) {
if (unleash == null) {
Log.w("UnleashFeatureManager", "Unleash client is not initialized.")
return@withContext null
}
try {
// Use coroutine timeout instead of blocking wait
withTimeoutOrNull(maxWaitTimeMs) {
while (!isReady()) {
delay(100) // Non-blocking delay
}
unleash?.getVariant(flagName)
} ?: run {
Log.w("UnleashFeatureManager", "Unleash client is not ready within the timeout.")
null
}
} catch (e: Exception) {
Log.e("UnleashFeatureManager", "Error getting feature variant: ${e.message}")
null
}
}
/**
* WARNING: This method blocks the calling thread and should NOT be called from the main thread.
* Consider using getFeatureVariant() with coroutines instead.
*/
@WorkerThread
fun getFeatureVariantBlocking(
flagName: String,
maxWaitTimeMs: Long = 1000
): Variant? {
if (Looper.getMainLooper().isCurrentThread) {
if (BuildConfig.DEBUG) {
throw IllegalStateException("isFeatureEnabledBlocking called on main thread!")
} else {
Log.e("UnleashFeatureManager", "isFeatureEnabledBlocking called on main thread!")
return null
}
}
return runBlocking {
getFeatureVariant(flagName, maxWaitTimeMs)
}
}
/**
* Updates the Unleash context with new user information.
* Executes on IO dispatcher to avoid blocking the main thread.
*
* @param userId User identifier
* @param sessionId Session identifier
* @param properties Additional context properties
*/
suspend fun updateContext(
userId: String, properties: Map<String, String> = emptyMap()
) = withContext(Dispatchers.IO) {
// Use the Mutex to ensure thread-safe updates
contextUpdateMutex.withLock {
try {
val context = UnleashContext.newBuilder().userId(userId).apply {
properties.forEach { (key, value) ->
addProperty(key, value)
}
}.build()
unleash?.setContext(context)
} catch (e: Exception) {
PwLog.e("Error updating context: ${e.message}")
}
}
}
fun isReady(): Boolean {
return unleash?.isReady() ?: false
}
/**
* Updates the Unleash context asynchronously using coroutines.
* Executes on IO dispatcher and waits for client readiness.
*
* @param userId User identifier
* @param properties Additional context properties
* @return Boolean indicating if the update was successful
*/
suspend fun updateContextAsync(
userId: String,
properties: Map<String, String> = emptyMap(),
maxWaitTimeMs: Long = 1000
): Boolean = withContext(Dispatchers.IO) {
if (unleash == null) {
Log.w("UnleashFeatureManager", "Unleash client is not initialized.")
return@withContext false
}
executeWhenReady(maxWaitTimeMs) {
try {
val context = UnleashContext.newBuilder()
.userId(userId)
.apply {
properties.forEach { (key, value) ->
addProperty(key, value)
}
}
.build()
unleash?.setContext(context)
true
} catch (e: Exception) {
Log.e("UnleashFeatureManager", "Error updating context: ${e.message}")
false
}
} ?: false
}
/**
* Waits until the Unleash client is ready and then executes the provided block.
*
* @param maxWaitTimeMs Maximum time to wait for readiness in milliseconds
* @param block The block of code to execute once Unleash is ready
* @return The result of the block or null if Unleash is not ready within the timeout
*/
suspend fun <T> executeWhenReady(maxWaitTimeMs: Long = 1000, block: suspend () -> T): T? {
return withContext(Dispatchers.Main) {
if (withTimeoutOrNull(maxWaitTimeMs) {
while (unleash?.isReady() == false) {
delay(100)
}
unleash?.isReady() == true
} == true) {
block()
} else {
Log.w("UnleashFeatureManager", "Unleash client is not ready within the timeout.")
null
}
}
}
/**
* Stops the Unleash client and releases resources.
*/
fun stop() {
unleash?.close()
unleash = null
}
/**
* Cleans up resources when the feature manager is no longer needed.
*/
fun cleanup() {
stop()
coroutineScope.cancel()
applicationContext = null
instance = null
Steps to reproduce the bug
- Initialize Unleash with the configuration:
val unleashConfig = UnleashConfig.newBuilder(appName = config.appName)
.proxyUrl(config.proxyUrl)
.clientKey(config.clientKey)
.build()
2.Set Unleash context with a userId:
-
Fetch a feature variant:
val variant = unleash?.getVariant("my_feature")
Log.d("UnleashDebug", "Feature Variant: ${variant?.name}") -
Restart the app and observe that the variant is changing instead of remaining consistent for the same userId.
Expected behavior
For a given userId, the variant should remain the same across app launches.
Logs, error output, etc.
The variant changes across app launches even when the userId remains the same.
Screenshots
No response
Additional context
When i am increasing target group, userId from control group is moving to target group.
Unleash version
1.2.1
Subscription type
Open source
Hosting type
Hosted by Unleash
SDK information (language and version)
1.2.1
Metadata
Metadata
Assignees
Labels
Type
Projects
Status