diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml new file mode 100644 index 0000000..47efcec --- /dev/null +++ b/.github/workflows/android-test.yml @@ -0,0 +1,40 @@ +name: Android Tests + +on: + pull_request: + branches: ["develop"] + +jobs: + android-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Create temporary Flutter project + run: | + flutter create --template=app /tmp/test_app + cd /tmp/test_app + + # Add the plugin with path dependency + flutter pub add flutter_device_apps_android --path $GITHUB_WORKSPACE + + # Build to generate Gradle files + flutter build apk --debug + + - name: Run Android unit tests + run: | + cd /tmp/test_app/android + ./gradlew :flutter_device_apps_android:testDebugUnitTest diff --git a/.github/workflows/only-develop-to-main.yml b/.github/workflows/only-develop-to-main.yml new file mode 100644 index 0000000..46a3553 --- /dev/null +++ b/.github/workflows/only-develop-to-main.yml @@ -0,0 +1,18 @@ +name: only-develop-to-main + +on: + pull_request: + branches: ["main"] + +jobs: + only-develop-to-main: + runs-on: ubuntu-latest + steps: + - name: Allow only develop -> main PRs + run: | + echo "head_ref: ${{ github.head_ref }}" + echo "base_ref: ${{ github.base_ref }}" + if [ "${{ github.base_ref }}" = "main" ] && [ "${{ github.head_ref }}" != "develop" ]; then + echo "Only PRs from 'develop' to 'main' are allowed." + exit 1 + fi diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..50d3c91 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,30 @@ +name: Quality + +on: + pull_request: + branches: ["develop"] + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Check formatting + run: dart format --set-exit-if-changed . + + - name: Static analysis + run: flutter analyze + + - name: pub.dev dry-run + run: flutter pub publish --dry-run diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b870dfc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test + +on: + pull_request: + branches: ["develop"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6897f58..1ca4f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.0 +- **BREAKING**: Updated to match platform interface 0.6.0 - `requestedPermissions` removed from `AppInfo` +- Added `getRequestedPermissions(String packageName)` method implementation for on-demand permission retrieval +- Added GitHub Actions workflows for Android unit tests, quality checks, and PR enforcement +- Added comprehensive Dart unit tests (27 tests) for method channel mocking +- Added Kotlin unit tests (11 tests) with Robolectric for Android plugin +- Made `FlutterDeviceAppsAndroidPlugin` class `open` with `protected` fields for testability + ## 0.5.1 - Added support for additional `AppInfo` fields from the Android package manager: `category`, `targetSdkVersion`, `minSdkVersion`, `enabled`, `processName`, `installLocation`, `requestedPermissions`. - Populates `requestedPermissions` via `PackageManager.GET_PERMISSIONS`. diff --git a/android/build.gradle b/android/build.gradle index d216ebb..740f7b9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -48,18 +48,21 @@ android { } dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit") + testImplementation("org.mockito:mockito-inline:5.2.0") + testImplementation("org.robolectric:robolectric:4.11.1") } testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true + unitTests { + includeAndroidResources = true + all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } } } } diff --git a/android/src/main/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPlugin.kt b/android/src/main/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPlugin.kt index 068f2ea..703738c 100644 --- a/android/src/main/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPlugin.kt +++ b/android/src/main/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPlugin.kt @@ -19,12 +19,12 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* import java.io.ByteArrayOutputStream -class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { +open class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { private lateinit var methodChannel: MethodChannel private lateinit var eventChannel: EventChannel - private lateinit var appContext: Context - private lateinit var pm: PackageManager + protected lateinit var appContext: Context + protected lateinit var pm: PackageManager private val mainHandler = Handler(Looper.getMainLooper()) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -99,6 +99,35 @@ class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHa } } } + "getRequestedPermissions" -> { + val pkg = call.argument("packageName") + if (pkg == null) { + result.error("ARG", "packageName required", null) + return + } + scope.launch { + try { + val pInfo: PackageInfo = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo( + pkg, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) + ) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(pkg, PackageManager.GET_PERMISSIONS) + } + } catch (_: Exception) { + mainHandler.post { result.success(null) } + return@launch + } + val requested = pInfo.requestedPermissions?.toList() + mainHandler.post { result.success(requested) } + } catch (e: Exception) { + mainHandler.post { result.error("ERR_PERMS", e.message, null) } + } + } + } "openApp" -> { val pkg = call.argument("packageName") if (pkg == null) return result.error("ARG", "packageName required", null) @@ -248,10 +277,13 @@ class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHa private fun getAppMap(packageName: String, includeIcon: Boolean): Map? { val pInfo: PackageInfo = try { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) + pm.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(0) + ) } else { @Suppress("DEPRECATION") - pm.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS) + pm.getPackageInfo(packageName, 0) } } catch (_: Exception) { return null @@ -260,7 +292,6 @@ class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHa val aInfo: ApplicationInfo = pInfo.applicationInfo ?: return null val category: Int? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) aInfo.category else null - val requestedPermissionsList: List? = pInfo.requestedPermissions?.toList() val isSystem = (aInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 val label = try { @@ -300,8 +331,7 @@ class FlutterDeviceAppsAndroidPlugin : FlutterPlugin, MethodChannel.MethodCallHa "minSdkVersion" to aInfo.minSdkVersion, "enabled" to aInfo.enabled, "processName" to aInfo.processName, - "installLocation" to pInfo.installLocation, - "requestedPermissions" to requestedPermissionsList + "installLocation" to pInfo.installLocation ) } diff --git a/android/src/test/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPluginTest.kt b/android/src/test/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPluginTest.kt index e6bd5cd..656a176 100644 --- a/android/src/test/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPluginTest.kt +++ b/android/src/test/kotlin/com/okmsbun/flutter_device_apps_android/FlutterDeviceAppsAndroidPluginTest.kt @@ -1,27 +1,169 @@ package com.okmsbun.flutter_device_apps_android +import android.content.Context +import android.content.pm.PackageManager import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mockito -import kotlin.test.Test - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +/** + * Unit tests for FlutterDeviceAppsAndroidPlugin. + * + * These tests verify that methods handle null/invalid arguments correctly + * using a TestablePlugin that extends the main plugin for testing purposes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) internal class FlutterDeviceAppsAndroidPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = FlutterDeviceAppsAndroidPlugin() - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + private lateinit var plugin: TestableFlutterDeviceAppsAndroidPlugin + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + plugin = TestableFlutterDeviceAppsAndroidPlugin(context, context.packageManager) + } + + private fun createMockResult(): MethodChannel.Result = mock(MethodChannel.Result::class.java) + + // ---- getRequestedPermissions ---- + @Test + fun onMethodCall_getRequestedPermissions_handlesNullPackageGracefully() { + val call = MethodCall("getRequestedPermissions", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + @Test + fun onMethodCall_getRequestedPermissions_handlesEmptyArgsGracefully() { + val call = MethodCall("getRequestedPermissions", emptyMap()) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- getApp ---- + @Test + fun onMethodCall_getApp_handlesNullPackageGracefully() { + val call = MethodCall("getApp", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + @Test + fun onMethodCall_getApp_handlesEmptyArgsGracefully() { + val call = MethodCall("getApp", mapOf("includeIcon" to false)) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- openApp ---- + @Test + fun onMethodCall_openApp_handlesNullPackageGracefully() { + val call = MethodCall("openApp", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- openAppSettings ---- + @Test + fun onMethodCall_openAppSettings_handlesNullPackageGracefully() { + val call = MethodCall("openAppSettings", null) + val mockResult = createMockResult() - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- uninstallApp ---- + @Test + fun onMethodCall_uninstallApp_handlesNullPackageGracefully() { + val call = MethodCall("uninstallApp", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- getInstallerStore ---- + @Test + fun onMethodCall_getInstallerStore_handlesNullPackageGracefully() { + val call = MethodCall("getInstallerStore", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull()) + } + + // ---- Unknown method ---- + @Test + fun onMethodCall_unknownMethod_returnsNotImplemented() { + val call = MethodCall("unknownMethod", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).notImplemented() + } + + // ---- startAppChangeStream / stopAppChangeStream ---- + @Test + fun onMethodCall_startAppChangeStream_returnsSuccess() { + val call = MethodCall("startAppChangeStream", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).success(null) + } + + @Test + fun onMethodCall_stopAppChangeStream_returnsSuccess() { + val call = MethodCall("stopAppChangeStream", null) + val mockResult = createMockResult() + + plugin.onMethodCall(call, mockResult) + + verify(mockResult).success(null) + } } + +/** + * A testable version of FlutterDeviceAppsAndroidPlugin that allows + * direct initialization without FlutterPluginBinding. + */ +internal class TestableFlutterDeviceAppsAndroidPlugin( + context: Context, + packageManager: PackageManager +) : FlutterDeviceAppsAndroidPlugin() { + + init { + appContext = context + pm = packageManager + } +} \ No newline at end of file diff --git a/lib/flutter_device_apps_android.dart b/lib/flutter_device_apps_android.dart index 2ceb365..9e81ea1 100644 --- a/lib/flutter_device_apps_android.dart +++ b/lib/flutter_device_apps_android.dart @@ -64,6 +64,17 @@ class FlutterDeviceAppsAndroid extends FlutterDeviceAppsPlatform { return m == null ? null : AppInfo.fromMap(Map.from(m)); } + @override + Future?> getRequestedPermissions(String packageName) async { + final List? raw = await _mch.invokeMethod>( + 'getRequestedPermissions', + { + 'packageName': packageName, + }, + ); + return raw?.map((e) => e.toString()).toList(); + } + @override Future openApp(String packageName) async { final bool ok = await _mch.invokeMethod('openApp', {'packageName': packageName}); diff --git a/pubspec.yaml b/pubspec.yaml index 561fbee..d3fb2c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_device_apps_android description: Android implementation of flutter_device_apps (federated plugin). -version: 0.5.1 +version: 0.6.0 repository: https://github.com/okmsbun/flutter_device_apps_android issue_tracker: https://github.com/okmsbun/flutter_device_apps_android/issues topics: @@ -17,10 +17,12 @@ environment: dependencies: flutter: sdk: flutter - flutter_device_apps_platform_interface: ^0.5.1 + flutter_device_apps_platform_interface: ^0.6.0 dev_dependencies: flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter flutter: plugin: diff --git a/test/flutter_device_apps_android_test.dart b/test/flutter_device_apps_android_test.dart new file mode 100644 index 0000000..1b43d2e --- /dev/null +++ b/test/flutter_device_apps_android_test.dart @@ -0,0 +1,321 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_device_apps_android/flutter_device_apps_android.dart'; +import 'package:flutter_device_apps_platform_interface/flutter_device_apps_app_change_event.dart'; +import 'package:flutter_device_apps_platform_interface/flutter_device_apps_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FlutterDeviceAppsAndroid plugin; + late List methodCalls; + + setUp(() { + plugin = FlutterDeviceAppsAndroid(); + methodCalls = []; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('flutter_device_apps/methods'), + (MethodCall methodCall) async { + methodCalls.add(methodCall); + return _handleMethodCall(methodCall); + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('flutter_device_apps/methods'), + null, + ); + }); + + group('registerWith', () { + test('registers instance as platform implementation', () { + FlutterDeviceAppsAndroid.registerWith(); + expect( + FlutterDeviceAppsPlatform.instance, + isA(), + ); + }); + }); + + group('listApps', () { + test('calls method channel with default parameters', () async { + await plugin.listApps(); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'listApps'); + expect(methodCalls.first.arguments, { + 'includeSystem': false, + 'onlyLaunchable': true, + 'includeIcons': false, + }); + }); + + test('calls method channel with custom parameters', () async { + await plugin.listApps( + includeSystem: true, + onlyLaunchable: false, + includeIcons: true, + ); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.arguments, { + 'includeSystem': true, + 'onlyLaunchable': false, + 'includeIcons': true, + }); + }); + + test('returns list of AppInfo', () async { + final List apps = await plugin.listApps(); + + expect(apps, hasLength(2)); + expect(apps[0].packageName, 'com.example.app1'); + expect(apps[1].packageName, 'com.example.app2'); + }); + + test('returns empty list when no apps', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('flutter_device_apps/methods'), + (MethodCall methodCall) async => [], + ); + + final List apps = await plugin.listApps(); + expect(apps, isEmpty); + }); + }); + + group('getApp', () { + test('calls method channel with package name', () async { + await plugin.getApp('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'getApp'); + expect(methodCalls.first.arguments, { + 'packageName': 'com.example.app1', + 'includeIcon': false, + }); + }); + + test('calls method channel with includeIcon true', () async { + await plugin.getApp('com.example.app1', includeIcon: true); + + expect(methodCalls.first.arguments, { + 'packageName': 'com.example.app1', + 'includeIcon': true, + }); + }); + + test('returns AppInfo when app exists', () async { + final AppInfo? app = await plugin.getApp('com.example.app1'); + + expect(app, isNotNull); + expect(app!.packageName, 'com.example.app1'); + expect(app.appName, 'App 1'); + }); + + test('returns null when app does not exist', () async { + final AppInfo? app = await plugin.getApp('com.nonexistent.app'); + expect(app, isNull); + }); + }); + + group('getRequestedPermissions', () { + test('calls method channel with package name', () async { + await plugin.getRequestedPermissions('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'getRequestedPermissions'); + expect(methodCalls.first.arguments, {'packageName': 'com.example.app1'}); + }); + + test('returns list of permissions', () async { + final List? permissions = await plugin.getRequestedPermissions('com.example.app1'); + + expect(permissions, isNotNull); + expect(permissions, contains('android.permission.INTERNET')); + expect(permissions, contains('android.permission.CAMERA')); + }); + + test('returns null for unknown package', () async { + final List? permissions = await plugin.getRequestedPermissions('com.nonexistent.app'); + expect(permissions, isNull); + }); + }); + + group('openApp', () { + test('calls method channel with package name', () async { + await plugin.openApp('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'openApp'); + expect(methodCalls.first.arguments, {'packageName': 'com.example.app1'}); + }); + + test('returns true on success', () async { + final bool result = await plugin.openApp('com.example.app1'); + expect(result, isTrue); + }); + + test('returns false on failure', () async { + final bool result = await plugin.openApp('com.nonexistent.app'); + expect(result, isFalse); + }); + }); + + group('openAppSettings', () { + test('calls method channel with package name', () async { + await plugin.openAppSettings('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'openAppSettings'); + expect(methodCalls.first.arguments, {'packageName': 'com.example.app1'}); + }); + + test('returns true on success', () async { + final bool result = await plugin.openAppSettings('com.example.app1'); + expect(result, isTrue); + }); + + test('returns false on failure', () async { + final bool result = await plugin.openAppSettings('com.nonexistent.app'); + expect(result, isFalse); + }); + }); + + group('uninstallApp', () { + test('calls method channel with package name', () async { + await plugin.uninstallApp('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'uninstallApp'); + expect(methodCalls.first.arguments, {'packageName': 'com.example.app1'}); + }); + + test('returns true on success', () async { + final bool result = await plugin.uninstallApp('com.example.app1'); + expect(result, isTrue); + }); + + test('returns false when method returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('flutter_device_apps/methods'), + (MethodCall methodCall) async => null, + ); + + final bool result = await plugin.uninstallApp('com.example.app1'); + expect(result, isFalse); + }); + }); + + group('getInstallerStore', () { + test('calls method channel with package name', () async { + await plugin.getInstallerStore('com.example.app1'); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.first.method, 'getInstallerStore'); + expect(methodCalls.first.arguments, {'packageName': 'com.example.app1'}); + }); + + test('returns store package name', () async { + final String? store = await plugin.getInstallerStore('com.example.app1'); + expect(store, 'com.android.vending'); + }); + + test('returns null for sideloaded app', () async { + final String? store = await plugin.getInstallerStore('com.sideloaded.app'); + expect(store, isNull); + }); + }); + + group('appChanges stream', () { + test('returns a stream', () { + expect(plugin.appChanges, isA()); + }); + + test('stream is broadcast', () { + final Stream stream = plugin.appChanges + // Broadcast streams allow multiple listeners + ..listen((_) {}); + expect(() => stream.listen((_) {}), returnsNormally); + }); + + test('calls startAppChangeStream when listening starts', () async { + plugin.appChanges.listen((_) {}); + + // Give time for async onListen to execute + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + methodCalls.any((c) => c.method == 'startAppChangeStream'), + isTrue, + ); + }); + }); +} + +/// Mock handler for method calls +Object? _handleMethodCall(MethodCall call) { + final args = call.arguments as Map?; + + switch (call.method) { + case 'listApps': + return [ + _createAppMap('com.example.app1', 'App 1'), + _createAppMap('com.example.app2', 'App 2'), + ]; + + case 'getApp': + final packageName = args!['packageName']! as String; + if (packageName == 'com.nonexistent.app') return null; + return _createAppMap(packageName, 'App 1'); + + case 'getRequestedPermissions': + final packageName = args!['packageName']! as String; + if (packageName == 'com.nonexistent.app') return null; + return ['android.permission.INTERNET', 'android.permission.CAMERA']; + + case 'openApp': + case 'openAppSettings': + final packageName = args!['packageName']! as String; + return packageName != 'com.nonexistent.app'; + + case 'uninstallApp': + final packageName = args!['packageName']! as String; + return packageName != 'com.nonexistent.app'; + + case 'getInstallerStore': + final packageName = args!['packageName']! as String; + if (packageName == 'com.sideloaded.app') return null; + return 'com.android.vending'; + + case 'startAppChangeStream': + case 'stopAppChangeStream': + return null; + + default: + return null; + } +} + +Map _createAppMap(String packageName, String appName) { + return { + 'packageName': packageName, + 'appName': appName, + 'versionName': '1.0.0', + 'versionCode': 1, + 'systemApp': false, + 'firstInstallTime': 1700000000000, + 'lastUpdateTime': 1700000000000, + 'apkFilePath': '/data/app/$packageName/base.apk', + 'dataDir': '/data/data/$packageName', + 'category': 0, + 'targetSdkVersion': 34, + 'minSdkVersion': 21, + 'enabled': true, + 'processName': packageName, + 'installLocation': 0, + }; +}