From cc723444cf18e410b896b76b69c86d9108803fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 14:36:59 +0200 Subject: [PATCH 01/21] feat: ability to bootstrap from a stored response from the proxy (#53) --- .../io/getunleash/android/DefaultUnleash.kt | 13 +++++- .../getunleash/android/DefaultUnleashTest.kt | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt index c434e78..ace9ac2 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.data.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 @@ -113,6 +116,7 @@ class DefaultUnleash( fun start( eventListeners: List = emptyList(), + bootstrapFile: File? = null, bootstrap: List = emptyList() ) { if (!started.compareAndSet(false, true)) { @@ -130,7 +134,14 @@ class DefaultUnleash( } 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 })) } diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt index 87d630b..c86f359 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt @@ -20,6 +20,7 @@ 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 class DefaultUnleashTest : BaseTest() { @@ -395,4 +396,45 @@ 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 inspectionableCache = object : ToggleCache { + var toggles: Map = emptyMap() + override fun read(): Map { + TODO("Not needed") + } + + override fun get(key: String): Toggle? { + TODO("Not needed") + } + + override fun write(state: UnleashState) { + toggles = state.toggles + } + + } + 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 = inspectionableCache + ) + + unleash.start(bootstrapFile = sampleBackupResponse) + + await().atMost(2, TimeUnit.SECONDS).until { inspectionableCache.toggles.isNotEmpty() } + assertThat(inspectionableCache.toggles).hasSize(8) + val aToggle = inspectionableCache.toggles["AwesomeDemo"] + assertThat(aToggle).isNotNull + assertThat(aToggle!!.enabled).isTrue() + assertThat(aToggle.variant).isNotNull + assertThat(aToggle.variant.name).isEqualTo("black") + } + } From 5790ef7f1867f6ea39c979101690bce4109aa8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 14:39:08 +0200 Subject: [PATCH 02/21] chore: create tag from GHA (#54) --- .github/workflows/create-release.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/create-release.yml diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..8074287 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,34 @@ +name: Create a new release tag + +on: + workflow_dispatch: + inputs: + release: + description: 'Release version' + required: true + options: + - major + - minor + - patch + - pre-release +jobs: + deploy-release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags') + steps: + - uses: actions/checkout@v4 + name: Checkout code + - 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 + if: github.event.inputs.release == 'pre-release' \ No newline at end of file From 773d793d4881ad4b8c04c7d3b87de2b39947e0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 14:45:22 +0200 Subject: [PATCH 03/21] fix: workflow create release (#55) * Make options a choice * Able to run on branches without tags --- .github/workflows/create-release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8074287..fdceb54 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -6,15 +6,19 @@ on: release: description: 'Release version' required: true + type: choice options: - major - minor - patch - pre-release + +permissions: + contents: write + jobs: - deploy-release: + create-tag: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags') steps: - uses: actions/checkout@v4 name: Checkout code From 48b0b1262d57964d5f28aae10aa31dff67f22425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 14:50:57 +0200 Subject: [PATCH 04/21] fix: typo --- .github/workflows/create-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index fdceb54..2405b92 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -34,5 +34,5 @@ jobs: if: github.event.inputs.release == 'minor' - run: ./gradlew release -Prelease.versionIncrementer=incrementPatch if: github.event.inputs.release == 'patch' - - run: ./gradlew release -Prelease.versionIncrementer=incrementPreRelease - if: github.event.inputs.release == 'pre-release' \ No newline at end of file + - run: ./gradlew release -Prelease.versionIncrementer=incrementPrerelease + if: github.event.inputs.release == 'pre-release' From b5320f37f0f2296541e8fbe14aa1290e102acf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 15:21:11 +0200 Subject: [PATCH 05/21] fix: add actions write --- .github/workflows/create-release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 2405b92..5c892fd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -15,6 +15,7 @@ on: permissions: contents: write + actions: write jobs: create-tag: @@ -22,6 +23,8 @@ jobs: steps: - uses: actions/checkout@v4 name: Checkout code + with: + fetch-depth: 0 - name: Setup JDK uses: actions/setup-java@v4 with: @@ -34,5 +37,5 @@ jobs: if: github.event.inputs.release == 'minor' - run: ./gradlew release -Prelease.versionIncrementer=incrementPatch if: github.event.inputs.release == 'patch' - - run: ./gradlew release -Prelease.versionIncrementer=incrementPrerelease + - run: ./gradlew release -Prelease.versionIncrementer=incrementPrerelease -Prelease.versionIncrementer.initialPreReleaseIfNotOnPrerelease=-rc1 if: github.event.inputs.release == 'pre-release' From e38a3448c6b93739e9ffce1b6687b1c97b402fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 15:21:11 +0200 Subject: [PATCH 06/21] fix: change order of options --- .github/workflows/create-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 5c892fd..5e7ea23 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -8,10 +8,10 @@ on: required: true type: choice options: - - major - - minor - - patch - pre-release + - patch + - minor + - major permissions: contents: write From c737047c7e95e99b7cbaa29af860b19e013cc1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 15:48:56 +0200 Subject: [PATCH 07/21] fix: sources already exist when packing (#56) * fix: sources already exist when packing --- unleashandroidsdk/build.gradle.kts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 5b48c7c..008de40 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -48,11 +48,7 @@ android { } publishing { - multipleVariants { - includeBuildTypeValues("debug", "release") - allVariants() - withJavadocJar() - } + singleVariant("release") } } From 78f047a7c824fb52803f81798ffac485ff6dcd55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 15:55:54 +0200 Subject: [PATCH 08/21] Run when other workflow finishes --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) 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: From 34bec1f281123e2a41205f4f62199fa1889ec1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 15:57:09 +0200 Subject: [PATCH 09/21] Include javadoc and sources --- unleashandroidsdk/build.gradle.kts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 008de40..a824e82 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -48,7 +48,10 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } } } From 576968249c8ad91160e586d60f76f3231320d6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 22 Jul 2024 16:11:19 +0200 Subject: [PATCH 10/21] chore: remove extra dependencies --- unleashandroidsdk/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index a824e82..e111782 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -58,8 +58,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) From 9eb26bd6c0879aa771169049a4fa8786af6e9875 Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Tue, 23 Jul 2024 13:32:12 +0200 Subject: [PATCH 11/21] Coveralls jacoco report (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove jacoco-coveralls plugin and use coveralls github app instead --------- Co-authored-by: Gastón Fournier --- .github/workflows/build.yaml | 13 ++++++------ .github/workflows/prs.yaml | 7 ++++++- unleashandroidsdk/build.gradle.kts | 32 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 09592cf..a99ae74 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,10 +18,9 @@ jobs: 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 + run: ./gradlew build jacocoTestReport + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + allow-empty: true \ No newline at end of file diff --git a/.github/workflows/prs.yaml b/.github/workflows/prs.yaml index 9060d5d..64bd0a2 100644 --- a/.github/workflows/prs.yaml +++ b/.github/workflows/prs.yaml @@ -16,4 +16,9 @@ jobs: distribution: 'temurin' cache: gradle - name: Build and test - run: ./gradlew build + run: ./gradlew build jacocoTestReport + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + allow-empty: true diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index e111782..8d489b8 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -8,6 +8,7 @@ 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() @@ -157,4 +158,35 @@ tasks.withType().configureEach { } } } +} + +jacoco { + toolVersion = "0.8.8" +} + +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/**/*.*") + } + + sourceDirectories.setFrom(files("${projectDir}/src/main/java")) + classDirectories.setFrom(listOf( + fileTree("${buildDir}/classes", fileTreeConfig), + fileTree("${buildDir}/intermediates/javac/debug", fileTreeConfig), + fileTree("${buildDir}/tmp/kotlin-classes/debug", fileTreeConfig) + )) + executionData.setFrom(fileTree(buildDir) { + include("jacoco/*.exec") + }) +} + +tasks.named("check") { + finalizedBy("jacocoTestReport") } \ No newline at end of file From c5c41a46c7ca0df02d848f5aa5401e50f7a38d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 09:45:01 +0200 Subject: [PATCH 12/21] fix: jacoco report proper coverage (#60) --- .github/workflows/build.yaml | 3 ++- .github/workflows/prs.yaml | 1 + unleashandroidsdk/build.gradle.kts | 31 ++++++++++++++++++++---------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a99ae74..64109ea 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,4 +23,5 @@ jobs: uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - allow-empty: true \ No newline at end of file + allow-empty: true + base-path: unleashandroidsdk/src/main/java \ No newline at end of file diff --git a/.github/workflows/prs.yaml b/.github/workflows/prs.yaml index 64bd0a2..46f8fa4 100644 --- a/.github/workflows/prs.yaml +++ b/.github/workflows/prs.yaml @@ -22,3 +22,4 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} allow-empty: true + base-path: unleashandroidsdk/src/main/java diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 8d489b8..8826724 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -14,6 +14,10 @@ plugins { val tagVersion = System.getenv("GITHUB_REF")?.split('/')?.last() project.version = scmVersion.version +jacoco { + toolVersion = "0.8.8" +} + android { namespace = "io.getunleash.android" compileSdk = 34 @@ -30,10 +34,10 @@ android { buildTypes { debug { - + isMinifyEnabled = false } release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -160,10 +164,6 @@ tasks.withType().configureEach { } } -jacoco { - toolVersion = "0.8.8" -} - val jacocoTestReport by tasks.register("jacocoTestReport") { dependsOn("testDebugUnitTest") @@ -173,7 +173,8 @@ val jacocoTestReport by tasks.register("jacocoTestReport") { } val fileTreeConfig: (ConfigurableFileTree) -> Unit = { - it.exclude("**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "android/**/*.*") + it.exclude("**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "android/**/*.*", + "**/data/**", "**/errors/**", "**/events/**") } sourceDirectories.setFrom(files("${projectDir}/src/main/java")) @@ -187,6 +188,16 @@ val jacocoTestReport by tasks.register("jacocoTestReport") { }) } -tasks.named("check") { - finalizedBy("jacocoTestReport") -} \ No newline at end of file +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) +} From d6551ee2b2969985526a662ed6890bb7ffaa3e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 10:35:21 +0200 Subject: [PATCH 13/21] chore: reorganize classes (#61) * chore: reorganize classes * Remove unused listener * Undo minified enabled * Linting --- unleashandroidsdk/build.gradle.kts | 2 +- .../io/getunleash/android/DefaultUnleash.kt | 6 +-- .../io/getunleash/android/ReadyListener.kt | 5 --- .../getunleash/android/backup/LocalBackup.kt | 2 +- .../android/cache/ToggleStoreListener.kt | 10 ----- .../getunleash/android/data/FetchResponse.kt | 40 ------------------- .../java/io/getunleash/android/data/Toggle.kt | 2 +- .../android/errors/NoBodyException.kt | 3 +- .../android/errors/NotAuthorizedException.kt | 3 +- .../android/errors/ServerException.kt | 3 +- .../android/events/HeartbeatEvent.kt | 2 +- .../events/UnleashAllEventsListener.kt | 22 ---------- .../events/UnleashFetcherHeartbeatListener.kt | 7 ++++ .../events/UnleashImpressionEventListener.kt | 7 ++++ .../android/events/UnleashListener.kt | 4 ++ .../android/events/UnleashReadyListener.kt | 5 +++ .../android/events/UnleashStateListener.kt | 5 +++ .../io/getunleash/android/http/Throttler.kt | 4 +- .../{data => metrics}/MetricsBucket.kt | 3 +- .../android/metrics/MetricsSender.kt | 3 -- .../android/polling/FetchResponse.kt | 16 ++++++++ .../{data => polling}/ProxyResponse.kt | 4 +- .../io/getunleash/android/polling/Status.kt | 13 ++++++ .../android/polling/ToggleResponse.kt | 17 ++++++++ .../android/polling/UnleashFetcher.kt | 4 -- .../io/getunleash/android/CountBucketTest.kt | 2 +- .../getunleash/android/DefaultUnleashTest.kt | 2 +- .../io/getunleash/android/data/PayloadTest.kt | 1 + .../getunleash/android/data/TestResponses.kt | 1 + 29 files changed, 95 insertions(+), 103 deletions(-) delete mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/ReadyListener.kt delete mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/cache/ToggleStoreListener.kt delete mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/data/FetchResponse.kt delete mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashAllEventsListener.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashFetcherHeartbeatListener.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashImpressionEventListener.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashListener.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashReadyListener.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/events/UnleashStateListener.kt rename unleashandroidsdk/src/main/java/io/getunleash/android/{data => metrics}/MetricsBucket.kt (96%) create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/polling/FetchResponse.kt rename unleashandroidsdk/src/main/java/io/getunleash/android/{data => polling}/ProxyResponse.kt (56%) create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/polling/Status.kt create mode 100644 unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 8826724..1421d49 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -37,7 +37,7 @@ android { isMinifyEnabled = false } release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt index ace9ac2..7c06712 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt @@ -13,7 +13,7 @@ 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.data.ProxyResponse +import io.getunleash.android.polling.ProxyResponse import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.data.UnleashState @@ -103,7 +103,7 @@ class DefaultUnleash( unleashContextState.asStateFlow() ) else null taskManager = LifecycleAwareTaskManager( - dataJobs = buildDataJobs(fetcher, metrics), + dataJobs = buildDataJobs(metrics, fetcher), networkAvailable = networkStatusHelper.isAvailable(), scope = coroutineScope ) @@ -147,7 +147,7 @@ class DefaultUnleash( } } - private fun buildDataJobs(fetcher: UnleashFetcher?, metricsSender: MetricsReporter) = buildList { + private fun buildDataJobs(metricsSender: MetricsReporter, fetcher: UnleashFetcher?) = buildList { if (fetcher != null) { add( DataJob( 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..896a963 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. 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/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..2575ed3 --- /dev/null +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt @@ -0,0 +1,17 @@ +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) { + fun isFetched() = status.isSuccess() + fun isNotModified() = status.isNotModified() + fun isFailed() = status.isFailed() +} \ 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 c86f359..fdebb4b 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.lifecycle.Lifecycle import io.getunleash.android.cache.ToggleCache import io.getunleash.android.data.ImpressionEvent -import io.getunleash.android.data.Status +import io.getunleash.android.polling.Status import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.data.UnleashState 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 = """ From 4fb5c6315d071cd09732906c1e45d82ba0daadf6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:37:41 +0200 Subject: [PATCH 14/21] fix(deps): update dependency jacoco to v0.8.12 (#58) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Christopher Kolstad --- unleashandroidsdk/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index 1421d49..025a3ab 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -15,7 +15,7 @@ val tagVersion = System.getenv("GITHUB_REF")?.split('/')?.last() project.version = scmVersion.version jacoco { - toolVersion = "0.8.8" + toolVersion = "0.8.12" } android { From 36eaf4571540b7a2132af106c663bd475aa23ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 13:04:57 +0200 Subject: [PATCH 15/21] chore: tests for local backup (#63) * chore: tests for local backup * Split tests into unit and more functional * Renamed class --- .../io/getunleash/android/DefaultUnleash.kt | 3 +- .../getunleash/android/backup/LocalBackup.kt | 4 +- .../getunleash/android/DefaultUnleashTest.kt | 80 ++++++++++--------- .../io/getunleash/android/InspectableCache.kt | 25 ++++++ .../android/backup/LocalBackupTest.kt | 30 +++++++ .../src/test/resources/unleash-state.json | 36 +++++++++ 6 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 unleashandroidsdk/src/test/java/io/getunleash/android/InspectableCache.kt create mode 100644 unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt create mode 100644 unleashandroidsdk/src/test/resources/unleash-state.json diff --git a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt index 7c06712..f017101 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt @@ -71,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) @@ -170,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/backup/LocalBackup.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt index 896a963..afa4c88 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt @@ -26,7 +26,7 @@ class LocalBackup( ) { 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 @@ -63,7 +63,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/test/java/io/getunleash/android/DefaultUnleashTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt index fdebb4b..afa383f 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.polling.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 @@ -22,26 +22,13 @@ 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() { @@ -52,7 +39,7 @@ class DefaultUnleashTest : BaseTest() { .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) .build(), - cacheImpl = testCache, + cacheImpl = InspectableCache(staticToggleList.associateBy { it.name }), lifecycle = mock(Lifecycle::class.java), ) assertThat(unleash.isEnabled("feature1")).isTrue() @@ -136,7 +123,7 @@ class DefaultUnleashTest : BaseTest() { .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) .build(), - cacheImpl = testCache, + cacheImpl = InspectableCache(staticToggleList.associateBy { it.name }), lifecycle = mock(Lifecycle::class.java), ) @@ -400,21 +387,7 @@ class DefaultUnleashTest : BaseTest() { @Test fun `can load from disk using a backup`() { val sampleBackupResponse = File(this::class.java.classLoader?.getResource("sample-response.json")!!.path) - val inspectionableCache = object : ToggleCache { - var toggles: Map = emptyMap() - override fun read(): Map { - TODO("Not needed") - } - - override fun get(key: String): Toggle? { - TODO("Not needed") - } - - override fun write(state: UnleashState) { - toggles = state.toggles - } - - } + val inspectableCache = InspectableCache() val unleash = DefaultUnleash( androidContext = mock(Context::class.java), unleashConfig = UnleashConfig.newBuilder("test-android-app") @@ -423,18 +396,49 @@ class DefaultUnleashTest : BaseTest() { .localStorageConfig.enabled(false) .build(), lifecycle = mock(Lifecycle::class.java), - cacheImpl = inspectionableCache + cacheImpl = inspectableCache ) unleash.start(bootstrapFile = sampleBackupResponse) - await().atMost(2, TimeUnit.SECONDS).until { inspectionableCache.toggles.isNotEmpty() } - assertThat(inspectionableCache.toggles).hasSize(8) - val aToggle = inspectionableCache.toggles["AwesomeDemo"] + 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..450c351 --- /dev/null +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt @@ -0,0 +1,30 @@ +package io.getunleash.android.backup + +import io.getunleash.android.BaseTest +import io.getunleash.android.data.UnleashContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.io.File +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() + } +} \ 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 From 2805b69b509606be5dfdb5ecf0702b3a23be153f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 13:16:46 +0200 Subject: [PATCH 16/21] chore: avoid flaky test (#65) --- .../src/test/java/io/getunleash/android/DefaultUnleashTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt index afa383f..fc26cf1 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt @@ -296,6 +296,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(), From 39b2a6d5bccdcc557f49ae2082bb23b8adfa6a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 13:17:05 +0200 Subject: [PATCH 17/21] tests: test metrics sender (#64) --- gradle/libs.versions.toml | 2 + unleashandroidsdk/build.gradle.kts | 1 + .../android/metrics/MetricsSenderTest.kt | 94 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 unleashandroidsdk/src/test/java/io/getunleash/android/metrics/MetricsSenderTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 64829af..0e97b77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.5.1" kotlin = "1.9.0" 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 025a3ab..c19698b 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { testImplementation(libs.robolectric.test) testImplementation(libs.okhttp.mockserver) testImplementation(libs.awaitility) + testImplementation(libs.jsonunit) androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.assertj) androidTestImplementation(libs.mockito) 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 From 812f882d33081cd136cf7665c7aad69f99111607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Jul 2024 14:20:35 +0200 Subject: [PATCH 18/21] chore: build only the sdk when possible (#62) * chore: build only the sdk when possible * Fetch other branches and commits --- .github/workflows/build.yaml | 16 +- .github/workflows/prs.yaml | 16 +- unleashandroidsdk/build.gradle.kts | 9 -- .../java/io/getunleash/android/Coroutines.kt | 101 ------------- .../android/ExampleInstrumentedTest.kt | 39 ----- .../android/FeatureFeatureFlagWorkerTest.kt | 88 ----------- .../androidTest/resources/edgeresponse.json | 84 ----------- .../androidTest/resources/proxyresponse.json | 140 ------------------ .../io/getunleash/android/DefaultUnleash.kt | 2 +- 9 files changed, 31 insertions(+), 464 deletions(-) delete mode 100644 unleashandroidsdk/src/androidTest/java/io/getunleash/android/Coroutines.kt delete mode 100644 unleashandroidsdk/src/androidTest/java/io/getunleash/android/ExampleInstrumentedTest.kt delete mode 100644 unleashandroidsdk/src/androidTest/java/io/getunleash/android/FeatureFeatureFlagWorkerTest.kt delete mode 100644 unleashandroidsdk/src/androidTest/resources/edgeresponse.json delete mode 100644 unleashandroidsdk/src/androidTest/resources/proxyresponse.json diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 64109ea..c38d02e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,13 +11,27 @@ 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 + - 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 diff --git a/.github/workflows/prs.yaml b/.github/workflows/prs.yaml index 46f8fa4..5feb6a0 100644 --- a/.github/workflows/prs.yaml +++ b/.github/workflows/prs.yaml @@ -9,13 +9,27 @@ 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 + - 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 diff --git a/unleashandroidsdk/build.gradle.kts b/unleashandroidsdk/build.gradle.kts index c19698b..97ccdc1 100644 --- a/unleashandroidsdk/build.gradle.kts +++ b/unleashandroidsdk/build.gradle.kts @@ -78,13 +78,6 @@ dependencies { testImplementation(libs.okhttp.mockserver) testImplementation(libs.awaitility) testImplementation(libs.jsonunit) - 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) } publishing { @@ -180,8 +173,6 @@ val jacocoTestReport by tasks.register("jacocoTestReport") { sourceDirectories.setFrom(files("${projectDir}/src/main/java")) classDirectories.setFrom(listOf( - fileTree("${buildDir}/classes", fileTreeConfig), - fileTree("${buildDir}/intermediates/javac/debug", fileTreeConfig), fileTree("${buildDir}/tmp/kotlin-classes/debug", fileTreeConfig) )) executionData.setFrom(fileTree(buildDir) { 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 f017101..11a7947 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/DefaultUnleash.kt @@ -124,6 +124,7 @@ class DefaultUnleash( Log.w(TAG, "Unleash already started, ignoring start call") return } + eventListeners.forEach { addUnleashEventListener(it) } networkStatusHelper.registerNetworkListener(taskManager) if (unleashConfig.localStorageConfig.enabled) { val localBackup = getLocalBackup() @@ -134,7 +135,6 @@ class DefaultUnleash( cache.subscribeTo(it.getFeaturesReceivedFlow()) } lifecycle.addObserver(taskManager) - eventListeners.forEach { addUnleashEventListener(it) } if (bootstrapFile != null && bootstrapFile.exists()) { Log.i(TAG, "Using provided bootstrap file") Parser.jackson.readValue(bootstrapFile, ProxyResponse::class.java)?.let { state -> From 8664b84bd697edd2d483f53b6fe2a00024bf1117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Thu, 25 Jul 2024 09:31:51 +0200 Subject: [PATCH 19/21] chore: several test cases in 3 commits (#66) * chore: add tests for cache directory provder * chore: client builder tests * Remove unused methods * Add more tests for local backup * Have a case without delayed init * Have a case with metrics enabled * Add more timeout to heartbeat event test --- .../getunleash/android/backup/LocalBackup.kt | 5 +- .../android/cache/CacheDirectoryProvider.kt | 8 +- .../android/polling/ToggleResponse.kt | 3 - .../getunleash/android/DefaultUnleashTest.kt | 8 +- .../android/backup/LocalBackupTest.kt | 54 +++++++++++++ .../cache/CacheDirectoryProviderTest.kt | 75 +++++++++++++++++++ .../android/http/ClientBuilderTest.kt | 41 ++++++++++ 7 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 unleashandroidsdk/src/test/java/io/getunleash/android/cache/CacheDirectoryProviderTest.kt create mode 100644 unleashandroidsdk/src/test/java/io/getunleash/android/http/ClientBuilderTest.kt 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 afa4c88..913284b 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/backup/LocalBackup.kt @@ -22,15 +22,14 @@ private data class BackupState(val contextId: String, val toggles: Map) { unleashScope.launch { withContext(Dispatchers.IO) { 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/polling/ToggleResponse.kt b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt index 2575ed3..7da70db 100644 --- a/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt +++ b/unleashandroidsdk/src/main/java/io/getunleash/android/polling/ToggleResponse.kt @@ -11,7 +11,4 @@ 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() } \ No newline at end of file diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt index fc26cf1..ec9d736 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/DefaultUnleashTest.kt @@ -38,9 +38,10 @@ class DefaultUnleashTest : BaseTest() { .pollingStrategy.enabled(false) .metricsStrategy.enabled(false) .localStorageConfig.enabled(false) + .delayedInitialization(false) // start immediately .build(), cacheImpl = InspectableCache(staticToggleList.associateBy { it.name }), - lifecycle = mock(Lifecycle::class.java), + lifecycle = mock(Lifecycle::class.java) ) assertThat(unleash.isEnabled("feature1")).isTrue() assertThat(unleash.isEnabled("feature2")).isFalse() @@ -78,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), @@ -323,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 diff --git a/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt b/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt index 450c351..1bcfb70 100644 --- a/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt +++ b/unleashandroidsdk/src/test/java/io/getunleash/android/backup/LocalBackupTest.kt @@ -2,9 +2,17 @@ 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() { @@ -27,4 +35,50 @@ class LocalBackupTest : BaseTest() { 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/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 From f644902ab6e04589a5169bcb9b714f6753bac738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Thu, 25 Jul 2024 13:02:28 +0200 Subject: [PATCH 20/21] tests: network status helper (#69) * tests: network status helper * Test listener registration --- .../android/http/NetworkStatusHelper.kt | 13 ++- .../android/http/NetworkStatusHelperTest.kt | 97 ++++++++++++++++++- 2 files changed, 104 insertions(+), 6 deletions(-) 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/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 From f1589ce2d2be6fa1fd9cf5f20d6eb267d9b60b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Thu, 25 Jul 2024 14:09:07 +0200 Subject: [PATCH 21/21] docs: migration guide (#70) * docs: migration guide --------- Co-authored-by: Thomas Heartman --- README.md | 8 +-- docs/MigrationGuide.md | 117 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 docs/MigrationGuide.md 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.