├── 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 | 
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