From 89dcef8ffd0f3e3630723b47cd04311243e7aae9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 24 Jul 2026 12:16:41 +0700 Subject: [PATCH] test(native-worker): add Android regression gates --- .github/workflows/ci.yml | 83 +++++++++- .github/workflows/release.yml | 4 +- android/app/build.gradle.kts | 2 + .../zarz/spotiflac/NativeDownloadFinalizer.kt | 2 +- .../zarz/spotiflac/NativeWorkerPolicyTest.kt | 152 ++++++++++++++++++ lib/services/history_database.dart | 3 +- test/models_and_utils_test.dart | 32 ++++ 7 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859a1dfd..4fe15a1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: outputs: dart: ${{ steps.filter.outputs.dart }} go: ${{ steps.filter.outputs.go }} + android: ${{ steps.filter.outputs.android }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -40,6 +41,17 @@ jobs: go: - 'go_backend/**' - '.github/workflows/ci.yml' + android: + - 'android/**' + - 'go_backend/**' + - 'lib/models/settings.dart' + - 'lib/providers/download_queue_provider*.dart' + - 'lib/services/download_request_payload.dart' + - 'lib/services/history_database.dart' + - 'lib/services/platform_bridge.dart' + - 'pubspec.yaml' + - 'pubspec.lock' + - '.github/workflows/ci.yml' flutter: name: Flutter analyze & test @@ -91,7 +103,7 @@ jobs: uses: actions/setup-go@v6 with: # Keep in sync with release.yml - go-version: "1.25.9" + go-version: "1.26.5" cache-dependency-path: go_backend/go.sum - name: Check formatting @@ -102,3 +114,72 @@ jobs: - name: Run tests run: go test ./... + + android: + name: Android compile & native tests + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.android == 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "25" + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.5" + cache-dependency-path: go_backend/go.sum + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Install Android SDK & NDK + run: | + yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + "ndk;29.0.14206865" \ + "platforms;android-37" \ + "build-tools;37.0.0" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + + - name: Build Go backend for Android + working-directory: go_backend + run: | + go install golang.org/x/mobile/cmd/gomobile + gomobile init + mkdir -p ../android/app/libs + gomobile bind \ + -target=android/arm,android/arm64 \ + -androidapi 24 \ + -o ../android/app/libs/gobackend.aar \ + . + env: + CGO_ENABLED: 1 + + - name: Get Flutter dependencies + run: flutter pub get + + - name: Configure Flutter SDK for Gradle + run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties + + - name: Compile Kotlin and run native unit tests + run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ced26d43..8951de4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,8 +99,8 @@ jobs: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true # Install NDK r29 (supports 16KB page size for Android 15+) - # Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16) - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0" + # Keep the installed platform aligned with compileSdk/targetSdk. + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0" # Set NDK path echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3719983a..a04bf399 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -127,4 +127,6 @@ dependencies { implementation("androidx.documentfile:documentfile:1.1.0") implementation("androidx.activity:activity-ktx:1.13.0") implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0") + + testImplementation("junit:junit:4.13.2") } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index d853a328..7ea14452 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -33,7 +33,7 @@ object NativeDownloadFinalizer { const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - private const val HISTORY_SCHEMA_VERSION = 10 + const val HISTORY_SCHEMA_VERSION = 10 private val activeFFmpegSessionIds = mutableSetOf() private val nativeFFmpegSessionIds = mutableSetOf() private val activeFFmpegSessionLock = Any() diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt new file mode 100644 index 00000000..776ed503 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt @@ -0,0 +1,152 @@ +package com.zarz.spotiflac + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeWorkerPolicyTest { + @Test + fun rateLimitRetriesExactlyOnce() { + assertTrue( + NativeWorkerPolicy.shouldRetryRateLimit( + errorType = "rate_limit", + errorMessage = "Too many requests", + attempts = 0, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldRetryRateLimit( + errorType = "rate_limit", + errorMessage = "Too many requests", + attempts = 1, + ), + ) + } + + @Test + fun rateLimitDetectionFallsBackToMessage() { + assertTrue( + NativeWorkerPolicy.shouldRetryRateLimit( + errorType = "network", + errorMessage = "HTTP 429: retry later", + attempts = 0, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldRetryRateLimit( + errorType = "network", + errorMessage = "Connection reset", + attempts = 0, + ), + ) + } + + @Test + fun retryDelayUsesServerHintAndSafeBounds() { + assertEquals( + 45, + NativeWorkerPolicy.rateLimitDelaySeconds( + retryAfterSeconds = 45, + errorMessage = null, + ), + ) + assertEquals( + 12, + NativeWorkerPolicy.rateLimitDelaySeconds( + retryAfterSeconds = null, + errorMessage = "Retry-After seconds: 12", + ), + ) + assertEquals( + 5, + NativeWorkerPolicy.rateLimitDelaySeconds( + retryAfterSeconds = 1, + errorMessage = null, + ), + ) + assertEquals( + 300, + NativeWorkerPolicy.rateLimitDelaySeconds( + retryAfterSeconds = 900, + errorMessage = null, + ), + ) + } + + @Test + fun wifiOnlyPausesWithoutWifi() { + assertTrue( + NativeWorkerPolicy.shouldPauseForNetwork( + downloadNetworkMode = "wifi_only", + hasWifi = false, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldPauseForNetwork( + downloadNetworkMode = "wifi_only", + hasWifi = true, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldPauseForNetwork( + downloadNetworkMode = "any", + hasWifi = false, + ), + ) + } + + @Test + fun verificationDetectionUsesTypeAndMessageFallback() { + assertTrue( + NativeWorkerPolicy.isVerificationRequired( + errorType = "verification_required", + errorMessage = null, + ), + ) + assertTrue( + NativeWorkerPolicy.isVerificationRequired( + errorType = "unknown", + errorMessage = "Challenge required before download", + ), + ) + assertFalse( + NativeWorkerPolicy.isVerificationRequired( + errorType = "network", + errorMessage = "Connection reset", + ), + ) + } + + @Test + fun completionAlertSkipsCancelledOrEmptyRuns() { + assertTrue( + NativeWorkerPolicy.shouldNotifyQueueComplete( + cancelRequested = false, + completed = 1, + failed = 0, + ), + ) + assertTrue( + NativeWorkerPolicy.shouldNotifyQueueComplete( + cancelRequested = false, + completed = 0, + failed = 1, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldNotifyQueueComplete( + cancelRequested = true, + completed = 1, + failed = 0, + ), + ) + assertFalse( + NativeWorkerPolicy.shouldNotifyQueueComplete( + cancelRequested = false, + completed = 0, + failed = 0, + ), + ) + } +} diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 57663624..b8db6c5d 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -65,6 +65,7 @@ class HistoryBatchLookupRequest { } class HistoryDatabase { + static const int schemaVersion = 10; static final HistoryDatabase instance = HistoryDatabase._init(); static Database? _database; @@ -74,7 +75,7 @@ class HistoryDatabase { if (_database != null) return _database!; _database = await sqlite.openAppDatabase( 'history.db', - version: 10, + version: schemaVersion, onCreate: _createDB, onUpgrade: _upgradeDB, ); diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 398f15bb..c053e305 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -8,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; @@ -16,6 +19,35 @@ import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; void main() { + group('native worker contracts', () { + final finalizerSource = File( + 'android/app/src/main/kotlin/com/zarz/spotiflac/' + 'NativeDownloadFinalizer.kt', + ).readAsStringSync(); + + int kotlinConstant(String name) { + final match = RegExp( + 'const val $name = (\\d+)', + ).firstMatch(finalizerSource); + expect(match, isNotNull, reason: 'Missing Kotlin constant $name'); + return int.parse(match!.group(1)!); + } + + test('uses the same worker contract version in Dart and Kotlin', () { + expect( + kotlinConstant('NATIVE_WORKER_CONTRACT_VERSION'), + DownloadRequestPayload.nativeWorkerContractVersion, + ); + }); + + test('uses the same history schema version in Dart and Kotlin', () { + expect( + kotlinConstant('HISTORY_SCHEMA_VERSION'), + HistoryDatabase.schemaVersion, + ); + }); + }); + group('quality variant filenames', () { test('uses measured lossless specifications instead of request labels', () { expect(