Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/android-test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .github/workflows/only-develop-to-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: only-develop-to-main

on:
pull_request:
branches: ["main"]

jobs:
enforce:
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
30 changes: 30 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
21 changes: 12 additions & 9 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

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 plugin = FlutterDeviceAppsAndroidPlugin()
val call = MethodCall("getRequestedPermissions", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
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<String, Any>())
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)

Mockito.verify(mockResult).error(Mockito.eq("ARG"), Mockito.eq("packageName required"), Mockito.isNull())
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()

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
}
}
6 changes: 4 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
Loading