diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74a99948b..edc011e94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,3 +44,47 @@ jobs: - name: Make stop run: make stop + + downgrade-compatibility: + name: Downgrade compatibility (${{ matrix.downgrade_version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + downgrade_version: + # Pin persisted-storage schema boundaries rather than the last N releases. + # 3.43.3 predates the replay minimum-duration buffer queue. + - 3.43.3 + # 3.45.1 predates the persisted logs queue. + - 3.45.1 + # 3.47.0 includes the logs queue but predates the current captureLog surface. + - 3.47.0 + + steps: + - name: Git checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: 'Set up Java 17' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Test downgrade compatibility + env: + DOWNGRADE_VERSION: ${{ matrix.downgrade_version }} + run: make testDowngradeCompatibility + + - name: Make stop + if: always() + run: make stop diff --git a/Makefile b/Makefile index 03a512570..c44da190e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava generateLintBaseLine updateLocks +.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava testDowngradeCompatibility generateLintBaseLine updateLocks clean: ./gradlew clean @@ -61,6 +61,9 @@ test: testJava: ./gradlew :posthog:test +testDowngradeCompatibility: + DOWNGRADE_VERSION="$${DOWNGRADE_VERSION:-3.45.1}" ./scripts/test-downgrade-compatibility.sh + generateLintBaseLine: rm -f posthog-android/lint-baseline.xml ./gradlew lintDebug -Dlint.baselines.continue=true diff --git a/scripts/downgrade-compatibility-smoke/.gitignore b/scripts/downgrade-compatibility-smoke/.gitignore new file mode 100644 index 000000000..67bcc2f72 --- /dev/null +++ b/scripts/downgrade-compatibility-smoke/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/scripts/downgrade-compatibility-smoke/build.gradle b/scripts/downgrade-compatibility-smoke/build.gradle new file mode 100644 index 000000000..e488cd66e --- /dev/null +++ b/scripts/downgrade-compatibility-smoke/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'com.android.library' version '8.9.1' +} + +android { + namespace 'com.posthog.downgradecompatibility' + compileSdk 36 + + defaultConfig { + minSdk 23 + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + all { + systemProperty 'posthog.smoke.mode', System.getProperty('posthog.smoke.mode', 'read') + systemProperty 'posthog.smoke.stateDir', System.getProperty('posthog.smoke.stateDir', '') + systemProperty 'posthog.smoke.apiKey', System.getProperty('posthog.smoke.apiKey', 'downgrade_compatibility_project') + } + } + } +} + +dependencies { + String posthogVersion = providers.gradleProperty('posthogVersion').orElse('3.45.1').get() + + testImplementation "com.posthog:posthog-android:${posthogVersion}" + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + testImplementation 'com.google.code.gson:gson:2.10.1' +} diff --git a/scripts/downgrade-compatibility-smoke/gradle.properties b/scripts/downgrade-compatibility-smoke/gradle.properties new file mode 100644 index 000000000..5bac8ac50 --- /dev/null +++ b/scripts/downgrade-compatibility-smoke/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/scripts/downgrade-compatibility-smoke/settings.gradle b/scripts/downgrade-compatibility-smoke/settings.gradle new file mode 100644 index 000000000..257f40d04 --- /dev/null +++ b/scripts/downgrade-compatibility-smoke/settings.gradle @@ -0,0 +1,33 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'posthog-downgrade-compatibility-smoke' + +String dependencyMode = settings.startParameter.projectProperties['posthogDependencyMode'] +String dependencyPath = settings.startParameter.projectProperties['posthogDependencyPath'] + +if (dependencyMode == 'composite') { + if (dependencyPath == null || dependencyPath.trim().isEmpty()) { + throw new GradleException('posthogDependencyPath is required when posthogDependencyMode=composite') + } + + includeBuild(dependencyPath) { + dependencySubstitution { + substitute(module('com.posthog:posthog')).using(project(':posthog')) + substitute(module('com.posthog:posthog-android')).using(project(':posthog-android')) + } + } +} diff --git a/scripts/downgrade-compatibility-smoke/src/test/java/com/posthog/downgrade/DowngradeCompatibilitySmokeTest.java b/scripts/downgrade-compatibility-smoke/src/test/java/com/posthog/downgrade/DowngradeCompatibilitySmokeTest.java new file mode 100644 index 000000000..9c6c2b031 --- /dev/null +++ b/scripts/downgrade-compatibility-smoke/src/test/java/com/posthog/downgrade/DowngradeCompatibilitySmokeTest.java @@ -0,0 +1,478 @@ +package com.posthog.downgrade; + +import android.app.Application; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.posthog.PostHog; +import com.posthog.android.PostHogAndroid; +import com.posthog.android.PostHogAndroidConfig; +import com.posthog.internal.PostHogPreferences; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class DowngradeCompatibilitySmokeTest { + private static final String DEFAULT_API_KEY = "downgrade_compatibility_project"; + private static final String HOST = "http://127.0.0.1:9"; + private static final String WRITER_DISTINCT_ID = "downgrade-compatibility-user"; + private static final Type PREFERENCES_TYPE = new TypeToken>() {}.getType(); + + private final List uncaught = Collections.synchronizedList(new ArrayList<>()); + private Thread.UncaughtExceptionHandler previousUncaughtExceptionHandler; + + @Before + public void setUp() { + previousUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + uncaught.add(throwable); + if (previousUncaughtExceptionHandler != null) { + previousUncaughtExceptionHandler.uncaughtException(thread, throwable); + } + }); + closePostHog(); + } + + @After + public void tearDown() { + closePostHog(); + Thread.setDefaultUncaughtExceptionHandler(previousUncaughtExceptionHandler); + } + + @Test + public void persistedStateSurvivesDowngradedSdkStartup() throws Exception { + String mode = System.getProperty("posthog.smoke.mode", "read"); + if ("write".equals(mode)) { + writeCurrentSdkState(); + } else if ("read".equals(mode)) { + readDowngradedSdkState(); + } else { + throw new IllegalArgumentException("Unknown smoke mode: " + mode); + } + } + + private void writeCurrentSdkState() throws Exception { + setupSdk(10_000); + assertSdkEnabled(); + + invokePostHog("identify", WRITER_DISTINCT_ID, mapOf("source", "downgrade-compatibility"), null); + invokePostHog("group", "organization", "posthog", mapOf("ci", true)); + invokePostHog("screen", "Downgrade Compatibility", mapOf("source", "current-sdk")); + + for (int index = 0; index < 5; index++) { + invokePostHog( + "capture", + "downgrade compatibility event", + null, + mapOf("index", index, "source", "current-sdk"), + null, + null, + null, + null + ); + } + + for (int index = 0; index < 2; index++) { + invokePostHog( + "capture", + "$snapshot", + null, + snapshotProperties(index), + null, + null, + null, + null + ); + } + + captureLogsRequired(); + drainPostHogExecutors(); + + assertFileCountAtLeast("analytics event queue", eventsDir(), 5); + assertFileCountAtLeast("replay snapshot queue", replayDir(), 2); + assertFileCountAtLeast("logs queue", logsDir(), 2); + + FileBackedPreferences preferences = new FileBackedPreferences(preferencesFile()); + assertEquals(WRITER_DISTINCT_ID, preferences.getValue("distinctId", null)); + assertNoUncaughtExceptions(); + System.out.println("Wrote downgrade compatibility state in " + stateDir().getAbsolutePath()); + } + + private void readDowngradedSdkState() throws Exception { + setupSdk(1); + assertSdkEnabled(); + assertEquals(WRITER_DISTINCT_ID, invokePostHog("distinctId")); + + invokePostHog( + "capture", + "downgrade compatibility read smoke", + null, + mapOf("source", "downgraded-sdk"), + null, + null, + null, + null + ); + invokePostHog("flush"); + drainPostHogExecutors(); + + System.out.println("Downgraded SDK started against state in " + stateDir().getAbsolutePath()); + } + + private void setupSdk(int flushAt) throws Exception { + Application application = RuntimeEnvironment.getApplication(); + PostHogAndroidConfig config = newConfig(); + + invokeSetter(config, "setDebug", boolean.class, true); + invokeSetter(config, "setFlushAt", int.class, flushAt); + invokeSetter(config, "setMaxBatchSize", int.class, 10_000); + invokeSetter(config, "setMaxQueueSize", int.class, 10_000); + invokeSetter(config, "setFlushIntervalSeconds", int.class, 10_000); + invokeSetter(config, "setPreloadFeatureFlags", boolean.class, false); + invokeSetter(config, "setRemoteConfig", boolean.class, false); + invokeSetter(config, "setCaptureApplicationLifecycleEvents", boolean.class, false); + invokeSetter(config, "setCaptureDeepLinks", boolean.class, false); + invokeSetter(config, "setCaptureScreenViews", boolean.class, false); + invokeSetter(config, "setSessionReplay", boolean.class, false); + invokeSetter(config, "setSurveys", boolean.class, false); + + invokeSetter(config, "setLegacyStoragePrefix", String.class, legacyDir().getAbsolutePath()); + invokeSetter(config, "setStoragePrefix", String.class, eventsRootDir().getAbsolutePath()); + invokeSetter(config, "setReplayStoragePrefix", String.class, replayRootDir().getAbsolutePath()); + invokeSetter(config, "setLogsStoragePrefix", String.class, logsRootDir().getAbsolutePath()); + invokeSetter(config, "setCachePreferences", PostHogPreferences.class, new FileBackedPreferences(preferencesFile())); + + PostHogAndroid.Companion.setup(application, config); + } + + private static PostHogAndroidConfig newConfig() throws Exception { + try { + Constructor constructor = PostHogAndroidConfig.class.getConstructor(String.class, String.class); + return constructor.newInstance(apiKey(), HOST); + } catch (NoSuchMethodException ignored) { + return new PostHogAndroidConfig(apiKey()); + } + } + + private static void invokeSetter(Object target, String methodName, Class parameterType, Object value) throws Exception { + try { + Method method = target.getClass().getMethod(methodName, parameterType); + method.invoke(target, value); + } catch (NoSuchMethodException ignored) { + // Older pinned SDKs may not have newer config knobs. The smoke test only needs the + // knobs that exist on that version to be set safely. + } + } + + private static Object invokePostHog(String methodName, Object... args) throws Exception { + Object companion = PostHog.Companion; + for (Method method : companion.getClass().getMethods()) { + if (method.getName().equals(methodName) && method.getParameterCount() == args.length) { + return method.invoke(companion, args); + } + } + throw new NoSuchMethodException("PostHog." + methodName + " with " + args.length + " argument(s)"); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void captureLogsRequired() throws Exception { + try { + Class severityClass = Class.forName("com.posthog.logs.PostHogLogSeverity"); + Object warn = Enum.valueOf((Class) severityClass.asSubclass(Enum.class), "WARN"); + Object error = Enum.valueOf((Class) severityClass.asSubclass(Enum.class), "ERROR"); + + invokePostHog( + "captureLog", + "downgrade compatibility warning log", + warn, + mapOf("source", "current-sdk", "index", 0), + null, + null, + null + ); + invokePostHog( + "captureLog", + "downgrade compatibility error log", + error, + mapOf("source", "current-sdk", "index", 1), + null, + null, + null + ); + } catch (ClassNotFoundException | NoSuchMethodException e) { + throw new AssertionError("Current SDK did not expose the expected captureLog API", e); + } + } + + private static void closePostHog() { + try { + invokePostHog("close"); + } catch (Throwable ignored) { + } + } + + private static void assertSdkEnabled() throws Exception { + assertFalse("PostHog should be enabled after setup", (Boolean) invokePostHog("isOptOut")); + } + + private void drainPostHogExecutors() throws Exception { + Object shared = sharedPostHogInstance(); + drainExecutor(shared, "queueExecutor"); + drainExecutor(shared, "replayExecutor"); + drainExecutor(shared, "logsExecutor"); + assertNoUncaughtExceptions(); + } + + private static Object sharedPostHogInstance() throws Exception { + Field shared = PostHog.class.getDeclaredField("shared"); + shared.setAccessible(true); + return shared.get(null); + } + + private static void drainExecutor(Object target, String fieldName) throws Exception { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object executor = field.get(target); + if (executor instanceof ExecutorService) { + ((ExecutorService) executor).submit(() -> null).get(5, TimeUnit.SECONDS); + } + } catch (NoSuchFieldException ignored) { + // Older pinned SDKs do not have every queue executor (e.g. logsExecutor before logs). + } + } + + private void assertNoUncaughtExceptions() { + if (uncaught.isEmpty()) { + return; + } + + AssertionError assertionError = new AssertionError("Uncaught SDK exception on background thread"); + synchronized (uncaught) { + for (Throwable throwable : uncaught) { + assertionError.addSuppressed(throwable); + } + } + throw assertionError; + } + + private static Map snapshotProperties(int index) { + Map snapshotData = new LinkedHashMap<>(); + snapshotData.put("type", 4); + snapshotData.put("data", mapOf("href", "http://example.com/" + index)); + + return mapOf( + "$session_id", "downgrade-compatibility-session", + "$snapshot_data", snapshotData + ); + } + + private static Map mapOf(Object... keysAndValues) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keysAndValues.length; i += 2) { + map.put((String) keysAndValues[i], keysAndValues[i + 1]); + } + return map; + } + + private static void assertFileCountAtLeast(String description, File directory, int minimumCount) { + int count = countFiles(directory); + if (count >= minimumCount) { + return; + } + + fail("Expected at least " + minimumCount + " file(s) in " + description + " at " + directory + + ", found " + count + ". State tree:\n" + describeTree(stateDir(), "")); + } + + private static int countFiles(File directory) { + File[] files = directory.listFiles(File::isFile); + return files == null ? 0 : files.length; + } + + private static String describeTree(File file, String indent) { + if (!file.exists()) { + return indent + file.getAbsolutePath() + " (missing)\n"; + } + StringBuilder builder = new StringBuilder(indent).append(file.getName()).append('\n'); + File[] children = file.listFiles(); + if (children != null) { + Arrays.sort(children, (left, right) -> left.getName().compareTo(right.getName())); + for (File child : children) { + builder.append(describeTree(child, indent + " ")); + } + } + return builder.toString(); + } + + private static String apiKey() { + return System.getProperty("posthog.smoke.apiKey", DEFAULT_API_KEY); + } + + private static File stateDir() { + String path = System.getProperty("posthog.smoke.stateDir"); + if (path == null || path.trim().isEmpty()) { + throw new IllegalStateException("posthog.smoke.stateDir is required"); + } + File dir = new File(path); + assertTrue("Could not create state dir " + dir, dir.exists() || dir.mkdirs()); + return dir; + } + + private static File preferencesFile() { + return new File(stateDir(), "preferences.json"); + } + + private static File legacyDir() { + return new File(stateDir(), "legacy"); + } + + private static File eventsRootDir() { + return new File(stateDir(), "events"); + } + + private static File replayRootDir() { + return new File(stateDir(), "replay"); + } + + private static File logsRootDir() { + return new File(stateDir(), "logs"); + } + + private static File eventsDir() { + return new File(eventsRootDir(), apiKey()); + } + + private static File replayDir() { + return new File(replayRootDir(), apiKey()); + } + + private static File logsDir() { + return new File(logsRootDir(), apiKey()); + } + + public static final class FileBackedPreferences implements PostHogPreferences { + private static final Set INTERNAL_KEYS = new HashSet<>(Arrays.asList( + "groups", + "anonymousId", + "distinctId", + "isIdentified", + "personProcessingEnabled", + "opt-out", + "flags", + "featureFlags", + "featureFlagsPayload", + "feature_flag_request_id", + "feature_flag_evaluated_at", + "sessionReplay", + "surveys", + "errorTracking", + "capturePerformance", + "personPropertiesForFlags", + "groupPropertiesForFlags", + "surveySeen", + "lastSeenSurveyDate", + "version", + "build", + "deviceId", + "stringifiedKeys" + )); + + private final File file; + private final Gson gson = new Gson(); + + FileBackedPreferences(File file) { + this.file = file; + } + + @Override + public synchronized Object getValue(String key, Object defaultValue) { + Object value = read().get(key); + return value == null ? defaultValue : value; + } + + @Override + public synchronized void setValue(String key, Object value) { + Map values = read(); + values.put(key, value); + write(values); + } + + @Override + public synchronized void clear(List except) { + Map values = read(); + values.keySet().removeIf(key -> !except.contains(key)); + write(values); + } + + @Override + public synchronized void remove(String key) { + Map values = read(); + values.remove(key); + write(values); + } + + @Override + public synchronized Map getAll() { + Map values = new HashMap<>(read()); + values.keySet().removeIf(INTERNAL_KEYS::contains); + return values; + } + + private Map read() { + if (!file.exists()) { + return new LinkedHashMap<>(); + } + try (FileReader reader = new FileReader(file)) { + Map values = gson.fromJson(reader, PREFERENCES_TYPE); + return values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values); + } catch (IOException e) { + throw new IllegalStateException("Failed to read preferences from " + file, e); + } + } + + private void write(Map values) { + File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IllegalStateException("Failed to create preferences directory " + parent); + } + try (FileWriter writer = new FileWriter(file)) { + gson.toJson(values, writer); + } catch (IOException e) { + throw new IllegalStateException("Failed to write preferences to " + file, e); + } + } + } +} diff --git a/scripts/test-downgrade-compatibility.sh b/scripts/test-downgrade-compatibility.sh new file mode 100755 index 000000000..c71e7f5fb --- /dev/null +++ b/scripts/test-downgrade-compatibility.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Verifies that persisted state written by the current Android SDK does not break +# startup for older pinned SDK versions. Pin versions at meaningful persisted-state +# schema boundaries rather than testing the last N releases. +# +# Usage: DOWNGRADE_VERSION= ./scripts/test-downgrade-compatibility.sh + +set -euo pipefail + +DOWNGRADE_VERSION="${1:-${DOWNGRADE_VERSION:-3.45.1}}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SMOKE_TEMPLATE_DIR="$SCRIPT_DIR/downgrade-compatibility-smoke" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/posthog-android-downgrade-compat.XXXXXX")" + +cleanup() { + if [ "${KEEP_DOWNGRADE_COMPAT_TMP:-}" = "1" ]; then + echo "Keeping temp directory: $TMP_DIR" + else + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT + +STATE_DIR="$TMP_DIR/state" +CURRENT_SMOKE_DIR="$TMP_DIR/current-smoke" +DOWNGRADED_SMOKE_DIR="$TMP_DIR/downgraded-smoke" +API_KEY="downgrade_compatibility_project" +CURRENT_VERSION="$(awk -F= '/^androidVersion=/ { print $2; exit }' "$REPO_ROOT/gradle.properties")" + +validate_version() { + local version="$1" + if [[ -z "$version" || "$version" == -* || "$version" == *..* || ! "$version" =~ ^[A-Za-z0-9._+%-]+$ ]]; then + echo "Invalid downgrade version: $version" >&2 + exit 64 + fi +} + +create_smoke_project() { + local project_dir="$1" + mkdir -p "$project_dir" + cp -R "$SMOKE_TEMPLATE_DIR/." "$project_dir" +} + +run_smoke() { + local project_dir="$1" + local mode="$2" + local dependency_mode="$3" + local version="$4" + + ( + cd "$REPO_ROOT" + ./gradlew \ + --project-dir "$project_dir" \ + --no-daemon \ + --no-parallel \ + --rerun-tasks \ + -PposthogDependencyMode="$dependency_mode" \ + -PposthogDependencyPath="$REPO_ROOT" \ + -PposthogVersion="$version" \ + -Dposthog.smoke.mode="$mode" \ + -Dposthog.smoke.stateDir="$STATE_DIR" \ + -Dposthog.smoke.apiKey="$API_KEY" \ + testReleaseUnitTest \ + --tests "com.posthog.downgrade.DowngradeCompatibilitySmokeTest" + ) +} + +count_files() { + local directory="$1" + if [ ! -d "$directory" ]; then + echo 0 + return + fi + find "$directory" -maxdepth 1 -type f | wc -l | tr -d ' ' +} + +require_positive_count() { + local description="$1" + local count="$2" + if [ "$count" -eq 0 ]; then + echo "Expected $description to be persisted, but none were found. State tree:" >&2 + find "$STATE_DIR" -maxdepth 8 -print | sort >&2 || true + exit 1 + fi +} + +validate_version "$DOWNGRADE_VERSION" +mkdir -p "$STATE_DIR" + +create_smoke_project "$CURRENT_SMOKE_DIR" +echo "Writing persisted SDK state with current checkout ($CURRENT_VERSION)" +run_smoke "$CURRENT_SMOKE_DIR" write composite "$CURRENT_VERSION" + +EVENT_QUEUE_FILE_COUNT="$(count_files "$STATE_DIR/events/$API_KEY")" +REPLAY_QUEUE_FILE_COUNT="$(count_files "$STATE_DIR/replay/$API_KEY")" +LOGS_QUEUE_FILE_COUNT="$(count_files "$STATE_DIR/logs/$API_KEY")" + +require_positive_count "queued analytics event files" "$EVENT_QUEUE_FILE_COUNT" +require_positive_count "queued replay snapshot files" "$REPLAY_QUEUE_FILE_COUNT" +require_positive_count "queued log files" "$LOGS_QUEUE_FILE_COUNT" +require_positive_count "preference storage" "$(count_files "$STATE_DIR")" + +echo "Current SDK persisted $EVENT_QUEUE_FILE_COUNT analytics event file(s), $REPLAY_QUEUE_FILE_COUNT replay snapshot file(s), and $LOGS_QUEUE_FILE_COUNT log file(s)." + +create_smoke_project "$DOWNGRADED_SMOKE_DIR" +echo "Starting downgraded SDK ($DOWNGRADE_VERSION) against current SDK state" +run_smoke "$DOWNGRADED_SMOKE_DIR" read maven "$DOWNGRADE_VERSION" + +echo "Downgrade compatibility smoke test passed for $DOWNGRADE_VERSION"