├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .editorconfig ├── .devcontainer └── devcontainer.json ├── gradle.properties ├── src ├── main │ ├── java │ │ └── com │ │ │ └── statsig │ │ │ └── androidsdk │ │ │ ├── LogEventResponse.kt │ │ │ ├── BaseConfig.kt │ │ │ ├── CoroutineDispatcherProvider.kt │ │ │ ├── UrlConnectionProvider.kt │ │ │ ├── StatsigPendingRequests.kt │ │ │ ├── BoundedMemo.kt │ │ │ ├── StatsigOverrides.kt │ │ │ ├── ExposureKey.kt │ │ │ ├── StatsigRuntimeMutableOptions.kt │ │ │ ├── ExternalInitializeResponse.kt │ │ │ ├── InitializationDetails.kt │ │ │ ├── EvaluationDetails.kt │ │ │ ├── LogEvent.kt │ │ │ ├── StatsigNetworkConnectivityListener.kt │ │ │ ├── evaluator │ │ │ ├── SpecStore.kt │ │ │ ├── SpecsResponse.kt │ │ │ └── EvaluatorUtils.kt │ │ │ ├── Hashing.kt │ │ │ ├── StatsigNetworkConfig.kt │ │ │ ├── BootstrapValidator.kt │ │ │ ├── FeatureGate.kt │ │ │ ├── StatsigActivityLifecycleListener.kt │ │ │ ├── StatsigMetadata.kt │ │ │ ├── StatsigUtil.kt │ │ │ ├── InitializeResponse.kt │ │ │ ├── DnsTxtQuery.kt │ │ │ ├── DiagnosticData.kt │ │ │ ├── OnDeviceEvalAdapter.kt │ │ │ ├── StatsigUser.kt │ │ │ ├── Diagnostics.kt │ │ │ ├── ErrorBoundary.kt │ │ │ ├── DebugView.kt │ │ │ ├── NetworkFallbackResolver.kt │ │ │ └── ParameterStore.kt │ └── AndroidManifest.xml └── test │ ├── AndroidManifest.xml │ └── java │ └── com │ └── statsig │ └── androidsdk │ ├── DnsTxtQueryTest.kt │ ├── StatsigOptionsTest.kt │ ├── SerializationTest.kt │ ├── BootstrapValidatorTest.kt │ ├── CacheKeyWithSDKKeyTest.kt │ ├── DiagnosticsTest.kt │ ├── LogEventRetryTest.kt │ ├── InitializationTest.kt │ ├── StatsigLongInitializationTimeoutTest.kt │ ├── StatsigUtilTest.kt │ ├── LogEventCompressionTest.ts.kt │ ├── StatsigNetworkTest.kt │ ├── ErrorBoundaryTest.kt │ ├── StatsigInitializationTimeoutTest.kt │ ├── StatsigCacheTest.kt │ ├── OfflineStorageTest.kt │ ├── DynamicConfigTest.kt │ ├── ErrorBoundaryNetworkConnectivityTest.kt │ ├── InitializationRetryFailedLogsTest.kt │ ├── EvaluationCallbackTest.kt │ ├── ParameterStoreTest.kt │ ├── StatsigMultipleInitializeTest.kt │ ├── StatsigOfflineInitializationTest.kt │ ├── StatsigOverridesTest.kt │ ├── ExperimentCacheTest.kt │ ├── AsyncInitVsUpdateTest.kt │ └── StatsigFromJavaTest.java ├── config └── ktlint │ └── baseline.xml ├── settings.gradle.kts ├── .github └── workflows │ ├── release-bot.yml │ ├── tests.yml │ ├── scheduler.yml │ ├── kong.yml │ └── publish-to-maven.yml ├── README.md ├── LICENSE ├── consumer-rules.pro ├── .gitignore └── gradlew.bat /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statsig-io/android-sdk/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | [*.{kt,kts}] 3 | ktlint_code_style = android_studio 4 | ktlint_standard_no-wildcard-imports = disabled 5 | ktlint_standard_comment-wrapping = disabled 6 | 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/nordcominc/devcontainer-features/android-sdk:1": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | android.useAndroidX=true 2 | libraryVersion=4.45.1 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=true 5 | org.gradle.configuration-cache.problems=warn 6 | org.gradle.parallel=true 7 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/LogEventResponse.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | internal data class LogEventResponse(@SerializedName("success") val success: Boolean?) 6 | -------------------------------------------------------------------------------- /config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/BaseConfig.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | open class BaseConfig(private val name: String, private val details: EvaluationDetails) { 4 | open fun getName(): String = this.name 5 | 6 | open fun getEvaluationDetails(): EvaluationDetails = this.details 7 | } 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | plugins { 10 | id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") 11 | } 12 | 13 | rootProject.name = "android-sdk" 14 | -------------------------------------------------------------------------------- /src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/CoroutineDispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | data class CoroutineDispatcherProvider( 7 | val main: CoroutineDispatcher = Dispatchers.Main, 8 | val default: CoroutineDispatcher = Dispatchers.Default, 9 | val io: CoroutineDispatcher = Dispatchers.IO 10 | ) 11 | -------------------------------------------------------------------------------- /src/test/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/UrlConnectionProvider.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import java.net.URL 4 | import java.net.URLConnection 5 | 6 | /** 7 | * Layer of indirection for Statsig classes opening HTTP connections, used as a test hook. 8 | */ 9 | interface UrlConnectionProvider { 10 | fun open(url: URL): URLConnection 11 | } 12 | 13 | internal val defaultProvider = object : UrlConnectionProvider { 14 | override fun open(url: URL): URLConnection = url.openConnection() 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigPendingRequests.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | internal data class StatsigPendingRequests( 6 | @SerializedName("requests") val requests: List 7 | ) 8 | 9 | internal data class StatsigOfflineRequest( 10 | @SerializedName("timestamp") val timestamp: Long, 11 | @SerializedName("requestBody") val requestBody: String, 12 | @SerializedName("retryCount") val retryCount: Int = 0 13 | ) 14 | -------------------------------------------------------------------------------- /.github/workflows/release-bot.yml: -------------------------------------------------------------------------------- 1 | name: Release Bot 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, closed] 6 | branches: [main, stable] 7 | release: 8 | types: [released, prereleased] 9 | 10 | jobs: 11 | run: 12 | timeout-minutes: 10 13 | if: startsWith(github.head_ref, 'releases/') || github.event_name == 'release' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: statsig-io/statsig-publish-sdk-action@main 17 | with: 18 | kong-private-key: ${{ secrets.KONG_GH_APP_PRIVATE_KEY }} 19 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/BoundedMemo.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import java.util.concurrent.ConcurrentHashMap 4 | 5 | internal class BoundedMemo { 6 | private val cache = ConcurrentHashMap() 7 | companion object { 8 | private const val MAX_CACHE_SIZE = 1000 9 | } 10 | 11 | fun computeIfAbsent(key: K, mappingFunction: (K) -> V): V { 12 | if (cache.size >= MAX_CACHE_SIZE) { 13 | cache.clear() 14 | } 15 | return cache.getOrPut(key) { mappingFunction.invoke(key) } 16 | } 17 | 18 | fun size(): Int = cache.size 19 | 20 | fun clear() = cache.clear() 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigOverrides.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import java.util.concurrent.ConcurrentHashMap 5 | 6 | data class StatsigOverrides( 7 | @SerializedName("gates") 8 | val gates: ConcurrentHashMap, 9 | 10 | @SerializedName("configs") 11 | val configs: ConcurrentHashMap>, 12 | 13 | @SerializedName("layers") 14 | val layers: ConcurrentHashMap> 15 | ) { 16 | companion object { 17 | fun empty(): StatsigOverrides = 18 | StatsigOverrides(ConcurrentHashMap(), ConcurrentHashMap(), ConcurrentHashMap()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/ExposureKey.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | sealed class ExposureKey { 4 | data class Gate( 5 | val name: String, 6 | val ruleID: String, 7 | val reason: EvaluationReason, 8 | val value: Boolean 9 | ) : ExposureKey() 10 | 11 | data class Config(val name: String, val ruleID: String, val reason: EvaluationReason) : 12 | ExposureKey() 13 | 14 | data class Layer( 15 | val configName: String, 16 | val ruleID: String, 17 | val allocatedExperiment: String, 18 | val parameterName: String, 19 | val isExplicitParameter: Boolean, 20 | val reason: EvaluationReason 21 | ) : ExposureKey() 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/DnsTxtQueryTest.kt: -------------------------------------------------------------------------------- 1 | import com.statsig.androidsdk.fetchTxtRecords 2 | import java.io.IOException 3 | import kotlinx.coroutines.runBlocking 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Assert.fail 6 | import org.junit.Test 7 | 8 | class DnsTxtQueryTest { 9 | @Test 10 | fun testTxtRecords() = runBlocking { 11 | try { 12 | val records = fetchTxtRecords() 13 | assertTrue(records.any { it.contains("i=") }) 14 | assertTrue(records.any { it.contains("d=") }) 15 | assertTrue(records.any { it.contains("e=") }) 16 | } catch (e: IOException) { 17 | fail("Test failed due to exception: ${e.message}") 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Statsig Android SDK 2 | 3 | ![Maven Central Version](https://img.shields.io/maven-central/v/com.statsig/android-sdk) 4 | 5 | 6 | ## Getting Started 7 | The Statsig Android SDK for single user client environments. If you need a SDK for another language or server environment, check out our [other SDKs](https://docs.statsig.com/#sdks). 8 | 9 | Statsig helps you move faster with feature gates (feature flags), and/or dynamic configs. It also allows you to run A/B/n tests to validate your new features and understand their impact on your KPIs. If you're new to Statsig, check out our product and create an account at [statsig.com](https://www.statsig.com). 10 | 11 | Check out our [SDK docs](https://docs.statsig.com/client/androidClientSDK) to get started. 12 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigRuntimeMutableOptions.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | const val DEFAULT_LOGGING_ENABLED: Boolean = true 6 | 7 | /** 8 | * A subset of SDK options that can be defined at initialization and updated at runtime. 9 | * Call [Statsig.updateRuntimeOptions] or [StatsigClient.updateRuntimeOptions] to update values. 10 | */ 11 | open class StatsigRuntimeMutableOptions( 12 | 13 | /** 14 | * Controls whether logged events will be sent over the network. 15 | * [loggingEnabled] defaults to true if a value is not provided. 16 | */ 17 | @SerializedName("loggingEnabled") 18 | var loggingEnabled: Boolean = DEFAULT_LOGGING_ENABLED 19 | ) 20 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/ExternalInitializeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | /** 4 | * A helper class for interfacing with Initialize Response currently being used in the Statsig console 5 | */ 6 | class ExternalInitializeResponse( 7 | private val values: String?, 8 | private val evaluationDetails: EvaluationDetails 9 | ) { 10 | internal companion object { 11 | fun getUninitialized(): ExternalInitializeResponse = ExternalInitializeResponse( 12 | null, 13 | EvaluationDetails(EvaluationReason.Uninitialized, lcut = 0) 14 | ) 15 | } 16 | fun getInitializeResponseJSON(): String? = values 17 | 18 | fun getEvaluationDetails(): EvaluationDetails = evaluationDetails.copy() 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2021, Statsig, Inc. 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose 5 | with or without fee is hereby granted, provided that the above copyright notice 6 | and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 10 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 12 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 13 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 14 | THIS SOFTWARE. -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/InitializationDetails.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Details relating to the initialize process 7 | * Passed in initCallback and returned in static initialize call 8 | * @property duration the time in milliseconds it took for initialize to complete 9 | * @property success boolean indicating whether initialize was successful or not 10 | * @property failureDetails additional details on failure 11 | */ 12 | data class InitializationDetails( 13 | @SerializedName("duration") 14 | var duration: Long, 15 | @SerializedName("success") 16 | var success: Boolean, 17 | @SerializedName("failureDetails") 18 | var failureDetails: InitializeResponse.FailedInitializeResponse? = null 19 | ) 20 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/EvaluationDetails.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | enum class EvaluationReason { 4 | Network, 5 | Cache, 6 | Sticky, 7 | LocalOverride, 8 | Unrecognized, 9 | Uninitialized, 10 | Bootstrap, 11 | OnDeviceEvalAdapterBootstrapRecognized, 12 | OnDeviceEvalAdapterBootstrapUnrecognized, 13 | InvalidBootstrap, 14 | NetworkNotModified, 15 | Error ; 16 | 17 | override fun toString(): String = when (this) { 18 | OnDeviceEvalAdapterBootstrapRecognized -> "[OnDevice]Bootstrap:Recognized" 19 | OnDeviceEvalAdapterBootstrapUnrecognized -> "[OnDevice]Bootstrap:Unrecognized" 20 | else -> this.name 21 | } 22 | } 23 | 24 | data class EvaluationDetails( 25 | var reason: EvaluationReason, 26 | val time: Long = System.currentTimeMillis(), 27 | @Transient val lcut: Long 28 | ) 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | gradle: 12 | timeout-minutes: 12 13 | runs-on: ubuntu-latest-16core 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-java@v5 18 | with: 19 | java-version: 17 20 | distribution: "adopt" 21 | 22 | - name: Setup Gradle 23 | uses: gradle/actions/setup-gradle@v5 24 | 25 | - name: Lint 26 | run: ./gradlew ktLintCheck 27 | 28 | - name: Test 29 | run: ./gradlew testDebugUnitTest -i 30 | 31 | - name: Upload test report on failure 32 | if: failure() 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: test-report 36 | path: build/reports/tests/testDebugUnitTest/ 37 | -------------------------------------------------------------------------------- /.github/workflows/scheduler.yml: -------------------------------------------------------------------------------- 1 | name: Scheduler 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0/4 * * *" 7 | 8 | jobs: 9 | trigger-runs: 10 | timeout-minutes: 10 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Trigger Scheduled Test Runs 14 | if: github.event.repository.private 15 | uses: actions/github-script@v6 16 | with: 17 | script: | 18 | const args = { 19 | owner: context.repo.owner, 20 | repo: context.repo.repo, 21 | ref: 'main', 22 | } 23 | 24 | // Kong 25 | github.rest.actions.createWorkflowDispatch({ 26 | ...args, 27 | workflow_id: 'kong.yml' 28 | }) 29 | 30 | // Tests 31 | github.rest.actions.createWorkflowDispatch({ 32 | ...args, 33 | workflow_id: 'tests.yml' 34 | }) 35 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/LogEvent.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | internal data class LogEvent(@SerializedName("eventName") val eventName: String) { 6 | @SerializedName("value") 7 | var value: Any? = null 8 | 9 | @SerializedName("metadata") 10 | var metadata: Map? = null 11 | 12 | @SerializedName("user") 13 | var user: StatsigUser? = null 14 | set(value) { 15 | // We need to use a special copy of the user object that strips out private attributes for logging purposes 16 | field = value?.getCopyForLogging() 17 | } 18 | 19 | @SerializedName("time") 20 | val time: Long = System.currentTimeMillis() 21 | 22 | @SerializedName("statsigMetadata") 23 | var statsigMetadata: Map? = null 24 | 25 | @SerializedName("secondaryExposures") 26 | var secondaryExposures: Array>? = null 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigOptionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.junit.Test 5 | 6 | class StatsigOptionsTest { 7 | @Test 8 | fun autoValueUpdateInterval_valueLessThanMinimum_enforcesMinimum() { 9 | val options = StatsigOptions(autoValueUpdateIntervalMinutes = 0.5) 10 | assertThat(options.autoValueUpdateIntervalMinutes).isEqualTo( 11 | AUTO_VALUE_UPDATE_INTERVAL_MINIMUM_VALUE 12 | ) 13 | 14 | options.autoValueUpdateIntervalMinutes = 1.5 15 | assertThat(options.autoValueUpdateIntervalMinutes).isEqualTo(1.5) 16 | } 17 | 18 | @Test 19 | fun setTier_writesLowerCaseEnvironmentVariable() { 20 | val options = StatsigOptions() 21 | options.setTier(Tier.PRODUCTION) 22 | 23 | assertThat(options.getEnvironment()?.values).contains( 24 | Tier.PRODUCTION.toString().lowercase() 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /consumer-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can edit the include path and order by changing the ProGuard 3 | # include property in project.properties. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | -keep class com.google.gson.reflect.TypeToken 9 | -keep class * extends com.google.gson.reflect.TypeToken 10 | -keep public class * implements java.lang.reflect.Type 11 | 12 | -keep class com.statsig.** { *; } 13 | 14 | # Keep Kotlin metadata for reflection 15 | -keepclassmembers class kotlin.Metadata { *; } 16 | 17 | # Keep Kotlin lambdas and companion objects 18 | -keepclassmembers class com.statsig.* { 19 | *** Companion; 20 | } 21 | 22 | -keep class com.statsig.**$$Lambda$* { *; } 23 | -keep class com.statsig.**$$ExternalSyntheticLambda* { *; } 24 | 25 | # Keep all anonymous classes (used by computeIfAbsent lambda) 26 | -keepclassmembers class com.statsig.**$* { *; } 27 | -keepclassmembers class com.statsig.**$$* { *; } 28 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigNetworkConnectivityListener.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import android.os.Build 7 | 8 | class StatsigNetworkConnectivityListener(context: Context) { 9 | private val connectivityManager: ConnectivityManager = 10 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 11 | 12 | fun isNetworkAvailable(): Boolean { 13 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 14 | val activeNetwork = connectivityManager.activeNetwork 15 | val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) 16 | return networkCapabilities?.hasCapability( 17 | NetworkCapabilities.NET_CAPABILITY_INTERNET 18 | ) == 19 | true 20 | } 21 | 22 | @Suppress("DEPRECATION") 23 | return connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/evaluator/SpecStore.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk.evaluator 2 | 3 | internal class SpecStore { 4 | private var rawSpecs: SpecsResponse? = null 5 | private var gates: Map = mapOf() 6 | private var configs: Map = mapOf() 7 | private var layers: Map = mapOf() 8 | private var paramStores: Map = mapOf() 9 | 10 | fun getRawSpecs(): SpecsResponse? = rawSpecs 11 | 12 | fun getLcut(): Long? = rawSpecs?.time 13 | 14 | fun setSpecs(specs: SpecsResponse) { 15 | rawSpecs = specs 16 | 17 | gates = specs.featureGates.associateBy { it.name } 18 | configs = specs.dynamicConfigs.associateBy { it.name } 19 | layers = specs.layerConfigs.associateBy { it.name } 20 | paramStores = specs.paramStores ?: mapOf() 21 | } 22 | 23 | fun getGate(name: String): Spec? = gates[name] 24 | 25 | fun getConfig(name: String): Spec? = configs[name] 26 | 27 | fun getLayer(name: String): Spec? = layers[name] 28 | 29 | fun getParamStore(name: String): SpecParamStore? = paramStores[name] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Built application files 4 | *.apk 5 | *.aar 6 | *.ap_ 7 | *.aab 8 | 9 | # Files for the ART/Dalvik VM 10 | *.dex 11 | 12 | # Java class files 13 | *.class 14 | 15 | # Generated files 16 | bin/ 17 | gen/ 18 | out/ 19 | # Uncomment the following line in case you need and you don't have the release build type files in your app 20 | # release/ 21 | 22 | # Gradle files 23 | .gradle/ 24 | build/ 25 | 26 | # Local configuration file (sdk path, etc) 27 | local.properties 28 | 29 | # Proguard folder generated by Eclipse 30 | proguard/ 31 | 32 | # Log Files 33 | *.log 34 | 35 | # Android Studio Navigation editor temp files 36 | .navigation/ 37 | 38 | # Android Studio captures folder 39 | captures/ 40 | 41 | # IntelliJ 42 | *.iml 43 | .idea/workspace.xml 44 | .idea/tasks.xml 45 | .idea/gradle.xml 46 | .idea/assetWizardSettings.xml 47 | .idea/dictionaries 48 | .idea/libraries 49 | # Android Studio 3 in .gitignore file. 50 | .idea/caches 51 | .idea/modules.xml 52 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 53 | .idea/navEditor.xml 54 | .idea/ 55 | 56 | 57 | ### macOS ### 58 | # General 59 | .DS_Store -------------------------------------------------------------------------------- /.github/workflows/kong.yml: -------------------------------------------------------------------------------- 1 | name: KONG 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | env: 11 | test_api_key: ${{ secrets.KONG_SERVER_SDK_KEY }} 12 | test_client_key: ${{ secrets. KONG_CLIENT_SDK_KEY }} 13 | repo_pat: ${{ secrets.KONG_FINE_GRAINED_REPO_PAT }} 14 | FORCE_COLOR: true 15 | 16 | jobs: 17 | KONG: 18 | timeout-minutes: 10 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Get KONG 22 | run: git clone https://oauth2:$repo_pat@github.com/statsig-io/kong.git . 23 | 24 | - uses: actions/setup-java@v4 25 | with: 26 | java-version: "17" 27 | distribution: "temurin" 28 | 29 | - uses: actions/cache@v3 30 | with: 31 | path: | 32 | ~/.gradle/caches 33 | ~/.gradle/wrapper 34 | build/ 35 | key: ${{ runner.os }}-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 36 | restore-keys: | 37 | ${{ runner.os }}-android-gradle- 38 | 39 | - name: Install Deps 40 | run: npm install 41 | 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v4 44 | 45 | - name: Setup Android SDK 46 | run: npm run kong -- setup android -v 47 | 48 | - name: Precompile Android Bridge 49 | run: (cd bridges/android-bridge ; ./gradlew assemble) 50 | 51 | - name: Run Tests 52 | run: npm run kong -- test android -v -r 53 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-maven.yml: -------------------------------------------------------------------------------- 1 | name: Publish Android SDK 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_run: 6 | workflows: [Release Bot] 7 | types: 8 | - completed 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') 14 | timeout-minutes: 15 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up JDK 17 21 | uses: actions/setup-java@v3 22 | with: 23 | distribution: 'temurin' 24 | java-version: '17' 25 | 26 | - name: Validate Gradle Wrapper 27 | uses: gradle/actions/wrapper-validation@v5 28 | 29 | - name: Grant Execute Permission for Gradlew 30 | run: chmod +x gradlew 31 | 32 | - name: Build & Publish to Maven Central 33 | run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository --no-configuration-cache 34 | env: 35 | ORG_GRADLE_PROJECT_SIGNING_KEY_ID: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY_ID }} 36 | ORG_GRADLE_PROJECT_SIGNING_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PASSWORD }} 37 | ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }} 38 | ORG_GRADLE_PROJECT_MAVEN_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_MAVEN_USERNAME }} 39 | ORG_GRADLE_PROJECT_MAVEN_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_MAVEN_PASSWORD }} 40 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/Hashing.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import java.security.MessageDigest 5 | 6 | enum class HashAlgorithm(val value: String) { 7 | @SerializedName("sha256") 8 | SHA256("sha256"), 9 | 10 | @SerializedName("djb2") 11 | DJB2("djb2"), 12 | 13 | @SerializedName("none") 14 | NONE("none") 15 | } 16 | 17 | internal object Hashing { 18 | private val sha256Cache = BoundedMemo() 19 | private val djb2Cache = BoundedMemo() 20 | 21 | fun getHashedString(input: String, algorithm: HashAlgorithm?): String = when (algorithm) { 22 | HashAlgorithm.DJB2 -> djb2Cache.computeIfAbsent(input) { getDJB2HashString(it) } 23 | HashAlgorithm.SHA256 -> sha256Cache.computeIfAbsent(input) { getSHA256HashString(it) } 24 | HashAlgorithm.NONE -> input 25 | else -> sha256Cache.computeIfAbsent(input) { getSHA256HashString(it) } 26 | } 27 | 28 | private fun getSHA256HashString(input: String): String { 29 | val md = MessageDigest.getInstance("SHA-256") 30 | val inputBytes = input.toByteArray() 31 | val bytes = md.digest(inputBytes) 32 | return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) 33 | } 34 | 35 | fun getDJB2HashString(input: String): String { 36 | var hash = 0 37 | for (c in input.toCharArray()) { 38 | hash = (hash shl 5) - hash + c.code 39 | hash = hash and hash 40 | } 41 | 42 | return (hash.toUInt()).toString() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigNetworkConfig.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | enum class Endpoint(val value: String) { 6 | @SerializedName("log_event") 7 | Rgstr("log_event"), 8 | 9 | @SerializedName("initialize") 10 | Initialize("initialize") ; 11 | 12 | override fun toString(): String = value 13 | } 14 | 15 | typealias EndpointDnsKey = String // 'i' | 'e' | 'd' 16 | 17 | val ENDPOINT_DNS_KEY_MAP: Map = mapOf( 18 | Endpoint.Initialize to "i", 19 | Endpoint.Rgstr to "e" 20 | ) 21 | 22 | val NetworkDefault: Map = mapOf( 23 | Endpoint.Initialize to DEFAULT_INIT_API, 24 | Endpoint.Rgstr to DEFAULT_EVENT_API 25 | ) 26 | 27 | class UrlConfig( 28 | val endpoint: Endpoint, 29 | inputApi: String? = null, 30 | var userFallbackUrls: List? = null 31 | ) { 32 | val endpointDnsKey: EndpointDnsKey = ENDPOINT_DNS_KEY_MAP[endpoint] ?: "" 33 | var defaultUrl: String 34 | var customUrl: String? = null 35 | var statsigFallbackUrl: String? = null 36 | var fallbackUrl: String? = null 37 | 38 | init { 39 | val defaultApi = NetworkDefault[endpoint] 40 | defaultUrl = "$defaultApi${endpoint.value}" 41 | 42 | if (customUrl == null && inputApi != null) { 43 | val inputUrl = "${inputApi.trimEnd('/')}/${endpoint.value}" 44 | if (inputUrl != defaultUrl) { 45 | customUrl = inputUrl 46 | } 47 | } 48 | } 49 | 50 | fun getUrl(): String = customUrl ?: defaultUrl 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/BootstrapValidator.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import java.lang.Exception 4 | 5 | object BootstrapValidator { 6 | fun isValid(initializeValues: Map, user: StatsigUser): Boolean { 7 | try { 8 | // If no evaluated key being passed in, return true 9 | val evaluatedKeys = initializeValues["evaluated_keys"] as? Map<*, *> ?: return true 10 | val userCopy = getUserIdentifier(user.customIDs) 11 | if (user.userID != null) { 12 | userCopy["userID"] = user.userID 13 | } 14 | val evaluatedKeyCopy = getUserIdentifier(evaluatedKeys) 15 | // compare each key value pair in the map 16 | return userCopy == evaluatedKeyCopy 17 | } catch (e: Exception) { 18 | // Best effort, return true if we fail 19 | return true 20 | } 21 | } 22 | 23 | private fun getUserIdentifier(customIDs: Map<*, *>?): MutableMap { 24 | val result: MutableMap = mutableMapOf() 25 | if (customIDs == null) { 26 | return result 27 | } 28 | 29 | for (entry in customIDs.entries.iterator()) { 30 | val key = entry.key 31 | if (key == "stableID" || key !is String) { 32 | // ignore stableID 33 | continue 34 | } 35 | 36 | val value = entry.value 37 | if (value is String?) { 38 | result[key] = value 39 | continue 40 | } 41 | 42 | if (value is Map<*, *>) { 43 | val flattenMap = getUserIdentifier(value) 44 | result.putAll(flattenMap) 45 | } 46 | } 47 | return result 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/SerializationTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.Gson 4 | import org.junit.Test 5 | 6 | /* 7 | * Gson serialization and deserialization does not use default value 8 | * set in data class when a field is missing 9 | * */ 10 | class SerializationTest { 11 | val gson = Gson() 12 | 13 | @Test 14 | fun testSerializeResponseWithIncomplete() { 15 | val initializeResponseSkipFields = "{\"feature_gates\":{\"245595137\":{\"name\":\"245595137\",\"value\":true,\"rule_id\":\"1uj9J1jxY2jnBAChgGB1jR:0.00:35\",\"id_type\":\"userID\"}},\"dynamic_configs\":{\"2887220988\":{\"name\":\"2887220988\",\"value\":{\"num\": 13},\"rule_id\":\"prestart\",\"group\":\"prestart\",\"is_device_based\":false,\"id_type\":\"userID\",\"is_experiment_active\":true,\"is_user_in_experiment\":true}},\"layer_configs\":{},\"sdkParams\":{},\"has_updates\":true,\"time\":1717536742309,\"company_lcut\":1717536742309,\"hash_used\":\"djb2\"}" 16 | val parsedResponse = gson.fromJson( 17 | initializeResponseSkipFields, 18 | InitializeResponse.SuccessfulInitializeResponse::class.java 19 | ) 20 | val gate = 21 | FeatureGate( 22 | "some_gate", 23 | parsedResponse.featureGates!!.get("245595137")!!, 24 | EvaluationDetails(EvaluationReason.Error, lcut = 0) 25 | ) 26 | val config = 27 | DynamicConfig( 28 | "some_config", 29 | parsedResponse.configs!!.get("2887220988")!!, 30 | EvaluationDetails(EvaluationReason.Error, lcut = 0) 31 | ) 32 | assert(gate.getValue()) 33 | assert(gate.getSecondaryExposures().isEmpty()) 34 | assert(config.getInt("num", 0) == 13) 35 | assert(config.getSecondaryExposures().isEmpty()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/FeatureGate.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.statsig.androidsdk.evaluator.ConfigEvaluation 4 | 5 | /** A helper class for interfacing with Feature Gate defined in the Statsig console */ 6 | class FeatureGate( 7 | private val name: String, 8 | private val details: EvaluationDetails, 9 | private val value: Boolean, 10 | private val rule: String = "", 11 | private val groupName: String? = null, 12 | private val secondaryExposures: Array> = arrayOf(), 13 | private val idType: String? = null 14 | ) : BaseConfig(name, details) { 15 | internal constructor( 16 | gateName: String, 17 | apiFeatureGate: APIFeatureGate, 18 | evalDetails: EvaluationDetails 19 | ) : this( 20 | gateName, 21 | evalDetails, 22 | apiFeatureGate.value, 23 | apiFeatureGate.ruleID, 24 | apiFeatureGate.groupName, 25 | apiFeatureGate.secondaryExposures ?: arrayOf(), 26 | apiFeatureGate.idType 27 | ) 28 | 29 | internal constructor( 30 | gateName: String, 31 | evaluation: ConfigEvaluation, 32 | details: EvaluationDetails 33 | ) : this( 34 | gateName, 35 | details, 36 | evaluation.booleanValue, 37 | evaluation.ruleID, 38 | evaluation.groupName, 39 | evaluation.secondaryExposures.toTypedArray() 40 | ) 41 | 42 | internal companion object { 43 | fun getError(name: String): FeatureGate = 44 | FeatureGate(name, EvaluationDetails(EvaluationReason.Error, lcut = 0), false, "") 45 | } 46 | 47 | fun getValue(): Boolean = this.value 48 | 49 | fun getRuleID(): String = this.rule 50 | 51 | fun getGroupName(): String? = this.groupName 52 | 53 | fun getSecondaryExposures(): Array> = this.secondaryExposures 54 | 55 | fun getIDType(): String? = this.idType 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/BootstrapValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import junit.framework.TestCase.assertFalse 4 | import junit.framework.TestCase.assertTrue 5 | import org.junit.Test 6 | 7 | class BootstrapValidatorTest { 8 | @Test 9 | fun testIsValid() { 10 | val user = StatsigUser("test_user") 11 | val initializeValues = mutableMapOf() 12 | val customIDs = mutableMapOf("id_1" to "value_1", "id_2" to "value_2") 13 | // Only userID 14 | initializeValues["evaluated_keys"] = mapOf("userID" to "test_user") 15 | assertTrue(BootstrapValidator.isValid(initializeValues, user)) 16 | // With CustomIDs 17 | user.customIDs = HashMap(customIDs) 18 | initializeValues["evaluated_keys"] = 19 | mapOf("userID" to "test_user", "customIDs" to (HashMap(customIDs))) 20 | assertTrue(BootstrapValidator.isValid(mapOf(), user)) 21 | // With StableID 22 | var newCustomIDs = HashMap(customIDs) 23 | newCustomIDs["stableID"] = "a" 24 | initializeValues["evaluated_keys"] = 25 | mapOf("userID" to "test_user", "customIDs" to newCustomIDs) 26 | assertTrue(BootstrapValidator.isValid(initializeValues, user)) 27 | 28 | // mismatched user id 29 | val user2 = StatsigUser("test_user_2") 30 | initializeValues["evaluated_keys"] = mapOf("userID" to "test_user") 31 | assertFalse(BootstrapValidator.isValid(initializeValues, user2)) 32 | // mismatched customID 33 | newCustomIDs = HashMap(customIDs) 34 | newCustomIDs["id_3"] = "value_3" 35 | initializeValues["evaluated_keys"] = 36 | mapOf("userID" to "test_user", "customIDs" to newCustomIDs) 37 | assertFalse(BootstrapValidator.isValid(initializeValues, user2)) 38 | 39 | // No user id 40 | val userWithoutUID = StatsigUser() 41 | userWithoutUID.customIDs = HashMap(customIDs) 42 | initializeValues["evaluated_keys"] = mapOf("customIDs" to (HashMap(customIDs))) 43 | assertTrue(BootstrapValidator.isValid(mapOf(), user)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/CacheKeyWithSDKKeyTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlinx.coroutines.runBlocking 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.robolectric.RobolectricTestRunner 11 | import org.robolectric.RuntimeEnvironment 12 | 13 | @RunWith(RobolectricTestRunner::class) 14 | class CacheKeyWithSDKKeyTest { 15 | private lateinit var app: Application 16 | private lateinit var testSharedPrefs: SharedPreferences 17 | 18 | private var user = StatsigUser("testUser") 19 | 20 | @Before 21 | internal fun setup() = runBlocking { 22 | TestUtil.mockDispatchers() 23 | app = RuntimeEnvironment.getApplication() 24 | user.customIDs = mapOf("companyId" to "123") 25 | TestUtil.mockHashing() 26 | var values: MutableMap = HashMap() 27 | val sticky: MutableMap = HashMap() 28 | values["values"] = TestUtil.makeInitializeResponse() 29 | values["stickyUserExperiments"] = sticky 30 | var cacheById: MutableMap = HashMap() 31 | // Write a cached by original key 32 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 33 | testSharedPrefs.edit().putString( 34 | "Statsig.CACHE_BY_USER", 35 | StatsigUtil.getOrBuildGson().toJson(cacheById) 36 | ).apply() 37 | TestUtil.startStatsigAndWait(app, user, network = TestUtil.mockBrokenNetwork()) 38 | 39 | return@runBlocking 40 | } 41 | 42 | @Test 43 | fun testWriteToCacheWithNewKey() = runBlocking { 44 | Statsig.client.shutdown() 45 | TestUtil.startStatsigAndWait(app, user, network = TestUtil.mockNetwork()) 46 | val cacheById = StatsigUtil.getOrBuildGson().fromJson( 47 | StatsigUtil.getFromSharedPrefs(testSharedPrefs, "Statsig.CACHE_BY_USER"), 48 | Map::class.java 49 | ) 50 | assertThat( 51 | cacheById.keys 52 | ).contains("${user.toHashString(gson = StatsigUtil.getOrBuildGson())}:client-apikey") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | androidGradlePlugin = "8.13.0" 3 | gradleKtlint = "13.1.0" 4 | gradleNexusPublish = "2.0.0" 5 | kotlin = "1.9.25" 6 | appCompat = "1.7.1" 7 | coreKtx = "1.9.0" 8 | gson = "2.13.2" 9 | junit = "4.13.2" 10 | kotlinxCoroutines = "1.8.0" 11 | mockk = "1.13.13" 12 | okhttp = "4.12.0" 13 | robolectric = "4.16" 14 | slf4jSimple = "2.0.17" 15 | truth = "1.4.5" 16 | wiremock = "3.0.1" 17 | ktor = "3.0.0" 18 | 19 | [libraries] 20 | appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompat" } 21 | core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } 22 | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } 23 | junit = { module = "junit:junit", version.ref = "junit" } 24 | kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } 25 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 26 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 27 | ktor-server-test-host = {module = "io.ktor:ktor-server-test-host", version.ref = "ktor"} 28 | ktor-server-netty = {module = "io.ktor:ktor-server-netty", version.ref = "ktor"} 29 | mockk = { module = "io.mockk:mockk", version.ref = "mockk" } 30 | mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } 31 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 32 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } 33 | truth = { module = "com.google.truth:truth", version.ref = "truth" } 34 | wiremock = { module = "com.github.tomakehurst:wiremock", version.ref = "wiremock" } 35 | 36 | [plugins] 37 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin"} 38 | android-library = { id = "com.android.library", version.ref = "androidGradlePlugin"} 39 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin"} 40 | gradle-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "gradleKtlint"} 41 | gradle-nexus-publish = {id = "io.github.gradle-nexus.publish-plugin", version.ref = "gradleNexusPublish"} 42 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigActivityLifecycleListener.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.os.Bundle 6 | 7 | internal interface LifecycleEventListener { 8 | fun onAppFocus() 9 | fun onAppBlur() 10 | } 11 | 12 | internal class StatsigActivityLifecycleListener( 13 | private val application: Application, 14 | private val listener: LifecycleEventListener 15 | ) : Application.ActivityLifecycleCallbacks { 16 | 17 | private var currentActivity: Activity? = null 18 | private var resumed = 0 19 | private var paused = 0 20 | private var started = 0 21 | private var stopped = 0 22 | 23 | init { 24 | application.registerActivityLifecycleCallbacks(this) 25 | } 26 | 27 | fun shutdown() { 28 | application.unregisterActivityLifecycleCallbacks(this) 29 | } 30 | 31 | fun getCurrentActivity(): Activity? = currentActivity 32 | 33 | override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { 34 | currentActivity = activity 35 | } 36 | 37 | override fun onActivityStarted(activity: Activity) { 38 | ++started 39 | currentActivity = activity 40 | } 41 | 42 | override fun onActivityResumed(activity: Activity) { 43 | currentActivity = activity 44 | ++resumed 45 | listener.onAppFocus() 46 | } 47 | 48 | override fun onActivityPaused(activity: Activity) { 49 | ++paused 50 | if (!this.isApplicationInForeground()) { // app is entering background 51 | listener.onAppBlur() 52 | } 53 | } 54 | 55 | override fun onActivityStopped(activity: Activity) { 56 | ++stopped 57 | currentActivity = null 58 | if (!this.isApplicationVisible()) { 59 | listener.onAppBlur() 60 | } 61 | } 62 | 63 | private fun isApplicationVisible(): Boolean = started > stopped 64 | 65 | private fun isApplicationInForeground(): Boolean = resumed > paused 66 | 67 | override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { 68 | } 69 | 70 | override fun onActivityDestroyed(activity: Activity) { 71 | currentActivity = null 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigMetadata.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.os.Build 4 | import com.google.gson.annotations.SerializedName 5 | import java.util.* 6 | 7 | data class StatsigMetadata( 8 | @SerializedName("stableID") var stableID: String? = null, 9 | @SerializedName("sdkType") var sdkType: String? = "android-client", 10 | @SerializedName("sdkVersion") var sdkVersion: String? = BuildConfig.VERSION_NAME, 11 | @SerializedName("sessionID") var sessionID: String = UUID.randomUUID().toString(), 12 | @SerializedName("appIdentifier") var appIdentifier: String? = null, 13 | @SerializedName("appVersion") var appVersion: String? = null, 14 | @SerializedName("deviceModel") var deviceModel: String? = null, 15 | @SerializedName("deviceOS") var deviceOS: String? = null, 16 | @SerializedName("locale") var locale: String? = null, 17 | @SerializedName("language") var language: String? = null, 18 | @SerializedName("systemVersion") var systemVersion: String? = null, 19 | @SerializedName("systemName") var systemName: String? = null 20 | ) { 21 | internal fun overrideStableID(overrideStableID: String?) { 22 | if (overrideStableID != null && overrideStableID != stableID) { 23 | stableID = overrideStableID 24 | } 25 | } 26 | } 27 | 28 | internal fun createStatsigMetadata(): StatsigMetadata = StatsigMetadata( 29 | stableID = null, 30 | sdkType = "android-client", 31 | sdkVersion = BuildConfig.VERSION_NAME, 32 | sessionID = UUID.randomUUID().toString(), 33 | appIdentifier = null, 34 | appVersion = null, 35 | deviceModel = Build.MODEL, 36 | deviceOS = "Android", 37 | locale = Locale.getDefault().toString(), 38 | language = Locale.getDefault().toLanguageTag(), 39 | systemVersion = Build.VERSION.SDK_INT.toString(), 40 | systemName = "Android" 41 | ) 42 | 43 | internal fun createCoreStatsigMetadata(): StatsigMetadata = StatsigMetadata( 44 | stableID = null, 45 | sdkType = "android-client", 46 | sdkVersion = BuildConfig.VERSION_NAME, 47 | sessionID = UUID.randomUUID().toString(), 48 | appIdentifier = null, 49 | appVersion = null, 50 | deviceModel = null, 51 | deviceOS = null, 52 | locale = null, 53 | language = null, 54 | systemVersion = null, 55 | systemName = null 56 | ) 57 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigUtil.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.content.SharedPreferences 4 | import androidx.core.content.edit 5 | import com.google.gson.Gson 6 | import com.google.gson.GsonBuilder 7 | import com.google.gson.ToNumberPolicy 8 | import kotlinx.coroutines.withContext 9 | 10 | internal object StatsigUtil { 11 | private val dispatcherProvider = CoroutineDispatcherProvider() 12 | private val gson by lazy { 13 | GsonBuilder() 14 | .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) 15 | .create() 16 | } 17 | 18 | fun normalizeUser(user: Map?): Map? { 19 | if (user == null) { 20 | return null 21 | } 22 | return user.filterValues { value -> 23 | if (value is Array<*>) { 24 | value.size == (value.filter { it is String }).size 25 | } else { 26 | value is String || 27 | value is Boolean || 28 | value is Double 29 | } 30 | } 31 | } 32 | 33 | internal fun syncGetFromSharedPrefs(sharedPrefs: SharedPreferences?, key: String): String? { 34 | if (sharedPrefs == null) { 35 | return null 36 | } 37 | return try { 38 | sharedPrefs.getString(key, null) 39 | } catch (e: ClassCastException) { 40 | null 41 | } 42 | } 43 | 44 | internal suspend fun saveStringToSharedPrefs( 45 | sharedPrefs: SharedPreferences?, 46 | key: String, 47 | value: String 48 | ) { 49 | if (sharedPrefs == null) { 50 | return 51 | } 52 | withContext(dispatcherProvider.io) { 53 | sharedPrefs.edit { 54 | putString(key, value) 55 | } 56 | } 57 | } 58 | 59 | internal suspend fun removeFromSharedPrefs(sharedPrefs: SharedPreferences?, key: String) { 60 | if (sharedPrefs == null) { 61 | return 62 | } 63 | withContext(dispatcherProvider.io) { 64 | sharedPrefs.edit { 65 | remove(key) 66 | } 67 | } 68 | } 69 | 70 | internal suspend fun getFromSharedPrefs(sharedPrefs: SharedPreferences?, key: String): String? { 71 | if (sharedPrefs == null) { 72 | return null 73 | } 74 | return withContext(dispatcherProvider.io) { 75 | return@withContext try { 76 | sharedPrefs.getString(key, null) 77 | } catch (e: ClassCastException) { 78 | removeFromSharedPrefs(sharedPrefs, key) 79 | null 80 | } 81 | } 82 | } 83 | 84 | internal fun getOrBuildGson(): Gson = gson 85 | } 86 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/DiagnosticsTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.google.gson.Gson 6 | import com.google.gson.reflect.TypeToken 7 | import junit.framework.TestCase.assertEquals 8 | import kotlinx.coroutines.runBlocking 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | import org.robolectric.RuntimeEnvironment 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class DiagnosticsTest { 17 | private lateinit var app: Application 18 | private lateinit var sharedPrefs: SharedPreferences 19 | private lateinit var network: StatsigNetwork 20 | private var client: StatsigClient = StatsigClient() 21 | private val user: StatsigUser = StatsigUser("testUser") 22 | private val logEvents: MutableList = mutableListOf() 23 | 24 | @Before 25 | fun setup() = runBlocking { 26 | app = RuntimeEnvironment.getApplication() 27 | sharedPrefs = TestUtil.getTestSharedPrefs(app) 28 | TestUtil.mockHashing() 29 | TestUtil.mockDispatchers() 30 | client = StatsigClient() 31 | network = TestUtil.mockNetwork(onLog = { 32 | logEvents.add(it) 33 | }) 34 | client.statsigNetwork = network 35 | } 36 | 37 | private fun enforceDiagnosticsSample() { 38 | val diagnosticsField = client::class.java.getDeclaredField("diagnostics") 39 | diagnosticsField.isAccessible = true 40 | val diagnostics = diagnosticsField[client] as Diagnostics 41 | val maxMarkersField = diagnostics::class.java.getDeclaredField("maxMarkers") 42 | maxMarkersField.isAccessible = true 43 | } 44 | 45 | private fun getMarkers(log: LogEvent): List { 46 | val listType = object : TypeToken>() {}.type 47 | return Gson().fromJson(log.metadata?.get("markers") ?: "", listType) 48 | } 49 | 50 | @Test 51 | fun testInitialize() { 52 | val options = StatsigOptions( 53 | api = "http://statsig.api", 54 | initializeValues = mapOf(), 55 | initTimeoutMs = 10000 56 | ) 57 | runBlocking { 58 | client.initialize(app, "client-key", StatsigUser("test-user"), options) 59 | client.shutdown() 60 | } 61 | val optionsLoggingCopy: Map = Gson().fromJson( 62 | logEvents[0].events[0].metadata?.get("statsigOptions"), 63 | object : TypeToken>() {}.type 64 | ) 65 | assertEquals(optionsLoggingCopy["api"], "http://statsig.api") 66 | assertEquals(optionsLoggingCopy["initializeValues"], "SET") 67 | assertEquals(optionsLoggingCopy["initTimeoutMs"], 10000.0) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/LogEventRetryTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import com.google.gson.Gson 5 | import kotlinx.coroutines.runBlocking 6 | import okhttp3.mockwebserver.Dispatcher 7 | import okhttp3.mockwebserver.MockResponse 8 | import okhttp3.mockwebserver.MockWebServer 9 | import okhttp3.mockwebserver.RecordedRequest 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.robolectric.RobolectricTestRunner 14 | import org.robolectric.RuntimeEnvironment 15 | 16 | @RunWith(RobolectricTestRunner::class) 17 | class LogEventRetryTest { 18 | private lateinit var mockWebServer: MockWebServer 19 | private var logEventHits = 0 20 | private val gson = Gson() 21 | private val app: Application = RuntimeEnvironment.getApplication() 22 | private var enforceLogEventException = false 23 | 24 | @Before 25 | fun setup() { 26 | logEventHits = 0 27 | TestUtil.mockDispatchers() 28 | mockWebServer = MockWebServer() 29 | val dispatcher = object : Dispatcher() { 30 | override fun dispatch(request: RecordedRequest): MockResponse { 31 | return if (request.path!!.contains("initialize")) { 32 | MockResponse() 33 | .setBody(gson.toJson(TestUtil.makeInitializeResponse())) 34 | .setResponseCode(200) 35 | } else if (request.path!!.contains("log_event")) { 36 | logEventHits++ 37 | val logEventStatusCode = if (logEventHits >= 2) 404 else 599 38 | val response = MockResponse().setResponseCode(logEventStatusCode) 39 | if (!enforceLogEventException) { 40 | response.setBody("err") 41 | } 42 | return response 43 | } else { 44 | MockResponse().setResponseCode(404) 45 | } 46 | } 47 | } 48 | mockWebServer.dispatcher = dispatcher 49 | mockWebServer.start() 50 | } 51 | 52 | @Test 53 | fun testRetryOnRetryCode() = runBlocking { 54 | val url = mockWebServer.url("/v1").toString() 55 | Statsig.initialize( 56 | app, 57 | "client-key", 58 | StatsigUser("test"), 59 | StatsigOptions(api = url, eventLoggingAPI = url) 60 | ) 61 | Statsig.logEvent("test-event1") 62 | Statsig.shutdown() 63 | assert(logEventHits == 2) 64 | } 65 | 66 | @Test 67 | fun testNoRetryOnException() = runBlocking { 68 | val url = mockWebServer.url("/v1").toString() 69 | Statsig.initialize( 70 | app, 71 | "client-key", 72 | StatsigUser("test"), 73 | StatsigOptions(api = url, eventLoggingAPI = url) 74 | ) 75 | enforceLogEventException = true 76 | Statsig.logEvent("test") 77 | Statsig.shutdown() 78 | assert(logEventHits == 1) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/InitializationTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.test.TestScope 8 | import kotlinx.coroutines.test.runTest 9 | import okhttp3.mockwebserver.Dispatcher 10 | import okhttp3.mockwebserver.MockResponse 11 | import okhttp3.mockwebserver.MockWebServer 12 | import okhttp3.mockwebserver.RecordedRequest 13 | import org.junit.After 14 | import org.junit.Before 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | import org.robolectric.RobolectricTestRunner 18 | import org.robolectric.RuntimeEnvironment 19 | 20 | @RunWith(RobolectricTestRunner::class) 21 | class InitializationTest { 22 | private lateinit var dispatcher: CoroutineDispatcher 23 | private lateinit var app: Application 24 | private lateinit var mockWebServer: MockWebServer 25 | private var user: StatsigUser = StatsigUser("test-user") 26 | private lateinit var testSharedPrefs: SharedPreferences 27 | private var initializationHits = 0 28 | 29 | @OptIn(ExperimentalCoroutinesApi::class) 30 | @Before 31 | internal fun setup() { 32 | mockWebServer = MockWebServer() 33 | 34 | dispatcher = TestUtil.mockDispatchers() 35 | app = RuntimeEnvironment.getApplication() 36 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 37 | TestUtil.mockHashing() 38 | 39 | initializationHits = 0 40 | val dispatcher = object : Dispatcher() { 41 | override fun dispatch(request: RecordedRequest): MockResponse { 42 | return if (request.path!!.contains("initialize")) { 43 | ++initializationHits 44 | return MockResponse().setResponseCode(408) 45 | } else { 46 | MockResponse().setResponseCode(200) 47 | } 48 | } 49 | } 50 | mockWebServer.dispatcher = dispatcher 51 | mockWebServer.start() 52 | } 53 | 54 | @After 55 | fun tearDown() { 56 | mockWebServer.shutdown() 57 | } 58 | 59 | @OptIn(ExperimentalCoroutinesApi::class) 60 | @Test 61 | fun testDefaultInitialization() = runTest(dispatcher) { 62 | val options = StatsigOptions(api = mockWebServer.url("/v1").toString()) 63 | val client = StatsigClient() 64 | client.statsigScope = TestScope(dispatcher) 65 | client.initialize(app, "client-key", user, options) 66 | assert(initializationHits == 1) 67 | client.shutdown() 68 | } 69 | 70 | @OptIn(ExperimentalCoroutinesApi::class) 71 | @Test 72 | fun testRetry() = runTest(dispatcher) { 73 | val options = 74 | StatsigOptions( 75 | api = mockWebServer.url("/v1").toString(), 76 | initRetryLimit = 2, 77 | initTimeoutMs = 10000L 78 | ) 79 | val client = StatsigClient() 80 | 81 | client.initialize(app, "client-key", user, options) 82 | assert(initializationHits == 3) 83 | client.shutdown() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/InitializeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import java.lang.Exception 5 | 6 | enum class InitializeFailReason { 7 | CoroutineTimeout, 8 | NetworkTimeout, 9 | NetworkError, 10 | InternalError 11 | } 12 | 13 | sealed class InitializeResponse { 14 | data class FailedInitializeResponse( 15 | @SerializedName("reason") val reason: InitializeFailReason, 16 | @SerializedName("exception") val exception: Exception? = null, 17 | @SerializedName("statusCode") val statusCode: Int? = null 18 | ) : InitializeResponse() 19 | internal data class SuccessfulInitializeResponse( 20 | @SerializedName("feature_gates") val featureGates: Map?, 21 | @SerializedName("dynamic_configs") val configs: Map?, 22 | @SerializedName("layer_configs") var layerConfigs: Map?, 23 | @SerializedName("has_updates") val hasUpdates: Boolean, 24 | @SerializedName("hash_used") val hashUsed: HashAlgorithm? = null, 25 | @SerializedName("time") val time: Long, 26 | @SerializedName("derived_fields") val derivedFields: Map?, 27 | @SerializedName( 28 | "param_stores" 29 | ) val paramStores: Map>>? = 30 | null, 31 | @SerializedName("full_checksum") val fullChecksum: String? = null, 32 | @SerializedName("sdk_flags") val sdkFlags: Map? = null 33 | ) : InitializeResponse() 34 | } 35 | 36 | internal data class APIFeatureGate( 37 | @SerializedName("name") val name: String, 38 | @SerializedName("value") val value: Boolean = false, 39 | @SerializedName("rule_id") val ruleID: String = "", 40 | @SerializedName("group_name") val groupName: String? = null, 41 | @SerializedName("secondary_exposures") val secondaryExposures: Array>? = 42 | arrayOf(), 43 | @SerializedName("id_type") val idType: String? = null 44 | ) 45 | 46 | internal data class APIDynamicConfig( 47 | @SerializedName("name") val name: String, 48 | @SerializedName("value") val value: Map, 49 | @SerializedName("rule_id") val ruleID: String = "", 50 | @SerializedName("group_name") val groupName: String? = null, 51 | @SerializedName("secondary_exposures") val secondaryExposures: Array>? = 52 | arrayOf(), 53 | @SerializedName("undelegated_secondary_exposures") val undelegatedSecondaryExposures: 54 | Array>? = arrayOf(), 55 | @SerializedName("is_device_based") val isDeviceBased: Boolean = false, 56 | @SerializedName("is_user_in_experiment") val isUserInExperiment: Boolean = false, 57 | @SerializedName("is_experiment_active") val isExperimentActive: Boolean = false, 58 | @SerializedName("allocated_experiment_name") val allocatedExperimentName: String? = null, 59 | @SerializedName("explicit_parameters") val explicitParameters: Array? = arrayOf(), 60 | @SerializedName("passed") val rulePassed: Boolean? = null, 61 | @SerializedName("parameter_rule_ids") val parameterRuleIDs: Map? = null 62 | ) 63 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/DnsTxtQuery.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import java.io.ByteArrayOutputStream 4 | import java.net.HttpURLConnection 5 | import java.net.URL 6 | import java.nio.charset.StandardCharsets 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | 10 | val FEATURE_ASSETS_DNS_QUERY = byteArrayOf( 11 | 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 12 | 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 13 | 0x03, 0x6f, 0x72, 0x67, 0x00, 0x00, 0x10, 0x00, 0x01 14 | ) 15 | 16 | const val DNS_QUERY_ENDPOINT = "https://cloudflare-dns.com/dns-query" 17 | 18 | val DOMAIN_CHARS = listOf('i', 'e', 'd') // valid domain characters: 'i', 'e', 'd' 19 | const val MAX_START_LOOKUP = 200 20 | 21 | private val coroutineDispatcherProvider by lazy { 22 | CoroutineDispatcherProvider() 23 | } 24 | 25 | suspend fun fetchTxtRecords( 26 | urlConnectionProvider: UrlConnectionProvider = defaultProvider 27 | ): List = withContext(coroutineDispatcherProvider.io) { 28 | val connection = createHttpConnection(DNS_QUERY_ENDPOINT, urlConnectionProvider) 29 | 30 | try { 31 | connection.outputStream.use { outputStream -> 32 | val byteArray = ByteArrayOutputStream().apply { 33 | write(FEATURE_ASSETS_DNS_QUERY) 34 | }.toByteArray() 35 | outputStream.write(byteArray) 36 | } 37 | 38 | if (connection.responseCode != HttpURLConnection.HTTP_OK) { 39 | throw DnsTxtFetchError("Failed to fetch TXT records from DNS") 40 | } 41 | 42 | val inputStream = connection.inputStream 43 | 44 | val bytes = inputStream.readBytes() 45 | return@withContext parseDnsResponse(bytes) 46 | } catch (e: Exception) { 47 | throw DnsTxtFetchError("Request timed out while fetching TXT records") 48 | } finally { 49 | connection.disconnect() 50 | } 51 | } 52 | 53 | private fun createHttpConnection( 54 | url: String, 55 | urlConnectionProvider: UrlConnectionProvider = defaultProvider 56 | ): HttpURLConnection { 57 | val connection = urlConnectionProvider.open(URL(url))as HttpURLConnection 58 | connection.apply { 59 | requestMethod = "POST" 60 | setRequestProperty("Content-Type", "application/dns-message") 61 | setRequestProperty("Accept", "application/dns-message") 62 | doOutput = true 63 | // connectTimeout = TimeUnit.SECONDS.toMillis(10).toInt() 64 | // readTimeout = TimeUnit.SECONDS.toMillis(10).toInt() 65 | } 66 | return connection 67 | } 68 | 69 | fun parseDnsResponse(input: ByteArray): List { 70 | val startIndex = input.withIndex().indexOfFirst { (index, byte) -> 71 | index < MAX_START_LOOKUP && 72 | byte.toInt().toChar() == '=' && 73 | index > 0 && DOMAIN_CHARS.contains(input[index - 1].toInt().toChar()) 74 | } 75 | 76 | if (startIndex == -1) { 77 | throw DnsTxtParseError("Failed to parse TXT records from DNS") 78 | } 79 | 80 | val result = String(input.copyOfRange(startIndex - 1, input.size), StandardCharsets.UTF_8) 81 | return result.split(",") 82 | } 83 | 84 | class DnsTxtFetchError(message: String) : Exception(message) 85 | class DnsTxtParseError(message: String) : Exception(message) 86 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/DiagnosticData.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.annotations.SerializedName 4 | /* 5 | Interface 6 | * */ 7 | data class Marker( 8 | @SerializedName("key") val key: KeyType? = null, 9 | @SerializedName("action") val action: ActionType? = null, 10 | @SerializedName("timestamp") val timestamp: Double? = null, 11 | @SerializedName("step") var step: StepType? = null, 12 | @SerializedName("statusCode") var statusCode: Int? = null, 13 | @SerializedName("success") var success: Boolean? = null, 14 | @SerializedName("url") var url: String? = null, 15 | @SerializedName("idListCount") var idListCount: Int? = null, 16 | @SerializedName("reason") var reason: String? = null, 17 | @SerializedName("sdkRegion") var sdkRegion: String? = null, 18 | @SerializedName("markerID") var markerID: String? = null, 19 | @SerializedName("attempt") var attempt: Int? = null, 20 | @SerializedName("isRetry") var isRetry: Boolean? = null, 21 | @SerializedName("isDelta") var isDelta: Boolean? = null, 22 | @SerializedName("configName") var configName: String? = null, 23 | @SerializedName("evaluationDetails") var evaluationDetails: EvaluationDetails? = null, 24 | @SerializedName("error") var error: ErrorMessage? = null, 25 | @SerializedName("hasNetwork") var hasNetwork: Boolean? = null, 26 | @SerializedName("timeoutMS") var timeoutMS: Int? = null, 27 | @SerializedName("isBlocking") var isBlocking: Boolean? = null 28 | ) { 29 | data class ErrorMessage( 30 | @SerializedName("message") val message: String? = null, 31 | @SerializedName("name") val name: String? = null, 32 | @SerializedName("code") val code: String? = null 33 | ) 34 | } 35 | 36 | enum class ContextType { 37 | @SerializedName("initialize") 38 | INITIALIZE, 39 | 40 | @SerializedName("update_user") 41 | UPDATE_USER 42 | } 43 | 44 | enum class KeyType { 45 | @SerializedName("initialize") 46 | INITIALIZE, 47 | 48 | @SerializedName("bootstrap") 49 | BOOTSTRAP, 50 | 51 | @SerializedName("overall") 52 | OVERALL, 53 | 54 | @SerializedName("check_gate") 55 | CHECK_GATE, 56 | 57 | @SerializedName("get_config") 58 | GET_CONFIG, 59 | 60 | @SerializedName("get_experiment") 61 | GET_EXPERIMENT, 62 | 63 | @SerializedName("get_layer") 64 | GET_LAYER, 65 | 66 | @SerializedName("retry_failed_log") 67 | RETRY_FAILED_LOG 68 | 69 | ; 70 | 71 | companion object { 72 | fun convertFromString(value: String): KeyType? = when (value) { 73 | in "checkGate" -> 74 | KeyType.CHECK_GATE 75 | in "getExperiment" -> 76 | KeyType.GET_EXPERIMENT 77 | in "getConfig" -> 78 | KeyType.GET_CONFIG 79 | in "getLayer" -> 80 | KeyType.GET_LAYER 81 | else -> 82 | null 83 | } 84 | } 85 | } 86 | 87 | enum class StepType { 88 | @SerializedName("process") 89 | PROCESS, 90 | 91 | @SerializedName("network_request") 92 | NETWORK_REQUEST, 93 | 94 | @SerializedName("load_cache") 95 | LOAD_CACHE 96 | } 97 | 98 | enum class ActionType { 99 | @SerializedName("start") 100 | START, 101 | 102 | @SerializedName("end") 103 | END 104 | } 105 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigLongInitializationTimeoutTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import io.mockk.every 5 | import io.mockk.spyk 6 | import java.util.concurrent.CountDownLatch 7 | import java.util.concurrent.TimeUnit 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.runBlocking 11 | import okhttp3.mockwebserver.Dispatcher 12 | import okhttp3.mockwebserver.MockResponse 13 | import okhttp3.mockwebserver.MockWebServer 14 | import okhttp3.mockwebserver.RecordedRequest 15 | import org.junit.After 16 | import org.junit.Assert.assertTrue 17 | import org.junit.Before 18 | import org.junit.Test 19 | import org.junit.runner.RunWith 20 | import org.robolectric.RobolectricTestRunner 21 | import org.robolectric.RuntimeEnvironment 22 | 23 | @RunWith(RobolectricTestRunner::class) 24 | class StatsigLongInitializationTimeoutTest { 25 | 26 | private var app: Application = RuntimeEnvironment.getApplication() 27 | private lateinit var client: StatsigClient 28 | private lateinit var errorBoundary: ErrorBoundary 29 | private lateinit var mockWebServer: MockWebServer 30 | private var initializeHits = 0 31 | 32 | @OptIn(ExperimentalCoroutinesApi::class) 33 | @Before 34 | fun setup() { 35 | mockWebServer = MockWebServer() 36 | val dispatcher = object : Dispatcher() { 37 | override fun dispatch(request: RecordedRequest): MockResponse = 38 | if (request.path!!.contains("initialize")) { 39 | initializeHits++ 40 | runBlocking { 41 | delay(500) 42 | } 43 | MockResponse() 44 | .setBody("{\"result\":\"error logged\"}") 45 | .setResponseCode(503) 46 | } else { 47 | MockResponse().setResponseCode(404) 48 | } 49 | } 50 | mockWebServer.dispatcher = dispatcher 51 | mockWebServer.start() 52 | client = spyk(StatsigClient(), recordPrivateCalls = true) 53 | client.errorBoundary = spyk(client.errorBoundary) 54 | errorBoundary = client.errorBoundary 55 | 56 | TestUtil.mockDispatchers() 57 | 58 | every { 59 | errorBoundary.getUrl() 60 | } returns mockWebServer.url("/v1/sdk_exception").toString() 61 | 62 | client.errorBoundary = errorBoundary 63 | } 64 | 65 | @After 66 | fun tearDown() { 67 | mockWebServer.shutdown() 68 | } 69 | 70 | @Test 71 | fun testInitializeAsyncWithSlowErrorBoundary() = runBlocking { 72 | val initTimeout = 10_000L // ms 73 | val latch = CountDownLatch(1) 74 | 75 | client.initializeAsync( 76 | app, 77 | "client-key", 78 | StatsigUser("test_user"), 79 | object : IStatsigCallback { 80 | override fun onStatsigInitialize(initDetails: InitializationDetails) { 81 | latch.countDown() 82 | } 83 | override fun onStatsigUpdateUser() {} 84 | }, 85 | StatsigOptions(initTimeoutMs = initTimeout, api = mockWebServer.url("/").toString()) 86 | ) 87 | latch.await(10, TimeUnit.SECONDS) 88 | 89 | assert(client.isInitialized()) 90 | assertTrue(initializeHits == 1) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/OnDeviceEvalAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.util.Log 4 | import com.google.gson.Gson 5 | import com.statsig.androidsdk.evaluator.Evaluator 6 | import com.statsig.androidsdk.evaluator.SpecStore 7 | import com.statsig.androidsdk.evaluator.SpecsResponse 8 | 9 | class OnDeviceEvalAdapter(private val data: String?) { 10 | private companion object { 11 | private const val TAG: String = "statsig::OnDeviceEval" 12 | } 13 | private val store = SpecStore() 14 | private val evaluator = Evaluator(store) 15 | 16 | init { 17 | data?.let { setData(it) } 18 | } 19 | 20 | fun setData(data: String) { 21 | val specs: SpecsResponse = try { 22 | StatsigUtil.getOrBuildGson().fromJson(data, SpecsResponse::class.java) 23 | } catch (e: Exception) { 24 | Log.e(TAG, "Failed to parse specs from data string") 25 | return 26 | } 27 | 28 | store.setSpecs(specs) 29 | } 30 | 31 | fun getGate(current: FeatureGate, user: StatsigUser): FeatureGate? { 32 | if (!shouldTryOnDeviceEvaluation(current.getEvaluationDetails())) { 33 | return null 34 | } 35 | 36 | val gateName = current.getName() 37 | val evaluation = evaluator.evaluateGate(gateName, user) 38 | val details = getEvaluationDetails(evaluation.isUnrecognized) 39 | 40 | return FeatureGate(gateName, evaluation, details) 41 | } 42 | 43 | fun getDynamicConfig(current: DynamicConfig, user: StatsigUser): DynamicConfig? { 44 | if (!shouldTryOnDeviceEvaluation(current.getEvaluationDetails())) { 45 | return null 46 | } 47 | 48 | val configName = current.getName() 49 | val evaluation = evaluator.evaluateConfig(configName, user) 50 | val details = getEvaluationDetails(evaluation.isUnrecognized) 51 | 52 | return DynamicConfig(configName, evaluation, details) 53 | } 54 | 55 | fun getLayer(client: StatsigClient?, current: Layer, user: StatsigUser): Layer? { 56 | if (!shouldTryOnDeviceEvaluation(current.getEvaluationDetails())) { 57 | return null 58 | } 59 | 60 | val layerName = current.getName() 61 | val evaluation = evaluator.evaluateLayer(layerName, user) 62 | val details = getEvaluationDetails(evaluation.isUnrecognized) 63 | 64 | return Layer(client, layerName, evaluation, details) 65 | } 66 | 67 | fun getParamStore(client: StatsigClient, current: ParameterStore): ParameterStore? { 68 | if (!shouldTryOnDeviceEvaluation(current.evaluationDetails)) { 69 | return null 70 | } 71 | 72 | val spec = store.getParamStore(current.name) 73 | val details = getEvaluationDetails(spec == null) 74 | 75 | return ParameterStore(client, spec?.parameters ?: mapOf(), current.name, details, null) 76 | } 77 | 78 | private fun shouldTryOnDeviceEvaluation(details: EvaluationDetails): Boolean { 79 | val specs = store.getRawSpecs() ?: return false 80 | return specs.time > details.lcut 81 | } 82 | 83 | private fun getEvaluationDetails(isUnrecognized: Boolean): EvaluationDetails { 84 | val lcut = store.getLcut() ?: 0 85 | if (isUnrecognized) { 86 | return EvaluationDetails( 87 | EvaluationReason.OnDeviceEvalAdapterBootstrapUnrecognized, 88 | lcut = lcut 89 | ) 90 | } 91 | 92 | return EvaluationDetails( 93 | EvaluationReason.OnDeviceEvalAdapterBootstrapRecognized, 94 | lcut = lcut 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/StatsigUser.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.annotations.SerializedName 5 | 6 | internal const val STATSIG_NULL_USER: String = "Statsig.NULL_USER" 7 | 8 | /** 9 | * An object of properties relating to the current user 10 | * Provide as many as possible to take advantage of advanced conditions in the Statsig console 11 | * A dictionary of additional fields can be provided under the "custom" field 12 | * @property userID a unique identifier for the user 13 | * @property email an email associated with the current user 14 | * @property ip the ip address of the requests for the user 15 | * @property userAgent the user agent of the requests for this user 16 | * @property country the country location of the user 17 | * @property locale the locale for the user 18 | * @property appVersion the current version of the app 19 | * @property custom any additional custom user attributes for custom conditions in the console 20 | * NOTE: values other than String, Double, Boolean, Array 21 | * will be dropped from the map 22 | * @property privateAttributes any user attributes that should be used in evaluation only and removed in any logs. 23 | */ 24 | data class StatsigUser( 25 | @SerializedName("userID") 26 | var userID: String? = null 27 | ) { 28 | @SerializedName("email") 29 | var email: String? = null 30 | 31 | @SerializedName("ip") 32 | var ip: String? = null 33 | 34 | @SerializedName("userAgent") 35 | var userAgent: String? = null 36 | 37 | @SerializedName("country") 38 | var country: String? = null 39 | 40 | @SerializedName("locale") 41 | var locale: String? = null 42 | 43 | @SerializedName("appVersion") 44 | var appVersion: String? = null 45 | 46 | @SerializedName("custom") 47 | var custom: Map? = null 48 | 49 | @SerializedName("privateAttributes") 50 | var privateAttributes: Map? = null 51 | 52 | @SerializedName("customIDs") 53 | var customIDs: Map? = null 54 | 55 | @SerializedName("statsigEnvironment") 56 | internal var statsigEnvironment: Map? = null 57 | 58 | internal fun getCopyForEvaluation(): StatsigUser { 59 | val userCopy = StatsigUser(userID) 60 | userCopy.email = email 61 | userCopy.ip = ip 62 | userCopy.userAgent = userAgent 63 | userCopy.country = country 64 | userCopy.locale = locale 65 | userCopy.appVersion = appVersion 66 | userCopy.custom = custom?.toMap() 67 | userCopy.statsigEnvironment = statsigEnvironment?.toMap() 68 | userCopy.customIDs = customIDs?.toMap() 69 | userCopy.privateAttributes = privateAttributes?.toMap() 70 | return userCopy 71 | } 72 | 73 | internal fun getCopyForLogging(): StatsigUser { 74 | val userCopy = StatsigUser(userID) 75 | userCopy.email = email 76 | userCopy.ip = ip 77 | userCopy.userAgent = userAgent 78 | userCopy.country = country 79 | userCopy.locale = locale 80 | userCopy.appVersion = appVersion 81 | userCopy.custom = custom 82 | userCopy.statsigEnvironment = statsigEnvironment 83 | userCopy.customIDs = customIDs 84 | // DO NOT copy privateAttributes to the logging copy! 85 | userCopy.privateAttributes = null 86 | 87 | return userCopy 88 | } 89 | 90 | fun getCacheKey(): String { 91 | var id = userID ?: STATSIG_NULL_USER 92 | 93 | for ((k, v) in customIDs ?: mapOf()) { 94 | id = "$id$k:$v" 95 | } 96 | return id 97 | } 98 | 99 | internal fun toHashString(gson: Gson): String = 100 | Hashing.getHashedString(gson.toJson(this), HashAlgorithm.DJB2) 101 | } 102 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigUtilTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.util.Base64 4 | import io.mockk.every 5 | import io.mockk.mockkStatic 6 | import io.mockk.slot 7 | import io.mockk.unmockkAll 8 | import org.junit.After 9 | import org.junit.Assert.* 10 | import org.junit.Before 11 | import org.junit.Test 12 | 13 | class StatsigUtilTest { 14 | 15 | @Before 16 | fun `Bypass android_util_Base64 to java_util_Base64`() { 17 | mockkStatic(Base64::class) 18 | val arraySlot = slot() 19 | 20 | every { 21 | Base64.encodeToString(capture(arraySlot), Base64.NO_WRAP) 22 | } answers { 23 | java.util.Base64.getEncoder().encodeToString(arraySlot.captured) 24 | } 25 | 26 | val stringSlot = slot() 27 | every { 28 | Base64.decode(capture(stringSlot), Base64.DEFAULT) 29 | } answers { 30 | java.util.Base64.getDecoder().decode(stringSlot.captured) 31 | } 32 | } 33 | 34 | @After 35 | internal fun tearDown() { 36 | unmockkAll() 37 | } 38 | 39 | @Test 40 | fun normalizeUser() { 41 | assertNull(StatsigUtil.normalizeUser(null)) 42 | 43 | assertEquals(0, StatsigUtil.normalizeUser(mapOf())!!.size) 44 | 45 | val inputMap: Map = mapOf( 46 | "testString" to "test", 47 | "testBoolean" to true, 48 | "testInt" to 12, 49 | "testDouble" to 42.3, 50 | "testLong" to 7L, 51 | "testArray" to arrayOf("one", "two"), 52 | "testIntArray" to intArrayOf(3, 2) 53 | ) 54 | val resultMap = StatsigUtil.normalizeUser(inputMap) 55 | 56 | assertEquals(42.3, resultMap!!.get("testDouble")) 57 | assertFalse(resultMap.containsKey("testInt")) 58 | assertEquals(true, resultMap.get("testBoolean")) 59 | assertEquals("test", resultMap.get("testString")) 60 | assertEquals(42.3, resultMap.get("testDouble")) 61 | assertTrue(resultMap.containsKey("testArray")) 62 | assertFalse(resultMap.containsKey("testIntArray")) 63 | } 64 | 65 | @Test 66 | fun testHashing() { 67 | assertEquals( 68 | "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", 69 | Hashing.getHashedString("test", null) 70 | ) 71 | assertEquals( 72 | "7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=", 73 | Hashing.getHashedString("test123", null) 74 | ) 75 | } 76 | 77 | @Test 78 | fun testUserHashing() { 79 | val gson = StatsigUtil.getOrBuildGson() 80 | val userA = gson.fromJson( 81 | "{\"userID\":\"userA\",\"email\":\"userA@gmail.com\",\"country\":\"US\"}", 82 | StatsigUser::class.java 83 | ) 84 | userA.statsigEnvironment = mapOf("tier" to "production") 85 | val userB = gson.fromJson( 86 | "{\"userID\":\"userB\",\"email\":\"userB@gmail.com\",\"country\":\"US\"}", 87 | StatsigUser::class.java 88 | ) 89 | val userADifferentEnvironment = userA.getCopyForEvaluation() 90 | userADifferentEnvironment.statsigEnvironment = mapOf("tier" to "staging") 91 | val userADifferentOrder = gson.fromJson( 92 | "{\"userID\":\"userA\",\"country\":\"US\", \"email\":\"userA@gmail.com\"}", 93 | StatsigUser::class.java 94 | ) 95 | userADifferentOrder.statsigEnvironment = mapOf("tier" to "production") 96 | val userAMoreDetails = gson.fromJson( 97 | "{\"userID\":\"userA\",\"email\":\"userA@gmail.com\",\"country\":\"US\",\"userAgent\":\"userAgent\"}", 98 | StatsigUser::class.java 99 | ) 100 | userAMoreDetails.statsigEnvironment = mapOf("tier" to "production") 101 | 102 | assertTrue(userA.toHashString(gson) == userADifferentOrder.toHashString(gson)) 103 | assertTrue(userA.toHashString(gson) != userB.toHashString(gson)) 104 | assertTrue(userA.toHashString(gson) != userADifferentEnvironment.toHashString(gson)) 105 | assertTrue(userA.toHashString(gson) != userAMoreDetails.toHashString(gson)) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/LogEventCompressionTest.ts.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import kotlinx.coroutines.test.TestDispatcher 8 | import kotlinx.coroutines.test.TestScope 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | import org.robolectric.RuntimeEnvironment 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class LogEventCompressionTest { 17 | private lateinit var app: Application 18 | private lateinit var testSharedPrefs: SharedPreferences 19 | 20 | private lateinit var dispatcher: TestDispatcher 21 | 22 | private lateinit var coroutineScope: TestScope 23 | 24 | @Before 25 | internal fun setup() { 26 | app = RuntimeEnvironment.getApplication() 27 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 28 | dispatcher = TestUtil.mockDispatchers() 29 | coroutineScope = TestScope(dispatcher) 30 | 31 | TestUtil.mockHashing() 32 | } 33 | 34 | private fun setupNetwork(store: Store, options: StatsigOptions): StatsigNetworkImpl { 35 | val network = 36 | StatsigNetworkImpl( 37 | app, 38 | "sdk-key", 39 | testSharedPrefs, 40 | options, 41 | mockk(), 42 | coroutineScope, 43 | store, 44 | gson = StatsigUtil.getOrBuildGson() 45 | ) 46 | return network 47 | } 48 | 49 | @Test 50 | fun testOverrideApi() { 51 | val api = "https://google.com/v1" 52 | var config = UrlConfig(Endpoint.Rgstr, api) 53 | val store = mockk() 54 | val network = setupNetwork(store, StatsigOptions(api)) 55 | // Turn off flags 56 | every { 57 | store.getSDKFlags() 58 | } answers { 59 | mapOf("enable_log_event_compression" to false) 60 | } 61 | assert(!network.shouldCompressLogEvent(config, "$api/log_event")) 62 | 63 | // Turn on flags 64 | every { 65 | store.getSDKFlags() 66 | } answers { 67 | mapOf("enable_log_event_compression" to true) 68 | } 69 | assert(network.shouldCompressLogEvent(config, "$api/log_event")) 70 | } 71 | 72 | @Test 73 | fun testDefaultAPI() { 74 | val api = DEFAULT_EVENT_API 75 | var config = UrlConfig(Endpoint.Rgstr, api) 76 | val store = mockk() 77 | val network = setupNetwork(store, StatsigOptions(api)) 78 | // Turn off flags 79 | every { 80 | store.getSDKFlags() 81 | } answers { 82 | mapOf("enable_log_event_compression" to false) 83 | } 84 | assert(network.shouldCompressLogEvent(config, "$api/log_event")) 85 | 86 | // Turn on flags 87 | every { 88 | store.getSDKFlags() 89 | } answers { 90 | mapOf("enable_log_event_compression" to true) 91 | } 92 | assert(network.shouldCompressLogEvent(config, "$api/log_event")) 93 | } 94 | 95 | @Test 96 | fun testDisableCompressionFromOption() { 97 | var api = DEFAULT_EVENT_API 98 | var fallbackUrl = DEFAULT_EVENT_API 99 | var config = UrlConfig(Endpoint.Rgstr, api, listOf(fallbackUrl)) 100 | val store = mockk() 101 | var network = setupNetwork(store, StatsigOptions(api, disableLoggingCompression = true)) 102 | // Turn on flags 103 | every { 104 | store.getSDKFlags() 105 | } answers { 106 | mapOf("enable_log_event_compression" to true) 107 | } 108 | assert(!network.shouldCompressLogEvent(config, api)) 109 | 110 | api = "https://google.com" 111 | fallbackUrl = "https://chatgpt.com" 112 | config = UrlConfig(Endpoint.Rgstr, api, listOf(fallbackUrl)) 113 | network = setupNetwork(store, StatsigOptions(api, disableLoggingCompression = true)) 114 | assert(!network.shouldCompressLogEvent(config, api)) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigNetworkTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.github.tomakehurst.wiremock.client.WireMock.aResponse 6 | import com.github.tomakehurst.wiremock.client.WireMock.equalTo 7 | import com.github.tomakehurst.wiremock.client.WireMock.post 8 | import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor 9 | import com.github.tomakehurst.wiremock.client.WireMock.stubFor 10 | import com.github.tomakehurst.wiremock.client.WireMock.urlMatching 11 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options 12 | import com.github.tomakehurst.wiremock.junit.WireMockRule 13 | import kotlinx.coroutines.runBlocking 14 | import kotlinx.coroutines.test.TestScope 15 | import org.junit.After 16 | import org.junit.Before 17 | import org.junit.Rule 18 | import org.junit.Test 19 | import org.junit.runner.RunWith 20 | import org.robolectric.RobolectricTestRunner 21 | import org.robolectric.RuntimeEnvironment 22 | 23 | @RunWith(RobolectricTestRunner::class) 24 | class StatsigNetworkTest { 25 | // Dynamic port so this test can run in parallel with other wiremock tests 26 | @Rule 27 | @JvmField 28 | val wireMockRule = WireMockRule(options().dynamicPort()) 29 | 30 | private val app: Application = RuntimeEnvironment.getApplication() 31 | private val overrideID: String = "override_id" 32 | private val metadata = StatsigMetadata() 33 | 34 | private val user = StatsigUser() 35 | 36 | private val gson = StatsigUtil.getOrBuildGson() 37 | private lateinit var network: StatsigNetworkImpl 38 | 39 | private lateinit var options: StatsigOptions 40 | private lateinit var fallbackResolver: NetworkFallbackResolver 41 | private lateinit var sharedPreferences: SharedPreferences 42 | 43 | @Before 44 | fun setup() { 45 | val dispatcher = TestUtil.mockDispatchers() 46 | val coroutineScope = TestScope(dispatcher) 47 | TestUtil.mockHashing() 48 | sharedPreferences = TestUtil.getTestSharedPrefs(app) 49 | 50 | stubFor( 51 | post(urlMatching("/initialize")) 52 | .willReturn(aResponse().withStatus(202)) 53 | ) 54 | 55 | options = StatsigOptions(api = wireMockRule.baseUrl()) 56 | fallbackResolver = 57 | NetworkFallbackResolver( 58 | sharedPreferences, 59 | coroutineScope, 60 | gson = gson 61 | ) 62 | 63 | val store = 64 | Store( 65 | coroutineScope, 66 | sharedPreferences, 67 | user, 68 | "client-apikey", 69 | options, 70 | gson = gson 71 | ) 72 | network = 73 | StatsigNetworkImpl( 74 | app, 75 | "client-key", 76 | TestUtil.getTestSharedPrefs(app), 77 | options, 78 | networkResolver = fallbackResolver, 79 | coroutineScope, 80 | store, 81 | gson = gson 82 | ) 83 | } 84 | 85 | @After 86 | fun teardown() { 87 | TestUtil.reset() 88 | } 89 | 90 | @Test 91 | fun initialize_includesStableIDinHeader() { 92 | metadata.overrideStableID(overrideID) 93 | 94 | runBlocking { makeInitializeRequest() } 95 | 96 | wireMockRule.verify( 97 | postRequestedFor( 98 | urlMatching("/initialize") 99 | ).withHeader(STATSIG_STABLE_ID_HEADER_KEY, equalTo(overrideID)) 100 | ) 101 | } 102 | 103 | private suspend fun makeInitializeRequest() { 104 | try { 105 | network.initializeImpl( 106 | wireMockRule.baseUrl(), 107 | user, 108 | null, 109 | metadata, 110 | ContextType.INITIALIZE, 111 | null, 112 | 1, 113 | 50, 114 | HashAlgorithm.NONE, 115 | mapOf(), 116 | null 117 | ) 118 | } catch (e: Exception) { 119 | // noop 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/Diagnostics.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import java.util.Queue 4 | import java.util.concurrent.ConcurrentHashMap 5 | import java.util.concurrent.ConcurrentLinkedQueue 6 | 7 | const val NANO_IN_MS = 1_000_000.0 8 | internal class Diagnostics(val statsigOptionsLoggingCopy: Map) { 9 | companion object { 10 | fun formatFailedResponse( 11 | failResponse: InitializeResponse.FailedInitializeResponse 12 | ): Marker.ErrorMessage { 13 | val name = failResponse.exception?.javaClass?.toString() ?: "unknown" 14 | val message = "${failResponse.reason} : ${failResponse.exception?.message}" 15 | return Marker.ErrorMessage(message = message, name = name) 16 | } 17 | } 18 | var diagnosticsContext: ContextType = ContextType.INITIALIZE 19 | private var defaultMaxMarkers: Int = 30 20 | 21 | private var maxMarkers: MutableMap = mutableMapOf( 22 | ContextType.INITIALIZE to this.defaultMaxMarkers, 23 | ContextType.UPDATE_USER to this.defaultMaxMarkers 24 | ) 25 | 26 | private var markers = ConcurrentHashMap>() 27 | 28 | fun getMarkers(context: ContextType? = null): Queue = 29 | this.markers[context ?: this.diagnosticsContext] ?: ConcurrentLinkedQueue() 30 | 31 | fun clearContext(context: ContextType? = null) { 32 | this.markers.put(context ?: this.diagnosticsContext, ConcurrentLinkedQueue()) 33 | } 34 | 35 | fun markStart( 36 | key: KeyType, 37 | step: StepType? = null, 38 | additionalMarker: Marker? = null, 39 | overrideContext: ContextType? = null 40 | ): Boolean { 41 | val context = overrideContext ?: this.diagnosticsContext 42 | if (this.defaultMaxMarkers < (this.markers[context]?.size ?: 0)) { 43 | return false 44 | } 45 | val marker = Marker( 46 | key = key, 47 | action = ActionType.START, 48 | timestamp = 49 | System.nanoTime() / NANO_IN_MS, 50 | step = step 51 | ) 52 | when (context) { 53 | ContextType.INITIALIZE, ContextType.UPDATE_USER -> { 54 | if (key == KeyType.INITIALIZE && step == StepType.NETWORK_REQUEST) { 55 | marker.attempt = additionalMarker?.attempt 56 | } 57 | } 58 | } 59 | 60 | return this.addMarker(marker, context) 61 | } 62 | 63 | fun markEnd( 64 | key: KeyType, 65 | success: Boolean, 66 | step: StepType? = null, 67 | additionalMarker: Marker? = null, 68 | overrideContext: ContextType? = null 69 | ): Boolean { 70 | val context = overrideContext ?: this.diagnosticsContext 71 | if (this.defaultMaxMarkers < (this.markers[context]?.size ?: 0)) { 72 | return false 73 | } 74 | val marker = Marker( 75 | key = key, 76 | action = ActionType.END, 77 | timestamp = 78 | System.nanoTime() / NANO_IN_MS, 79 | success = success, 80 | step = step 81 | ) 82 | when (context) { 83 | ContextType.INITIALIZE, ContextType.UPDATE_USER -> { 84 | marker.evaluationDetails = additionalMarker?.evaluationDetails 85 | marker.attempt = additionalMarker?.attempt 86 | marker.sdkRegion = additionalMarker?.sdkRegion 87 | marker.statusCode = additionalMarker?.statusCode 88 | marker.error = additionalMarker?.error 89 | } 90 | } 91 | if (step == StepType.NETWORK_REQUEST) { 92 | marker.hasNetwork = additionalMarker?.hasNetwork 93 | } 94 | return this.addMarker(marker, context) 95 | } 96 | 97 | private fun addMarker(marker: Marker, overrideContext: ContextType? = null): Boolean { 98 | val context = overrideContext ?: this.diagnosticsContext 99 | if (this.defaultMaxMarkers <= (this.markers[context]?.size ?: 0)) { 100 | return false 101 | } 102 | if (this.markers[context] == null) { 103 | this.markers[context] = ConcurrentLinkedQueue() 104 | } 105 | this.markers[context]?.add(marker) 106 | this.markers.values 107 | return true 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/ErrorBoundaryTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import com.github.tomakehurst.wiremock.client.WireMock.* 5 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration 6 | import com.github.tomakehurst.wiremock.junit.WireMockRule 7 | import io.mockk.unmockkAll 8 | import java.io.IOException 9 | import kotlinx.coroutines.runBlocking 10 | import kotlinx.coroutines.test.TestScope 11 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 12 | import org.junit.After 13 | import org.junit.Assert.* 14 | import org.junit.Before 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.robolectric.RobolectricTestRunner 19 | import org.robolectric.RuntimeEnvironment 20 | import org.robolectric.annotation.ConscryptMode 21 | 22 | @RunWith(RobolectricTestRunner::class) 23 | class ErrorBoundaryTest { 24 | private lateinit var boundary: ErrorBoundary 25 | private var app: Application = RuntimeEnvironment.getApplication() 26 | 27 | @Before 28 | internal fun setup() { 29 | val dispatcher = TestUtil.mockDispatchers() 30 | val coroutineScope = TestScope(dispatcher) 31 | boundary = ErrorBoundary(coroutineScope) 32 | boundary.initialize("client-key") 33 | boundary.urlString = wireMockRule.url("/v1/sdk_exception") 34 | 35 | stubFor(post(urlMatching("/v1/sdk_exception")).willReturn(aResponse().withStatus(202))) 36 | 37 | TestUtil.mockHashing() 38 | val network = TestUtil.mockBrokenNetwork() 39 | Statsig.client = StatsigClient() 40 | Statsig.client.statsigNetwork = network 41 | Statsig.client.errorBoundary = boundary 42 | } 43 | 44 | @After 45 | internal fun teardown() { 46 | unmockkAll() 47 | } 48 | 49 | // Dynamic port so this test can run in parallel with other wiremock tests 50 | @Rule 51 | @JvmField 52 | val wireMockRule = WireMockRule(WireMockConfiguration.options().dynamicPort()) 53 | 54 | @Test 55 | fun testLoggingToEndpoint() { 56 | boundary.capture({ 57 | throw IOException("Test") 58 | }) 59 | 60 | verify( 61 | postRequestedFor(urlEqualTo("/v1/sdk_exception")).withHeader( 62 | "STATSIG-API-KEY", 63 | equalTo("client-key") 64 | ).withRequestBody(matchingJsonPath("\$[?(@.exception == 'java.io.IOException')]")) 65 | ) 66 | } 67 | 68 | @Test 69 | fun testRecovery() { 70 | var called = false 71 | boundary.capture({ 72 | arrayOf(1)[2] 73 | }, recover = { 74 | called = true 75 | }) 76 | 77 | assertTrue(called) 78 | } 79 | 80 | @Test 81 | fun testItDoesNotLogTheSameExceptionMultipleTimes() { 82 | boundary.capture({ 83 | throw IOException("Test") 84 | }) 85 | 86 | verify( 87 | 1, 88 | postRequestedFor(urlEqualTo("/v1/sdk_exception")).withHeader( 89 | "STATSIG-API-KEY", 90 | equalTo("client-key") 91 | ) 92 | ) 93 | } 94 | 95 | @Test 96 | fun testExternalException() { 97 | // Expect exceptions thrown from user defined callbacks to be caught 98 | // by the ErrorBoundary but not logged 99 | val callback = object : IStatsigCallback { 100 | override fun onStatsigInitialize(): Unit = 101 | throw IOException("Thrown from onStatsigInitialize") 102 | 103 | override fun onStatsigUpdateUser(): Unit = 104 | throw IOException("Thrown from onStatsigUpdateUser") 105 | } 106 | Statsig.client.statsigNetwork = TestUtil.mockNetwork() 107 | try { 108 | runBlocking { 109 | Statsig.client.initializeAsync(app, "client-key", null, callback) 110 | Statsig.client.updateUserAsync(null, callback) 111 | Statsig.shutdown() 112 | } 113 | } catch (e: Throwable) { 114 | // Test fails if an error escapes the boundary 115 | fail("Non-callback error was thrown within boundary: $e") 116 | } 117 | verify( 118 | 0, 119 | postRequestedFor(urlEqualTo("/v1/sdk_exception")).withHeader( 120 | "STATSIG-API-KEY", 121 | equalTo("client-key") 122 | ) 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigInitializationTimeoutTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import io.mockk.coEvery 5 | import io.mockk.every 6 | import io.mockk.spyk 7 | import java.util.concurrent.CountDownLatch 8 | import java.util.concurrent.TimeUnit 9 | import kotlinx.coroutines.* 10 | import okhttp3.mockwebserver.Dispatcher 11 | import okhttp3.mockwebserver.MockResponse 12 | import okhttp3.mockwebserver.MockWebServer 13 | import okhttp3.mockwebserver.RecordedRequest 14 | import org.junit.After 15 | import org.junit.Assert.assertTrue 16 | import org.junit.Before 17 | import org.junit.Test 18 | import org.junit.runner.RunWith 19 | import org.robolectric.RobolectricTestRunner 20 | import org.robolectric.RuntimeEnvironment 21 | 22 | @RunWith(RobolectricTestRunner::class) 23 | class StatsigInitializationTimeoutTest { 24 | 25 | private var app: Application = RuntimeEnvironment.getApplication() 26 | private lateinit var client: StatsigClient 27 | private lateinit var network: StatsigNetwork 28 | private lateinit var errorBoundary: ErrorBoundary 29 | private lateinit var mockWebServer: MockWebServer 30 | private val hitErrorBoundary = CountDownLatch(1) 31 | 32 | @Before 33 | fun setup() { 34 | mockWebServer = MockWebServer() 35 | val dispatcher = object : Dispatcher() { 36 | override fun dispatch(request: RecordedRequest): MockResponse = 37 | if (request.path!!.contains("sdk_exception")) { 38 | hitErrorBoundary.countDown() 39 | runBlocking { 40 | delay(1000) 41 | } 42 | MockResponse() 43 | .setBody("{\"result\":\"error logged\"}") 44 | .setResponseCode(200) 45 | } else { 46 | MockResponse().setResponseCode(404) 47 | } 48 | } 49 | mockWebServer.dispatcher = dispatcher 50 | mockWebServer.start() 51 | client = spyk(StatsigClient(), recordPrivateCalls = true) 52 | client.errorBoundary = spyk(client.errorBoundary) 53 | errorBoundary = client.errorBoundary 54 | network = TestUtil.mockNetwork() 55 | 56 | TestUtil.mockDispatchers() 57 | 58 | coEvery { 59 | network.initialize(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) 60 | } coAnswers { 61 | TestUtil.makeInitializeResponse() 62 | } 63 | 64 | // Lets get a successful network response, and then trigger error boundary 65 | // so we can test that eb does not block the initialization beyond the init timeout 66 | every { 67 | println("causing exception") 68 | client["pollForUpdates"]() 69 | } throws (Exception("trigger the error boundary")) 70 | 71 | every { 72 | errorBoundary.getUrl() 73 | } returns mockWebServer.url("/v1/sdk_exception").toString() 74 | 75 | client.statsigNetwork = network 76 | client.errorBoundary = errorBoundary 77 | } 78 | 79 | @After 80 | fun tearDown() { 81 | mockWebServer.shutdown() 82 | } 83 | 84 | @Test 85 | fun testInitializeAsyncWithSlowErrorBoundary() = runBlocking { 86 | var initializationDetails: InitializationDetails? 87 | var initTimeout = 500L 88 | runBlocking { 89 | initializationDetails = 90 | client.initialize( 91 | app, 92 | "client-key", 93 | StatsigUser("test_user"), 94 | StatsigOptions(initTimeoutMs = initTimeout) 95 | ) 96 | } 97 | // initialize timeout was hit, we got a value back and we are considered initialized 98 | assert(initializationDetails != null) 99 | assert(client.isInitialized()) 100 | 101 | // error boundary was hit, but has not completed at this point, so the initialization timeout worked 102 | assertTrue(hitErrorBoundary.await(1, TimeUnit.SECONDS)) 103 | assertTrue( 104 | "initialization time ${initializationDetails!!.duration} not less than initTimeout $initTimeout", 105 | initializationDetails!!.duration < initTimeout + 100L 106 | ) 107 | 108 | // error boundary was hit, but has not completed at this point, so the initialization timeout worked 109 | assert(hitErrorBoundary.await(1, TimeUnit.SECONDS)) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigCacheTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.google.gson.Gson 6 | import io.mockk.* 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.After 9 | import org.junit.Assert.* 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.robolectric.RobolectricTestRunner 14 | import org.robolectric.RuntimeEnvironment 15 | 16 | @RunWith(RobolectricTestRunner::class) 17 | class StatsigCacheTest { 18 | 19 | private lateinit var app: Application 20 | private lateinit var client: StatsigClient 21 | private lateinit var testSharedPrefs: SharedPreferences 22 | private val gson = Gson() 23 | 24 | @Before 25 | internal fun setup() { 26 | TestUtil.mockDispatchers() 27 | 28 | app = RuntimeEnvironment.getApplication() 29 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 30 | TestUtil.mockHashing() 31 | } 32 | 33 | @After 34 | internal fun tearDown() { 35 | unmockkAll() 36 | } 37 | 38 | @Test 39 | fun testInitializeUsesCacheBeforeNetworkResponse() { 40 | val user = StatsigUser("123") 41 | 42 | var cacheByUser: MutableMap = HashMap() 43 | var values: MutableMap = HashMap() 44 | val sticky: MutableMap = HashMap() 45 | var initialize = TestUtil.makeInitializeResponse() 46 | values.put("values", initialize) 47 | values.put("stickyUserExperiments", sticky) 48 | cacheByUser.put("${user.getCacheKey()}:client-test", values) 49 | 50 | testSharedPrefs.edit().putString("Statsig.CACHE_BY_USER", gson.toJson(cacheByUser)).apply() 51 | 52 | TestUtil.startStatsigAndDontWait(app, user, StatsigOptions()) 53 | client = Statsig.client 54 | assertTrue(client.isInitialized()) 55 | assertEquals( 56 | EvaluationReason.Cache, 57 | client.getStore().checkGate("always_on").getEvaluationDetails().reason 58 | ) 59 | 60 | assertTrue(client.checkGate("always_on")) 61 | runBlocking { 62 | client.statsigNetwork.apiRetryFailedLogs("https://statsigapi.net/v1") 63 | } 64 | val config = client.getConfig("test_config") 65 | assertEquals("test", config.getString("string", "fallback")) 66 | assertEquals(EvaluationReason.Cache, config.getEvaluationDetails().reason) 67 | } 68 | 69 | @Test 70 | fun testSetupDoesntLoadFromCacheWhenSetToAsync() { 71 | val user = StatsigUser("123") 72 | 73 | var cacheByUser: MutableMap = HashMap() 74 | var values: MutableMap = HashMap() 75 | val sticky: MutableMap = HashMap() 76 | var initialize = TestUtil.makeInitializeResponse() 77 | values.put("values", initialize) 78 | values.put("stickyUserExperiments", sticky) 79 | cacheByUser.put("${user.getCacheKey()}:client-test", values) 80 | 81 | testSharedPrefs.edit().putString("Statsig.CACHE_BY_USER", gson.toJson(cacheByUser)).apply() 82 | 83 | TestUtil.startStatsigAndDontWait(app, user, StatsigOptions(loadCacheAsync = true)) 84 | client = Statsig.client 85 | client.statsigNetwork = TestUtil.mockNetwork() 86 | assertFalse(client.checkGate("always_on")) 87 | runBlocking { 88 | client.statsigNetwork.apiRetryFailedLogs("https://statsigapi.net/v1") 89 | client.statsigNetwork.addFailedLogRequest( 90 | StatsigOfflineRequest(System.currentTimeMillis(), "{}", 0) 91 | ) 92 | } 93 | val config = client.getConfig("test_config") 94 | assertEquals("fallback", config.getString("string", "fallback")) 95 | assertEquals(EvaluationReason.Uninitialized, config.getEvaluationDetails().reason) 96 | runBlocking { 97 | Statsig.client.shutdown() 98 | Statsig.client.initialize( 99 | app, 100 | "client-test", 101 | user, 102 | StatsigOptions(loadCacheAsync = true) 103 | ) 104 | } 105 | assertTrue(client.checkGate("always_on")) 106 | 107 | val netConfig = client.getConfig("test_config") 108 | assertEquals("test", netConfig.getString("string", "fallback")) 109 | assertEquals(EvaluationReason.Network, netConfig.getEvaluationDetails().reason) 110 | 111 | assertTrue(Statsig.client.isInitialized()) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/OfflineStorageTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.google.common.truth.Truth.assertThat 6 | import com.google.gson.Gson 7 | import io.mockk.mockk 8 | import kotlinx.coroutines.runBlocking 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.robolectric.RobolectricTestRunner 14 | import org.robolectric.RuntimeEnvironment 15 | 16 | @RunWith(RobolectricTestRunner::class) 17 | class OfflineStorageTest { 18 | 19 | private val app: Application = RuntimeEnvironment.getApplication() 20 | private lateinit var testSharedPrefs: SharedPreferences 21 | private val gson = StatsigUtil.getOrBuildGson() 22 | private lateinit var network: StatsigNetwork 23 | private val now = System.currentTimeMillis() 24 | private val threeDaysInMs = 3 * 24 * 60 * 60 * 1000L 25 | 26 | @Before 27 | fun setUp() { 28 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 29 | 30 | network = StatsigNetwork( 31 | app, 32 | "client-key", 33 | testSharedPrefs, 34 | StatsigOptions(), 35 | mockk(), 36 | mockk(), 37 | mockk(), 38 | gson = gson 39 | ) 40 | } 41 | 42 | @Test 43 | fun `getSavedLogs prunes logs older than 3 days`() = runBlocking { 44 | // Create 3 old and 3 recent logs 45 | val logs = listOf( 46 | StatsigOfflineRequest(now - threeDaysInMs - 100_000, "too old 1"), 47 | StatsigOfflineRequest(now - threeDaysInMs - 200_000, "too old 2"), 48 | StatsigOfflineRequest(now - threeDaysInMs - 300_000, "too old 3"), 49 | StatsigOfflineRequest(now - 3_000, "recent 1"), 50 | StatsigOfflineRequest(now - 2_000, "recent 2"), 51 | StatsigOfflineRequest(now - 1_000, "recent 3") 52 | ) 53 | 54 | val json = gson.toJson(StatsigPendingRequests(logs)) 55 | testSharedPrefs.edit().putString("StatsigNetwork.OFFLINE_LOGS:client-key", json).commit() 56 | 57 | val result = network.getSavedLogs() 58 | 59 | assertEquals(3, result.size) 60 | assertEquals("recent 1", result[0].requestBody) 61 | assertEquals("recent 2", result[1].requestBody) 62 | assertEquals("recent 3", result[2].requestBody) 63 | } 64 | 65 | @Test 66 | fun `filterValidLogs keeps only the most recent up to max cache`() { 67 | val now = System.currentTimeMillis() 68 | 69 | // Create 11 logs, timestamps increasing by 1 second (oldest first) 70 | val logs = (0..20).map { i -> 71 | StatsigOfflineRequest( 72 | timestamp = now - (11 - i) * 1_000L, 73 | requestBody = "log $i", 74 | retryCount = 0 75 | ) 76 | } 77 | 78 | val filtered = network.filterValidLogs(logs, now) 79 | 80 | // It should keep only the last 10 logs (log 1 to log 10) 81 | assertEquals(10, filtered.size) 82 | val expectedLogs = (11..20).map { "log $it" } 83 | assertEquals(expectedLogs, filtered.map { it.requestBody }) 84 | } 85 | 86 | @Test 87 | fun `getSavedLogs prunes logs exceeding max count of 10`() = runBlocking { 88 | // Create 11 logs within valid time window 89 | val logs = (0..10).map { i -> 90 | StatsigOfflineRequest(now - (11 - i) * 1_000L, "log $i") 91 | } 92 | 93 | val json = gson.toJson(StatsigPendingRequests(logs)) 94 | testSharedPrefs.edit().putString("StatsigNetwork.OFFLINE_LOGS:client-key", json).apply() 95 | 96 | val result = network.getSavedLogs() 97 | 98 | // Should retain only the last 10 logs (log 1..10) 99 | assertThat(result).hasSize(10) 100 | (1..10).forEach { i -> 101 | assertEquals("log $i", result[i - 1].requestBody) 102 | } 103 | // Add one more log and check again 104 | network.addFailedLogRequest(StatsigOfflineRequest(now, "log 11")) 105 | val savedJson = testSharedPrefs.getString("StatsigNetwork.OFFLINE_LOGS:client-key", null) 106 | val saved = gson.fromJson(savedJson, StatsigPendingRequests::class.java) 107 | 108 | // See MAX_LOG_REQUESTS_TO_CACHE 109 | assertThat(saved.requests).hasSize(10) 110 | // Should drop "log 1", and now include "log 2" to "log 11" 111 | (2..11).forEachIndexed { index, i -> 112 | assertEquals("log $i", saved.requests[index].requestBody) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/ErrorBoundary.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.util.Log 4 | import com.google.gson.Gson 5 | import java.io.DataOutputStream 6 | import java.lang.RuntimeException 7 | import java.net.HttpURLConnection 8 | import java.net.URL 9 | import kotlinx.coroutines.* 10 | 11 | internal class ExternalException(message: String? = null) : Exception(message) 12 | 13 | internal class ErrorBoundary( 14 | private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 15 | ) { 16 | private companion object { 17 | private const val TAG: String = "statsig::ErrorBoundary" 18 | } 19 | internal var urlString = "https://prodregistryv2.org/v1/rgstr_e" 20 | 21 | private var apiKey: String? = null 22 | private var statsigMetadata: StatsigMetadata? = null 23 | private var seen = HashSet() 24 | 25 | private lateinit var urlConnectionProvider: UrlConnectionProvider 26 | 27 | fun initialize(apiKey: String, urlConnectionProvider: UrlConnectionProvider = defaultProvider) { 28 | this.apiKey = apiKey 29 | this.urlConnectionProvider = urlConnectionProvider 30 | } 31 | 32 | fun getUrl(): String = urlString 33 | 34 | fun setMetadata(statsigMetadata: StatsigMetadata) { 35 | this.statsigMetadata = statsigMetadata 36 | } 37 | 38 | private fun handleException( 39 | exception: Throwable, 40 | tag: String? = null, 41 | configName: String? = null 42 | ) { 43 | Log.e(TAG, "An unexpected exception occurred.", exception) 44 | if (exception !is ExternalException) { 45 | this.logException(exception, tag, configName) 46 | } 47 | } 48 | 49 | fun getExceptionHandler(): CoroutineExceptionHandler = CoroutineExceptionHandler { 50 | _, 51 | exception 52 | -> 53 | this.handleException(exception) 54 | } 55 | 56 | fun getNoopExceptionHandler(): CoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> 57 | // No-op 58 | } 59 | 60 | fun capture( 61 | task: () -> Unit, 62 | tag: String? = null, 63 | recover: ((exception: Exception?) -> Unit)? = null, 64 | configName: String? = null 65 | ) { 66 | try { 67 | task() 68 | } catch (e: Exception) { 69 | handleException(e, tag, configName) 70 | recover?.let { it(e) } 71 | } 72 | } 73 | 74 | suspend fun captureAsync(task: suspend () -> T): T? = try { 75 | task() 76 | } catch (e: Exception) { 77 | handleException(e) 78 | null 79 | } 80 | 81 | suspend fun captureAsync(task: suspend () -> T, recover: (suspend (e: Exception) -> T)): T = 82 | try { 83 | task() 84 | } catch (e: Exception) { 85 | handleException(e) 86 | recover(e) 87 | } 88 | 89 | internal fun logException( 90 | exception: Throwable, 91 | tag: String? = null, 92 | configName: String? = null 93 | ) { 94 | try { 95 | this.coroutineScope.launch(this.getNoopExceptionHandler()) { 96 | if (apiKey == null) { 97 | return@launch 98 | } 99 | 100 | val name = exception.javaClass.canonicalName ?: exception.javaClass.name 101 | if (seen.contains(name)) { 102 | return@launch 103 | } 104 | 105 | seen.add(name) 106 | 107 | val metadata = statsigMetadata ?: StatsigMetadata("") 108 | val url = URL(getUrl()) 109 | val body = mapOf( 110 | "exception" to name, 111 | "info" to RuntimeException(exception).stackTraceToString(), 112 | "statsigMetadata" to metadata, 113 | "tag" to (tag ?: "unknown"), 114 | "configName" to configName 115 | ) 116 | val postData = Gson().toJson(body) 117 | 118 | val conn = urlConnectionProvider.open(url) as HttpURLConnection 119 | conn.requestMethod = "POST" 120 | conn.doOutput = true 121 | conn.setRequestProperty("Content-Type", "application/json") 122 | conn.setRequestProperty("STATSIG-API-KEY", apiKey) 123 | conn.useCaches = false 124 | DataOutputStream(conn.outputStream).use { it.writeBytes(postData) } 125 | conn.responseCode // triggers request 126 | } 127 | } catch (e: Exception) { 128 | // noop 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/DebugView.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.content.DialogInterface 6 | import android.os.Build 7 | import android.view.KeyEvent 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.view.Window 11 | import android.webkit.ConsoleMessage 12 | import android.webkit.CookieManager 13 | import android.webkit.WebChromeClient 14 | import android.webkit.WebView 15 | import android.webkit.WebViewClient 16 | import com.google.gson.Gson 17 | typealias DebugViewCallback = (Boolean) -> Unit // RELOAD_REQUIRED -> callback 18 | class DebugView { 19 | companion object { 20 | fun show( 21 | context: Context, 22 | sdkKey: String, 23 | state: Map, 24 | callback: DebugViewCallback? 25 | ) { 26 | val dialog = Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen) 27 | val client = DebugWebViewClient(Gson().toJson(state)) 28 | val chromeClient = DebugWebChromeClient(dialog, callback) 29 | val webView = getConfiguredWebView(context, client, chromeClient) 30 | 31 | dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) 32 | dialog.setOnKeyListener( 33 | DialogInterface.OnKeyListener { _, keyCode, event -> 34 | if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP && 35 | webView.canGoBack() 36 | ) { 37 | webView.goBack() 38 | 39 | if (webView.url?.split("/")?.last()?.startsWith("client_sdk_debugger") == 40 | true 41 | ) { 42 | dialog.dismiss() 43 | } 44 | return@OnKeyListener true 45 | } 46 | return@OnKeyListener false 47 | } 48 | ) 49 | 50 | webView.loadUrl( 51 | "https://console.statsig.com/client_sdk_debugger_redirect?sdkKey=$sdkKey" 52 | ) 53 | 54 | dialog.setContentView(webView) 55 | dialog.show() 56 | 57 | // Do after .show() 58 | dialog.window?.setLayout( 59 | ViewGroup.LayoutParams.MATCH_PARENT, 60 | ViewGroup.LayoutParams.MATCH_PARENT 61 | ) 62 | } 63 | 64 | private fun getConfiguredWebView( 65 | context: Context, 66 | client: DebugWebViewClient, 67 | chromeClient: DebugWebChromeClient 68 | ): WebView { 69 | val webView = WebView(context) 70 | webView.webViewClient = client 71 | webView.webChromeClient = chromeClient 72 | 73 | webView.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 74 | webView.settings.let { 75 | it.javaScriptCanOpenWindowsAutomatically = true 76 | it.javaScriptEnabled = true 77 | it.databaseEnabled = true 78 | it.domStorageEnabled = true 79 | } 80 | webView.layoutParams = ViewGroup.LayoutParams( 81 | ViewGroup.LayoutParams.MATCH_PARENT, 82 | ViewGroup.LayoutParams.MATCH_PARENT 83 | ) 84 | 85 | CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) 86 | return webView 87 | } 88 | } 89 | 90 | private class DebugWebViewClient(private val json: String) : WebViewClient() { 91 | override fun onPageFinished(view: WebView?, url: String?) { 92 | super.onPageFinished(view, url) 93 | 94 | view?.evaluateJavascript("window.__StatsigAndroidDebug=true;", null) 95 | 96 | val js = "window.__StatsigClientState = $json;" 97 | view?.evaluateJavascript(js, null) 98 | } 99 | } 100 | 101 | private class DebugWebChromeClient( 102 | private val dialog: Dialog, 103 | private val callback: DebugViewCallback? 104 | ) : WebChromeClient() { 105 | private val closeAction = "STATSIG_ANDROID_DEBUG_CLOSE_DIALOG" 106 | private val reloadRequired = "STATSIG_ANDROID_DEBUG_RELOAD_REQUIRED" 107 | 108 | override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { 109 | consoleMessage?.message()?.let { 110 | if (it.contentEquals(closeAction, ignoreCase = true)) { 111 | dialog.dismiss() 112 | } 113 | if (it.contentEquals(reloadRequired, ignoreCase = true)) { 114 | callback?.invoke(true) 115 | } 116 | } 117 | 118 | return super.onConsoleMessage(consoleMessage) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/DynamicConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import java.util.Collections 5 | import org.junit.Assert.* 6 | import org.junit.Before 7 | import org.junit.Test 8 | 9 | class DynamicConfigTest { 10 | 11 | private lateinit var dc: DynamicConfig 12 | 13 | @Before 14 | internal fun setup() { 15 | dc = DynamicConfig( 16 | "test_config", 17 | EvaluationDetails(EvaluationReason.Network, lcut = 0), 18 | TestUtil.getConfigValueMap(), 19 | "default" 20 | ) 21 | } 22 | 23 | @Test 24 | fun testDummy() { 25 | val dummyConfig = 26 | DynamicConfig("", EvaluationDetails(EvaluationReason.Unrecognized, lcut = 0)) 27 | assertEquals("provided default", dummyConfig.getString("test", "provided default")) 28 | assertEquals(true, dummyConfig.getBoolean("test", true)) 29 | assertEquals(12, dummyConfig.getInt("test", 12)) 30 | assertEquals("hello world", dummyConfig.getString("test", "hello world")) 31 | assertEquals(0, dummyConfig.getValue().size) 32 | assertEquals("", dummyConfig.getRuleID()) 33 | assertNull(dummyConfig.getGroupName()) 34 | assertNull(dummyConfig.getString("test", null)) 35 | assertNull(dummyConfig.getConfig("nested")) 36 | assertNull(dummyConfig.getString("testnodefault", null)) 37 | assertNull(dummyConfig.getArray("testnodefault", null)) 38 | assertNull(dummyConfig.getDictionary("testnodefault", null)) 39 | assertEquals(dummyConfig.getEvaluationDetails().reason, EvaluationReason.Unrecognized) 40 | } 41 | 42 | @Test 43 | fun testEmpty() { 44 | val emptyConfig = DynamicConfig( 45 | "test_config", 46 | EvaluationDetails(EvaluationReason.Uninitialized, lcut = 0), 47 | mapOf(), 48 | "default" 49 | ) 50 | 51 | assertEquals("provided default", emptyConfig.getString("test", "provided default")) 52 | assertEquals(12, emptyConfig.getInt("testInt", 12)) 53 | assertEquals(true, emptyConfig.getBoolean("test_config", true)) 54 | assertEquals(3.0, emptyConfig.getDouble("test_config", 3.0), 0.0) 55 | val arr = arrayOf("test", "one") 56 | @Suppress("UNCHECKED_CAST") 57 | assertArrayEquals(arr, emptyConfig.getArray("test_config", arr as Array)) 58 | assertEquals("default", emptyConfig.getRuleID()) 59 | assertNull(emptyConfig.getGroupName()) 60 | assertNull(emptyConfig.getConfig("nested")) 61 | assertNull(emptyConfig.getString("testnodefault", null)) 62 | assertNull(emptyConfig.getArray("testnodefault", null)) 63 | assertNull(emptyConfig.getDictionary("testnodefault", null)) 64 | 65 | assertEquals(emptyConfig.getEvaluationDetails().reason, EvaluationReason.Uninitialized) 66 | } 67 | 68 | @Test 69 | fun testPrimitives() { 70 | assertEquals("test", dc.getString("testString", "1234")) 71 | assertTrue(dc.getBoolean("testBoolean", false)) 72 | assertEquals(12, dc.getInt("testInt", 13)) 73 | assertEquals(42.3, dc.getDouble("testDouble", 13.0), 0.0) 74 | assertEquals(9223372036854775806, dc.getLong("testLong", 1)) 75 | assertEquals(12.0, dc.getDouble("testInt", 13.0), 0.0) 76 | assertEquals(41, dc.getInt("testAnotherDouble", 44)) 77 | } 78 | 79 | @Test 80 | fun testArrays() { 81 | assertArrayEquals(arrayOf("one", "two"), dc.getArray("testArray", arrayOf(1, "one"))) 82 | assertArrayEquals(arrayOf(3L, 2L), dc.getArray("testIntArray", arrayOf(1, 2))) 83 | assertArrayEquals(arrayOf(3.1, 2.1), dc.getArray("testDoubleArray", arrayOf(1, "one"))) 84 | assertArrayEquals(arrayOf(true, false), dc.getArray("testBooleanArray", arrayOf(1, "one"))) 85 | } 86 | 87 | @Test 88 | fun testNested() { 89 | assertEquals("nested", dc.getConfig("testNested")!!.getString("nestedString", "111")) 90 | assertTrue(dc.getConfig("testNested")!!.getBoolean("nestedBoolean", false)) 91 | assertEquals(13.74, dc.getConfig("testNested")!!.getDouble("nestedDouble", 99.99), 0.0) 92 | assertEquals(13, dc.getConfig("testNested")!!.getInt("nestedInt", 13)) 93 | assertNull(dc.getConfig("testNested")!!.getConfig("testNestedAgain")) 94 | 95 | assertEquals( 96 | mapOf( 97 | "nestedString" to "nested", 98 | "nestedBoolean" to true, 99 | "nestedDouble" to 13.74, 100 | "nestedLong" to 13L, 101 | "nestedEmptyDict" to Collections.EMPTY_MAP 102 | ), 103 | dc.getDictionary("testNested", mapOf()) 104 | ) 105 | } 106 | 107 | @Test 108 | fun testEmptyDict() { 109 | assertThat(dc.getConfig("testEmptyDict")).isNotNull() 110 | assertThat(dc.getDictionary("testEmptyDict", null)).isEqualTo(Collections.EMPTY_MAP) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/ErrorBoundaryNetworkConnectivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.net.ConnectivityManager 6 | import android.net.NetworkCapabilities 7 | import android.os.Build 8 | import com.github.tomakehurst.wiremock.client.WireMock.aResponse 9 | import com.github.tomakehurst.wiremock.client.WireMock.post 10 | import com.github.tomakehurst.wiremock.client.WireMock.stubFor 11 | import com.github.tomakehurst.wiremock.client.WireMock.urlMatching 12 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options 13 | import com.github.tomakehurst.wiremock.http.Fault 14 | import com.github.tomakehurst.wiremock.junit.WireMockRule 15 | import io.mockk.clearAllMocks 16 | import io.mockk.every 17 | import io.mockk.mockk 18 | import junit.framework.TestCase.assertFalse 19 | import kotlinx.coroutines.ExperimentalCoroutinesApi 20 | import kotlinx.coroutines.runBlocking 21 | import kotlinx.coroutines.test.TestScope 22 | import org.junit.Before 23 | import org.junit.Rule 24 | import org.junit.Test 25 | import org.junit.runner.RunWith 26 | import org.robolectric.RobolectricTestRunner 27 | import org.robolectric.RuntimeEnvironment 28 | import org.robolectric.Shadows 29 | import org.robolectric.Shadows.shadowOf 30 | import org.robolectric.annotation.Config 31 | import org.robolectric.shadows.ShadowConnectivityManager 32 | import org.robolectric.shadows.ShadowNetworkCapabilities 33 | 34 | @RunWith(RobolectricTestRunner::class) 35 | class ErrorBoundaryNetworkConnectivityTest { 36 | private lateinit var eb: ErrorBoundary 37 | private val app: Application = RuntimeEnvironment.getApplication() 38 | private lateinit var network: StatsigNetworkImpl 39 | 40 | private lateinit var conMan: ConnectivityManager 41 | private lateinit var shadowConMan: ShadowConnectivityManager 42 | 43 | private var ebCalled = false 44 | 45 | // Dynamic port so this test can run in parallel with other wiremock tests 46 | @Rule 47 | @JvmField 48 | val wireMockRule = WireMockRule(options().dynamicPort()) 49 | 50 | @OptIn(ExperimentalCoroutinesApi::class) 51 | @Before 52 | internal fun setup() { 53 | clearAllMocks() 54 | val dispatcher = TestUtil.mockDispatchers() 55 | val coroutineScope = TestScope(dispatcher) 56 | 57 | ebCalled = false 58 | eb = mockk() 59 | every { eb.logException(any()) } answers { 60 | ebCalled = true 61 | } 62 | conMan = app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 63 | shadowConMan = Shadows.shadowOf(conMan) 64 | shadowConMan.setDefaultNetworkActive(false) 65 | val gson = StatsigUtil.getOrBuildGson() 66 | 67 | stubFor( 68 | post(urlMatching("/initialize")) 69 | .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)) 70 | ) 71 | val store = 72 | Store( 73 | coroutineScope, 74 | TestUtil.getTestSharedPrefs(app), 75 | StatsigUser(), 76 | "client-apikey", 77 | StatsigOptions(), 78 | gson 79 | ) 80 | network = 81 | StatsigNetworkImpl( 82 | app, 83 | "client-key", 84 | TestUtil.getTestSharedPrefs(app), 85 | StatsigOptions(), 86 | mockk(), 87 | coroutineScope, 88 | store, 89 | gson = gson 90 | ) 91 | } 92 | 93 | @Config(sdk = [Build.VERSION_CODES.M]) 94 | @Test 95 | fun testAndroidM_ErrorBoundaryNotHitWhenNoNetwork(): Unit = runBlocking { 96 | makeNetworkRequest() 97 | assertFalse(ebCalled) 98 | } 99 | 100 | @Config(sdk = [Build.VERSION_CODES.M]) 101 | @Test 102 | fun testAndroidM_ErrorBoundaryIsNotHitWhenNetworkExists(): Unit = runBlocking { 103 | setNetworkInternetCapabilities() 104 | makeNetworkRequest() 105 | assertFalse(ebCalled) 106 | } 107 | 108 | private suspend fun makeNetworkRequest() { 109 | try { 110 | network.initializeImpl( 111 | wireMockRule.baseUrl(), 112 | StatsigUser(), 113 | null, 114 | StatsigMetadata(), 115 | ContextType.INITIALIZE, 116 | null, 117 | 1, 118 | 50, 119 | HashAlgorithm.NONE, 120 | mapOf(), 121 | null 122 | ) 123 | } catch (e: Exception) { 124 | // noop 125 | } 126 | } 127 | 128 | private fun setNetworkInternetCapabilities() { 129 | val networkCapabilities: NetworkCapabilities = ShadowNetworkCapabilities.newInstance() 130 | shadowOf(networkCapabilities).addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 131 | shadowConMan.setNetworkCapabilities(conMan.activeNetwork, networkCapabilities) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/InitializationRetryFailedLogsTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import com.google.common.truth.Truth.assertThat 5 | import com.google.gson.Gson 6 | import kotlinx.coroutines.* 7 | import okhttp3.mockwebserver.Dispatcher 8 | import okhttp3.mockwebserver.MockResponse 9 | import okhttp3.mockwebserver.MockWebServer 10 | import okhttp3.mockwebserver.RecordedRequest 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | import org.robolectric.RobolectricTestRunner 15 | import org.robolectric.RuntimeEnvironment 16 | 17 | @RunWith(RobolectricTestRunner::class) 18 | class InitializationRetryFailedLogsTest { 19 | private lateinit var mockWebServer: MockWebServer 20 | private var logEventHits = 0 21 | private val gson = Gson() 22 | private val app: Application = RuntimeEnvironment.getApplication() 23 | private lateinit var url: String 24 | 25 | @Before 26 | fun setup() { 27 | mockWebServer = MockWebServer() 28 | TestUtil.mockDispatchers() 29 | 30 | url = mockWebServer.url("/v1").toString() 31 | val sharedPrefs = TestUtil.getTestSharedPrefs(app) 32 | 33 | val failedLogsJson = this::class.java.classLoader!! 34 | .getResource("sample_failed_logs.json") 35 | .readText() 36 | 37 | sharedPrefs.edit() 38 | .putString("StatsigNetwork.OFFLINE_LOGS:client-key", failedLogsJson) 39 | .commit() 40 | 41 | val dispatcher = object : Dispatcher() { 42 | override fun dispatch(request: RecordedRequest): MockResponse = when { 43 | request.path!!.contains("initialize") -> { 44 | MockResponse() 45 | .setBody(gson.toJson(TestUtil.makeInitializeResponse())) 46 | .setResponseCode(200) 47 | } 48 | request.path!!.contains("log_event") -> { 49 | logEventHits++ 50 | MockResponse().setResponseCode(200) 51 | } 52 | else -> MockResponse().setResponseCode(404) 53 | } 54 | } 55 | mockWebServer.dispatcher = dispatcher 56 | } 57 | 58 | @Test 59 | fun testStoredFailedLogsDoNotBlockInitialize() = runBlocking { 60 | Statsig.initialize( 61 | app, 62 | "client-key", 63 | StatsigUser("test"), 64 | StatsigOptions(api = url, eventLoggingAPI = url) 65 | ) 66 | assertThat(logEventHits).isEqualTo(0) 67 | 68 | val gateResult = Statsig.checkGate("test_gate") 69 | 70 | Statsig.shutdown() 71 | 72 | assert(!gateResult) 73 | } 74 | 75 | @Test 76 | fun testStoredFailedLogsDoNotBlockInitializeAsync() = runBlocking { 77 | val callback = object : IStatsigCallback { 78 | override fun onStatsigInitialize(initDetails: InitializationDetails) { 79 | assertThat(logEventHits).isEqualTo(0) 80 | } 81 | 82 | override fun onStatsigUpdateUser() { 83 | // Not needed for this test 84 | } 85 | } 86 | Statsig.initializeAsync( 87 | app, 88 | "client-key", 89 | StatsigUser("test"), 90 | callback, 91 | StatsigOptions(api = url, eventLoggingAPI = url) 92 | ) 93 | 94 | val gateResult = Statsig.checkGate("test_gate") 95 | 96 | Statsig.shutdown() 97 | 98 | assertThat(gateResult).isFalse() 99 | } 100 | 101 | @Test 102 | fun testRetryingAndDroppingStoredFailedLogs() = runBlocking { 103 | var receivedRequestBodies = mutableListOf() 104 | val maxAttemptsPerRequest = 3 105 | 106 | // Mock server with handler to track received requests 107 | mockWebServer.dispatcher = object : Dispatcher() { 108 | override fun dispatch(request: RecordedRequest): MockResponse { 109 | if (request.path!!.contains("log_event")) { 110 | receivedRequestBodies.add(request.body.readUtf8()) 111 | return MockResponse().setResponseCode(500) // Force failure to trigger retries 112 | } 113 | return MockResponse() 114 | .setBody(gson.toJson(TestUtil.makeInitializeResponse())) 115 | .setResponseCode(200) 116 | } 117 | } 118 | 119 | Statsig.initialize( 120 | app, 121 | "client-key", 122 | StatsigUser("test"), 123 | StatsigOptions(api = url, eventLoggingAPI = url) 124 | ) 125 | Statsig.shutdown() 126 | 127 | // Group received requests by unique requestBody 128 | val requestCountMap = receivedRequestBodies.groupingBy { it }.eachCount() 129 | // Assert: no more than 10 distinct requests sent (others were dropped) 130 | assertThat(requestCountMap.size).isAtMost(10) 131 | 132 | // Assert: no request was retried more than 3 times 133 | val maxRetriesSeen = requestCountMap.values.maxOrNull() ?: 0 134 | assertThat(maxRetriesSeen).isAtMost(maxAttemptsPerRequest) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/evaluator/SpecsResponse.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk.evaluator 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.JsonNull 7 | import com.google.gson.JsonParser 8 | import com.google.gson.JsonSerializationContext 9 | import com.google.gson.JsonSerializer 10 | import com.google.gson.annotations.JsonAdapter 11 | import com.google.gson.annotations.SerializedName 12 | import java.lang.reflect.Type 13 | 14 | internal data class SpecsResponse( 15 | @SerializedName("dynamic_configs") val dynamicConfigs: List, 16 | @SerializedName("feature_gates") val featureGates: List, 17 | @SerializedName("layer_configs") val layerConfigs: List, 18 | @SerializedName("param_stores") val paramStores: Map?, 19 | @SerializedName("layers") val layers: Map>?, 20 | @SerializedName("time") val time: Long = 0, 21 | @SerializedName("has_updates") val hasUpdates: Boolean, 22 | @SerializedName("diagnostics") val diagnostics: Map? = null, 23 | @SerializedName("default_environment") val defaultEnvironment: String? = null 24 | ) 25 | 26 | internal data class Spec( 27 | @SerializedName("name") val name: String, 28 | @SerializedName("type") val type: String, 29 | @SerializedName("isActive") val isActive: Boolean, 30 | @SerializedName("salt") val salt: String, 31 | @SerializedName("defaultValue") val defaultValue: ReturnableValue, 32 | @SerializedName("enabled") val enabled: Boolean, 33 | @SerializedName("rules") val rules: List, 34 | @SerializedName("idType") val idType: String, 35 | @SerializedName("entity") val entity: String, 36 | @SerializedName("explicitParameters") val explicitParameters: List?, 37 | @SerializedName("hasSharedParams") val hasSharedParams: Boolean?, 38 | @SerializedName("targetAppIDs") val targetAppIDs: List? = null, 39 | @SerializedName("version") val version: Int? = null 40 | ) 41 | 42 | internal data class SpecRule( 43 | @SerializedName("name") val name: String, 44 | @SerializedName("passPercentage") val passPercentage: Double, 45 | @SerializedName("returnValue") val returnValue: ReturnableValue, 46 | @SerializedName("id") val id: String, 47 | @SerializedName("salt") val salt: String?, 48 | @SerializedName("conditions") val conditions: List, 49 | @SerializedName("idType") val idType: String, 50 | @SerializedName("groupName") val groupName: String, 51 | @SerializedName("configDelegate") val configDelegate: String?, 52 | @SerializedName("isExperimentGroup") val isExperimentGroup: Boolean? 53 | ) 54 | 55 | internal data class SpecCondition( 56 | @SerializedName("type") val type: String, 57 | @SerializedName("targetValue") val targetValue: Any?, 58 | @SerializedName("operator") val operator: String?, 59 | @SerializedName("field") val field: String?, 60 | @SerializedName("additionalValues") val additionalValues: Map?, 61 | @SerializedName("idType") val idType: String 62 | ) 63 | 64 | internal data class SpecParamStore( 65 | @SerializedName("targetAppIDs") val targetAppIDs: List, 66 | @SerializedName("parameters") val parameters: Map> 67 | ) 68 | 69 | @JsonAdapter(ReturnableValue.CustomSerializer::class) 70 | internal data class ReturnableValue( 71 | val booleanValue: Boolean? = null, 72 | val rawJson: String = "null", 73 | val mapValue: Map? = null 74 | ) { 75 | fun getValue(): Any? { 76 | if (booleanValue != null) { 77 | return booleanValue 78 | } 79 | 80 | if (mapValue != null) { 81 | return mapValue 82 | } 83 | 84 | return null 85 | } 86 | 87 | internal class CustomSerializer : 88 | JsonDeserializer, 89 | JsonSerializer { 90 | override fun deserialize( 91 | json: JsonElement?, 92 | typeOfT: Type?, 93 | context: JsonDeserializationContext? 94 | ): ReturnableValue { 95 | if (json == null) { 96 | return ReturnableValue() 97 | } 98 | 99 | if (json.isJsonPrimitive && json.asJsonPrimitive.isBoolean) { 100 | val booleanValue = json.asJsonPrimitive.asBoolean 101 | return ReturnableValue(booleanValue, json.toString(), null) 102 | } 103 | 104 | if (json.isJsonObject) { 105 | val jsonObject = json.asJsonObject 106 | val mapValue = context?.deserialize>(jsonObject, Map::class.java) 107 | ?: emptyMap() 108 | return ReturnableValue(null, json.toString(), mapValue) 109 | } 110 | 111 | return ReturnableValue() 112 | } 113 | 114 | override fun serialize( 115 | src: ReturnableValue?, 116 | typeOfSrc: Type?, 117 | context: JsonSerializationContext? 118 | ): JsonElement { 119 | if (src == null) { 120 | return JsonNull.INSTANCE 121 | } 122 | 123 | return JsonParser.parseString(src.rawJson) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/EvaluationCallbackTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import io.mockk.coEvery 6 | import io.mockk.unmockkAll 7 | import org.junit.After 8 | import org.junit.Assert.* 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | import org.robolectric.RuntimeEnvironment 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class EvaluationCallbackTest { 17 | 18 | private lateinit var app: Application 19 | private var flushedLogs: String = "" 20 | private var initUser: StatsigUser? = null 21 | private var client: StatsigClient = StatsigClient() 22 | private lateinit var network: StatsigNetwork 23 | private lateinit var testSharedPrefs: SharedPreferences 24 | private var checkedGate = "" 25 | private var checkedGateCount = 0 26 | private var checkedConfig = "" 27 | private var checkedConfigCount = 0 28 | private var checkedLayer = "" 29 | private var checkedLayerCount = 0 30 | 31 | @Before 32 | internal fun setup() { 33 | TestUtil.mockDispatchers() 34 | 35 | app = RuntimeEnvironment.getApplication() 36 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 37 | 38 | TestUtil.mockHashing() 39 | 40 | network = TestUtil.mockNetwork(captureUser = { user -> 41 | initUser = user 42 | }) 43 | 44 | coEvery { 45 | network.apiPostLogs(any(), any(), any()) 46 | } answers { 47 | flushedLogs = secondArg() 48 | } 49 | } 50 | 51 | @After 52 | internal fun tearDown() { 53 | unmockkAll() 54 | } 55 | 56 | @Test 57 | fun testInitialize() { 58 | val user = StatsigUser("123") 59 | user.customIDs = mapOf("random_id" to "abcde") 60 | 61 | fun evalCallback(config: BaseConfig) { 62 | if (config is FeatureGate) { 63 | checkedGate = config.getName() 64 | checkedGateCount++ 65 | } else if (config is DynamicConfig) { 66 | checkedConfig = config.getName() 67 | checkedConfigCount++ 68 | } else if (config is Layer) { 69 | checkedLayer = config.getName() 70 | checkedLayerCount++ 71 | } 72 | } 73 | 74 | val evalCallback: (BaseConfig) -> Unit = ::evalCallback 75 | 76 | TestUtil.startStatsigAndWait( 77 | app, 78 | user, 79 | StatsigOptions( 80 | overrideStableID = "custom_stable_id", 81 | evaluationCallback = evalCallback 82 | ), 83 | network = network 84 | ) 85 | client = Statsig.client 86 | 87 | assertTrue(client.checkGate("always_on")) 88 | assertEquals("always_on", checkedGate) 89 | assertEquals(1, checkedGateCount) 90 | assertTrue(client.checkGateWithExposureLoggingDisabled("always_on_v2")) 91 | assertEquals("always_on_v2", checkedGate) 92 | assertEquals(2, checkedGateCount) 93 | assertFalse(client.checkGateWithExposureLoggingDisabled("a_different_gate")) 94 | assertEquals("a_different_gate", checkedGate) 95 | assertEquals(3, checkedGateCount) 96 | assertFalse(client.checkGate("always_off")) 97 | assertEquals("always_off", checkedGate) 98 | assertEquals(4, checkedGateCount) 99 | assertFalse(client.checkGate("not_a_valid_gate_name")) 100 | assertEquals("not_a_valid_gate_name", checkedGate) 101 | assertEquals(5, checkedGateCount) 102 | 103 | client.getConfig("test_config") 104 | assertEquals("test_config", checkedConfig) 105 | assertEquals(1, checkedConfigCount) 106 | 107 | client.getConfigWithExposureLoggingDisabled("a_different_config") 108 | assertEquals("a_different_config", checkedConfig) 109 | assertEquals(2, checkedConfigCount) 110 | 111 | client.getConfig("not_a_valid_config") 112 | assertEquals("not_a_valid_config", checkedConfig) 113 | assertEquals(3, checkedConfigCount) 114 | 115 | client.getExperiment("exp") 116 | assertEquals("exp", checkedConfig) 117 | assertEquals(4, checkedConfigCount) 118 | 119 | client.getExperimentWithExposureLoggingDisabled("exp_other") 120 | assertEquals("exp_other", checkedConfig) 121 | assertEquals(5, checkedConfigCount) 122 | 123 | client.getLayer("layer") 124 | assertEquals("layer", checkedLayer) 125 | assertEquals(1, checkedLayerCount) 126 | 127 | client.getLayerWithExposureLoggingDisabled("layer_other") 128 | assertEquals("layer_other", checkedLayer) 129 | assertEquals(2, checkedLayerCount) 130 | 131 | // check a few previously checked gate and config; they should not result in exposure logs due to deduping logic 132 | client.checkGate("always_on") 133 | assertEquals("always_on", checkedGate) 134 | assertEquals(6, checkedGateCount) 135 | client.getConfig("test_config") 136 | assertEquals("test_config", checkedConfig) 137 | assertEquals(6, checkedConfigCount) 138 | client.getExperiment("exp") 139 | assertEquals("exp", checkedConfig) 140 | assertEquals(7, checkedConfigCount) 141 | 142 | client.shutdown() 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/ParameterStoreTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.Assert.* 6 | import org.junit.Before 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | import org.robolectric.RobolectricTestRunner 10 | import org.robolectric.RuntimeEnvironment 11 | 12 | @RunWith(RobolectricTestRunner::class) 13 | class ParameterStoreTest { 14 | private var app: Application = RuntimeEnvironment.getApplication() 15 | private val user = StatsigUser(userID = "a-user") 16 | private var client: StatsigClient = StatsigClient() 17 | 18 | private lateinit var paramStore: ParameterStore 19 | 20 | @Before 21 | internal fun setup() = runBlocking { 22 | TestUtil.mockHashing() 23 | TestUtil.mockDispatchers() 24 | 25 | val statsigNetwork = TestUtil.mockNetwork() 26 | 27 | client = StatsigClient() 28 | client.statsigNetwork = statsigNetwork 29 | 30 | client.initialize(app, "test-key") 31 | 32 | TestUtil.startStatsigAndWait( 33 | app, 34 | user, 35 | StatsigOptions(overrideStableID = "custom_stable_id"), 36 | network = TestUtil.mockNetwork() 37 | ) 38 | paramStore = ParameterStore( 39 | statsigClient = client, 40 | paramStore = mapOf( 41 | "static_value" to mapOf( 42 | "value" to "test", 43 | "ref_type" to "static", 44 | "param_type" to "string" 45 | ), 46 | "static_bool" to mapOf( 47 | "value" to true, 48 | "ref_type" to "static", 49 | "param_type" to "boolean" 50 | ), 51 | "gate_value" to mapOf( 52 | "ref_type" to "gate", 53 | "param_type" to "string", 54 | "gate_name" to "always_on", 55 | "pass_value" to "pass", 56 | "fail_value" to "fail" 57 | ), 58 | "gate_bool" to mapOf( 59 | "ref_type" to "gate", 60 | "param_type" to "string", 61 | "gate_name" to "always_on", 62 | "pass_value" to true, 63 | "fail_value" to false 64 | ), 65 | "gate_number" to mapOf( 66 | "ref_type" to "gate", 67 | "param_type" to "number", 68 | "gate_name" to "always_on", 69 | "pass_value" to 1, 70 | "fail_value" to -1 71 | ), 72 | "layer_value" to mapOf( 73 | "ref_type" to "layer", 74 | "param_type" to "string", 75 | "layer_name" to "allocated_layer", 76 | "param_name" to "string" 77 | ), 78 | "experiment_value" to mapOf( 79 | "ref_type" to "experiment", 80 | "param_type" to "string", 81 | "experiment_name" to "exp", 82 | "param_name" to "string" 83 | ), 84 | "dynamic_config_value" to mapOf( 85 | "ref_type" to "dynamic_config", 86 | "param_type" to "string", 87 | "config_name" to "test_config", 88 | "param_name" to "string" 89 | ) 90 | ), 91 | name = "test_parameter_store", 92 | evaluationDetails = EvaluationDetails(EvaluationReason.Network, lcut = 0), 93 | options = ParameterStoreEvaluationOptions(disableExposureLog = true) 94 | ) 95 | 96 | return@runBlocking 97 | } 98 | 99 | @Test 100 | fun testNullFallback() { 101 | assertNull(paramStore.getString("nonexistent", null)) 102 | assertEquals("test", paramStore.getString("static_value", null)) 103 | } 104 | 105 | @Test 106 | fun testNullFallbackForGates() { 107 | assertEquals("pass", paramStore.getString("gate_value", null)) 108 | } 109 | 110 | @Test 111 | fun testNullFallbackForLayers() { 112 | assertEquals("test", paramStore.getString("layer_value", null)) 113 | } 114 | 115 | @Test 116 | fun testNullFallbackForExperiments() { 117 | assertEquals("test", paramStore.getString("experiment_value", null)) 118 | } 119 | 120 | @Test 121 | fun testNullFallbackForDynamicConfigs() { 122 | assertEquals("test", paramStore.getString("dynamic_config_value", null)) 123 | } 124 | 125 | @Test 126 | fun testWrongStaticType() { 127 | assertEquals("DEFAULT", paramStore.getString("gate_bool", "DEFAULT")) 128 | } 129 | 130 | @Test 131 | fun testGetBoolean() { 132 | assertEquals(true, paramStore.getBoolean("gate_bool", false)) 133 | } 134 | 135 | @Test 136 | fun testGetBooleanFallback() { 137 | assertEquals(true, paramStore.getBoolean("nonexistent", true)) 138 | assertEquals(false, paramStore.getBoolean("nonexistent", false)) 139 | } 140 | 141 | @Test 142 | fun testGetNumber() { 143 | assertEquals(1.0, paramStore.getDouble("gate_number", 2.0), 0.01) 144 | } 145 | 146 | @Test 147 | fun testGetNumberFallback() { 148 | assertEquals(2.0, paramStore.getDouble("nonexistent", 2.0), 0.01) 149 | assertEquals(0.0, paramStore.getDouble("nonexistent", 0.0), 0.01) 150 | } 151 | 152 | @Test 153 | fun testGetNumberWrongType() { 154 | assertEquals("DEFAULT", paramStore.getString("gate_number", "DEFAULT")) 155 | assertEquals(true, paramStore.getBoolean("gate_number", true)) 156 | } 157 | 158 | @Test 159 | fun testWrongGateType() { 160 | assertEquals("DEFAULT", paramStore.getString("static_bool", "DEFAULT")) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigMultipleInitializeTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import io.mockk.* 5 | import kotlinx.coroutines.* 6 | import org.junit.Before 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | import org.robolectric.RobolectricTestRunner 10 | import org.robolectric.RuntimeEnvironment 11 | 12 | @OptIn(DelicateCoroutinesApi::class) 13 | @RunWith(RobolectricTestRunner::class) 14 | class StatsigMultipleInitializeTest { 15 | 16 | private lateinit var client: StatsigClient 17 | private lateinit var app: Application 18 | private lateinit var network: StatsigNetwork 19 | 20 | @Before 21 | fun setup() { 22 | TestUtil.mockDispatchers() 23 | app = RuntimeEnvironment.getApplication() 24 | client = spyk(StatsigClient(), recordPrivateCalls = true) 25 | network = TestUtil.mockNetwork() 26 | client.statsigNetwork = network 27 | 28 | coEvery { 29 | network.initialize( 30 | api = any(), 31 | user = any(), 32 | sinceTime = any(), 33 | metadata = any(), 34 | coroutineScope = any(), 35 | contextType = any(), 36 | diagnostics = any(), 37 | hashUsed = any(), 38 | previousDerivedFields = any(), 39 | fullChecksum = any() 40 | ) 41 | } coAnswers { 42 | TestUtil.makeInitializeResponse() 43 | } 44 | } 45 | 46 | @Test 47 | fun testMultipleInitializeAsyncCalls() { 48 | val job1 = GlobalScope.launch(Dispatchers.IO) { 49 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 50 | } 51 | 52 | val job2 = GlobalScope.launch(Dispatchers.IO) { 53 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 54 | } 55 | 56 | val job3 = GlobalScope.launch(Dispatchers.IO) { 57 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 58 | } 59 | 60 | runBlocking { 61 | joinAll(job1, job2, job3) 62 | } 63 | coVerify(exactly = 1) { 64 | network.initialize( 65 | api = any(), 66 | user = any(), 67 | sinceTime = any(), 68 | metadata = any(), 69 | coroutineScope = any(), 70 | contextType = any(), 71 | diagnostics = any(), 72 | hashUsed = any(), 73 | previousDerivedFields = any(), 74 | fullChecksum = any() 75 | ) 76 | } 77 | } 78 | 79 | @Test 80 | fun testMultipleInitializeCalls() { 81 | val job1 = GlobalScope.launch(Dispatchers.IO) { 82 | client.initialize(app, "client-key", StatsigUser("test_user")) 83 | } 84 | 85 | val job2 = GlobalScope.launch(Dispatchers.IO) { 86 | client.initialize(app, "client-key", StatsigUser("test_user")) 87 | } 88 | 89 | val job3 = GlobalScope.launch(Dispatchers.IO) { 90 | client.initialize(app, "client-key", StatsigUser("test_user")) 91 | } 92 | 93 | runBlocking { 94 | joinAll(job1, job2, job3) 95 | } 96 | coVerify(exactly = 1) { 97 | network.initialize( 98 | api = any(), 99 | user = any(), 100 | sinceTime = any(), 101 | metadata = any(), 102 | coroutineScope = any(), 103 | contextType = any(), 104 | diagnostics = any(), 105 | hashUsed = any(), 106 | previousDerivedFields = any(), 107 | fullChecksum = any() 108 | ) 109 | } 110 | } 111 | 112 | @Test 113 | fun testMultipleInitializeCallsOnMain() { 114 | val job1 = GlobalScope.launch(Dispatchers.Default) { 115 | client.initialize(app, "client-key", StatsigUser("test_user")) 116 | } 117 | 118 | val job2 = GlobalScope.launch(Dispatchers.Default) { 119 | client.initialize(app, "client-key", StatsigUser("test_user")) 120 | } 121 | 122 | val job3 = GlobalScope.launch(Dispatchers.Default) { 123 | client.initialize(app, "client-key", StatsigUser("test_user")) 124 | } 125 | 126 | runBlocking { 127 | joinAll(job1, job2, job3) 128 | } 129 | 130 | coVerify(exactly = 1) { 131 | network.initialize( 132 | api = any(), 133 | user = any(), 134 | sinceTime = any(), 135 | metadata = any(), 136 | coroutineScope = any(), 137 | contextType = any(), 138 | diagnostics = any(), 139 | hashUsed = any(), 140 | previousDerivedFields = any(), 141 | fullChecksum = any() 142 | ) 143 | } 144 | } 145 | 146 | @Test 147 | fun testMultipleInitializeAsyncCallsOnMain() { 148 | val job1 = GlobalScope.launch(Dispatchers.Default) { 149 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 150 | } 151 | 152 | val job2 = GlobalScope.launch(Dispatchers.Default) { 153 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 154 | } 155 | 156 | val job3 = GlobalScope.launch(Dispatchers.Default) { 157 | client.initializeAsync(app, "client-key", StatsigUser("test_user")) 158 | } 159 | 160 | runBlocking { 161 | joinAll(job1, job2, job3) 162 | } 163 | coVerify(exactly = 1) { 164 | network.initialize( 165 | api = any(), 166 | user = any(), 167 | sinceTime = any(), 168 | metadata = any(), 169 | coroutineScope = any(), 170 | contextType = any(), 171 | diagnostics = any(), 172 | hashUsed = any(), 173 | previousDerivedFields = any(), 174 | fullChecksum = any() 175 | ) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigOfflineInitializationTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import io.mockk.coEvery 5 | import io.mockk.mockk 6 | import java.util.concurrent.CountDownLatch 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.robolectric.RobolectricTestRunner 12 | import org.robolectric.RuntimeEnvironment 13 | 14 | @RunWith(RobolectricTestRunner::class) 15 | class StatsigOfflineInitializationTest { 16 | private lateinit var app: Application 17 | private lateinit var initializeCountdown: CountDownLatch 18 | 19 | private val logs = mutableListOf() 20 | private var initializeNetworkCalled = 0 21 | private val user = StatsigUser("123") 22 | private val gson = StatsigUtil.getOrBuildGson() 23 | 24 | @Before 25 | internal fun setup() { 26 | TestUtil.mockDispatchers() 27 | 28 | app = RuntimeEnvironment.getApplication() 29 | TestUtil.mockHashing() 30 | initializeCountdown = CountDownLatch(1) 31 | // Initialize and get response from network first so cache has most recent value 32 | TestUtil.startStatsigAndWait(app, user, network = TestUtil.mockNetwork()) 33 | Statsig.shutdown() 34 | Statsig.client.statsigNetwork = mockNetwork() 35 | } 36 | 37 | @Test 38 | fun testInitializeOffline() = runBlocking { 39 | // Initialize async offline 40 | Statsig.client.initializeAsync( 41 | app, 42 | "client-apikey", 43 | user, 44 | options = StatsigOptions(initializeOffline = true), 45 | callback = InitializeCallback(initializeCountdown) 46 | ) 47 | initializeCountdown.await() 48 | var config = Statsig.client.getConfig("test_config") 49 | assert(config.getEvaluationDetails().reason == EvaluationReason.Cache) 50 | assert(config.getString("string", "DEFAULT") == "test") 51 | assert(initializeNetworkCalled == 0) 52 | Statsig.shutdown() 53 | // Initialize offline 54 | Statsig.client.statsigNetwork = mockNetwork() 55 | Statsig.client.initialize( 56 | app, 57 | "client-apikey", 58 | user, 59 | StatsigOptions(initializeOffline = true) 60 | ) 61 | config = Statsig.client.getConfig("test_config") 62 | assert(config.getEvaluationDetails().reason == EvaluationReason.Cache) 63 | assert(config.getString("string", "DEFAULT") == "test") 64 | assert(initializeNetworkCalled == 0) 65 | Statsig.shutdown() 66 | assert(logs.size == 2) 67 | assert(logs[0].events[0].eventName == "statsig::diagnostics") 68 | var diagnosticMarkers = ( 69 | gson.fromJson( 70 | logs[0].events[0].metadata!!["markers"], 71 | Collection::class.java 72 | ) 73 | ).map { 74 | gson.fromJson(gson.toJson(it), Marker::class.java) 75 | } 76 | println(diagnosticMarkers.size) 77 | assert(diagnosticMarkers.size == 4) 78 | assert(diagnosticMarkers[0].key == KeyType.OVERALL) 79 | assert(diagnosticMarkers[0].action == ActionType.START) 80 | assert(diagnosticMarkers[3].key == KeyType.OVERALL) 81 | assert(diagnosticMarkers[3].action == ActionType.END) 82 | assert(diagnosticMarkers[3].success == true) 83 | 84 | diagnosticMarkers = 85 | (gson.fromJson(logs[1].events[0].metadata!!["markers"], Collection::class.java)).map { 86 | gson.fromJson(gson.toJson(it), Marker::class.java) 87 | } 88 | println(diagnosticMarkers.size) 89 | assert(diagnosticMarkers.size == 4) 90 | assert(diagnosticMarkers[0].key == KeyType.OVERALL) 91 | assert(diagnosticMarkers[0].action == ActionType.START) 92 | assert(diagnosticMarkers[3].key == KeyType.OVERALL) 93 | assert(diagnosticMarkers[3].action == ActionType.END) 94 | assert(diagnosticMarkers[3].success == true) 95 | } 96 | 97 | @Test 98 | fun testInitializeOfflineAndUpdateUser() = runBlocking { 99 | // Initialize async offline 100 | Statsig.client.initializeAsync( 101 | app, 102 | "client-apikey", 103 | user, 104 | options = StatsigOptions(initializeOffline = true), 105 | callback = InitializeCallback(initializeCountdown) 106 | ) 107 | initializeCountdown.await() 108 | var config = Statsig.client.getConfig("test_config") 109 | assert(config.getEvaluationDetails().reason == EvaluationReason.Cache) 110 | assert(config.getString("string", "DEFAULT") == "test") 111 | assert(initializeNetworkCalled == 0) 112 | user.email = "abc@gmail.com" 113 | Statsig.client.updateUser(user) 114 | config = Statsig.client.getConfig("test_config") 115 | assert(config.getEvaluationDetails().reason == EvaluationReason.Network) 116 | assert(config.getString("string", "DEFAULT") == "test") 117 | 118 | // From UpdateUser 119 | assert(initializeNetworkCalled == 1) 120 | } 121 | 122 | private fun mockNetwork(): StatsigNetwork { 123 | val statsigNetwork = mockk() 124 | coEvery { 125 | statsigNetwork.apiRetryFailedLogs(any()) 126 | } answers {} 127 | 128 | coEvery { 129 | statsigNetwork.initialize( 130 | api = any(), 131 | user = any(), 132 | sinceTime = any(), 133 | metadata = any(), 134 | coroutineScope = any(), 135 | contextType = any(), 136 | diagnostics = any(), 137 | hashUsed = any(), 138 | previousDerivedFields = any(), 139 | fullChecksum = any() 140 | ) 141 | } coAnswers { 142 | initializeNetworkCalled++ 143 | TestUtil.makeInitializeResponse() 144 | } 145 | 146 | coEvery { 147 | statsigNetwork.addFailedLogRequest(any()) 148 | } coAnswers {} 149 | 150 | coEvery { 151 | statsigNetwork.apiPostLogs(any(), any(), any()) 152 | } answers { 153 | logs.add(gson.fromJson(secondArg(), LogEventData::class.java)) 154 | } 155 | return statsigNetwork 156 | } 157 | 158 | class InitializeCallback(val initializeCountdown: CountDownLatch) : IStatsigCallback { 159 | override fun onStatsigInitialize() { 160 | initializeCountdown.countDown() 161 | } 162 | 163 | override fun onStatsigUpdateUser() { 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/NetworkFallbackResolver.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.content.SharedPreferences 4 | import com.google.gson.Gson 5 | import com.google.gson.reflect.TypeToken 6 | import java.net.URL 7 | import java.util.Date 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.launch 10 | 11 | data class FallbackInfoEntry( 12 | var url: String? = null, 13 | var previous: MutableList = mutableListOf(), 14 | var expiryTime: Long 15 | ) 16 | 17 | const val DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days 18 | const val COOLDOWN_TIME_MS = 4 * 60 * 60 * 1000L // 4 hours 19 | 20 | internal class NetworkFallbackResolver( 21 | private val sharedPreferences: SharedPreferences, 22 | private val statsigScope: CoroutineScope, 23 | private val urlConnectionProvider: UrlConnectionProvider = defaultProvider, 24 | private val gson: Gson 25 | ) { 26 | private var fallbackInfo: MutableMap? = null 27 | private val dnsQueryCooldowns: MutableMap = mutableMapOf() 28 | private val dispatcherProvider = CoroutineDispatcherProvider() 29 | 30 | suspend fun tryBumpExpiryTime(urlConfig: UrlConfig) { 31 | val info = fallbackInfo?.get(urlConfig.endpoint) ?: return 32 | info.expiryTime = Date().time + DEFAULT_TTL_MS 33 | val updatedFallbackInfo = fallbackInfo?.toMutableMap()?.apply { 34 | this[urlConfig.endpoint] = info 35 | } 36 | tryWriteFallbackInfoToCache(updatedFallbackInfo) 37 | } 38 | 39 | fun initializeFallbackInfo() { 40 | fallbackInfo = readFallbackInfoFromCache() 41 | } 42 | 43 | fun getActiveFallbackUrlFromMemory(urlConfig: UrlConfig): String? { 44 | if (!(urlConfig.customUrl == null && urlConfig.userFallbackUrls == null)) return null 45 | val entry = fallbackInfo?.get(urlConfig.endpoint) 46 | if (entry == null || Date().time > (entry.expiryTime)) { 47 | fallbackInfo?.remove(urlConfig.endpoint) 48 | statsigScope.launch(dispatcherProvider.io) { 49 | tryWriteFallbackInfoToCache(fallbackInfo) 50 | } 51 | return null 52 | } 53 | 54 | return entry.url 55 | } 56 | 57 | suspend fun tryFetchUpdatedFallbackInfo( 58 | urlConfig: UrlConfig, 59 | errorMessage: String?, 60 | timedOut: Boolean, 61 | hasNetwork: Boolean 62 | ): Boolean { 63 | return try { 64 | if (!isDomainFailure(errorMessage, timedOut, hasNetwork)) return false 65 | 66 | val canUseNetworkFallbacks = 67 | urlConfig.customUrl == null && urlConfig.userFallbackUrls == null 68 | 69 | val urls = if (canUseNetworkFallbacks) { 70 | tryFetchFallbackUrlsFromNetwork(urlConfig) 71 | } else { 72 | urlConfig.userFallbackUrls 73 | } 74 | 75 | val newUrl = 76 | pickNewFallbackUrl(fallbackInfo?.get(urlConfig.endpoint), urls) ?: return false 77 | 78 | updateFallbackInfoWithNewUrl(urlConfig.endpoint, newUrl) 79 | true 80 | } catch (error: Exception) { 81 | false 82 | } 83 | } 84 | 85 | private suspend fun updateFallbackInfoWithNewUrl(endpoint: Endpoint, newUrl: String) { 86 | val newFallbackInfo = FallbackInfoEntry( 87 | url = newUrl, 88 | expiryTime = 89 | Date().time + DEFAULT_TTL_MS 90 | ) 91 | 92 | val previousInfo = fallbackInfo?.get(endpoint) 93 | previousInfo?.let { newFallbackInfo.previous.addAll(it.previous) } 94 | 95 | if (newFallbackInfo.previous.size > 10) newFallbackInfo.previous.clear() 96 | 97 | val previousUrl = fallbackInfo?.get(endpoint)?.url 98 | previousUrl?.let { newFallbackInfo.previous.add(it) } 99 | 100 | fallbackInfo = (fallbackInfo ?: mutableMapOf()).apply { put(endpoint, newFallbackInfo) } 101 | tryWriteFallbackInfoToCache(fallbackInfo) 102 | } 103 | 104 | private suspend fun tryFetchFallbackUrlsFromNetwork(urlConfig: UrlConfig): List? { 105 | val cooldown = dnsQueryCooldowns[urlConfig.endpoint] 106 | if (cooldown != null && Date().time < cooldown) return null 107 | 108 | dnsQueryCooldowns[urlConfig.endpoint] = Date().time + COOLDOWN_TIME_MS 109 | 110 | val result = mutableListOf() 111 | val records = fetchTxtRecords(urlConnectionProvider) 112 | val path = extractPathFromUrl(urlConfig.defaultUrl) 113 | 114 | for (record in records) { 115 | if (!record.startsWith("${urlConfig.endpointDnsKey}=")) continue 116 | 117 | val parts = record.split("=") 118 | if (parts.size > 1) { 119 | val baseUrl = parts[1].removeSuffix("/") 120 | result.add("https://$baseUrl$path") 121 | } 122 | } 123 | return result 124 | } 125 | 126 | suspend fun tryWriteFallbackInfoToCache(info: MutableMap?) { 127 | val hashKey = getFallbackInfoStorageKey() 128 | if (info.isNullOrEmpty()) { 129 | StatsigUtil.removeFromSharedPrefs(sharedPreferences, hashKey) 130 | } else { 131 | StatsigUtil.saveStringToSharedPrefs(sharedPreferences, hashKey, gson.toJson(info)) 132 | } 133 | } 134 | 135 | fun readFallbackInfoFromCache(): MutableMap? { 136 | val hashKey = getFallbackInfoStorageKey() 137 | val data = StatsigUtil.syncGetFromSharedPrefs(sharedPreferences, hashKey) ?: return null 138 | return try { 139 | val mapType = object : TypeToken>() {}.type 140 | gson.fromJson(data, mapType) 141 | } catch (e: Exception) { 142 | null 143 | } 144 | } 145 | 146 | private fun pickNewFallbackUrl( 147 | currentFallbackInfo: FallbackInfoEntry?, 148 | urls: List? 149 | ): String? { 150 | if (urls == null) return null 151 | 152 | val previouslyUsed = currentFallbackInfo?.previous?.toSet() ?: emptySet() 153 | val currentFallbackUrl = currentFallbackInfo?.url 154 | 155 | for (loopUrl in urls) { 156 | val url = loopUrl.removeSuffix("/") 157 | if (url !in previouslyUsed && url != currentFallbackUrl) { 158 | return url 159 | } 160 | } 161 | return null 162 | } 163 | } 164 | fun getFallbackInfoStorageKey(): String = "statsig.network_fallback" 165 | 166 | fun isDomainFailure(errorMsg: String?, timedOut: Boolean, hasNetwork: Boolean): Boolean { 167 | if (!hasNetwork) return false 168 | return timedOut || errorMsg != null 169 | } 170 | 171 | fun extractPathFromUrl(urlString: String): String? = try { 172 | URL(urlString).path 173 | } catch (e: Exception) { 174 | null 175 | } 176 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigOverridesTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import io.mockk.* 5 | import kotlinx.coroutines.runBlocking 6 | import org.junit.After 7 | import org.junit.Assert.* 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.robolectric.RobolectricTestRunner 12 | import org.robolectric.RuntimeEnvironment 13 | 14 | @RunWith(RobolectricTestRunner::class) 15 | class StatsigOverridesTest { 16 | private lateinit var app: Application 17 | 18 | private val aValueMap = mapOf( 19 | "str" to "string-val", 20 | "bool" to true, 21 | "num" to 123 22 | ) 23 | 24 | private val aConfig = mapOf( 25 | "str" to "string-val", 26 | "bool" to true, 27 | "num" to 123 28 | ) 29 | 30 | @Before 31 | internal fun setup() { 32 | runBlocking { 33 | TestUtil.mockDispatchers() 34 | TestUtil.mockHashing() 35 | app = RuntimeEnvironment.getApplication() 36 | 37 | val statsigNetwork = TestUtil.mockNetwork() 38 | 39 | Statsig.client = StatsigClient() 40 | Statsig.client.statsigNetwork = statsigNetwork 41 | 42 | Statsig.initialize(app, "test-key") 43 | } 44 | } 45 | 46 | @After 47 | internal fun tearDown() { 48 | unmockkAll() 49 | } 50 | 51 | @Test 52 | fun testOverrideGate() { 53 | Statsig.overrideGate("always_off", true) 54 | assertTrue(Statsig.checkGate("always_off")) 55 | 56 | Statsig.overrideGate("always_on", false) 57 | assertFalse(Statsig.checkGate("always_on")) 58 | } 59 | 60 | @Test 61 | fun testOverrideConfig() { 62 | Statsig.overrideConfig("a_config", aConfig) 63 | 64 | val config = Statsig.getConfig("a_config") 65 | assertEquals("a_config", config.getName()) 66 | assertEquals("string-val", config.getString("str", null)) 67 | assertEquals(true, config.getBoolean("bool", false)) 68 | assertEquals(123, config.getInt("num", 0)) 69 | assertEquals(EvaluationReason.LocalOverride, config.getEvaluationDetails().reason) 70 | } 71 | 72 | @Test 73 | fun testOverrideExperiment() { 74 | Statsig.overrideConfig("a_config", aConfig) 75 | 76 | val experiment = Statsig.getExperiment("a_config") 77 | assertEquals("a_config", experiment.getName()) 78 | assertEquals("string-val", experiment.getString("str", null)) 79 | assertEquals(true, experiment.getBoolean("bool", false)) 80 | assertEquals(123, experiment.getInt("num", 0)) 81 | assertEquals(EvaluationReason.LocalOverride, experiment.getEvaluationDetails().reason) 82 | } 83 | 84 | @Test 85 | fun testOverrideLayer() { 86 | Statsig.overrideLayer("a_layer", aValueMap) 87 | 88 | val layer = Statsig.getLayer("a_layer") 89 | assertEquals("a_layer", layer.getName()) 90 | assertEquals("string-val", layer.getString("str", null)) 91 | assertEquals(true, layer.getBoolean("bool", false)) 92 | assertEquals(123, layer.getInt("num", 0)) 93 | assertEquals(EvaluationReason.LocalOverride, layer.getEvaluationDetails().reason) 94 | } 95 | 96 | @Test 97 | fun testRemovingSingleLayerOverride() { 98 | Statsig.overrideGate("a_gate", true) 99 | Statsig.overrideConfig("a_config", aValueMap) 100 | Statsig.overrideLayer("a_layer", aValueMap) 101 | Statsig.overrideLayer("b_layer", aValueMap) 102 | 103 | Statsig.removeOverride("a_layer") 104 | 105 | assertTrue(Statsig.checkGate("a_gate")) 106 | 107 | val config = Statsig.getConfig("a_config") 108 | assertEquals(EvaluationReason.LocalOverride, config.getEvaluationDetails().reason) 109 | 110 | val layer = Statsig.getLayer("a_layer") 111 | assertEquals(EvaluationReason.Unrecognized, layer.getEvaluationDetails().reason) 112 | 113 | val anotherLayer = Statsig.getLayer("b_layer") 114 | assertEquals(EvaluationReason.LocalOverride, anotherLayer.getEvaluationDetails().reason) 115 | } 116 | 117 | @Test 118 | fun testRemovingSingleOverride() { 119 | Statsig.overrideGate("a_gate", true) 120 | Statsig.overrideConfig("a_config", aValueMap) 121 | Statsig.overrideConfig("b_config", aValueMap) 122 | Statsig.overrideLayer("a_layer", aValueMap) 123 | 124 | Statsig.removeOverride("a_config") 125 | 126 | assertTrue(Statsig.checkGate("a_gate")) 127 | 128 | val config = Statsig.getConfig("a_config") 129 | assertEquals(EvaluationReason.Unrecognized, config.getEvaluationDetails().reason) 130 | 131 | val anotherConfig = Statsig.getConfig("b_config") 132 | assertEquals(EvaluationReason.LocalOverride, anotherConfig.getEvaluationDetails().reason) 133 | 134 | val layer = Statsig.getLayer("a_layer") 135 | assertEquals(EvaluationReason.LocalOverride, layer.getEvaluationDetails().reason) 136 | } 137 | 138 | @Test 139 | fun testRemovingOverrides() { 140 | Statsig.overrideGate("always_off", true) 141 | Statsig.overrideGate("always_on", false) 142 | Statsig.overrideConfig("a_config", aValueMap) 143 | Statsig.overrideLayer("a_layer", aValueMap) 144 | 145 | Statsig.removeAllOverrides() 146 | assertTrue(Statsig.checkGate("always_on")) 147 | assertFalse(Statsig.checkGate("always_off")) 148 | 149 | val config = Statsig.getConfig("a_config") 150 | assertEquals("a_config", config.getName()) 151 | assertNull(config.getString("str", null)) 152 | assertFalse(config.getBoolean("bool", false)) 153 | assertEquals(0, config.getInt("num", 0)) 154 | // reason should be unrecognized because a_config does not exist 155 | assertEquals(EvaluationReason.Unrecognized, config.getEvaluationDetails().reason) 156 | 157 | val layer = Statsig.getConfig("a_layer") 158 | assertEquals("a_layer", layer.getName()) 159 | assertNull(config.getString("str", null)) 160 | assertEquals(EvaluationReason.Unrecognized, layer.getEvaluationDetails().reason) 161 | } 162 | 163 | @Test 164 | fun testGetAllOverrides() { 165 | Statsig.overrideGate("always_off", true) 166 | Statsig.overrideGate("always_on", false) 167 | Statsig.overrideConfig("a_config", aValueMap) 168 | Statsig.overrideLayer("a_layer", aValueMap) 169 | 170 | val overrides = Statsig.getAllOverrides() 171 | 172 | assertEquals(true, overrides.gates["always_off"]) 173 | assertEquals(false, overrides.gates["always_on"]) 174 | 175 | val config = overrides.configs["a_config"] ?: mapOf() 176 | assertEquals(aValueMap["str"], config["str"]) 177 | 178 | val layer = overrides.layers["a_layer"] ?: mapOf() 179 | assertEquals(aValueMap["str"], layer["str"]) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/evaluator/EvaluatorUtils.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk.evaluator 2 | 3 | import com.statsig.androidsdk.Statsig 4 | import com.statsig.androidsdk.StatsigUser 5 | import java.lang.Long.parseLong 6 | import java.text.SimpleDateFormat 7 | import java.util.Date 8 | 9 | internal object EvaluatorUtils { 10 | 11 | fun getValueAsString(input: Any?): String? { 12 | if (input == null) { 13 | return null 14 | } 15 | if (input is String) { 16 | return input 17 | } 18 | return input.toString() 19 | } 20 | 21 | fun getValueAsDouble(input: Any?): Double? { 22 | if (input == null) { 23 | return null 24 | } 25 | 26 | if (input is String) { 27 | return input.toDoubleOrNull() 28 | } 29 | 30 | if (input is ULong) { 31 | return input.toDouble() 32 | } 33 | 34 | if (input is Double) { 35 | return input 36 | } 37 | 38 | if (input is Number) { 39 | return input.toDouble() 40 | } 41 | 42 | return null 43 | } 44 | 45 | fun contains(targets: Any?, value: Any?, ignoreCase: Boolean): Boolean { 46 | if (targets == null || value == null) { 47 | return false 48 | } 49 | val iterable: Iterable<*> = 50 | when (targets) { 51 | is Iterable<*> -> { 52 | targets 53 | } 54 | 55 | is Array<*> -> { 56 | targets.asIterable() 57 | } 58 | 59 | else -> { 60 | return false 61 | } 62 | } 63 | for (option in iterable) { 64 | if ((option is String) && (value is String) && option.equals(value, ignoreCase)) { 65 | return true 66 | } 67 | if (option == value) { 68 | return true 69 | } 70 | } 71 | return false 72 | } 73 | 74 | fun getUserValueForField(user: StatsigUser, field: String): Any? = when (field) { 75 | "userid", "user_id" -> user.userID 76 | "email" -> user.email 77 | "ip", "ipaddress", "ip_address" -> user.ip 78 | "useragent", "user_agent" -> user.userAgent 79 | "country" -> user.country 80 | "locale" -> user.locale 81 | "appversion", "app_version" -> user.appVersion 82 | else -> null 83 | } 84 | 85 | fun getFromUser(user: StatsigUser, field: String): Any? { 86 | var value: Any? = 87 | getUserValueForField(user, field) ?: getUserValueForField(user, field.lowercase()) 88 | 89 | if ((value == null || value == "") && user.custom != null) { 90 | value = user.custom?.get(field) ?: user.custom?.get(field.lowercase()) 91 | } 92 | if ((value == null || value == "") && user.privateAttributes != null) { 93 | value = 94 | user.privateAttributes?.get(field) 95 | ?: user.privateAttributes?.get(field.lowercase()) 96 | } 97 | 98 | return value 99 | } 100 | 101 | fun getFromEnvironment(user: StatsigUser, field: String): String? = 102 | user.statsigEnvironment?.get(field) 103 | ?: user.statsigEnvironment?.get(field.lowercase()) 104 | 105 | fun getUnitID(user: StatsigUser, idType: String?): String? { 106 | val lowerIdType = idType?.lowercase() 107 | if (lowerIdType != "userid" && lowerIdType?.isEmpty() == false) { 108 | return user.customIDs?.get(idType) ?: user.customIDs?.get(lowerIdType) 109 | } 110 | return user.userID 111 | } 112 | 113 | fun matchStringInArray( 114 | value: Any?, 115 | target: Any?, 116 | compare: (value: String, target: String) -> Boolean 117 | ): Boolean { 118 | val strValue = getValueAsString(value) ?: return false 119 | val iterable = 120 | when (target) { 121 | is Iterable<*> -> { 122 | target 123 | } 124 | 125 | is Array<*> -> { 126 | target.asIterable() 127 | } 128 | 129 | else -> { 130 | return false 131 | } 132 | } 133 | 134 | for (match in iterable) { 135 | val strMatch = this.getValueAsString(match) ?: continue 136 | if (compare(strValue, strMatch)) { 137 | return true 138 | } 139 | } 140 | return false 141 | } 142 | 143 | fun compareDates(compare: ((a: Date, b: Date) -> Boolean), a: Any?, b: Any?): ConfigEvaluation { 144 | if (a == null || b == null) { 145 | return ConfigEvaluation(booleanValue = false) 146 | } 147 | 148 | val firstEpoch = getDate(a) 149 | val secondEpoch = getDate(b) 150 | 151 | if (firstEpoch == null || secondEpoch == null) { 152 | return ConfigEvaluation( 153 | booleanValue = false 154 | ) 155 | } 156 | return ConfigEvaluation( 157 | booleanValue = compare(firstEpoch, secondEpoch) 158 | ) 159 | } 160 | 161 | private fun getEpoch(input: Any?): Long? { 162 | var epoch = 163 | when (input) { 164 | is String -> parseLong(input) 165 | is Number -> input.toLong() 166 | else -> return null 167 | } 168 | 169 | if (epoch.toString().length < 11) { 170 | // epoch in seconds (milliseconds would be before 1970) 171 | epoch *= 1000 172 | } 173 | 174 | return epoch 175 | } 176 | 177 | private fun parseISOTimestamp(input: Any?): Date? { 178 | if (input is String) { 179 | return try { 180 | val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") 181 | return format.parse(input) 182 | } catch (e: Exception) { 183 | Statsig.client.errorBoundary.logException(e, tag = "parseISOTimestamp") 184 | null 185 | } 186 | } 187 | return null 188 | } 189 | 190 | private fun getDate(input: Any?): Date? { 191 | if (input == null) { 192 | return null 193 | } 194 | return try { 195 | val epoch: Long = getEpoch(input) ?: return parseISOTimestamp(input) 196 | Date(epoch) 197 | } catch (e: Exception) { 198 | parseISOTimestamp(input) 199 | } 200 | } 201 | 202 | fun versionCompare(v1: String, v2: String): Int { 203 | val parts1 = v1.split(".") 204 | val parts2 = v2.split(".") 205 | 206 | var i = 0 207 | while (i < parts1.size.coerceAtLeast(parts2.size)) { 208 | var c1 = 0 209 | var c2 = 0 210 | if (i < parts1.size) { 211 | c1 = parts1[i].toInt() 212 | } 213 | if (i < parts2.size) { 214 | c2 = parts2[i].toInt() 215 | } 216 | if (c1 < c2) { 217 | return -1 218 | } else if (c1 > c2) { 219 | return 1 220 | } 221 | i++ 222 | } 223 | return 0 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/ExperimentCacheTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import io.mockk.coEvery 6 | import io.mockk.spyk 7 | import java.util.concurrent.CountDownLatch 8 | import java.util.concurrent.TimeUnit 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.withContext 12 | import org.junit.Assert.assertEquals 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.robolectric.RobolectricTestRunner 17 | import org.robolectric.RuntimeEnvironment 18 | 19 | internal suspend fun getResponseForUser(user: StatsigUser, configName: String): InitializeResponse = 20 | withContext(Dispatchers.IO) { 21 | delay(500) 22 | TestUtil.makeInitializeResponse( 23 | mapOf(), 24 | mapOf( 25 | "$configName!" to APIDynamicConfig( 26 | "$configName!", 27 | mutableMapOf( 28 | "key" to if (user.userID == "") "logged_out_value" else "value" 29 | ), 30 | "default" 31 | ) 32 | ), 33 | mapOf() 34 | ) 35 | } 36 | 37 | @RunWith(RobolectricTestRunner::class) 38 | class ExperimentCacheTest { 39 | private lateinit var app: Application 40 | private lateinit var testSharedPrefs: SharedPreferences 41 | private lateinit var network: StatsigNetwork 42 | private var initializeCalls: Int = 0 43 | 44 | @Before 45 | fun setup() { 46 | TestUtil.mockDispatchers() 47 | app = RuntimeEnvironment.getApplication() 48 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 49 | TestUtil.mockHashing() 50 | 51 | // Setup network mock that returns different responses for first/second session 52 | network = TestUtil.mockNetwork() 53 | coEvery { 54 | network.initialize(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) 55 | } coAnswers { 56 | val res = getResponseForUser( 57 | secondArg(), 58 | if (initializeCalls < 59 | 2 60 | ) { 61 | "a_config" 62 | } else { 63 | "b_config" 64 | } 65 | ) 66 | initializeCalls++ 67 | res 68 | } 69 | 70 | Statsig.client = spyk() 71 | Statsig.client.statsigNetwork = network 72 | } 73 | 74 | private fun StatsigUser.getTestCustomCacheKey(sdkKey: String): String { 75 | var id = userID ?: STATSIG_NULL_USER 76 | val ids = (customIDs ?: mapOf()).filter { it.key != "companyId" } 77 | 78 | for ((k, v) in ids) { 79 | id = "$id$k:$v" 80 | } 81 | return "$id:$sdkKey" 82 | } 83 | 84 | @Test 85 | fun testExperimentCacheAfterRestart() { 86 | val initializationOptions = StatsigOptions( 87 | disableHashing = true, 88 | customCacheKey = { sdkKey, user -> user.getTestCustomCacheKey(sdkKey) } 89 | ) 90 | val loggedOutUser = StatsigUser("") 91 | // Test user setup 92 | val loggedInUser = StatsigUser("test-user-id").apply { 93 | customIDs = mapOf( 94 | "deviceId" to "test-device-id", 95 | "companyId" to "test-company-id" 96 | ) 97 | custom = mapOf( 98 | "test" to "custom_value" 99 | ) 100 | } 101 | 102 | val didInitializeLoggedOutUser = CountDownLatch(1) 103 | val didInitializeLoggedInUser = CountDownLatch(1) 104 | 105 | val callback = object : IStatsigCallback { 106 | override fun onStatsigInitialize() { 107 | didInitializeLoggedOutUser.countDown() 108 | } 109 | 110 | override fun onStatsigUpdateUser() { 111 | didInitializeLoggedInUser.countDown() 112 | } 113 | } 114 | // First app session 115 | Statsig.initializeAsync(app, "client-key", loggedOutUser, callback, initializationOptions) 116 | 117 | // Wait for network response to complete 118 | didInitializeLoggedOutUser.await(3, TimeUnit.SECONDS) 119 | 120 | // Verify experiment works for logged out user 121 | var experiment = Statsig.getExperiment("a_config") 122 | assertEquals(EvaluationReason.Network, experiment.getEvaluationDetails().reason) 123 | assertEquals("logged_out_value", experiment.getString("key", "default")) 124 | 125 | Statsig.updateUserAsync(loggedInUser, callback) 126 | didInitializeLoggedInUser.await(3, TimeUnit.SECONDS) 127 | 128 | // Verify experiment works for logged in user 129 | experiment = Statsig.getExperiment("a_config") 130 | assertEquals(EvaluationReason.Network, experiment.getEvaluationDetails().reason) 131 | assertEquals("value", experiment.getString("key", "default")) 132 | // should be written to cache now 133 | 134 | // Shutdown SDK 135 | Statsig.shutdown() 136 | 137 | // Setup second session 138 | val didInitializeLoggedInUserOnNextSession = CountDownLatch(1) 139 | 140 | val callbackAgain = object : IStatsigCallback { 141 | override fun onStatsigInitialize() { 142 | didInitializeLoggedInUserOnNextSession.countDown() 143 | } 144 | 145 | override fun onStatsigUpdateUser() { 146 | } 147 | } 148 | TestUtil.mockDispatchers() 149 | 150 | Statsig.client = spyk() 151 | Statsig.client.statsigNetwork = network 152 | 153 | val loggedInUserV2 = StatsigUser("test-user-id").apply { 154 | customIDs = mapOf( 155 | "deviceId" to "test-device-id", 156 | "companyId" to "test-company-id" 157 | ) 158 | custom = mapOf( 159 | "test" to "custom_value", 160 | "prop" to "value" 161 | ) 162 | } 163 | 164 | // Initialize SDK for second session 165 | Statsig.initializeAsync( 166 | app, 167 | "client-key", 168 | loggedInUserV2, 169 | callbackAgain, 170 | initializationOptions 171 | ) 172 | 173 | // Check experiment immediately after init (before network response) for cached value 174 | experiment = Statsig.getExperiment("a_config") 175 | assertEquals(EvaluationReason.Cache, experiment.getEvaluationDetails().reason) 176 | assertEquals("value", experiment.getString("key", "default")) 177 | 178 | // Wait for network response to complete 179 | didInitializeLoggedInUserOnNextSession.await(3, TimeUnit.SECONDS) 180 | 181 | // Verify new experiment from network response 182 | experiment = Statsig.getExperiment("b_config") 183 | assertEquals(EvaluationReason.Network, experiment.getEvaluationDetails().reason) 184 | assertEquals("value", experiment.getString("key", "default")) 185 | 186 | // Verify old experiment is no longer available 187 | experiment = Statsig.getExperiment("a_config") 188 | assertEquals(EvaluationReason.Unrecognized, experiment.getEvaluationDetails().reason) 189 | assertEquals("default", experiment.getString("key", "default")) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/AsyncInitVsUpdateTest.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | import android.app.Application 4 | import android.content.SharedPreferences 5 | import com.google.gson.Gson 6 | import io.mockk.coEvery 7 | import io.mockk.spyk 8 | import java.util.concurrent.CountDownLatch 9 | import java.util.concurrent.TimeUnit 10 | import kotlinx.coroutines.DelicateCoroutinesApi 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.GlobalScope 13 | import kotlinx.coroutines.async 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.runBlocking 16 | import kotlinx.coroutines.withContext 17 | import org.junit.After 18 | import org.junit.Assert.assertEquals 19 | import org.junit.Before 20 | import org.junit.Test 21 | import org.junit.runner.RunWith 22 | import org.robolectric.RobolectricTestRunner 23 | import org.robolectric.RuntimeEnvironment 24 | 25 | internal suspend fun getResponseForUser(user: StatsigUser): InitializeResponse = 26 | withContext(Dispatchers.IO) { 27 | val isUserA = user.userID == "user-a" 28 | delay(if (isUserA) 500 else 2000) 29 | TestUtil.makeInitializeResponse( 30 | mapOf(), 31 | mapOf( 32 | "a_config!" to APIDynamicConfig( 33 | "a_config!", 34 | mutableMapOf( 35 | "key" to if (isUserA) "user_a_value" else "user_b_value" 36 | ), 37 | "default" 38 | ) 39 | ), 40 | mapOf(), 41 | if (isUserA) 1748624742068 else 1748624742069, 42 | true 43 | ) 44 | } 45 | 46 | @RunWith(RobolectricTestRunner::class) 47 | class AsyncInitVsUpdateTest { 48 | lateinit var app: Application 49 | private lateinit var testSharedPrefs: SharedPreferences 50 | private val gson = Gson() 51 | private lateinit var client: StatsigClient 52 | private lateinit var network: StatsigNetwork 53 | 54 | @Before 55 | internal fun setup() { 56 | TestUtil.mockDispatchers() 57 | 58 | app = RuntimeEnvironment.getApplication() 59 | testSharedPrefs = TestUtil.getTestSharedPrefs(app) 60 | TestUtil.mockHashing() 61 | 62 | network = TestUtil.mockNetwork() 63 | coEvery { 64 | network.initialize(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) 65 | } coAnswers { 66 | val user = secondArg() 67 | getResponseForUser(user) 68 | } 69 | client = spyk() 70 | client.statsigNetwork = network 71 | } 72 | 73 | @After 74 | fun tearDown() { 75 | runBlocking { 76 | client.shutdownSuspend() 77 | } 78 | TestUtil.clearMockDispatchers() 79 | } 80 | 81 | @Test 82 | fun testNoCache() { 83 | val userA = StatsigUser("user-a") 84 | userA.customIDs = mapOf("workID" to "employee-a") 85 | 86 | val userB = StatsigUser("user-b") 87 | userB.customIDs = mapOf("workID" to "employee-b") 88 | 89 | val didInitializeUserA = CountDownLatch(1) 90 | val didInitializeUserB = CountDownLatch(1) 91 | 92 | val callback = object : IStatsigCallback { 93 | override fun onStatsigInitialize() { 94 | didInitializeUserA.countDown() 95 | } 96 | 97 | override fun onStatsigUpdateUser() { 98 | didInitializeUserB.countDown() 99 | } 100 | } 101 | 102 | client.initializeAsync(app, "client-key", userA, callback) 103 | client.updateUserAsync(userB, callback) 104 | 105 | // Since updateUserAsync has been called, we void values for user_a 106 | var config = client.getConfig("a_config") 107 | var value = config.getString("key", "default") 108 | assertEquals("default", value) 109 | assertEquals(EvaluationReason.Uninitialized, config.getEvaluationDetails().reason) 110 | 111 | didInitializeUserA.await(3, TimeUnit.SECONDS) 112 | config = client.getConfig("a_config") 113 | value = config.getString("key", "default") 114 | assertEquals("default", value) 115 | assertEquals(EvaluationReason.Uninitialized, config.getEvaluationDetails().reason) 116 | 117 | didInitializeUserB.await(5, TimeUnit.SECONDS) 118 | 119 | value = client.getConfig("a_config").getString("key", "default") 120 | assertEquals("user_b_value", value) 121 | } 122 | 123 | @Test 124 | fun testLoadFromCache() { 125 | val userA = StatsigUser("user-a") 126 | val userB = StatsigUser("user-b") 127 | 128 | val didInitializeUserA = CountDownLatch(1) 129 | val didInitializeUserB = CountDownLatch(1) 130 | 131 | val callback = object : IStatsigCallback { 132 | override fun onStatsigInitialize() { 133 | didInitializeUserA.countDown() 134 | } 135 | 136 | override fun onStatsigUpdateUser() { 137 | didInitializeUserB.countDown() 138 | } 139 | } 140 | 141 | val cacheById: MutableMap = HashMap() 142 | val values: MutableMap = HashMap() 143 | val sticky: MutableMap = HashMap() 144 | val userBCacheValues = TestUtil.makeInitializeResponse( 145 | mapOf(), 146 | mapOf( 147 | "a_config!" to APIDynamicConfig( 148 | "a_config!", 149 | mutableMapOf( 150 | "key" to "user_b_value_cache" 151 | ), 152 | "default" 153 | ) 154 | ), 155 | mapOf() 156 | ) 157 | values["values"] = userBCacheValues 158 | values["stickyUserExperiments"] = sticky 159 | cacheById["${userB.getCacheKey()}:client-key"] = values 160 | testSharedPrefs.edit().putString("Statsig.CACHE_BY_USER", gson.toJson(cacheById)).apply() 161 | 162 | client.initializeAsync(app, "client-key", userA, callback) 163 | client.updateUserAsync(userB, callback) 164 | 165 | // Since updateUserAsync has been called, we void values for user_a and serve cache 166 | // values for user_b 167 | var config = client.getConfig("a_config") 168 | var value = config.getString("key", "default") 169 | assertEquals("user_b_value_cache", value) 170 | assertEquals(EvaluationReason.Cache, config.getEvaluationDetails().reason) 171 | 172 | didInitializeUserA.await(2, TimeUnit.SECONDS) 173 | config = client.getConfig("a_config") 174 | value = config.getString("key", "default") 175 | assertEquals("user_b_value_cache", value) 176 | assertEquals(EvaluationReason.Cache, config.getEvaluationDetails().reason) 177 | 178 | didInitializeUserB.await(3, TimeUnit.SECONDS) 179 | 180 | config = client.getConfig("a_config") 181 | value = config.getString("key", "default") 182 | assertEquals("user_b_value", value) 183 | assertEquals(EvaluationReason.Network, config.getEvaluationDetails().reason) 184 | } 185 | 186 | @OptIn(DelicateCoroutinesApi::class) 187 | @Test 188 | fun testNoAwait() { 189 | val userA = StatsigUser("user-a") 190 | val userB = StatsigUser("user-b") 191 | 192 | val didInitializeUserA = CountDownLatch(1) 193 | val callback = object : IStatsigCallback { 194 | override fun onStatsigInitialize() { 195 | didInitializeUserA.countDown() 196 | } 197 | 198 | override fun onStatsigUpdateUser() {} 199 | } 200 | client.initializeAsync(app, "client-key", userA, callback) 201 | didInitializeUserA.await(1, TimeUnit.SECONDS) 202 | GlobalScope.async { 203 | client.updateUser(userB) 204 | } 205 | 206 | // Calling updateUser without suspending will not guarantee synchronous load from cache 207 | val config = client.getConfig("a_config") 208 | val value = config.getString("key", "default") 209 | assertEquals("user_a_value", value) 210 | assertEquals(EvaluationReason.Network, config.getEvaluationDetails().reason) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/main/java/com/statsig/androidsdk/ParameterStore.kt: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk 2 | 3 | data class ParameterStoreEvaluationOptions( 4 | /** 5 | * Prevents an exposure log being created for checks on this parameter store 6 | * 7 | * default: `false` 8 | */ 9 | val disableExposureLog: Boolean = false 10 | ) 11 | 12 | enum class RefType(val value: String) { 13 | GATE("gate"), 14 | EXPERIMENT("experiment"), 15 | LAYER("layer"), 16 | DYNAMIC_CONFIG("dynamic_config"), 17 | STATIC("static"), 18 | UNKNOWN("unknown") 19 | ; 20 | 21 | override fun toString(): String = value 22 | 23 | companion object { 24 | fun fromString(value: String): RefType = values().find { it.value == value } ?: UNKNOWN 25 | } 26 | } 27 | 28 | enum class ParamType(val value: String) { 29 | BOOLEAN("boolean"), 30 | STRING("string"), 31 | NUMBER("number"), 32 | OBJECT("object"), 33 | ARRAY("array"), 34 | UNKNOWN("unknown") 35 | ; 36 | 37 | override fun toString(): String = value 38 | 39 | companion object { 40 | fun fromString(value: String): ParamType = values().find { it.value == value } ?: UNKNOWN 41 | } 42 | } 43 | 44 | class ParameterStore( 45 | private val statsigClient: StatsigClient, 46 | private val paramStore: Map>, 47 | public val name: String, 48 | public val evaluationDetails: EvaluationDetails, 49 | public val options: ParameterStoreEvaluationOptions? 50 | ) { 51 | fun getBoolean(paramName: String, fallback: Boolean): Boolean = 52 | getValueFromRef(paramName, fallback, Layer::getBoolean, DynamicConfig::getBoolean) 53 | 54 | fun getString(paramName: String, fallback: String?): String? = 55 | getValueFromRef(paramName, fallback, Layer::getString, DynamicConfig::getString) 56 | 57 | fun getDouble(paramName: String, fallback: Double): Double = 58 | getValueFromRef(paramName, fallback, Layer::getDouble, DynamicConfig::getDouble) 59 | 60 | fun getDictionary(paramName: String, fallback: Map?): Map? = 61 | getValueFromRef(paramName, fallback, Layer::getDictionary, DynamicConfig::getDictionary) 62 | 63 | fun getArray(paramName: String, fallback: Array<*>?): Array<*>? = 64 | getValueFromRef(paramName, fallback, Layer::getArray, DynamicConfig::getArray) 65 | 66 | // --------evaluation-------- 67 | 68 | private inline fun getValueFromRef( 69 | topLevelParamName: String, 70 | fallback: T, 71 | getLayerValue: Layer.(String, T) -> T, 72 | getDynamicConfigValue: DynamicConfig.(String, T) -> T 73 | ): T { 74 | val param = paramStore[topLevelParamName] ?: return fallback 75 | val referenceTypeString = param["ref_type"] as? String ?: return fallback 76 | val paramTypeString = param["param_type"] as? String ?: return fallback 77 | val refType = RefType.fromString(referenceTypeString) 78 | val paramType = ParamType.fromString(paramTypeString) 79 | 80 | return when (refType) { 81 | RefType.GATE -> evaluateFeatureGate(paramType, param, fallback) 82 | RefType.STATIC -> evaluateStaticValue(paramType, param, fallback) 83 | RefType.LAYER -> evaluateLayerParameter(param, fallback) { layer, paramName -> 84 | var v = layer.getLayerValue(paramName, fallback) 85 | return v 86 | } 87 | RefType.DYNAMIC_CONFIG -> evaluateDynamicConfigParameter(param, fallback) { 88 | config, 89 | paramName 90 | -> 91 | config.getDynamicConfigValue(paramName, fallback) 92 | } 93 | RefType.EXPERIMENT -> evaluateExperimentParameter(param, fallback) { 94 | experiment, 95 | paramName 96 | -> 97 | experiment.getDynamicConfigValue(paramName, fallback) 98 | } 99 | else -> fallback 100 | } 101 | } 102 | 103 | private inline fun evaluateFeatureGate( 104 | paramType: ParamType, 105 | param: Map, 106 | fallback: T 107 | ): T { 108 | val passValue = param["pass_value"] 109 | val failValue = param["fail_value"] 110 | val gateName = param["gate_name"] as? String 111 | if (passValue == null || failValue == null || gateName == null) { 112 | return fallback 113 | } 114 | val passes = if (options?.disableExposureLog == true) { 115 | statsigClient.checkGateWithExposureLoggingDisabled(gateName) 116 | } else { 117 | statsigClient.checkGate(gateName) 118 | } 119 | val retVal = if (passes) passValue else failValue 120 | if (paramType == ParamType.NUMBER) { 121 | return (retVal as? Number)?.toDouble() as? T ?: fallback 122 | } else if (paramType == ParamType.ARRAY) { 123 | return when (retVal) { 124 | is Array<*> -> return retVal as? T ?: fallback 125 | is ArrayList<*> -> return retVal.toTypedArray() as? T ?: fallback 126 | else -> fallback 127 | } 128 | } 129 | return retVal as? T ?: fallback 130 | } 131 | 132 | private inline fun evaluateStaticValue( 133 | paramType: ParamType, 134 | param: Map, 135 | fallback: T 136 | ): T = when (paramType) { 137 | ParamType.BOOLEAN -> param["value"] as? T ?: fallback 138 | ParamType.STRING -> param["value"] as? T ?: fallback 139 | ParamType.NUMBER -> (param["value"] as? Number)?.toDouble() as? T ?: fallback 140 | ParamType.OBJECT -> param["value"] as? T ?: fallback 141 | ParamType.ARRAY -> { 142 | when (val returnValue = param["value"]) { 143 | is Array<*> -> returnValue as? T ?: fallback 144 | is ArrayList<*> -> (returnValue.toTypedArray()) as? T ?: fallback 145 | else -> fallback 146 | } 147 | } 148 | else -> fallback 149 | } 150 | 151 | private inline fun evaluateLayerParameter( 152 | param: Map, 153 | fallback: T, 154 | getValue: (Layer, String) -> T 155 | ): T { 156 | val layerName = param["layer_name"] as? String 157 | val paramName = param["param_name"] as? String 158 | if (layerName == null || paramName == null) { 159 | return fallback 160 | } 161 | val layer = if (options?.disableExposureLog == true) { 162 | statsigClient.getLayerWithExposureLoggingDisabled(layerName) 163 | } else { 164 | statsigClient.getLayer(layerName) 165 | } 166 | return getValue(layer, paramName) 167 | } 168 | 169 | private inline fun evaluateDynamicConfigParameter( 170 | param: Map, 171 | fallback: T, 172 | getValue: (DynamicConfig, String) -> T 173 | ): T { 174 | val configName = param["config_name"] as? String 175 | val paramName = param["param_name"] as? String 176 | if (configName == null || paramName == null) { 177 | return fallback 178 | } 179 | val config = if (options?.disableExposureLog == true) { 180 | statsigClient.getConfigWithExposureLoggingDisabled(configName) 181 | } else { 182 | statsigClient.getConfig(configName) 183 | } 184 | return getValue(config, paramName) 185 | } 186 | 187 | private inline fun evaluateExperimentParameter( 188 | param: Map, 189 | fallback: T, 190 | getValue: (DynamicConfig, String) -> T 191 | ): T { 192 | val experimentName = param["experiment_name"] as? String 193 | val paramName = param["param_name"] as? String 194 | if (experimentName == null || paramName == null) { 195 | return fallback 196 | } 197 | val experiment = if (options?.disableExposureLog == true) { 198 | statsigClient.getExperimentWithExposureLoggingDisabled(experimentName) 199 | } else { 200 | statsigClient.getExperiment(experimentName) 201 | } 202 | return getValue(experiment, paramName) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/test/java/com/statsig/androidsdk/StatsigFromJavaTest.java: -------------------------------------------------------------------------------- 1 | package com.statsig.androidsdk; 2 | 3 | import static org.junit.Assert.assertArrayEquals; 4 | import static org.junit.Assert.assertEquals; 5 | import static org.junit.Assert.assertFalse; 6 | import static org.junit.Assert.assertNotNull; 7 | import static org.junit.Assert.assertTrue; 8 | 9 | import android.app.Application; 10 | 11 | import org.junit.Before; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | import org.robolectric.RobolectricTestRunner; 15 | import org.robolectric.RuntimeEnvironment; 16 | 17 | import java.util.HashMap; 18 | import java.util.Map; 19 | 20 | import kotlin.Unit; 21 | import kotlin.jvm.functions.Function1; 22 | 23 | @RunWith(RobolectricTestRunner.class) 24 | public class StatsigFromJavaTest { 25 | private Application app; 26 | private Map gates = new HashMap<>(); 27 | private Map configs = new HashMap<>(); 28 | private Map layers = new HashMap<>(); 29 | private final Map values = new HashMap() { 30 | { 31 | put("a_bool", true); 32 | put("an_int", 1); 33 | put("a_double", 1.0); 34 | put("a_long", 1L); 35 | put("a_string", "val"); 36 | put("an_array", new String[] { "a", "b" }); 37 | put("an_object", new HashMap() { 38 | { 39 | put("a_key", "val"); 40 | } 41 | }); 42 | put("another_object", new HashMap() { 43 | { 44 | put("another_key", "another_val"); 45 | } 46 | }); 47 | } 48 | }; 49 | private LogEventData logs; 50 | 51 | @Before 52 | public void setup() { 53 | TestUtil.Companion.mockDispatchers(); 54 | 55 | app = RuntimeEnvironment.getApplication(); 56 | TestUtil.Companion.mockHashing(); 57 | } 58 | 59 | @Test 60 | public void testGate() { 61 | gates = new HashMap() { 62 | { 63 | put("true_gate!", makeGate("true_gate!", true)); 64 | put("false_gate!", makeGate("false_gate!", false)); 65 | } 66 | }; 67 | start(); 68 | 69 | assertTrue(Statsig.checkGate("true_gate")); 70 | assertFalse(Statsig.checkGate("false_gate")); 71 | } 72 | 73 | @Test 74 | public void testConfig() { 75 | configs = new HashMap() { 76 | { 77 | put("config!", makeConfig("config!", values)); 78 | } 79 | }; 80 | start(); 81 | 82 | DynamicConfig config = Statsig.getConfig("config"); 83 | 84 | assertTrue(config.getBoolean("a_bool", false)); 85 | assertEquals(1, config.getInt("an_int", 0)); 86 | assertEquals(1.0, config.getDouble("a_double", 0.0), 0.1); 87 | assertEquals(1L, config.getLong("a_long", 0L)); 88 | assertEquals("val", config.getString("a_string", "err")); 89 | assertArrayEquals(new String[] { "a", "b" }, config.getArray("an_array", new String[0])); 90 | assertEquals(new HashMap() { 91 | { 92 | put("a_key", "val"); 93 | } 94 | }, config.getDictionary("an_object", new HashMap())); 95 | 96 | DynamicConfig another = config.getConfig("another_object"); 97 | assertNotNull(another); 98 | assertEquals("another_val", another.getString("another_key", "err")); 99 | } 100 | 101 | @Test 102 | public void testExperiment() { 103 | configs = new HashMap() { 104 | { 105 | put("exp!", makeConfig("exp!", values)); 106 | } 107 | }; 108 | start(); 109 | 110 | DynamicConfig experiment = Statsig.getExperiment("exp"); 111 | 112 | assertTrue(experiment.getBoolean("a_bool", false)); 113 | assertEquals(1, experiment.getInt("an_int", 0)); 114 | assertEquals(1.0, experiment.getDouble("a_double", 0.0), 0.1); 115 | assertEquals(1L, experiment.getLong("a_long", 0L)); 116 | assertEquals("val", experiment.getString("a_string", "err")); 117 | assertArrayEquals(new String[] { "a", "b" }, experiment.getArray("an_array", new String[0])); 118 | assertEquals(new HashMap() { 119 | { 120 | put("a_key", "val"); 121 | } 122 | }, experiment.getDictionary("an_object", new HashMap())); 123 | 124 | DynamicConfig another = experiment.getConfig("another_object"); 125 | assertNotNull(another); 126 | assertEquals("another_val", another.getString("another_key", "err")); 127 | } 128 | 129 | @Test 130 | public void testLayer() { 131 | layers = new HashMap() { 132 | { 133 | put("layer!", makeConfig("layer!", values)); 134 | } 135 | }; 136 | start(); 137 | 138 | Layer layer = Statsig.getLayer("layer"); 139 | 140 | assertTrue(layer.getBoolean("a_bool", false)); 141 | assertEquals(1, layer.getInt("an_int", 0)); 142 | assertEquals(1.0, layer.getDouble("a_double", 0.0), 0.1); 143 | assertEquals(1L, layer.getLong("a_long", 0L)); 144 | assertEquals("val", layer.getString("a_string", "err")); 145 | assertArrayEquals(new String[] { "a", "b" }, layer.getArray("an_array", new String[0])); 146 | assertEquals(new HashMap() { 147 | { 148 | put("a_key", "val"); 149 | } 150 | }, layer.getDictionary("an_object", new HashMap())); 151 | 152 | DynamicConfig config = layer.getConfig("another_object"); 153 | assertNotNull(config); 154 | assertEquals("another_val", config.getString("another_key", "err")); 155 | } 156 | 157 | @Test 158 | public void testLogging() { 159 | start(); 160 | 161 | Statsig.logEvent("test-event"); 162 | Statsig.shutdown(); 163 | 164 | assertEquals("test-event", logs.getEvents().get(1).getEventName()); 165 | } 166 | 167 | private void start() { 168 | StatsigNetwork network = TestUtil.Companion.mockNetwork( 169 | gates, 170 | configs, 171 | layers, 172 | null, 173 | true, 174 | null, null); 175 | 176 | TestUtil.Companion.captureLogs(network, new Function1() { 177 | @Override 178 | public Unit invoke(LogEventData logEventData) { 179 | logs = logEventData; 180 | return null; 181 | } 182 | }); 183 | 184 | StatsigOptions options = new StatsigOptions(); 185 | options.setLifetimeCallback(new TestStatsigLifetimeCallback()); 186 | 187 | TestUtil.Companion.startStatsigAndWait( 188 | app, 189 | new StatsigUser("dloomb"), 190 | options, 191 | network 192 | ); 193 | } 194 | 195 | private APIFeatureGate makeGate(String name, Boolean value) { 196 | //noinspection unchecked 197 | return new APIFeatureGate( 198 | name, 199 | value, 200 | "default", 201 | null, 202 | new Map[] {}, 203 | null); 204 | } 205 | 206 | private APIDynamicConfig makeConfig(String name, Map values) { 207 | //noinspection unchecked 208 | return new APIDynamicConfig( 209 | name, 210 | values, 211 | "default", 212 | null, 213 | new Map[] {}, 214 | new Map[] {}, 215 | false, 216 | true, 217 | true, 218 | null, 219 | new String[] {}, 220 | null, 221 | null); 222 | } 223 | 224 | /** 225 | * This will fail to build if a new callback is added to IStatsigLifetimeCallback without a 226 | * default implementation. 227 | * If you see a build failure here, you should add a default no-op implementation: "{}" 228 | */ 229 | private static class TestStatsigLifetimeCallback implements IStatsigLifetimeCallback { 230 | 231 | } 232 | } 233 | --------------------------------------------------------------------------------