diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 036e8210..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: CI - -on: - push: - branches: [ "master", "development" ] - pull_request: - branches: [ "master", "development" ] - -jobs: - ci: - name: ${{ matrix.target }} - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - include: - - target: Flutter - os: ubuntu-latest - run: flutter test - - target: Android - os: ubuntu-latest - java-version: "17" - run: flutter build apk --debug - working-directory: example - - target: iOS - os: macos-latest - run: flutter build ios --debug --simulator - working-directory: example - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - - - run: flutter --version - - - name: Set up Java - if: matrix.java-version - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: ${{ matrix.java-version }} - - - name: Install dependencies - run: flutter pub get - - - name: ${{ matrix.target }} - working-directory: ${{ matrix.working-directory || '.' }} - run: ${{ matrix.run }} diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..5c8dacb2 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,49 @@ +name: Integration Test + +on: + workflow_dispatch: + push: + branches: [ "master", "development" ] + # pull_request: + # branches: [ "master", "development" ] + +jobs: + android-integration: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Free up runner disk space + uses: endersonmenezes/free-disk-space@v3 + with: + remove_android: true + remove_dotnet: true + remove_haskell: true + remove_tool_cache: true + remove_swap: true + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: scripts/Dockerfile.integration-test + tags: fft-integration:latest + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run integration tests + run: docker run --device /dev/kvm fft-integration:latest diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 00000000..455229a5 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,45 @@ +name: Unit Test + +on: + push: + branches: [ "master", "development" ] + pull_request: + branches: [ "master", "development" ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run unit tests with coverage + run: flutter test --coverage + + - name: Install lcov + run: sudo apt-get install -y lcov + + - name: Generate HTML coverage report + run: genhtml coverage/lcov.info -o coverage/html --no-function-coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/html/ + retention-days: 14 diff --git a/README.md b/README.md index 79aa704a..2fd887bf 100644 --- a/README.md +++ b/README.md @@ -654,6 +654,34 @@ Go [here](./documentation/ios_continued_processing_task.md) to set up iOS 26+ `B > [!WARNING] > `BGContinuedProcessingTask` requires **Xcode 26+** (Swift 6.2+). If you configure `IOSContinuedProcessingTaskOptions` but build with an older Xcode, the plugin will raise a fatal error at runtime. Either upgrade Xcode or leave the `continuedProcessingTask` option as `null`. See the [continued processing task documentation](./documentation/ios_continued_processing_task.md#xcode-version-requirement) for details. +## Running Integration Tests Locally + +The repository includes Android instrumented tests that exercise the native `ForegroundService` lifecycle on a real emulator inside a Docker container. KVM hardware acceleration is required, so a **Linux host** (or a VM with nested virtualisation enabled) is needed. + +### Prerequisites + +* Docker installed and running +* `/dev/kvm` accessible to the current user (verify with `ls -l /dev/kvm`) + +### Build & run + +```bash +# Build the Docker image (first run downloads ~10 GB of SDK/emulator images) +docker build -t fft-integration -f scripts/Dockerfile.integration-test . + +# Run the tests — KVM passthrough is mandatory +docker run --device /dev/kvm fft-integration +``` + +The container boots a headless Android 14 (API 34) emulator, installs the example app + test APK, and executes the Kotlin instrumented tests via `./gradlew :app:connectedAndroidTest`. Test results are printed to stdout; the exit code reflects pass/fail. + +### macOS / Windows + +KVM is Linux-only. On macOS or Windows you can either: + +* Run the Docker command inside a Linux VM that exposes `/dev/kvm` (e.g. using UTM or WSL 2 with nested virtualisation). +* Skip Docker and run the emulator + tests natively: start an `x86_64` AVD, then execute `cd example && ./gradlew :app:connectedAndroidTest`. + ## Support If you find any bugs or issues while using the plugin, please register an issues on [GitHub](https://github.com/Dev-hwang/flutter_foreground_task/issues). You can also contact us at . diff --git a/example/android/.gitignore b/example/android/.gitignore index 0a741cb4..26bd9ae3 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -1,8 +1,5 @@ -gradle-wrapper.jar /.gradle /captures/ -/gradlew -/gradlew.bat /local.properties GeneratedPluginRegistrant.java diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 2d41fcda..1ae5a999 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -39,6 +39,7 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' + androidTest.java.srcDirs += 'src/androidTest/kotlin' } defaultConfig { @@ -48,6 +49,7 @@ android { targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -58,11 +60,8 @@ android { } debug { - shrinkResources true - minifyEnabled true - proguardFiles getDefaultProguardFile( - 'proguard-android-optimize.txt'), - 'proguard-rules.pro' + minifyEnabled false + shrinkResources false } } } @@ -71,4 +70,8 @@ flutter { source '../..' } -dependencies {} +dependencies { + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test:rules:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' +} diff --git a/example/android/app/src/androidTest/kotlin/com/pravera/flutter_foreground_task_example/ForegroundServiceInstrumentedTest.kt b/example/android/app/src/androidTest/kotlin/com/pravera/flutter_foreground_task_example/ForegroundServiceInstrumentedTest.kt new file mode 100644 index 00000000..a74841b3 --- /dev/null +++ b/example/android/app/src/androidTest/kotlin/com/pravera/flutter_foreground_task_example/ForegroundServiceInstrumentedTest.kt @@ -0,0 +1,217 @@ +package com.pravera.flutter_foreground_task_example + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey +import com.pravera.flutter_foreground_task.models.ForegroundServiceAction +import com.pravera.flutter_foreground_task.models.ForegroundServiceStatus +import com.pravera.flutter_foreground_task.models.ForegroundServiceTypes +import com.pravera.flutter_foreground_task.models.ForegroundTaskData +import com.pravera.flutter_foreground_task.models.ForegroundTaskOptions +import com.pravera.flutter_foreground_task.models.NotificationContent +import com.pravera.flutter_foreground_task.models.NotificationOptions +import com.pravera.flutter_foreground_task.service.ForegroundService +import com.pravera.flutter_foreground_task.storage.ForegroundTaskStorageProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ForegroundServiceInstrumentedTest { + + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + companion object { + private const val TEST_CHANNEL_ID = "test_channel" + private const val TEST_CHANNEL_NAME = "Test Channel" + private const val TEST_SERVICE_ID = 256 + private const val POLL_INTERVAL_MS = 200L + private const val DEFAULT_TIMEOUT_MS = 10_000L + } + + @Before + fun setUp() { + ForegroundTaskStorageProvider.configure(null) + clearAllPreferences() + } + + @After + fun tearDown() { + if (ForegroundService.isRunningServiceState.value) { + deliverStopIntent() + waitForCondition { !ForegroundService.isRunningServiceState.value } + } + clearAllPreferences() + deleteNotificationChannel() + } + + @Test + fun serviceStartsAndReportsRunning() { + configureServicePreferences() + startService() + + waitForCondition { ForegroundService.isRunningServiceState.value } + + val status = ForegroundServiceStatus.getData(context) + assertEquals(ForegroundServiceAction.API_START, status.action) + } + + @Test + fun serviceStopsAndCleansUp() { + configureServicePreferences() + startService() + waitForCondition { ForegroundService.isRunningServiceState.value } + + deliverStopIntent() + waitForCondition { !ForegroundService.isRunningServiceState.value } + + val status = ForegroundServiceStatus.getData(context) + assertTrue(status.isCorrectlyStopped()) + } + + @Test + fun notificationChannelCreated() { + configureServicePreferences() + startService() + waitForCondition { ForegroundService.isRunningServiceState.value } + + val nm = context.getSystemService(NotificationManager::class.java) + val channel = nm.getNotificationChannel(TEST_CHANNEL_ID) + assertNotNull("Notification channel should exist after service start", channel) + assertEquals(TEST_CHANNEL_NAME, channel!!.name.toString()) + } + + @Test + fun serviceLifecycleFullCycle() { + assertFalse( + "Service should not be running before test", + ForegroundService.isRunningServiceState.value + ) + + configureServicePreferences() + startService() + waitForCondition { ForegroundService.isRunningServiceState.value } + + // Simulate a period of work while the service is alive. + Thread.sleep(3_000) + assertTrue( + "Service should remain running during work period", + ForegroundService.isRunningServiceState.value + ) + + deliverStopIntent() + waitForCondition { !ForegroundService.isRunningServiceState.value } + + val status = ForegroundServiceStatus.getData(context) + assertTrue( + "Service should report correctly stopped", + status.isCorrectlyStopped() + ) + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private fun configureServicePreferences() { + ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_START) + + val notifPrefs = ForegroundTaskStorageProvider.getPreferences( + context, PrefsKey.NOTIFICATION_OPTIONS_PREFS + ) + with(notifPrefs.edit()) { + putInt(PrefsKey.SERVICE_ID, TEST_SERVICE_ID) + putString(PrefsKey.NOTIFICATION_CHANNEL_ID, TEST_CHANNEL_ID) + putString(PrefsKey.NOTIFICATION_CHANNEL_NAME, TEST_CHANNEL_NAME) + putInt(PrefsKey.NOTIFICATION_CHANNEL_IMPORTANCE, 2) + putBoolean(PrefsKey.ENABLE_VIBRATION, false) + putBoolean(PrefsKey.PLAY_SOUND, false) + putBoolean(PrefsKey.SHOW_WHEN, false) + putBoolean(PrefsKey.SHOW_BADGE, false) + putBoolean(PrefsKey.ONLY_ALERT_ONCE, true) + putInt(PrefsKey.VISIBILITY, 1) + + putString(PrefsKey.NOTIFICATION_CONTENT_TITLE, "Test Service") + putString(PrefsKey.NOTIFICATION_CONTENT_TEXT, "Running integration test") + commit() + } + + val taskPrefs = ForegroundTaskStorageProvider.getPreferences( + context, PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS + ) + with(taskPrefs.edit()) { + // NOTHING event type (value 1) -- no repeat events, avoids Dart task churn. + putString( + PrefsKey.TASK_EVENT_ACTION, + """{"taskEventType":1,"taskEventInterval":5000}""" + ) + putBoolean(PrefsKey.AUTO_RUN_ON_BOOT, false) + putBoolean(PrefsKey.AUTO_RUN_ON_MY_PACKAGE_REPLACED, false) + putBoolean(PrefsKey.ALLOW_WAKE_LOCK, false) + putBoolean(PrefsKey.ALLOW_WIFI_LOCK, false) + putBoolean(PrefsKey.ALLOW_AUTO_RESTART, false) + remove(PrefsKey.STOP_WITH_TASK) + remove(PrefsKey.CALLBACK_HANDLE) + commit() + } + } + + private fun startService() { + val intent = Intent(context, ForegroundService::class.java) + ContextCompat.startForegroundService(context, intent) + } + + /** + * Mirrors [com.pravera.flutter_foreground_task.service.ForegroundServiceManager.stop]: + * set status to API_STOP, clear data prefs, then deliver an intent so + * [ForegroundService.onStartCommand] reads the stop action. + */ + private fun deliverStopIntent() { + ForegroundServiceStatus.setData(context, ForegroundServiceAction.API_STOP) + ForegroundServiceTypes.clearData(context) + NotificationOptions.clearData(context) + ForegroundTaskOptions.clearData(context) + ForegroundTaskData.clearData(context) + NotificationContent.clearData(context) + context.startService(Intent(context, ForegroundService::class.java)) + } + + private fun waitForCondition( + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + condition: () -> Boolean + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition() && System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue("Condition not met within ${timeoutMs}ms", condition()) + } + + private fun clearAllPreferences() { + val prefNames = listOf( + PrefsKey.FOREGROUND_SERVICE_STATUS_PREFS, + PrefsKey.FOREGROUND_SERVICE_TYPES_PREFS, + PrefsKey.NOTIFICATION_OPTIONS_PREFS, + PrefsKey.FOREGROUND_TASK_OPTIONS_PREFS, + ) + for (name in prefNames) { + ForegroundTaskStorageProvider.getPreferences(context, name) + .edit().clear().commit() + } + } + + private fun deleteNotificationChannel() { + val nm = context.getSystemService(NotificationManager::class.java) + nm.deleteNotificationChannel(TEST_CHANNEL_ID) + } +} diff --git a/example/android/gradle/wrapper/gradle-wrapper.jar b/example/android/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 00000000..13372aef Binary files /dev/null and b/example/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/example/android/gradlew b/example/android/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/example/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/example/android/gradlew.bat b/example/android/gradlew.bat new file mode 100755 index 00000000..aec99730 --- /dev/null +++ b/example/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/Dockerfile.integration-test b/scripts/Dockerfile.integration-test new file mode 100644 index 00000000..e594be34 --- /dev/null +++ b/scripts/Dockerfile.integration-test @@ -0,0 +1,64 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# ── System dependencies ────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl git unzip xz-utils zip ca-certificates \ + openjdk-17-jdk-headless \ + qemu-kvm libvirt-daemon-system \ + libglu1-mesa libx11-6 libpulse0 libnss3 \ + lib32stdc++6 lib32z1 \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + +# ── Android SDK ─────────────────────────────────────────────────────── +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=${ANDROID_SDK_ROOT} +ENV PATH="${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/emulator" + +RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \ + && curl -fsSL -o /tmp/cmdline-tools.zip \ + https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \ + && unzip -q /tmp/cmdline-tools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools \ + && mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \ + && rm /tmp/cmdline-tools.zip \ + && yes | sdkmanager --licenses > /dev/null 2>&1 \ + && sdkmanager --install \ + "platform-tools" \ + "platforms;android-35" \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "system-images;android-34;google_apis;x86_64" \ + "emulator" \ + && rm -rf "${ANDROID_SDK_ROOT}/system-images/android-34/google_apis/x86_64/skins" \ + "${ANDROID_SDK_ROOT}/emulator/lib64/qt/translations" \ + "${ANDROID_SDK_ROOT}/emulator/qemu/linux-x86_64/qemu-system-armel" \ + "${ANDROID_SDK_ROOT}/emulator/qemu/linux-x86_64/qemu-system-aarch64" \ + && echo "no" | avdmanager create avd \ + -n test_avd \ + -k "system-images;android-34;google_apis;x86_64" \ + --force + +# ── Flutter SDK ────────────────────────────────────────────────────── +ENV FLUTTER_HOME=/opt/flutter +ENV PATH="${PATH}:${FLUTTER_HOME}/bin" + +RUN git clone --depth 1 -b stable https://github.com/flutter/flutter.git ${FLUTTER_HOME} \ + && flutter precache --android \ + && flutter config --no-analytics \ + && yes | flutter doctor --android-licenses > /dev/null 2>&1 \ + && flutter doctor -v + +# ── Project sources ────────────────────────────────────────────────── +COPY . /app +WORKDIR /app + +RUN flutter pub get && cd example && flutter pub get + +# ── Entrypoint ─────────────────────────────────────────────────────── +COPY scripts/run-integration-tests.sh /run-tests.sh +RUN chmod +x /run-tests.sh + +ENTRYPOINT ["/run-tests.sh"] diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh new file mode 100755 index 00000000..3126de73 --- /dev/null +++ b/scripts/run-integration-tests.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +BOOT_TIMEOUT=180 +POLL_INTERVAL=2 +PACKAGE="com.pravera.flutter_foreground_task_example" + +echo "==> Starting Android emulator (headless)…" +emulator -avd test_avd \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -wipe-data & +EMULATOR_PID=$! + +cleanup() { + echo "==> Shutting down emulator (pid ${EMULATOR_PID})…" + kill "${EMULATOR_PID}" 2>/dev/null || true + wait "${EMULATOR_PID}" 2>/dev/null || true + return 0 +} +trap cleanup EXIT + +echo "==> Waiting for device…" +adb wait-for-device + +echo "==> Waiting for boot_completed (timeout ${BOOT_TIMEOUT}s)…" +elapsed=0 +while true; do + boot_flag=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || echo "") + if [[ "$boot_flag" = "1" ]]; then + echo " Boot completed after ~${elapsed}s." + break + fi + if [[ "$elapsed" -ge "$BOOT_TIMEOUT" ]]; then + echo "ERROR: Emulator did not finish booting within ${BOOT_TIMEOUT}s." >&2 + exit 1 + fi + sleep "$POLL_INTERVAL" + elapsed=$((elapsed + POLL_INTERVAL)) +done + +adb shell input keyevent 82 # dismiss lock-screen + +echo "==> Resolving Flutter dependencies…" +cd /app +flutter pub get +cd /app/example +flutter pub get + +echo "==> Running connectedAndroidTest…" +cd /app/example/android +./gradlew :app:connectedAndroidTest --stacktrace + +echo "==> Integration tests finished successfully."