diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 09592cf..c38d02e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,17 +11,31 @@ jobs: steps: - uses: actions/checkout@v4 name: Checkout code + with: + fetch-depth: 2 # Checkout HEAD^ - name: Setup JDK uses: actions/setup-java@v4 with: java-version: 17 distribution: 'temurin' cache: gradle - - name: Build and test - run: ./gradlew build - # TODO configure jacocoTestReport and coverallsJacoco - #- name: Run jacocoTestReport - # if: github.ref == 'refs/heads/main' - # env: - # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - # run: ./gradlew jacocoTestReport coverallsJacoco + - name: Check for changes in app directory + id: check-changes + run: | + CHANGED=$(git diff --name-only HEAD^...HEAD -- app/) + if [ -z "$CHANGED" ]; then + echo "No changes in app/ directory." + echo "skip-build-app=true" >> $GITHUB_ENV + fi + - name: Build and test SDK + if: env.skip-build-app == 'true' + run: ./gradlew unleashandroidsdk:build unleashandroidsdk:jacocoTestReport + - name: Build and test all + if: env.skip-build-app != 'true' + run: ./gradlew build jacocoTestReport + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + allow-empty: true + base-path: unleashandroidsdk/src/main/java \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..5e7ea23 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,41 @@ +name: Create a new release tag + +on: + workflow_dispatch: + inputs: + release: + description: 'Release version' + required: true + type: choice + options: + - pre-release + - patch + - minor + - major + +permissions: + contents: write + actions: write + +jobs: + create-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + name: Checkout code + with: + fetch-depth: 0 + - name: Setup JDK + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: gradle + - run: ./gradlew release -Prelease.versionIncrementer=incrementMajor + if: github.event.inputs.release == 'major' + - run: ./gradlew release -Prelease.versionIncrementer=incrementMinor + if: github.event.inputs.release == 'minor' + - run: ./gradlew release -Prelease.versionIncrementer=incrementPatch + if: github.event.inputs.release == 'patch' + - run: ./gradlew release -Prelease.versionIncrementer=incrementPrerelease -Prelease.versionIncrementer.initialPreReleaseIfNotOnPrerelease=-rc1 + if: github.event.inputs.release == 'pre-release' diff --git a/.github/workflows/prs.yaml b/.github/workflows/prs.yaml index 9060d5d..5feb6a0 100644 --- a/.github/workflows/prs.yaml +++ b/.github/workflows/prs.yaml @@ -9,11 +9,31 @@ jobs: steps: - uses: actions/checkout@v4 name: Checkout code + with: + fetch-depth: 0 - name: Setup JDK uses: actions/setup-java@v4 with: java-version: 17 distribution: 'temurin' cache: gradle - - name: Build and test - run: ./gradlew build + - name: Check for changes in app directory + id: check-changes + run: | + CHANGED=$(git diff --name-only origin/main...HEAD -- app/) + if [ -z "$CHANGED" ]; then + echo "No changes in app/ directory." + echo "skip-build-app=true" >> $GITHUB_ENV + fi + - name: Build and test SDK + if: env.skip-build-app == 'true' + run: ./gradlew unleashandroidsdk:build unleashandroidsdk:jacocoTestReport + - name: Build and test all + if: env.skip-build-app != 'true' + run: ./gradlew build jacocoTestReport + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + allow-empty: true + base-path: unleashandroidsdk/src/main/java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 456d3a1..e767661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,10 @@ on: push: tags: - 'v*' + workflow_run: + workflows: ["Create a new release tag"] + types: + - completed jobs: deploy-release: diff --git a/README.md b/README.md index 52df04a..6693d34 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,17 @@ ## Getting started -This is the Android SDK for Unleash Proxy. It is a lightweight SDK that allows you to connect to the Unleash Proxy and fetch feature toggles. +This is the Android SDK for [Unleash Frontend API](https://docs.getunleash.io/reference/front-end-api) provided by the [Unleash server](https://github.com/Unleash/unleash) or [Unleash Edge](https://github.com/Unleash/unleash-edge). It is a lightweight SDK that allows you to connect to the Unleash Proxy and fetch feature toggles. This supersedes the [previous Unleash Android Proxy SDK](https://github.com/Unleash/unleash-android-proxy-sdk/) this one is a an Android library instead of a Java library. It's not a drop-in replacement of the previous one, so it requires code changes to use it. **What are the benefits of migrating?** -1. This SDK will respect the Android lifecycle and stop polling when the app is in the background. -2. It will also respect the network state and only poll when the device is connected to the internet. +1. Respects the Android lifecycle and stops polling and sending metrics in the background. +2. Monitors network connectivity to avoid unnecessary polling (requires API level 23 or above). +3. Uses the native Android logging system instead of SLF4J. +4. Respects the minimum Android API level 21, but we recommend API level 23. ### Step 1 diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md new file mode 100644 index 0000000..7ac13f1 --- /dev/null +++ b/docs/MigrationGuide.md @@ -0,0 +1,117 @@ +# Migration Guide from unleash-android-proxy-sdk + +This guide provides detailed steps for migrating your project from the Unleash Android Proxy SDK version to the newer Unleash Android SDK. + +We will focus on the [previous sample application](https://github.com/Unleash/unleash-android-proxy-sdk/tree/main/samples/android), specifically highlighting the changes from this pull request: https://github.com/Unleash/unleash-android-proxy-sdk/pull/83 + +## Benefits of Migrating + +This version of the Unleash Android SDK introduces several improvements, including: +- Uses the native Android logging system instead of SLF4J. +- Respects the Android lifecycle and stops polling and sending metrics in the background. +- Respects the minimum Android API level 21, but we recommend API level 23. +- Monitors network connectivity to avoid unnecessary polling (requires API level 23 or above). + +## Overview + +The new SDK introduces several changes and improvements, including API modifications and a new initialization process. + +## Step-by-Step Migration + +### 1. Update Gradle Dependency + +First, update the dependency in your `build.gradle` file: + +```gradle +dependencies { + // Remove the old SDK + implementation 'io.getunleash:unleash-android-proxy-sdk:0.5.0' + + // Add the new SDK + implementation 'io.getunleash:unleash-android:$version' +} +``` + +### 2. Update the initialization code +We won't cover all the details here as most of the configuration can be set using the builders fluent methods. However, the main difference is that the new SDK requires an `Application` context to be passed to the `Unleash` constructor. This is necessary to monitor the network connectivity and respect the Android lifecycle. If you use hilt, this can be injected with `@ApplicationContext context`. + +#### Unleash context initialization +The main differences are: +1. The application name is no longer configurable through the context, as it is constant throughout the application's lifetime. The `appName` should be set using the `UnleashConfig` builder. +2. The instance ID is no longer configurable. The SDK will generate a unique instance ID for each instance. +3. Update the import statements to use the new SDK classes. + +```kotlin +val unleashContext = UnleashContext.newBuilder() + // .appName("unleash-android") // remove this line + // .instanceId("main-activity-unleash-demo-${Random.nextLong()}") // remove this line + .userId("unleash_demo_user") + .sessionId(Random.nextLong().toString()) + .build() +``` + +#### Unleash configuration +The main differences are: +1. Metrics are enabled by default. +2. App name is now a mandatory parameter to the builder. +3. Instance id is no longer configurable. +4. The polling mode is now a polling strategy with a fluent API. +5. The metrics interval is now part of the metrics strategy with a fluent API. + +**Old SDK** +```kotlin +UnleashConfig.newBuilder() + .appName("unleash-android") + .instanceId("unleash-android-${Random.nextLong()}") + .enableMetrics() + .clientKey("xyz") + .proxyUrl("https://eu.app.unleash-hosted.com/demo/api/frontend") + .pollingMode( + PollingModes.autoPoll( + autoPollIntervalSeconds = 15 + ) { + + } + ) + .metricsInterval(5000) + .build() +``` + +**New SDK** +```kotlin +UnleashConfig.newBuilder("unleash-android") + .clientKey("xyz") + .proxyUrl("https://eu.app.unleash-hosted.com/demo/api/frontend") + .pollingStrategy.interval(15000) + .metricsStrategy.interval(5000) + .build() +``` + +#### Creating the Unleash instance +The previous SDK used a builder to construct the Unleash instance while the new SDK relies on constructor parameters. There are also other meaningful changes: + +1. The new SDK does not start automatically. You need to call `unleash.start()` to start the polling and metrics collection. +2. The new SDK accepts event listeners at the constructor level or as parameters when calling `unleash.start()` (you can also edit your config object setting `delayedInitialization` to false). +3. The interface `UnleashClientSpec` is now `Unleash`. + +```kotlin +UnleashClient.newBuilder() + .unleashConfig(unleashConfig) + .unleashContext(unleashContext) + .build() +``` + +**New SDK** +_Note:_ Android context is now required to be passed to the Unleash constructor and you will usually want it to be bound to the application context. + +```kotlin +val unleash = DefaultUnleash( + androidContext = context, + unleashConfig = unleashConfig, + unleashContext = unleashContext + ) +unleash.start() +``` + +#### Updating class references +Most of the classes have been moved to `io.getunleash.android` package. Update the import statements in your classes. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e1f5ec..e45ab06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.5.1" kotlin = "1.9.25" coreKtx = "1.13.1" junit = "4.13.2" +jsonunit = "3.4.1" androidxTestExt = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" @@ -39,6 +40,7 @@ jackson-core = { group = "com.fasterxml.jackson.core", name = "jackson-core", ve jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" } jackson-datatype-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jackson" } assertj = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } +jsonunit = { group = "net.javacrumbs.json-unit", name = "json-unit-assertj", version.ref = "jsonunit" } ## sample app androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 5b48c7c..97ccdc1 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -8,11 +8,16 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) id("org.jetbrains.dokka") version "1.9.20" id("pl.allegro.tech.build.axion-release") version "1.18.2" + jacoco } val tagVersion = System.getenv("GITHUB_REF")?.split('/')?.last() project.version = scmVersion.version +jacoco { + toolVersion = "0.8.12" +} + android { namespace = "io.getunleash.android" compileSdk = 34 @@ -29,7 +34,7 @@ android { buildTypes { debug { - + isMinifyEnabled = false } release { isMinifyEnabled = false @@ -48,9 +53,8 @@ android { } publishing { - multipleVariants { - includeBuildTypeValues("debug", "release") - allVariants() + singleVariant("release") { + withSourcesJar() withJavadocJar() } } @@ -59,8 +63,6 @@ android { dependencies { implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.work.ktx) implementation(libs.androidx.lifecycle.process) implementation(libs.jackson.databind) implementation(libs.jackson.core) @@ -75,13 +77,7 @@ dependencies { testImplementation(libs.robolectric.test) testImplementation(libs.okhttp.mockserver) testImplementation(libs.awaitility) - androidTestImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.assertj) - androidTestImplementation(libs.mockito) - androidTestImplementation(libs.androidx.work.testing) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.okhttp.mockserver) + testImplementation(libs.jsonunit) } publishing { @@ -160,4 +156,40 @@ tasks.withType().configureEach { } } } -} \ No newline at end of file +} + +val jacocoTestReport by tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + val fileTreeConfig: (ConfigurableFileTree) -> Unit = { + it.exclude("**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "android/**/*.*", + "**/data/**", "**/errors/**", "**/events/**") + } + + sourceDirectories.setFrom(files("${projectDir}/src/main/java")) + classDirectories.setFrom(listOf( + fileTree("${buildDir}/tmp/kotlin-classes/debug", fileTreeConfig) + )) + executionData.setFrom(fileTree(buildDir) { + include("jacoco/*.exec") + }) +} + +tasks.withType { + testLogging { + showExceptions = true + showStackTraces = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + events("passed", "skipped", "failed") + } + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + finalizedBy(jacocoTestReport) +} diff --git a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/Coroutines.kt b/unleashandroidsdk/src/androidTest/java/io/getunleash/android/Coroutines.kt deleted file mode 100644 index 4c3a24f..0000000 --- a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/Coroutines.kt +++ /dev/null @@ -1,101 +0,0 @@ -package io.getunleash.android - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.withContext -import org.junit.Test -import java.util.concurrent.Executors - -class CoroutineTest { - private val testDispatcher = StandardTestDispatcher() - - val customIODispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { r -> - Thread(r, "Custom-IO-Thread").apply { isDaemon = true } - }.asCoroutineDispatcher() - @Test - fun testCoroutineScopes() { - runBlocking { - println("Running on: ${Thread.currentThread().name}") // Prints main thread - - launch { - println("Running on: ${Thread.currentThread().name}") // Prints main thread - - withContext(Dispatchers.IO) { - println("Switched to: ${Thread.currentThread().name}") // Prints a background thread - // Simulate IO work - delay(1000) - } - - launch { - println("[inner] Running on: ${Thread.currentThread().name}") // Prints a background thread - - withContext(Dispatchers.IO) { - println("[inner] Switched 3 to: ${Thread.currentThread().name}") // Prints a background thread - // Simulate IO work - delay(100) - } - - println("[inner] Back to: ${Thread.currentThread().name}") // Back to the main thread - } - - withContext(Dispatchers.IO) { - println("Switched 2 to: ${Thread.currentThread().name}") // Prints a background thread - // Simulate IO work - delay(1000) - } - - println("Back to: ${Thread.currentThread().name}") // Back to the main thread - } - println("End of runBlocking: ${Thread.currentThread().name}") // Back to the main thread - } - } - - @Test - fun supervisorTest() { - runBlocking { - val handler = CoroutineExceptionHandler { _, exception -> - println("Caught exception at supervisor level: ${exception.message}") - } - // Create a supervisor scope with SupervisorJob - val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) - - // Launch child coroutines - supervisorScope.launch { - println("Child 1 starts on ${Thread.currentThread().name}") - delay(1000) - println("Child 1 completes on ${Thread.currentThread().name}") - } - - supervisorScope.launch { - println("Child 2 starts on ${Thread.currentThread().name}") - delay(500) - throw Exception("Child 2 failed!") - } - - supervisorScope.launch { - println("Child 3 starts on ${Thread.currentThread().name}") - withContext(customIODispatcher) { - println("Child 3 switched to ${Thread.currentThread().name} for I/O work") - delay(2000) - println("Child 3 completes on ${Thread.currentThread().name} after I/O work") - } - println("Child 3 completes on ${Thread.currentThread().name}") - } - - // Give coroutines time to complete - delay(3000) - - // Cancel the supervisor scope to clean up resources - supervisorScope.cancel() - } - } -} diff --git a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/ExampleInstrumentedTest.kt b/unleashandroidsdk/src/androidTest/java/io/getunleash/android/ExampleInstrumentedTest.kt deleted file mode 100644 index 9c39ca1..0000000 --- a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.getunleash.android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.getunleash.android.test", appContext.packageName) - } -} - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedAndroidTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.getunleash.unleashandroid", appContext.packageName) - } -} \ No newline at end of file diff --git a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/FeatureFeatureFlagWorkerTest.kt b/unleashandroidsdk/src/androidTest/java/io/getunleash/android/FeatureFeatureFlagWorkerTest.kt deleted file mode 100644 index 4ffdca2..0000000 --- a/unleashandroidsdk/src/androidTest/java/io/getunleash/android/FeatureFeatureFlagWorkerTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.getunleash.android - -import android.util.Log -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.work.Configuration -import androidx.work.CoroutineWorker -import androidx.work.ListenableWorker -import androidx.work.impl.utils.SynchronousExecutor -import androidx.work.testing.TestListenableWorkerBuilder -import androidx.work.testing.WorkManagerTestInitHelper -import androidx.work.workDataOf -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.assertj.core.api.Assertions.assertThat - -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) - -class FeatureTogglesFetcherTest { - val context = InstrumentationRegistry.getInstrumentation().targetContext - @Before - fun setup() { - val config = Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .setExecutor(SynchronousExecutor()) - .build() - - // Initialize WorkManager for instrumentation tests. - WorkManagerTestInitHelper.initializeTestWorkManager(context, config) - } - - @Test - fun testFeatureToggleWorker() { - val server = MockWebServer() - server.enqueue(MockResponse().setBody( - this::class.java.classLoader?.getResource("edgeresponse.json")!!.readText())) - val input = workDataOf("proxyUrl" to server.url("").toString(), "clientKey" to "2") - val worker = TestListenableWorkerBuilder(context) - .setInputData(input) - .build() - runBlocking { - val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.success()) - } - } - - @Test - fun testFeatureToggleWorker_withInvalidBody_resultsInFailure() { - val server = MockWebServer() - server.enqueue(MockResponse().setBody("Invalid json")) - val input = workDataOf("proxyUrl" to server.url("").toString(), "clientKey" to "2") - val worker = TestListenableWorkerBuilder(context) - .setInputData(input) - .build() - runBlocking { - val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.failure()) - } - } - - @Test - fun testFeatureToggleWorker_notifiesFeatureUpdateListeners() { - var numberOfToggles = 0 - - val server = MockWebServer() - server.enqueue(MockResponse().setBody( - this::class.java.classLoader?.getResource("edgeresponse.json")!!.readText())) - val input = workDataOf("proxyUrl" to server.url(""). - toString(), "clientKey" to "2") - val worker = TestListenableWorkerBuilder(context) - .setInputData(input) - .build() - runBlocking { - val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.success()) - - // wait for 50ms until numberOfToggles is greater than zero - Thread.sleep(50) - assertThat(numberOfToggles).isGreaterThan(0) - } - - } -} diff --git a/unleashandroidsdk/src/androidTest/resources/edgeresponse.json b/unleashandroidsdk/src/androidTest/resources/edgeresponse.json deleted file mode 100644 index 3e29812..0000000 --- a/unleashandroidsdk/src/androidTest/resources/edgeresponse.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "toggles": [ - { - "name": "bff-response-cache-flag", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - }, - { - "name": "AwsomeDemo", - "enabled": true, - "variant": { - "name": "black", - "enabled": true, - "payload": null - }, - "impressionData": false - }, - { - "name": "PersonalisedHeroBanner", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": true - }, - { - "name": "prevent_multi_accounts", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - }, - { - "name": "Tunneldevente", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - }, - { - "name": "myff", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - }, - { - "name": "cache-flag", - "enabled": false, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - }, - { - "name": "RotateLogo", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false, - "payload": null - }, - "impressionData": false - } - ] -} \ No newline at end of file diff --git a/unleashandroidsdk/src/androidTest/resources/proxyresponse.json b/unleashandroidsdk/src/androidTest/resources/proxyresponse.json deleted file mode 100644 index b6c8735..0000000 --- a/unleashandroidsdk/src/androidTest/resources/proxyresponse.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "toggles": [ - { - "name": "asdasd", - "enabled": true, - "variant": { - "name": "123", - "payload": { - "type": "string", - "value": "11" - }, - "enabled": true - } - }, - { - "name": "atrxrmnqwe", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "cache.buster", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "clint.testToggle", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "demoApp.step2", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "demoApp.step3", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "demoApp.step4", - "enabled": true, - "variant": { - "name": "red", - "payload": { - "type": "string", - "value": "red" - }, - "enabled": true - } - }, - { - "name": "demounleash.cheque", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "demounleash.inversiones", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "demounleash.movimientosenriquecidos", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "EnableAt", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "novosite", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "testing-a", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "Test_release", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "Tete.teste", - "enabled": true, - "variant": { - "name": "disabled", - "enabled": false - } - }, - { - "name": "unleash_android_sdk_demo", - "enabled": true, - "variant": { - "name": "HelloJupiter", - "enabled": true - } - } - ] -} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt index c434e78..11a7947 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt @@ -12,6 +12,8 @@ import io.getunleash.android.cache.ObservableCache import io.getunleash.android.cache.ObservableToggleCache import io.getunleash.android.cache.ToggleCache import io.getunleash.android.data.ImpressionEvent +import io.getunleash.android.data.Parser +import io.getunleash.android.polling.ProxyResponse import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.data.UnleashState @@ -47,6 +49,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import okhttp3.internal.toImmutableList +import java.io.File import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicBoolean @@ -68,6 +71,7 @@ class DefaultUnleash( ) : Unleash { companion object { private const val TAG = "Unleash" + internal const val BACKUP_DIR_NAME = "unleash_backup" } private val unleashContextState = MutableStateFlow(unleashContext) @@ -100,7 +104,7 @@ class DefaultUnleash( unleashContextState.asStateFlow() ) else null taskManager = LifecycleAwareTaskManager( - dataJobs = buildDataJobs(fetcher, metrics), + dataJobs = buildDataJobs(metrics, fetcher), networkAvailable = networkStatusHelper.isAvailable(), scope = coroutineScope ) @@ -113,12 +117,14 @@ class DefaultUnleash( fun start( eventListeners: List = emptyList(), + bootstrapFile: File? = null, bootstrap: List = emptyList() ) { if (!started.compareAndSet(false, true)) { Log.w(TAG, "Unleash already started, ignoring start call") return } + eventListeners.forEach { addUnleashEventListener(it) } networkStatusHelper.registerNetworkListener(taskManager) if (unleashConfig.localStorageConfig.enabled) { val localBackup = getLocalBackup() @@ -129,14 +135,20 @@ class DefaultUnleash( cache.subscribeTo(it.getFeaturesReceivedFlow()) } lifecycle.addObserver(taskManager) - eventListeners.forEach { addUnleashEventListener(it) } - if (bootstrap.isNotEmpty()) { + if (bootstrapFile != null && bootstrapFile.exists()) { + Log.i(TAG, "Using provided bootstrap file") + Parser.jackson.readValue(bootstrapFile, ProxyResponse::class.java)?.let { state -> + val toggles = state.toggles.groupBy { it.name } + .mapValues { (_, v) -> v.first() } + cache.write(UnleashState(unleashContextState.value, toggles)) + } + } else if (bootstrap.isNotEmpty()) { Log.i(TAG, "Using provided bootstrap toggles") cache.write(UnleashState(unleashContextState.value, bootstrap.associateBy { it.name })) } } - private fun buildDataJobs(fetcher: UnleashFetcher?, metricsSender: MetricsReporter) = buildList { + private fun buildDataJobs(metricsSender: MetricsReporter, fetcher: UnleashFetcher?) = buildList { if (fetcher != null) { add( DataJob( @@ -159,7 +171,7 @@ class DefaultUnleash( private fun getLocalBackup(): LocalBackup { val backupDir = CacheDirectoryProvider(unleashConfig.localStorageConfig, androidContext) - .getCacheDirectory("unleash_backup") + .getCacheDirectory(BACKUP_DIR_NAME) val localBackup = LocalBackup(backupDir) coroutineScope.launch { withContext(Dispatchers.IO) { diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/ReadyListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/ReadyListener.kt deleted file mode 100644 index 9c8c6f2..0000000 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/ReadyListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.getunleash.android - -fun interface ReadyListener { - fun onReady(): Unit -} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt index 9db42a8..913284b 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -data class BackupState(val contextId: String, val toggles: Map) +private data class BackupState(val contextId: String, val toggles: Map) /** * Local backup of the last state of the Unleash SDK. @@ -22,15 +22,14 @@ data class BackupState(val contextId: String, val toggles: Map) * is the same when loading the state from disc. */ class LocalBackup( - private val localDir: File + private val localDir: File, + private var lastContext: UnleashContext? = null ) { companion object { private const val TAG = "LocalBackup" - private const val STATE_BACKUP_FILE = "unleash_state.json" + internal const val STATE_BACKUP_FILE = "unleash_state.json" } - private var lastContext: UnleashContext? = null - fun subscribeTo(state: Flow) { unleashScope.launch { withContext(Dispatchers.IO) { @@ -63,7 +62,7 @@ class LocalBackup( if (stateBackup.exists()) { val backupState = Parser.jackson.readValue(stateBackup.readBytes()) if (backupState.contextId != id(context)) { - Log.i(TAG, "Context id mismatch, ignoring backup") + Log.i(TAG, "Context id mismatch, ignoring backup for context id ${backupState.contextId}") return null } return UnleashState(context, backupState.toggles) diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/cache/CacheDirectoryProvider.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/cache/CacheDirectoryProvider.kt index c774268..85e8419 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/cache/CacheDirectoryProvider.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/cache/CacheDirectoryProvider.kt @@ -5,7 +5,11 @@ import android.util.Log import io.getunleash.android.backup.LocalStorageConfig import java.io.File -class CacheDirectoryProvider(private val config: LocalStorageConfig, private val context: Context) { +class CacheDirectoryProvider( + private val config: LocalStorageConfig, + private val context: Context, + private val runtime: Runtime = Runtime.getRuntime() +) { companion object { private const val TAG = "CacheDirProvider" @@ -35,7 +39,7 @@ class CacheDirectoryProvider(private val config: LocalStorageConfig, private val } private fun addShutdownHook(file: File) { - Runtime.getRuntime().addShutdownHook(DeleteFileShutdownHook(file)) + runtime.addShutdownHook(DeleteFileShutdownHook(file)) } private class DeleteFileShutdownHook(file: File) : Thread(Runnable { diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/cache/ToggleStoreListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/cache/ToggleStoreListener.kt deleted file mode 100644 index 6aa4f99..0000000 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/cache/ToggleStoreListener.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.getunleash.android.cache - -import io.getunleash.android.data.Toggle - -fun interface ToggleStoreListener { - /** - * This method will be called after the feature toggle store (usually a cache) is updated. - */ - fun onTogglesStored(flags: Map) -} diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/data/FetchResponse.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/data/FetchResponse.kt deleted file mode 100644 index 8144c41..0000000 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/data/FetchResponse.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.getunleash.android.data - -/** - * Modelling the fetch action - * @param status Status of the request - * @param config The response from the proxy parsed to a data class - */ -data class FetchResponse( - val status: Status, - val config: ProxyResponse? = null, - val error: Exception? = null) { - fun isSuccess() = status.isSuccess() - fun isNotModified() = status.isNotModified() - fun isFailed() = status.isFailed() -} - -/** - * We use this out from the fetcher to not have to do convert the proxyresponse to a map we can work on everytime we want answer user calls - * @param status Status of the request - * @param toggles The parsed feature toggles map from the unleash proxy - */ -data class ToggleResponse( - val status: Status, - val toggles: Map = emptyMap(), - val error: Exception? = null) { - fun isFetched() = status.isSuccess() - fun isNotModified() = status.isNotModified() - fun isFailed() = status.isFailed() -} -enum class Status { - SUCCESS, - NOT_MODIFIED, - FAILED, - THROTTLED; - - fun isSuccess() = this == SUCCESS - - fun isNotModified() = this == NOT_MODIFIED - fun isFailed() = this == FAILED || this == THROTTLED -} diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/data/Toggle.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/data/Toggle.kt index 867fb28..23d4e91 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/data/Toggle.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/data/Toggle.kt @@ -5,7 +5,7 @@ package io.getunleash.android.data * For creating toggles see docs - [Feature toggles](https://docs.getunleash.io/docs/user_guide/create_feature_toggle) * @property name Name of the toggle * @property enabled Did this toggle get evaluated to true - * @property variant used by [io.getunleash.UnleashClientSpec.getVariant] to get the variant data + * @property variant used by [io.getunleash.android.Unleash.getVariant] to get the variant data */ data class Toggle( val name: String, diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NoBodyException.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NoBodyException.kt index 251dc0a..545fe49 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NoBodyException.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NoBodyException.kt @@ -1,4 +1,3 @@ package io.getunleash.android.errors -class NoBodyException : Exception("Got response from proxy but had no body") { -} \ No newline at end of file +class NoBodyException : Exception("Got response from proxy but had no body") \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NotAuthorizedException.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NotAuthorizedException.kt index 4b18afb..e87792d 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NotAuthorizedException.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/NotAuthorizedException.kt @@ -1,4 +1,3 @@ package io.getunleash.android.errors -class NotAuthorizedException : Exception("Not authorized") { -} \ No newline at end of file +class NotAuthorizedException : Exception("Not authorized") \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/ServerException.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/ServerException.kt index 4ff36cd..d9ad95c 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/errors/ServerException.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/errors/ServerException.kt @@ -1,4 +1,3 @@ package io.getunleash.android.errors -class ServerException(statusCode: Int) : Exception("Unleash responded with $statusCode") { -} +class ServerException(statusCode: Int) : Exception("Unleash responded with $statusCode") diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/HeartbeatEvent.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/HeartbeatEvent.kt index 29fa42a..7f76b1f 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/events/HeartbeatEvent.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/HeartbeatEvent.kt @@ -1,6 +1,6 @@ package io.getunleash.android.events -import io.getunleash.android.data.Status +import io.getunleash.android.polling.Status /** diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashAllEventsListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashAllEventsListener.kt deleted file mode 100644 index 617ed94..0000000 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashAllEventsListener.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.getunleash.android.events - -import io.getunleash.android.data.ImpressionEvent - -interface UnleashListener -interface UnleashReadyListener: UnleashListener { - fun onReady() -} - -interface UnleashStateListener: UnleashListener { - fun onStateChanged() -} - -interface UnleashImpressionEventListener: UnleashListener { - fun onImpression(event: ImpressionEvent) -} - -interface UnleashFetcherHeartbeatListener: UnleashListener { - fun onError(event: HeartbeatEvent) - fun togglesChecked() - fun togglesUpdated() -} diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashFetcherHeartbeatListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashFetcherHeartbeatListener.kt new file mode 100644 index 0000000..106dee5 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashFetcherHeartbeatListener.kt @@ -0,0 +1,7 @@ +package io.getunleash.android.events + +interface UnleashFetcherHeartbeatListener: UnleashListener { + fun onError(event: HeartbeatEvent) + fun togglesChecked() + fun togglesUpdated() +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashImpressionEventListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashImpressionEventListener.kt new file mode 100644 index 0000000..0a401bd --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashImpressionEventListener.kt @@ -0,0 +1,7 @@ +package io.getunleash.android.events + +import io.getunleash.android.data.ImpressionEvent + +interface UnleashImpressionEventListener: UnleashListener { + fun onImpression(event: ImpressionEvent) +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashListener.kt new file mode 100644 index 0000000..8768e36 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashListener.kt @@ -0,0 +1,4 @@ +package io.getunleash.android.events + +interface UnleashListener + diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashReadyListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashReadyListener.kt new file mode 100644 index 0000000..e8a26d0 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashReadyListener.kt @@ -0,0 +1,5 @@ +package io.getunleash.android.events + +interface UnleashReadyListener: UnleashListener { + fun onReady() +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashStateListener.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashStateListener.kt new file mode 100644 index 0000000..618bfd9 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashStateListener.kt @@ -0,0 +1,5 @@ +package io.getunleash.android.events + +interface UnleashStateListener: UnleashListener { + fun onStateChanged() +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/http/NetworkStatusHelper.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/http/NetworkStatusHelper.kt index 7ecc676..3b71926 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/http/NetworkStatusHelper.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/http/NetworkStatusHelper.kt @@ -18,13 +18,16 @@ class NetworkStatusHelper(val context: Context) { private const val TAG = "NetworkState" } - private val networkCallbacks = mutableListOf() + internal val networkCallbacks = mutableListOf() fun registerNetworkListener(listener: NetworkListener) { val connectivityManager = getConnectivityManager() ?: return - val networkRequest = NetworkRequest.Builder() + val requestBuilder = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + val networkRequest = requestBuilder.build() // wrap the listener in a NetworkCallback so the listener doesn't have to know about Android specifics val networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -32,6 +35,10 @@ class NetworkStatusHelper(val context: Context) { listener.onAvailable() } + override fun onUnavailable() { + listener.onLost() + } + override fun onLost(network: Network) { listener.onLost() } diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/http/Throttler.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/http/Throttler.kt index 81d64bb..b49d7e3 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/http/Throttler.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/http/Throttler.kt @@ -99,10 +99,10 @@ class Throttler( fun handle(statusCode: Int) { if (statusCode in 200..399) { - decrementFailureCountAndResetSkips(); + decrementFailureCountAndResetSkips() } if (statusCode >= 400) { - handleHttpErrorCodes(statusCode); + handleHttpErrorCodes(statusCode) } } diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/data/MetricsBucket.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsBucket.kt similarity index 96% rename from unleashandroidsdk/src/main/java/io/getunleash/android/data/MetricsBucket.kt rename to unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsBucket.kt index 5dd2dc0..0628521 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/data/MetricsBucket.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsBucket.kt @@ -1,5 +1,6 @@ -package io.getunleash.android.data +package io.getunleash.android.metrics +import io.getunleash.android.data.Variant import java.util.Date import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsSender.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsSender.kt index 7e9c90c..b9d2954 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsSender.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/metrics/MetricsSender.kt @@ -2,9 +2,6 @@ package io.getunleash.android.metrics import android.util.Log import io.getunleash.android.UnleashConfig -import io.getunleash.android.data.Bucket -import io.getunleash.android.data.CountBucket -import io.getunleash.android.data.MetricsPayload import io.getunleash.android.data.Parser import io.getunleash.android.data.Variant import io.getunleash.android.http.Throttler diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/FetchResponse.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/FetchResponse.kt new file mode 100644 index 0000000..f734653 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/FetchResponse.kt @@ -0,0 +1,16 @@ +package io.getunleash.android.polling + +/** + * Modelling the fetch action + * @param status Status of the request + * @param config The response from the proxy parsed to a data class + */ +data class FetchResponse( + val status: Status, + val config: ProxyResponse? = null, + val error: Exception? = null) { + fun isSuccess() = status.isSuccess() + fun isNotModified() = status.isNotModified() + fun isFailed() = status.isFailed() +} + diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/data/ProxyResponse.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ProxyResponse.kt similarity index 56% rename from unleashandroidsdk/src/main/java/io/getunleash/android/data/ProxyResponse.kt rename to unleashandroidsdk/src/main/java/io/getunleash/android/polling/ProxyResponse.kt index 29f383b..3137e64 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/data/ProxyResponse.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ProxyResponse.kt @@ -1,4 +1,6 @@ -package io.getunleash.android.data +package io.getunleash.android.polling + +import io.getunleash.android.data.Toggle /** * Holder for parsing response from proxy diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/Status.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/Status.kt new file mode 100644 index 0000000..215067c --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/Status.kt @@ -0,0 +1,13 @@ +package io.getunleash.android.polling + +enum class Status { + SUCCESS, + NOT_MODIFIED, + FAILED, + THROTTLED; + + fun isSuccess() = this == SUCCESS + + fun isNotModified() = this == NOT_MODIFIED + fun isFailed() = this == FAILED || this == THROTTLED +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt new file mode 100644 index 0000000..7da70db --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt @@ -0,0 +1,14 @@ +package io.getunleash.android.polling + +import io.getunleash.android.data.Toggle + +/** + * We use this out from the fetcher to not have to do convert the proxyresponse to a map we can work on everytime we want answer user calls + * @param status Status of the request + * @param toggles The parsed feature toggles map from the unleash proxy + */ +data class ToggleResponse( + val status: Status, + val toggles: Map = emptyMap(), + val error: Exception? = null) { +} \ No newline at end of file diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/UnleashFetcher.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/UnleashFetcher.kt index 06c2676..509f632 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/UnleashFetcher.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/UnleashFetcher.kt @@ -3,11 +3,7 @@ package io.getunleash.android.polling import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue import io.getunleash.android.UnleashConfig -import io.getunleash.android.data.FetchResponse import io.getunleash.android.data.Parser -import io.getunleash.android.data.ProxyResponse -import io.getunleash.android.data.Status -import io.getunleash.android.data.ToggleResponse import io.getunleash.android.data.UnleashContext import io.getunleash.android.data.UnleashState import io.getunleash.android.errors.NoBodyException diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/CountBucketTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/CountBucketTest.kt index 54bc2f6..8af6f7b 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/CountBucketTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/CountBucketTest.kt @@ -1,6 +1,6 @@ package io.getunleash.android -import io.getunleash.android.data.CountBucket +import io.getunleash.android.metrics.CountBucket import io.getunleash.android.data.Variant import org.assertj.core.api.Assertions.assertThat import org.junit.Test diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt index 87d630b..ec9d736 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt @@ -2,16 +2,16 @@ package io.getunleash.android import android.content.Context import androidx.lifecycle.Lifecycle -import io.getunleash.android.cache.ToggleCache +import io.getunleash.android.backup.LocalBackup import io.getunleash.android.data.ImpressionEvent -import io.getunleash.android.data.Status import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext -import io.getunleash.android.data.UnleashState import io.getunleash.android.events.HeartbeatEvent import io.getunleash.android.events.UnleashFetcherHeartbeatListener import io.getunleash.android.events.UnleashImpressionEventListener import io.getunleash.android.events.UnleashReadyListener +import io.getunleash.android.events.UnleashStateListener +import io.getunleash.android.polling.Status import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat @@ -20,27 +20,15 @@ import org.awaitility.Awaitility.await import org.junit.Test import org.mockito.Mockito.mock import org.robolectric.shadows.ShadowLog +import java.io.File import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempDirectory class DefaultUnleashTest : BaseTest() { private val staticToggleList = listOf( Toggle(name = "feature1", enabled = true), Toggle(name = "feature2", enabled = false), ) - private val testCache = object : ToggleCache { - val staticToggles = staticToggleList.associateBy { it.name } - override fun read(): Map { - return staticToggles - } - - override fun get(key: String): Toggle? { - return staticToggles[key] - } - - override fun write(state: UnleashState) { - TODO("Should not be used") - } - } @Test fun testDefaultUnleashWithStaticCache() { @@ -50,9 +38,10 @@ class DefaultUnleashTest : BaseTest() { .pollingStrategy.enabled(false) .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) + .delayedInitialization(false) // start immediately .build(), - cacheImpl = testCache, - lifecycle = mock(Lifecycle::class.java), + cacheImpl = InspectableCache(staticToggleList.associateBy { it.name }), + lifecycle = mock(Lifecycle::class.java) ) assertThat(unleash.isEnabled("feature1")).isTrue() assertThat(unleash.isEnabled("feature2")).isFalse() @@ -90,7 +79,8 @@ class DefaultUnleashTest : BaseTest() { .proxyUrl(server.url("").toString()) .clientKey("key-123") .pollingStrategy.enabled(true) - .metricsStrategy.enabled(false) + .metricsStrategy.enabled(true) + .metricsStrategy.delay(10000) // delay enough so it won't trigger a new request .localStorageConfig.enabled(false) .build(), lifecycle = mock(Lifecycle::class.java), @@ -135,7 +125,7 @@ class DefaultUnleashTest : BaseTest() { .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) .build(), - cacheImpl = testCache, + cacheImpl = InspectableCache(staticToggleList.associateBy { it.name }), lifecycle = mock(Lifecycle::class.java), ) @@ -308,6 +298,7 @@ class DefaultUnleashTest : BaseTest() { .proxyUrl(server.url("").toString()) .clientKey("key-123") .pollingStrategy.enabled(true) + .pollingStrategy.delay(10000) // delay enough so it won't trigger a new request .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) .build(), @@ -334,7 +325,7 @@ class DefaultUnleashTest : BaseTest() { } })) - await().atMost(2, TimeUnit.SECONDS).until { + await().atMost(5, TimeUnit.SECONDS).until { togglesUpdated > 0 } // change context to force a refresh @@ -395,4 +386,62 @@ class DefaultUnleashTest : BaseTest() { assertThat(server.requestCount).isEqualTo(1) assertThat(server.takeRequest().requestUrl?.queryParameter("userId")).isEqualTo("123") } + + @Test + fun `can load from disk using a backup`() { + val sampleBackupResponse = File(this::class.java.classLoader?.getResource("sample-response.json")!!.path) + val inspectableCache = InspectableCache() + val unleash = DefaultUnleash( + androidContext = mock(Context::class.java), + unleashConfig = UnleashConfig.newBuilder("test-android-app") + .pollingStrategy.enabled(false) + .metricsStrategy.enabled(false) + .localStorageConfig.enabled(false) + .build(), + lifecycle = mock(Lifecycle::class.java), + cacheImpl = inspectableCache + ) + + unleash.start(bootstrapFile = sampleBackupResponse) + + await().atMost(2, TimeUnit.SECONDS).until { inspectableCache.toggles.isNotEmpty() } + assertThat(inspectableCache.toggles).hasSize(8) + val aToggle = inspectableCache.toggles["AwesomeDemo"] + assertThat(aToggle).isNotNull + assertThat(aToggle!!.enabled).isTrue() + assertThat(aToggle.variant).isNotNull + assertThat(aToggle.variant.name).isEqualTo("black") + } + + @Test + fun `can load state from a local backup`() { + val backupFile = this::class.java.classLoader?.getResource("unleash-state.json")!!.path + val tmpDir = createTempDirectory().toFile() + File(backupFile).copyTo(File(tmpDir, "${DefaultUnleash.BACKUP_DIR_NAME}/${LocalBackup.STATE_BACKUP_FILE}")) + + val inspectableCache = InspectableCache() + + val unleash = DefaultUnleash( + androidContext = mock(Context::class.java), + unleashConfig = UnleashConfig.newBuilder("test-android-app") + .pollingStrategy.enabled(false) + .metricsStrategy.enabled(false) + .localStorageConfig.enabled(true) + .localStorageConfig.dir(tmpDir.path) + .build(), + cacheImpl = inspectableCache, + unleashContext = UnleashContext(userId = "123"), + lifecycle = mock(Lifecycle::class.java), + ) + + var stateSet = false + unleash.start(eventListeners = listOf(object : UnleashStateListener { + override fun onStateChanged() { + stateSet = true + } + })) + + await().atMost(2, TimeUnit.SECONDS).until { stateSet } + assertThat(inspectableCache.toggles).hasSize(3) + } } diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/InspectableCache.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/InspectableCache.kt new file mode 100644 index 0000000..0725590 --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/InspectableCache.kt @@ -0,0 +1,25 @@ +package io.getunleash.android + +import io.getunleash.android.cache.ToggleCache +import io.getunleash.android.data.Toggle +import io.getunleash.android.data.UnleashState + +/** + * This class is used to expose the cache for inspection during tests + */ +class InspectableCache( + var toggles: Map = emptyMap() +): ToggleCache { + + override fun read(): Map { + return toggles + } + + override fun get(key: String): Toggle? { + return toggles[key] + } + + override fun write(state: UnleashState) { + toggles = state.toggles + } +} \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt new file mode 100644 index 0000000..1bcfb70 --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt @@ -0,0 +1,84 @@ +package io.getunleash.android.backup + +import io.getunleash.android.BaseTest +import io.getunleash.android.data.UnleashContext +import io.getunleash.android.data.UnleashState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.awaitility.Awaitility.await +import org.junit.Test +import org.robolectric.shadows.ShadowLog +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.io.path.createTempDirectory + +class LocalBackupTest : BaseTest() { + @Test + fun `loads a local backup when context matches`() { + val backupFile = this::class.java.classLoader?.getResource("unleash-state.json")!!.path + val tmpDir = createTempDirectory().toFile() + File(backupFile).copyTo(File(tmpDir, LocalBackup.STATE_BACKUP_FILE)) + + val backup = LocalBackup(tmpDir) + assertThat(backup.loadFromDisc(UnleashContext(userId = "123"))).isNotNull() + } + + @Test + fun `does not load a local backup with a different context`() { + val backupFile = this::class.java.classLoader?.getResource("unleash-state.json")!!.path + val tmpDir = createTempDirectory().toFile() + File(backupFile).copyTo(File(tmpDir, LocalBackup.STATE_BACKUP_FILE)) + + val backup = LocalBackup(tmpDir) + assertThat(backup.loadFromDisc(UnleashContext(userId = "456"))).isNull() + } + + @Test + fun `writes to disk on state change`() { + val tmpDir = createTempDirectory().toFile() + val context = UnleashContext(userId = "123") + val state = UnleashState(context, emptyMap()) + val stateFlow = MutableStateFlow(state) + + val backup = LocalBackup(tmpDir) + assertThat(backup.loadFromDisc(context)).isNull() + + // subscribing to state flow should trigger a write to disk + backup.subscribeTo(stateFlow.asStateFlow()) + await().atMost(3, TimeUnit.SECONDS).untilAsserted { + assertThat(backup.loadFromDisc(context)).isEqualTo(state) + } + + val newContext = context.copy(userId = "456") + val newState = UnleashState(newContext, emptyMap()) + stateFlow.value = newState + await().atMost(3, TimeUnit.SECONDS).untilAsserted { + assertThat(backup.loadFromDisc(newContext)).isEqualTo(newState) + } + + // old context is no longer stored + assertThat(backup.loadFromDisc(context)).isNull() + } + + @Test + fun `does not write to disk when state does not change`() { + val tmpDir = createTempDirectory().toFile() + val context = UnleashContext(userId = "123") + val state = UnleashState(context, emptyMap()) + val stateFlow = MutableStateFlow(state) + // initialize hte backup with a known context for testing + val backup = LocalBackup(tmpDir, context) + + assertThat(backup.loadFromDisc(context)).isNull() + + backup.subscribeTo(stateFlow.asStateFlow()) + + await().atMost(2, TimeUnit.SECONDS).until { + ShadowLog.getLogs().map { it.msg } + .find { it.contains("Context unchanged, not writing to disc") } != null + } + } +} \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/cache/CacheDirectoryProviderTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/cache/CacheDirectoryProviderTest.kt new file mode 100644 index 0000000..e78d703 --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/cache/CacheDirectoryProviderTest.kt @@ -0,0 +1,75 @@ +package io.getunleash.android.cache + +import android.content.Context +import io.getunleash.android.BaseTest +import io.getunleash.android.UnleashConfig +import io.getunleash.android.backup.LocalStorageConfig +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import kotlin.io.path.createTempDirectory +import kotlin.io.path.pathString + +class CacheDirectoryProviderTest : BaseTest() { + + @Test + fun `should return the correct cache directory`() { + val storageConfig = getStorageConfig(createTempDirectory("test1").pathString) + val cacheDirectoryProvider = CacheDirectoryProvider(storageConfig, mock(Context::class.java)) + val cacheDirectory = cacheDirectoryProvider.getCacheDirectory("backup-dir") + assertThat(cacheDirectory.isDirectory).isTrue() + assertThat(cacheDirectory.exists()).isTrue() + } + @Test + fun `should return the same cache directory if it already exists`() { + val storageConfig = getStorageConfig(createTempDirectory("test2").pathString) + val cacheDirectoryProvider = CacheDirectoryProvider(storageConfig, mock(Context::class.java)) + val cacheDirectory1 = cacheDirectoryProvider.getCacheDirectory("backup-dir") + val cacheDirectory2 = cacheDirectoryProvider.getCacheDirectory("backup-dir") + assertThat(cacheDirectory1.isDirectory).isTrue() + assertThat(cacheDirectory1.exists()).isTrue() + assertThat(cacheDirectory1).isEqualTo(cacheDirectory2) + } + + @Test + fun `should add a shutdown hook`() { + val shutdownHookCaptor = ArgumentCaptor.captor() + val mockRuntime = mock(Runtime::class.java) + val storageConfig = getStorageConfig(createTempDirectory("test3").pathString) + val cacheDirectoryProvider = CacheDirectoryProvider(storageConfig, mock(Context::class.java), mockRuntime) + val cacheDirectory = cacheDirectoryProvider.getCacheDirectory("backup-dir", true) + assertThat(cacheDirectory.isDirectory).isTrue() + assertThat(cacheDirectory.exists()).isTrue() + verify(mockRuntime).addShutdownHook(shutdownHookCaptor.capture()) + + shutdownHookCaptor.value.run() + assertThat(cacheDirectory.exists()).isFalse() + } + + @Test + fun `if no dir in config uses cacheDir from context`() { + val storageConfigWithoutDir = getStorageConfig(null) + val context = mock(Context::class.java) + val tempDirectory = createTempDirectory("test4").toFile() + `when`(context.cacheDir).thenReturn(tempDirectory) + val cacheDirectoryProvider = CacheDirectoryProvider(storageConfigWithoutDir, context) + val cacheDirectory = cacheDirectoryProvider.getCacheDirectory("backup-dir") + assertThat(cacheDirectory.isDirectory).isTrue() + assertThat(cacheDirectory.exists()).isTrue() + // check that the cache directory is a child of the context cache directory + assertThat(cacheDirectory.parent).isEqualTo(tempDirectory.path) + } + + private fun getStorageConfig(directory: String?): LocalStorageConfig { + val builder = UnleashConfig.newBuilder("test") + .pollingStrategy.enabled(false) + .metricsStrategy.enabled(false) + if (directory != null) { + builder.localStorageConfig.dir(directory) + } + return builder.build().localStorageConfig + } +} \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/data/PayloadTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/data/PayloadTest.kt index 4547fc1..befd0cc 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/data/PayloadTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/data/PayloadTest.kt @@ -1,6 +1,7 @@ package io.getunleash.android.data import com.fasterxml.jackson.module.kotlin.readValue +import io.getunleash.android.polling.ProxyResponse import org.assertj.core.api.Assertions.assertThat import org.junit.Test diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/data/TestResponses.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/data/TestResponses.kt index 97e8854..a22ee54 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/data/TestResponses.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/data/TestResponses.kt @@ -1,6 +1,7 @@ package io.getunleash.android.data import com.fasterxml.jackson.module.kotlin.readValue +import io.getunleash.android.polling.ProxyResponse object TestResponses { val threeToggles = """ diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/http/ClientBuilderTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/http/ClientBuilderTest.kt new file mode 100644 index 0000000..c698e7a --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/http/ClientBuilderTest.kt @@ -0,0 +1,41 @@ +package io.getunleash.android.http + +import android.content.Context +import io.getunleash.android.BaseTest +import io.getunleash.android.UnleashConfig +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.Mockito.mock +import kotlin.io.path.createTempDirectory + +class ClientBuilderTest : BaseTest() { + + @Test + fun `when local storage is enabled it uses a client cache`() { + val config = + UnleashConfig.newBuilder("my-app") + .proxyUrl("https://localhost:4242/proxy") + .localStorageConfig.dir(createTempDirectory("cbt1").toFile().path) + .clientKey("some-key").build() + + val clientBuilder = ClientBuilder(config, mock(Context::class.java)) + val client = clientBuilder.build("clientName", config.pollingStrategy) + + assertThat(client.cache).isNotNull() + } + + + @Test + fun `when local storage is disabled it does not use a client cache`() { + val config = + UnleashConfig.newBuilder("my-app") + .proxyUrl("https://localhost:4242/proxy") + .localStorageConfig.enabled(false) + .clientKey("some-key").build() + + val clientBuilder = ClientBuilder(config, mock(Context::class.java)) + val client = clientBuilder.build("clientName", config.pollingStrategy) + + assertThat(client.cache).isNull() + } +} \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/http/NetworkStatusHelperTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/http/NetworkStatusHelperTest.kt index 2e29199..3543141 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/http/NetworkStatusHelperTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/http/NetworkStatusHelperTest.kt @@ -4,11 +4,10 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities -import android.net.NetworkInfo - import io.getunleash.android.BaseTest import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyInt import org.mockito.Mockito.mock import org.mockito.Mockito.times @@ -29,7 +28,7 @@ class NetworkStatusHelperTest : BaseTest() { fun `when api version is 21 check active network info`() { val context = mock(Context::class.java) val connectivityManager = mock(ConnectivityManager::class.java) - val activeNetwork = mock(NetworkInfo::class.java) + val activeNetwork = mock(android.net.NetworkInfo::class.java) `when`(context.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(connectivityManager) `when`(connectivityManager.activeNetworkInfo).thenReturn(activeNetwork) `when`(activeNetwork.isConnected).thenReturn(true) @@ -54,4 +53,96 @@ class NetworkStatusHelperTest : BaseTest() { assertThat(networkStatusHelper.isAvailable()).isTrue() verify(networkCapabilities, times(2)).hasCapability(anyInt()) } + + @Test + @Config(sdk = [23]) + fun `when no active network the network is not available`() { + val context = contextWithNetwork(null) + val networkStatusHelper = NetworkStatusHelper(context) + assertThat(networkStatusHelper.isAvailable()).isFalse() + } + + @Test + @Config(sdk = [23]) + fun `when active network has no capability the network is not available`() { + val context = contextWithNetwork(mock(Network::class.java)) + val networkStatusHelper = NetworkStatusHelper(context) + assertThat(networkStatusHelper.isAvailable()).isFalse() + } + + @Test + @Config(sdk = [23]) + fun `when no internet capability then the network is not available`() { + val context = contextWithNetwork( + mock(Network::class.java), + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) + val networkStatusHelper = NetworkStatusHelper(context) + assertThat(networkStatusHelper.isAvailable()).isFalse() + } + + @Test + @Config(sdk = [23]) + fun `when network not validated then the network is not available`() { + val context = contextWithNetwork( + mock(Network::class.java), + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + val networkStatusHelper = NetworkStatusHelper(context) + assertThat(networkStatusHelper.isAvailable()).isFalse() + } + + @Test + @Config(sdk = [23]) + fun `when network is validated and has internet then the network is available`() { + val context = contextWithNetwork( + mock(Network::class.java), + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + val networkStatusHelper = NetworkStatusHelper(context) + assertThat(networkStatusHelper.isAvailable()).isTrue() + } + + @Test + @Config(sdk = [23]) + fun `can register a network listener with API level above 23`() { + val network = mock(Network::class.java) + val context = contextWithNetwork( + network, + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkStatusHelper = NetworkStatusHelper(context) + val listener = mock(NetworkListener::class.java) + networkStatusHelper.registerNetworkListener(listener) + verify(connectivityManager).registerNetworkCallback(any(), any()) + + networkStatusHelper.networkCallbacks[0].onAvailable(network) + verify(listener).onAvailable() + networkStatusHelper.networkCallbacks[0].onLost(network) + verify(listener).onLost() + networkStatusHelper.networkCallbacks[0].onUnavailable() + verify(listener, times(2)).onLost() + } + + private fun contextWithNetwork(network: Network?, vararg capabilities: Int): Context { + val context = mock(Context::class.java) + val connectivityManager = mock(ConnectivityManager::class.java) + if (network != null) { + `when`(connectivityManager.activeNetwork).thenReturn(network) + val mockedCapabilities = mock() + capabilities.forEach { + `when`(mockedCapabilities.hasCapability(it)).thenReturn(true) + } + `when`(connectivityManager.getNetworkCapabilities(network)).thenReturn(mockedCapabilities) + } + `when`(context.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn( + connectivityManager + ) + return context + } + + } \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/metrics/MetricsSenderTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/metrics/MetricsSenderTest.kt new file mode 100644 index 0000000..810b15d --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/metrics/MetricsSenderTest.kt @@ -0,0 +1,94 @@ +package io.getunleash.android.metrics + +import android.content.Context +import io.getunleash.android.BaseTest +import io.getunleash.android.UnleashConfig +import io.getunleash.android.data.Payload +import io.getunleash.android.data.Variant +import io.getunleash.android.http.ClientBuilder +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import java.util.concurrent.TimeUnit +import net.javacrumbs.jsonunit.assertj.assertThatJson +import java.math.BigDecimal.valueOf + +class MetricsSenderTest : BaseTest() { + var server: MockWebServer = MockWebServer() + var proxyUrl: String = "" + var configBuilder: UnleashConfig.Builder = UnleashConfig.newBuilder("my-test-app") + .clientKey("some-key") + .pollingStrategy.enabled(false) + .localStorageConfig.enabled(false) + + @Before + fun setUp() { + server = MockWebServer() + proxyUrl = server.url("proxy").toString() + configBuilder = configBuilder.proxyUrl(proxyUrl) + } + + @Test + fun `does not push metrics if no metrics`() = runTest { + val config = configBuilder.build() + val httpClient = ClientBuilder(config, mock(Context::class.java)).build("test", config.metricsStrategy) + val metricsSender = MetricsSender(config, httpClient) + + metricsSender.sendMetrics() + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `pushes metrics if metrics`() = runTest { + val config = configBuilder.build() + val httpClient = ClientBuilder(config, mock(Context::class.java)).build("test", config.metricsStrategy) + val metricsSender = MetricsSender(config, httpClient) + + metricsSender.count("feature1", true) + metricsSender.count("feature2", false) + metricsSender.countVariant( + "feature2", + Variant( + "variant1", + enabled = true, + featureEnabled = false, + Payload("string", "my variant") + ) + ) + metricsSender.count("feature1", false) + metricsSender.count("feature1", true) + val today = java.time.LocalDate.now() + metricsSender.sendMetrics() + val request = server.takeRequest( + 1, + TimeUnit.SECONDS + )!! + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/proxy/client/metrics") + assertThatJson(request.body.readUtf8()) { + node("appName").isString().isEqualTo("my-test-app") + node("instanceId").isString().matches(".+") + node("bucket").apply { + node("start").isString().matches("${today}T.+") + node("stop").isString().matches("${today}T.+") + node("toggles").apply { + node("feature1").apply { + node("yes").isNumber().isEqualTo(valueOf(2)) + node("no").isNumber().isEqualTo(valueOf(1)) + node("variants").isObject().isEqualTo(emptyMap()) + } + node("feature2").apply { + node("yes").isNumber().isEqualTo(valueOf(0)) + node("no").isNumber().isEqualTo(valueOf(1)) + node("variants").apply { + node("variant1").isNumber().isEqualTo(valueOf(1)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/unleashandroidsdk/src/test/resources/unleash-state.json b/unleashandroidsdk/src/test/resources/unleash-state.json new file mode 100644 index 0000000..a966ac3 --- /dev/null +++ b/unleashandroidsdk/src/test/resources/unleash-state.json @@ -0,0 +1,36 @@ +{ + "contextId": "1450523790", + "toggles": { + "ff1": { + "name": "ff1", + "enabled": false, + "variant": { + "name": "disabled", + "enabled": false, + "payload": null + }, + "impressionData": false + }, + "ff2": { + "name": "ff2", + "enabled": true, + "variant": { + "name": "black", + "enabled": true, + "feature_enabled": true, + "payload": null + }, + "impressionData": false + }, + "ff3": { + "name": "ff3", + "enabled": false, + "variant": { + "name": "disabled", + "enabled": false, + "payload": null + }, + "impressionData": true + } + } +} \ No newline at end of file