From 4defa650fd033a89362594e11accc655c9ee71c9 Mon Sep 17 00:00:00 2001 From: Tomas Psota Date: Thu, 12 Nov 2026 09:36:42 +0100 Subject: [PATCH 1/2] refactor!: replace Pigeon with the threat event channel Malware and ongoing call detections are sent over the existing threat EventChannel as {type, ...} maps next to the plain threat codes, so one subscription delivers every detection in order. PackageInfo, SuspiciousAppInfo and CallState become hand-written Dart types with the same names; ThreatCallback no longer extends the generated TalsecPigeonApi. The pigeon dev dependency and generated code are removed. Co-Authored-By: Claude Opus 5.5 --- .pubignore | 1 - CHANGELOG.md | 3 + .../app/talsec/rasp/flutter/Extensions.kt | 48 ++-- .../flutter/dispatchers/ThreatDispatcher.kt | 61 ++--- .../rasp/flutter/generated/TalsecPigeonApi.kt | 183 --------------- .../flutter/handlers/MethodCallHandler.kt | 42 ---- .../flutter/handlers/PluginThreatHandler.kt | 8 +- .../flutter/handlers/TalsecThreatHandler.kt | 8 - lib/freerasp.dart | 2 - lib/src/callbacks/threat_callback.dart | 13 +- lib/src/enums/call_state.dart | 13 ++ lib/src/enums/enums.dart | 1 + lib/src/generated/talsec_pigeon_api.g.dart | 217 ------------------ lib/src/models/models.dart | 2 + lib/src/models/package_info.dart | 62 +++++ lib/src/models/suspicious_app_info.dart | 54 +++++ lib/src/talsec.dart | 160 ++++++++----- lib/src/typedefs.dart | 3 +- pigeons/build.sh | 22 -- pigeons/talsec_pigeon_api.dart | 58 ----- pubspec.yaml | 1 - test/src/threat_callback_test.dart | 14 -- test/src/threat_events_test.dart | 103 +++++++++ test/test_utils/mock_event_channel.dart | 2 +- 24 files changed, 406 insertions(+), 675 deletions(-) delete mode 100644 android/src/main/kotlin/app/talsec/rasp/flutter/generated/TalsecPigeonApi.kt create mode 100644 lib/src/enums/call_state.dart delete mode 100644 lib/src/generated/talsec_pigeon_api.g.dart create mode 100644 lib/src/models/package_info.dart create mode 100644 lib/src/models/suspicious_app_info.dart delete mode 100755 pigeons/build.sh delete mode 100644 pigeons/talsec_pigeon_api.dart create mode 100644 test/src/threat_events_test.dart diff --git a/.pubignore b/.pubignore index f6ab228..925a7f6 100644 --- a/.pubignore +++ b/.pubignore @@ -7,6 +7,5 @@ example/* example/lib/* !example/lib/main.dart test/ -pigeons/ !**/TalsecRuntime.xcframework/ !**/TalsecRuntime.xcframework/** diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7cd88..63e25ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MalwareCallback` now receives a `List` without nullable items, and its parameter is renamed from `packageInfo` to `suspiciousApps` - The `Talsec.private` constructor and the `methodChannel`, `eventChannel` and `executionStateChannel` fields are no longer public; use `Talsec.instance` - `ThreatX.fromInt` throws an `ArgumentError` for an unknown code instead of terminating the app. The app is still terminated when the native side sends an unknown threat code, and now also for an unknown execution state code, like in the other freeRASP plugins +- `ThreatCallback` no longer extends the generated `TalsecPigeonApi`; its `onMalwareDetected` and `onOngoingCallDetected` methods are removed, use the `onMalware` and `onOngoingCall` callbacks instead +- `PackageInfo` and `SuspiciousAppInfo` are now immutable classes with value equality; their fields are final and the `encode`/`decode` methods are removed #### Added @@ -36,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Android package (namespace) of the plugin renamed from `com.aheaditec.freerasp` to `app.talsec.rasp.flutter`, following the Android SDK's `app.talsec.rasp` - On Android, `Talsec.isScreenCaptureBlocked()` reports the protection state of the current activity - On Android, freeRASP now starts in background mode (`TalsecMode.BACKGROUND`), so the initialization and synchronous checks no longer run on the main thread +- Malware and ongoing call detections are delivered over the same channel as the other threats, so `ThreatCallback` receives all detections in the order in which they occur. The plugin no longer uses Pigeon ### Android diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt index 2d87996..efb6332 100644 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt @@ -13,9 +13,6 @@ import io.flutter.plugin.common.MethodChannel import org.json.JSONArray import org.json.JSONObject import app.talsec.rasp.security.api.ThreatListener.CallState -import app.talsec.rasp.flutter.generated.CallState as FlutterCallState -import app.talsec.rasp.flutter.generated.PackageInfo as FlutterPackageInfo -import app.talsec.rasp.flutter.generated.SuspiciousAppInfo as FlutterSuspiciousAppInfo /** * Executes the provided block of code and catches any exceptions thrown by it, returning the @@ -34,31 +31,27 @@ internal inline fun runResultCatching(result: MethodChannel.Result, block: () -> } /** - * Converts a [SuspiciousAppInfo] instance to a [app.talsec.rasp.flutter.generated.SuspiciousAppInfo] - * instance used by Pigeon package for Flutter. - * - * @return A new [app.talsec.rasp.flutter.generated.SuspiciousAppInfo] object with information from - * this [SuspiciousAppInfo]. + * Converts a [SuspiciousAppInfo] to the map that the Dart `SuspiciousAppInfo.fromMap` reads. */ -internal fun SuspiciousAppInfo.toPigeon(context: Context): FlutterSuspiciousAppInfo { - return FlutterSuspiciousAppInfo(this.packageInfo.toPigeon(context), this.reasons.toList(), this.permissions?.toList()) +internal fun SuspiciousAppInfo.toFlutterMap(context: Context): Map { + return mapOf( + "packageInfo" to packageInfo.toFlutterMap(context), + "reasons" to reasons.toList(), + "permissions" to permissions?.toList(), + ) } /** - * Converts a [PackageInfo] instance to a [app.talsec.rasp.flutter.generated.PackageInfo] instance - * used by Pigeon package for Flutter. - * - * @return A new [app.talsec.rasp.flutter.generated.PackageInfo] object with information from - * this [PackageInfo]. + * Converts a [PackageInfo] to the map that the Dart `PackageInfo.fromMap` reads. */ -private fun PackageInfo.toPigeon(context: Context): FlutterPackageInfo { - return FlutterPackageInfo( - packageName = packageName, - appName = applicationInfo?.let { - context.packageManager.getApplicationLabel(it) as String +private fun PackageInfo.toFlutterMap(context: Context): Map { + return mapOf( + "packageName" to packageName, + "appName" to applicationInfo?.let { + context.packageManager.getApplicationLabel(it).toString() }, - version = getVersionString(), - installerStore = Utils.getInstallerPackageName(context, packageName), + "version" to getVersionString(), + "installerStore" to Utils.getInstallerPackageName(context, packageName), ) } @@ -121,13 +114,12 @@ internal fun JSONObject.toSuspiciousAppDetectionConfig(): SuspiciousAppDetection } /** - * Converts a [CallState] instance to a [app.talsec.rasp.flutter.generated.CallState] instance - * used by Pigeon package for Flutter. + * Converts a [CallState] to the name of the Dart `CallState` value. */ -internal fun CallState.toPigeon(): FlutterCallState { +internal fun CallState.toFlutterName(): String { return when (this) { - CallState.STARTED -> FlutterCallState.STARTED - CallState.ONGOING -> FlutterCallState.ONGOING - CallState.STOPPED -> FlutterCallState.STOPPED + CallState.STARTED -> "started" + CallState.ONGOING -> "ongoing" + CallState.STOPPED -> "stopped" } } diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt index f2ec65c..94231ec 100644 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt @@ -1,14 +1,20 @@ package app.talsec.rasp.flutter.dispatchers import app.talsec.rasp.flutter.Threat -import app.talsec.rasp.flutter.handlers.MethodCallHandler -import app.talsec.rasp.security.api.SuspiciousAppInfo +import app.talsec.rasp.flutter.toFlutterName import app.talsec.rasp.security.api.ThreatListener.CallState import io.flutter.plugin.common.EventChannel.EventSink +/** + * Sends detections to Flutter over the threat [EventSink], in the order in which they occur. + * + * A plain threat is sent as its [Int] code. A detection with a payload is sent as a map with a + * `type` key: `malware` (with `suspiciousApps`) or `ongoingCall` (with `state`). Detections that + * occur while the app is in background or no listener is attached are cached and sent later. + */ internal object ThreatDispatcher { private val threatCache = mutableSetOf() - private val malwareCache = mutableListOf() + private val malwareCache = mutableListOf>() private val callStateCache = mutableListOf() private var isAppInForeground = false @@ -17,28 +23,14 @@ internal object ThreatDispatcher { field = value if (value != null) { isAppInForeground = true - flushThreatCache() - } - } - - var methodSink: MethodCallHandler.MethodSink? = null - set(value) { - field = value - if (value != null) { - isAppInForeground = true - flushMalwareCache() - flushCallStateCache() + flushCache() } } fun onResume() { isAppInForeground = true if (eventSink != null) { - flushThreatCache() - } - if (methodSink != null) { - flushMalwareCache() - flushCallStateCache() + flushCache() } } @@ -56,9 +48,12 @@ internal object ThreatDispatcher { } } - fun dispatchMalware(apps: List) { - if (isAppInForeground && methodSink != null) { - methodSink?.onMalwareDetected(apps) + /** + * @param apps The suspicious apps, already converted with `toFlutterMap`. + */ + fun dispatchMalware(apps: List>) { + if (isAppInForeground && eventSink != null) { + eventSink?.success(malwareEvent(apps)) } else { synchronized(malwareCache) { malwareCache.addAll(apps) @@ -67,8 +62,8 @@ internal object ThreatDispatcher { } fun dispatchOngoingCall(state: CallState) { - if (isAppInForeground && methodSink != null) { - methodSink?.onOngoingCall(state) + if (isAppInForeground && eventSink != null) { + eventSink?.success(ongoingCallEvent(state)) } else { // Keep the order: a call that started and stopped in background is reported as both. synchronized(callStateCache) { @@ -77,6 +72,12 @@ internal object ThreatDispatcher { } } + private fun flushCache() { + flushThreatCache() + flushMalwareCache() + flushCallStateCache() + } + private fun flushThreatCache() { val threats = synchronized(threatCache) { val snapshot = threatCache.toSet() @@ -88,12 +89,12 @@ internal object ThreatDispatcher { private fun flushMalwareCache() { val malware = synchronized(malwareCache) { - val snapshot = malwareCache.toMutableList() + val snapshot = malwareCache.toList() malwareCache.clear() snapshot } if (malware.isNotEmpty()) { - methodSink?.onMalwareDetected(malware) + eventSink?.success(malwareEvent(malware)) } } @@ -103,6 +104,12 @@ internal object ThreatDispatcher { callStateCache.clear() snapshot } - states.forEach { methodSink?.onOngoingCall(it) } + states.forEach { eventSink?.success(ongoingCallEvent(it)) } } + + private fun malwareEvent(apps: List>): Map = + mapOf("type" to "malware", "suspiciousApps" to apps) + + private fun ongoingCallEvent(state: CallState): Map = + mapOf("type" to "ongoingCall", "state" to state.toFlutterName()) } diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/generated/TalsecPigeonApi.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/generated/TalsecPigeonApi.kt deleted file mode 100644 index 1fb0e1e..0000000 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/generated/TalsecPigeonApi.kt +++ /dev/null @@ -1,183 +0,0 @@ -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") - -package app.talsec.rasp.flutter.generated - -import android.util.Log -import io.flutter.plugin.common.BasicMessageChannel -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MessageCodec -import io.flutter.plugin.common.StandardMethodCodec -import io.flutter.plugin.common.StandardMessageCodec -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer - -private fun createConnectionError(channelName: String): FlutterError { - return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")} - -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -/** State of an ongoing call reported by the ongoing call detection. */ -enum class CallState(val raw: Int) { - /** A call started while the detection was running. */ - STARTED(0), - /** A call was already in progress when the detection started. */ - ONGOING(1), - /** A previously reported call has ended. */ - STOPPED(2); - - companion object { - fun ofRaw(raw: Int): CallState? { - return values().firstOrNull { it.raw == raw } - } - } -} - -/** Generated class from Pigeon that represents data sent in messages. */ -data class PackageInfo ( - val packageName: String, - val appIcon: String? = null, - val appName: String? = null, - val version: String? = null, - val installerStore: String? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): PackageInfo { - val packageName = pigeonVar_list[0] as String - val appIcon = pigeonVar_list[1] as String? - val appName = pigeonVar_list[2] as String? - val version = pigeonVar_list[3] as String? - val installerStore = pigeonVar_list[4] as String? - return PackageInfo(packageName, appIcon, appName, version, installerStore) - } - } - fun toList(): List { - return listOf( - packageName, - appIcon, - appName, - version, - installerStore, - ) - } -} - -/** Generated class from Pigeon that represents data sent in messages. */ -data class SuspiciousAppInfo ( - val packageInfo: PackageInfo, - val reasons: List, - val permissions: List? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): SuspiciousAppInfo { - val packageInfo = pigeonVar_list[0] as PackageInfo - val reasons = pigeonVar_list[1] as List - val permissions = pigeonVar_list[2] as List? - return SuspiciousAppInfo(packageInfo, reasons, permissions) - } - } - fun toList(): List { - return listOf( - packageInfo, - reasons, - permissions, - ) - } -} -private open class TalsecPigeonApiPigeonCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 129.toByte() -> { - return (readValue(buffer) as Long?)?.let { - CallState.ofRaw(it.toInt()) - } - } - 130.toByte() -> { - return (readValue(buffer) as? List)?.let { - PackageInfo.fromList(it) - } - } - 131.toByte() -> { - return (readValue(buffer) as? List)?.let { - SuspiciousAppInfo.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) - } - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is CallState -> { - stream.write(129) - writeValue(stream, value.raw) - } - is PackageInfo -> { - stream.write(130) - writeValue(stream, value.toList()) - } - is SuspiciousAppInfo -> { - stream.write(131) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) - } - } -} - -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ -class TalsecPigeonApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { - companion object { - /** The codec used by TalsecPigeonApi. */ - val codec: MessageCodec by lazy { - TalsecPigeonApiPigeonCodec() - } - } - fun onMalwareDetected(suspiciousAppsArg: List, callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(suspiciousAppsArg)) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(createConnectionError(channelName))) - } - } - } - fun onOngoingCallDetected(stateArg: CallState, callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.freerasp.TalsecPigeonApi.onOngoingCallDetected$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(stateArg)) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(createConnectionError(channelName))) - } - } - } -} diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt index d734d7b..0bae62a 100644 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt @@ -11,13 +11,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import app.talsec.rasp.flutter.Utils -import app.talsec.rasp.flutter.generated.TalsecPigeonApi import app.talsec.rasp.flutter.resolve import app.talsec.rasp.flutter.runResultCatching -import app.talsec.rasp.flutter.toPigeon -import app.talsec.rasp.security.api.SuspiciousAppInfo import app.talsec.rasp.security.api.Talsec -import app.talsec.rasp.security.api.ThreatListener.CallState import io.flutter.Log import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall @@ -30,7 +26,6 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { private var context: Context? = null private var methodChannel: MethodChannel? = null - private var talsecPigeon: TalsecPigeonApi? = null private val backgroundHandlerThread = HandlerThread("BackgroundThread").apply { start() } private val backgroundHandler = Handler(backgroundHandlerThread.looper) private val mainHandler = Handler(Looper.getMainLooper()) @@ -56,37 +51,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { private const val CHANNEL_NAME: String = "talsec.app/freerasp/methods" } - private val sink = object : MethodSink { - override fun onMalwareDetected(packageInfo: List) { - context?.let { context -> - val pigeonPackageInfo = packageInfo.map { it.toPigeon(context) } - talsecPigeon?.onMalwareDetected(pigeonPackageInfo) { result -> - // Parse the result (which is Unit so we can ignore it) or throw an exception - // Exceptions are translated to Flutter errors automatically - result.getOrElse { - Log.e("MethodCallHandlerSink", "Result ended with failure") - throw it - } - } - } - } - - override fun onOngoingCall(state: CallState) { - talsecPigeon?.onOngoingCallDetected(state.toPigeon()) { result -> - result.getOrElse { - Log.e("MethodCallHandlerSink", "Result ended with failure") - throw it - } - } - } - } - - internal interface MethodSink { - fun onMalwareDetected(packageInfo: List) - - fun onOngoingCall(state: CallState) - } - /** * Creates a new [MethodChannel] with the specified [BinaryMessenger] instance. Sets this class * as the [MethodCallHandler]. @@ -106,9 +70,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { } this.context = context - this.talsecPigeon = TalsecPigeonApi(messenger) - - TalsecThreatHandler.attachMethodSink(sink) } /** @@ -119,9 +80,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { methodChannel = null this.context = null - this.talsecPigeon = null - - TalsecThreatHandler.detachMethodSink() } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt index 46e4cd8..cda31c7 100644 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt @@ -5,6 +5,7 @@ import app.talsec.rasp.flutter.RaspExecutionStateEvent import app.talsec.rasp.flutter.Threat import app.talsec.rasp.flutter.dispatchers.ExecutionStateDispatcher import app.talsec.rasp.flutter.dispatchers.ThreatDispatcher +import app.talsec.rasp.flutter.toFlutterMap import app.talsec.rasp.security.api.SuspiciousAppInfo import app.talsec.rasp.security.api.ThreatListener import app.talsec.rasp.security.api.ThreatListener.CallState @@ -19,6 +20,9 @@ import app.talsec.rasp.security.api.ThreatListener.ThreatDetected */ internal object PluginThreatHandler { + /** Application context, used to resolve the details of apps reported as malware. */ + private var appContext: Context? = null + private val threatDetected = object : ThreatDetected() { override fun onPrivilegedAccess() { ThreatDispatcher.dispatchThreat(Threat.PrivilegedAccess) @@ -53,7 +57,8 @@ internal object PluginThreatHandler { } override fun onMalware(suspiciousApps: List) { - ThreatDispatcher.dispatchMalware(suspiciousApps) + val context = appContext ?: return + ThreatDispatcher.dispatchMalware(suspiciousApps.map { it.toFlutterMap(context) }) } override fun onScreenshot() { @@ -132,6 +137,7 @@ internal object PluginThreatHandler { private val internalListener = ThreatListener(threatDetected, deviceState, raspExecutionState) internal fun registerListener(context: Context) { + appContext = context.applicationContext internalListener.registerListener(context) } diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt index d33b1f9..fd20acd 100644 --- a/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt @@ -133,14 +133,6 @@ internal object TalsecThreatHandler { internal fun detachExecutionStateSink() { ExecutionStateDispatcher.eventSink = null } - - internal fun attachMethodSink(sink: MethodCallHandler.MethodSink) { - ThreatDispatcher.methodSink = sink - } - - internal fun detachMethodSink() { - ThreatDispatcher.methodSink = null - } } diff --git a/lib/freerasp.dart b/lib/freerasp.dart index b284c5d..cd681e3 100644 --- a/lib/freerasp.dart +++ b/lib/freerasp.dart @@ -2,8 +2,6 @@ export 'src/callbacks/rasp_execution_state_callback.dart'; export 'src/callbacks/threat_callback.dart'; export 'src/enums/enums.dart'; export 'src/errors/errors.dart'; -export 'src/generated/talsec_pigeon_api.g.dart' - show CallState, PackageInfo, SuspiciousAppInfo; export 'src/models/models.dart'; export 'src/talsec.dart' hide createTalsecForTesting; export 'src/typedefs.dart'; diff --git a/lib/src/callbacks/threat_callback.dart b/lib/src/callbacks/threat_callback.dart index 5a8b4b0..e612b0e 100644 --- a/lib/src/callbacks/threat_callback.dart +++ b/lib/src/callbacks/threat_callback.dart @@ -1,4 +1,3 @@ -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart'; import 'package:freerasp/src/typedefs.dart'; /// A class which represents a set of callbacks that are used to notify the @@ -18,7 +17,7 @@ import 'package:freerasp/src/typedefs.dart'; /// // Attaching callback to Talsec /// Talsec.instance.attachListener(callback); /// ``` -class ThreatCallback extends TalsecPigeonApi { +class ThreatCallback { /// Constructs a [ThreatCallback] instance. ThreatCallback({ this.onHooks, @@ -123,21 +122,11 @@ class ThreatCallback extends TalsecPigeonApi { /// Android only final VoidCallback? onBootloader; - @override - void onMalwareDetected(List suspiciousApps) { - onMalware?.call(suspiciousApps); - } - /// This method is called when malware is detected. /// /// Android only final MalwareCallback? onMalware; - @override - void onOngoingCallDetected(CallState state) { - onOngoingCall?.call(state); - } - /// This method is called when an ongoing call (cellular or VoIP) starts, /// is already in progress, or stops while ongoing call detection is /// running. diff --git a/lib/src/enums/call_state.dart b/lib/src/enums/call_state.dart new file mode 100644 index 0000000..6264fd4 --- /dev/null +++ b/lib/src/enums/call_state.dart @@ -0,0 +1,13 @@ +/// State of an ongoing call reported by the ongoing call detection. +/// +/// Android only +enum CallState { + /// A call started while the detection was running. + started, + + /// A call was already in progress when the detection started. + ongoing, + + /// A previously reported call has ended. + stopped, +} diff --git a/lib/src/enums/enums.dart b/lib/src/enums/enums.dart index ec737bf..f2b5d15 100644 --- a/lib/src/enums/enums.dart +++ b/lib/src/enums/enums.dart @@ -1,2 +1,3 @@ +export 'call_state.dart'; export 'rasp_execution_state.dart'; export 'threat.dart'; diff --git a/lib/src/generated/talsec_pigeon_api.g.dart b/lib/src/generated/talsec_pigeon_api.g.dart deleted file mode 100644 index fd37508..0000000 --- a/lib/src/generated/talsec_pigeon_api.g.dart +++ /dev/null @@ -1,217 +0,0 @@ -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers - -import 'dart:async'; -import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; - -import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; -import 'package:flutter/services.dart'; - -List wrapResponse( - {Object? result, PlatformException? error, bool empty = false}) { - if (empty) { - return []; - } - if (error == null) { - return [result]; - } - return [error.code, error.message, error.details]; -} - -/// State of an ongoing call reported by the ongoing call detection. -enum CallState { - /// A call started while the detection was running. - started, - - /// A call was already in progress when the detection started. - ongoing, - - /// A previously reported call has ended. - stopped, -} - -class PackageInfo { - PackageInfo({ - required this.packageName, - this.appIcon, - this.appName, - this.version, - this.installerStore, - }); - - String packageName; - - String? appIcon; - - String? appName; - - String? version; - - String? installerStore; - - Object encode() { - return [ - packageName, - appIcon, - appName, - version, - installerStore, - ]; - } - - static PackageInfo decode(Object result) { - result as List; - return PackageInfo( - packageName: result[0]! as String, - appIcon: result[1] as String?, - appName: result[2] as String?, - version: result[3] as String?, - installerStore: result[4] as String?, - ); - } -} - -class SuspiciousAppInfo { - SuspiciousAppInfo({ - required this.packageInfo, - required this.reasons, - this.permissions, - }); - - PackageInfo packageInfo; - - List reasons; - - List? permissions; - - Object encode() { - return [ - packageInfo, - reasons, - permissions, - ]; - } - - static SuspiciousAppInfo decode(Object result) { - result as List; - return SuspiciousAppInfo( - packageInfo: result[0]! as PackageInfo, - reasons: (result[1] as List?)!.cast(), - permissions: (result[2] as List?)?.cast(), - ); - } -} - -class _PigeonCodec extends StandardMessageCodec { - const _PigeonCodec(); - @override - void writeValue(WriteBuffer buffer, Object? value) { - if (value is int) { - buffer.putUint8(4); - buffer.putInt64(value); - } else if (value is CallState) { - buffer.putUint8(129); - writeValue(buffer, value.index); - } else if (value is PackageInfo) { - buffer.putUint8(130); - writeValue(buffer, value.encode()); - } else if (value is SuspiciousAppInfo) { - buffer.putUint8(131); - writeValue(buffer, value.encode()); - } else { - super.writeValue(buffer, value); - } - } - - @override - Object? readValueOfType(int type, ReadBuffer buffer) { - switch (type) { - case 129: - final int? value = readValue(buffer) as int?; - return value == null ? null : CallState.values[value]; - case 130: - return PackageInfo.decode(readValue(buffer)!); - case 131: - return SuspiciousAppInfo.decode(readValue(buffer)!); - default: - return super.readValueOfType(type, buffer); - } - } -} - -abstract class TalsecPigeonApi { - static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - - void onMalwareDetected(List suspiciousApps); - - void onOngoingCallDetected(CallState state); - - static void setUp( - TalsecPigeonApi? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( - 'dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected was null.'); - final List args = (message as List?)!; - final List? arg_suspiciousApps = - (args[0] as List?)?.cast(); - assert(arg_suspiciousApps != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected was null, expected non-null List.'); - try { - api.onMalwareDetected(arg_suspiciousApps!); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( - 'dev.flutter.pigeon.freerasp.TalsecPigeonApi.onOngoingCallDetected$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onOngoingCallDetected was null.'); - final List args = (message as List?)!; - final CallState? arg_state = (args[0] as CallState?); - assert(arg_state != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onOngoingCallDetected was null, expected non-null CallState.'); - try { - api.onOngoingCallDetected(arg_state!); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - } -} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index 2db18eb..1ff1a87 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -1,4 +1,6 @@ export 'android_config.dart'; export 'ios_config.dart'; +export 'package_info.dart'; export 'suspicious_app_detection_config.dart'; +export 'suspicious_app_info.dart'; export 'talsec_config.dart'; diff --git a/lib/src/models/package_info.dart b/lib/src/models/package_info.dart new file mode 100644 index 0000000..f39aa89 --- /dev/null +++ b/lib/src/models/package_info.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; + +/// Information about an installed application. +@immutable +class PackageInfo { + /// Creates a [PackageInfo]. + const PackageInfo({ + required this.packageName, + this.appIcon, + this.appName, + this.version, + this.installerStore, + }); + + /// Creates a [PackageInfo] from the map sent by the native platform. + /// + /// Throws if the map does not have the expected shape. + @internal + factory PackageInfo.fromMap(Map map) { + return PackageInfo( + packageName: map['packageName']! as String, + appIcon: map['appIcon'] as String?, + appName: map['appName'] as String?, + version: map['version'] as String?, + installerStore: map['installerStore'] as String?, + ); + } + + /// Package name of the application, e.g. `com.example.app`. + final String packageName; + + /// Application icon as a Base64-encoded PNG. + /// + /// Not sent with malware detections; use `Talsec.instance.getAppIcon()`. + final String? appIcon; + + /// User-visible name of the application. + final String? appName; + + /// Version code of the application. + final String? version; + + /// Package name of the store that installed the application. + final String? installerStore; + + @override + bool operator ==(Object other) => + other is PackageInfo && + other.packageName == packageName && + other.appIcon == appIcon && + other.appName == appName && + other.version == version && + other.installerStore == installerStore; + + @override + int get hashCode => + Object.hash(packageName, appIcon, appName, version, installerStore); + + @override + String toString() => 'PackageInfo(packageName: $packageName, ' + 'appName: $appName, version: $version, installerStore: $installerStore)'; +} diff --git a/lib/src/models/suspicious_app_info.dart b/lib/src/models/suspicious_app_info.dart new file mode 100644 index 0000000..3231089 --- /dev/null +++ b/lib/src/models/suspicious_app_info.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:freerasp/src/models/package_info.dart'; + +/// An application reported by the malware detection. +@immutable +class SuspiciousAppInfo { + /// Creates a [SuspiciousAppInfo]. + const SuspiciousAppInfo({ + required this.packageInfo, + required this.reasons, + this.permissions, + }); + + /// Creates a [SuspiciousAppInfo] from the map sent by the native platform. + /// + /// Throws if the map does not have the expected shape. + @internal + factory SuspiciousAppInfo.fromMap(Map map) { + return SuspiciousAppInfo( + packageInfo: + PackageInfo.fromMap(map['packageInfo']! as Map), + reasons: (map['reasons']! as List).cast().toList(), + permissions: + (map['permissions'] as List?)?.cast().toList(), + ); + } + + /// The application that was reported. + final PackageInfo packageInfo; + + /// Why the application was reported, e.g. `blacklist` or `permission`. + final List reasons; + + /// Permissions of the application that caused the report, if any. + final List? permissions; + + @override + bool operator ==(Object other) => + other is SuspiciousAppInfo && + other.packageInfo == packageInfo && + listEquals(other.reasons, reasons) && + listEquals(other.permissions, permissions); + + @override + int get hashCode => Object.hash( + packageInfo, + Object.hashAll(reasons), + permissions == null ? null : Object.hashAll(permissions!), + ); + + @override + String toString() => 'SuspiciousAppInfo(packageInfo: $packageInfo, ' + 'reasons: $reasons, permissions: $permissions)'; +} diff --git a/lib/src/talsec.dart b/lib/src/talsec.dart index 16de4dc..e028d58 100644 --- a/lib/src/talsec.dart +++ b/lib/src/talsec.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:freerasp/freerasp.dart'; -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart' as pigeon; /// Creates a [Talsec] instance that communicates over the given channels. /// @@ -84,9 +83,10 @@ class Talsec { /// platform. final EventChannel _executionStateChannel; - StreamSubscription? _streamSubscription; + StreamSubscription? _streamSubscription; StreamSubscription? _executionStateSubscription; + Stream? _events; Stream? _onThreatDetected; Stream? _onRaspExecutionState; @@ -118,15 +118,19 @@ class Talsec { return _onThreatDetected!; } - _onThreatDetected = _eventChannel - .receiveBroadcastStream() + _onThreatDetected = _threatEvents + .where((event) => event is int) .cast() - .map((code) => _decodeOrAbort(code, ThreatX.fromInt)) - .handleError(_handleStreamError); + .map((code) => _decodeOrAbort(code, ThreatX.fromInt)); return _onThreatDetected!; } + /// Broadcast stream of all events of the threat channel: an [int] threat + /// code, or a map with the payload of a malware or ongoing call detection. + Stream get _threatEvents => _events ??= + _eventChannel.receiveBroadcastStream().handleError(_handleStreamError); + /// Returns a broadcast stream. When RASP execution state changes /// [onRaspExecutionState] receives what state it is. Stream get onRaspExecutionState { @@ -384,63 +388,105 @@ class Talsec { /// already attached, current one will be detached and replaced with /// [callback]. /// - /// When invoked, functions starts listening to [onThreatDetected] and turns - /// stream events into function callbacks of [ThreatCallback]. + /// When invoked, functions starts listening to the threat events and turns + /// them into function callbacks of [ThreatCallback], in the order in which + /// they were detected. /// /// When threat is detected, respective callback of [ThreatCallback] is /// invoked. Future attachListener(ThreatCallback callback) async { - pigeon.TalsecPigeonApi.setUp(callback); - await detachListener(); - _streamSubscription ??= onThreatDetected.listen((event) { - switch (event) { - case Threat.hooks: - callback.onHooks?.call(); - case Threat.debug: - callback.onDebug?.call(); - case Threat.passcode: - callback.onPasscode?.call(); - case Threat.deviceID: - callback.onDeviceID?.call(); - case Threat.simulator: - callback.onSimulator?.call(); - case Threat.appIntegrity: - callback.onAppIntegrity?.call(); - case Threat.obfuscationIssues: - callback.onObfuscationIssues?.call(); - case Threat.deviceBinding: - callback.onDeviceBinding?.call(); - case Threat.unofficialStore: - callback.onUnofficialStore?.call(); - case Threat.privilegedAccess: - callback.onPrivilegedAccess?.call(); - case Threat.secureHardwareNotAvailable: - callback.onSecureHardwareNotAvailable?.call(); - case Threat.systemVPN: - callback.onSystemVPN?.call(); - case Threat.devMode: - callback.onDevMode?.call(); - case Threat.adbEnabled: - callback.onAdbEnabled?.call(); - case Threat.screenshot: - callback.onScreenshot?.call(); - case Threat.screenRecording: - callback.onScreenRecording?.call(); - case Threat.multiInstance: - callback.onMultiInstance?.call(); - case Threat.unsecureWifi: - callback.onUnsecureWifi?.call(); - case Threat.timeSpoofing: - callback.onTimeSpoofing?.call(); - case Threat.locationSpoofing: - callback.onLocationSpoofing?.call(); - case Threat.automation: - callback.onAutomation?.call(); - case Threat.bootloader: - callback.onBootloader?.call(); + _streamSubscription ??= _threatEvents.listen( + (event) => _dispatchThreatEvent(callback, event), + ); + } + + void _dispatchThreatEvent(ThreatCallback callback, Object? event) { + switch (event) { + case final int code: + _dispatchThreat(callback, _decodeOrAbort(code, ThreatX.fromInt)); + case final Map payload: + _dispatchPayload(callback, payload); + default: + // Unknown data came from the native platform. + exit(127); + } + } + + /// Delivers a malware or ongoing call detection. + /// + /// An unexpected payload is handled like an unknown threat code. + void _dispatchPayload( + ThreatCallback callback, + Map payload, + ) { + final void Function() deliver; + try { + switch (payload['type']) { + case 'malware': + final apps = (payload['suspiciousApps']! as List) + .map((app) => SuspiciousAppInfo.fromMap(app! as Map)) + .toList(); + deliver = () => callback.onMalware?.call(apps); + case 'ongoingCall': + final state = CallState.values.byName(payload['state']! as String); + deliver = () => callback.onOngoingCall?.call(state); + default: + exit(127); } - }); + } on Object { + exit(127); + } + deliver(); + } + + void _dispatchThreat(ThreatCallback callback, Threat threat) { + switch (threat) { + case Threat.hooks: + callback.onHooks?.call(); + case Threat.debug: + callback.onDebug?.call(); + case Threat.passcode: + callback.onPasscode?.call(); + case Threat.deviceID: + callback.onDeviceID?.call(); + case Threat.simulator: + callback.onSimulator?.call(); + case Threat.appIntegrity: + callback.onAppIntegrity?.call(); + case Threat.obfuscationIssues: + callback.onObfuscationIssues?.call(); + case Threat.deviceBinding: + callback.onDeviceBinding?.call(); + case Threat.unofficialStore: + callback.onUnofficialStore?.call(); + case Threat.privilegedAccess: + callback.onPrivilegedAccess?.call(); + case Threat.secureHardwareNotAvailable: + callback.onSecureHardwareNotAvailable?.call(); + case Threat.systemVPN: + callback.onSystemVPN?.call(); + case Threat.devMode: + callback.onDevMode?.call(); + case Threat.adbEnabled: + callback.onAdbEnabled?.call(); + case Threat.screenshot: + callback.onScreenshot?.call(); + case Threat.screenRecording: + callback.onScreenRecording?.call(); + case Threat.multiInstance: + callback.onMultiInstance?.call(); + case Threat.unsecureWifi: + callback.onUnsecureWifi?.call(); + case Threat.timeSpoofing: + callback.onTimeSpoofing?.call(); + case Threat.locationSpoofing: + callback.onLocationSpoofing?.call(); + case Threat.automation: + callback.onAutomation?.call(); + case Threat.bootloader: + callback.onBootloader?.call(); + } } /// Removes instance of latest [ThreatCallback]. Also cancels diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart index 7adaf31..a73c2e7 100644 --- a/lib/src/typedefs.dart +++ b/lib/src/typedefs.dart @@ -1,4 +1,5 @@ -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart'; +import 'package:freerasp/src/enums/call_state.dart'; +import 'package:freerasp/src/models/suspicious_app_info.dart'; /// Typedef for void methods typedef VoidCallback = void Function(); diff --git a/pigeons/build.sh b/pigeons/build.sh deleted file mode 100755 index 328c92f..0000000 --- a/pigeons/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/zsh - -# Exit on error -set -euo pipefail - -# Variables -INPUT_FILES=("talsec_pigeon_api.dart" "rasp_execution_state.dart") - -# Check current directory -if [ "$(basename "$PWD")" != "pigeons" ]; then - echo "⚠️ Not in the /pigeons directory." - echo "⚠️ Current directory: $PWD" - echo "Run this script from the /pigeons directory" - exit 1 -fi - -# Generate Pigeon code -cd .. -for file in "${INPUT_FILES[@]}"; do - echo "🚀 Generating Pigeon code for $file..." - dart run pigeon --input "./pigeons/$file" -done diff --git a/pigeons/talsec_pigeon_api.dart b/pigeons/talsec_pigeon_api.dart deleted file mode 100644 index f33d95b..0000000 --- a/pigeons/talsec_pigeon_api.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:pigeon/pigeon.dart'; - -@ConfigurePigeon( - PigeonOptions( - dartOut: 'lib/src/generated/talsec_pigeon_api.g.dart', - kotlinOut: - 'android/src/main/kotlin/app/talsec/rasp/flutter/generated/TalsecPigeonApi.kt', - input: 'pigeons/talsec_pigeon_api.dart', - kotlinOptions: KotlinOptions(package: 'app.talsec.rasp.flutter.generated'), - ), -) -class PackageInfo { - const PackageInfo({ - required this.packageName, - this.appIcon, - this.version, - this.appName, - this.installerStore, - }); - - final String packageName; - final String? appIcon; - final String? appName; - final String? version; - final String? installerStore; -} - -class SuspiciousAppInfo { - const SuspiciousAppInfo({ - required this.packageInfo, - required this.reasons, - this.permissions, - }); - - final PackageInfo packageInfo; - final List reasons; - final List? permissions; -} - -/// State of an ongoing call reported by the ongoing call detection. -enum CallState { - /// A call started while the detection was running. - started, - - /// A call was already in progress when the detection started. - ongoing, - - /// A previously reported call has ended. - stopped, -} - -@FlutterApi() -// Migrate whole Talsec API to pigeon -abstract class TalsecPigeonApi { - void onMalwareDetected(List suspiciousApps); - - void onOngoingCallDetected(CallState state); -} diff --git a/pubspec.yaml b/pubspec.yaml index aa109ea..fdc092a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,6 @@ dev_dependencies: sdk: flutter json_serializable: ^6.8.0 mocktail: ^1.0.4 - pigeon: ^22.7.2 very_good_analysis: ^7.0.0 flutter: diff --git a/test/src/threat_callback_test.dart b/test/src/threat_callback_test.dart index 4de0c10..862a94d 100644 --- a/test/src/threat_callback_test.dart +++ b/test/src/threat_callback_test.dart @@ -34,20 +34,6 @@ void main() { expect(SpyThreatListener.log[Threat.unofficialStore], 0); expect(SpyThreatListener.log[Threat.obfuscationIssues], 0); }); - - test('Should forward ongoing call state', () { - // Arrange - final states = []; - final callback = ThreatCallback(onOngoingCall: states.add); - - // Act - callback - ..onOngoingCallDetected(CallState.started) - ..onOngoingCallDetected(CallState.stopped); - - // Assert - expect(states, [CallState.started, CallState.stopped]); - }); }); } diff --git a/test/src/threat_events_test.dart b/test/src/threat_events_test.dart new file mode 100644 index 0000000..fd137a0 --- /dev/null +++ b/test/src/threat_events_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freerasp/freerasp.dart'; +import 'package:freerasp/src/talsec.dart' show createTalsecForTesting; + +import '../test_utils/test_utils.dart'; + +const _events = EventChannel('talsec.app/freerasp/events'); + +const _malwareApp = { + 'packageInfo': { + 'packageName': 'com.example.malware', + 'appName': 'Malware', + 'version': '42', + 'installerStore': null, + }, + 'reasons': ['blacklist'], + 'permissions': null, +}; + +Talsec _talsecWith(List data) { + final channel = MockEventChannel(eventChannel: _events, data: data); + return createTalsecForTesting( + FakeMethodChannel(), + channel.eventChannel, + FakeEventChannel(), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('attachListener delivers threats and payloads in order', () async { + // Arrange + final log = []; + final talsec = _talsecWith([ + 44506749, + { + 'type': 'malware', + 'suspiciousApps': [_malwareApp], + }, + {'type': 'ongoingCall', 'state': 'started'}, + {'type': 'ongoingCall', 'state': 'stopped'}, + ]); + final callback = ThreatCallback( + onPrivilegedAccess: () => log.add(Threat.privilegedAccess), + onMalware: log.add, + onOngoingCall: log.add, + ); + + // Act + await talsec.attachListener(callback); + await pumpEventQueue(); + + // Assert + expect(log, [ + Threat.privilegedAccess, + [ + const SuspiciousAppInfo( + packageInfo: PackageInfo( + packageName: 'com.example.malware', + appName: 'Malware', + version: '42', + ), + reasons: ['blacklist'], + ), + ], + CallState.started, + CallState.stopped, + ]); + await talsec.detachListener(); + }); + + test('onThreatDetected skips payload events', () { + final talsec = _talsecWith([ + {'type': 'ongoingCall', 'state': 'ongoing'}, + 1115787534, + ]); + + expectLater( + talsec.onThreatDetected, + emitsInOrder([Threat.appIntegrity, emitsDone]), + ); + }); + + test('SuspiciousAppInfo.fromMap reads permissions', () { + final app = SuspiciousAppInfo.fromMap(const { + 'packageInfo': {'packageName': 'com.example.malware'}, + 'reasons': ['permission'], + 'permissions': ['android.permission.READ_SMS'], + }); + + expect(app.permissions, ['android.permission.READ_SMS']); + expect(app.packageInfo.packageName, 'com.example.malware'); + }); + + test('SuspiciousAppInfo.fromMap throws on a malformed map', () { + expect( + () => SuspiciousAppInfo.fromMap(const {'reasons': []}), + throwsA(anything), + ); + }); +} diff --git a/test/test_utils/mock_event_channel.dart b/test/test_utils/mock_event_channel.dart index 4aa6e7c..d7204c3 100644 --- a/test/test_utils/mock_event_channel.dart +++ b/test/test_utils/mock_event_channel.dart @@ -12,7 +12,7 @@ class MockEventChannel { } final EventChannel eventChannel; - final List data; + final List data; final List exceptions; MockStreamHandler _handler() { From 74a4c73205ed4508cace40c852a5d052b0cf69de Mon Sep 17 00:00:00 2001 From: Tomas Psota Date: Thu, 12 Nov 2026 10:31:06 +0100 Subject: [PATCH 2/2] fix: import @internal and @immutable from package:meta flutter/foundation does not export @internal in Flutter 3.24, the minimum supported version used by CI. Co-Authored-By: Claude Opus 5.5 --- lib/src/models/package_info.dart | 2 +- lib/src/models/suspicious_app_info.dart | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/src/models/package_info.dart b/lib/src/models/package_info.dart index f39aa89..6916892 100644 --- a/lib/src/models/package_info.dart +++ b/lib/src/models/package_info.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; /// Information about an installed application. @immutable diff --git a/lib/src/models/suspicious_app_info.dart b/lib/src/models/suspicious_app_info.dart index 3231089..972d196 100644 --- a/lib/src/models/suspicious_app_info.dart +++ b/lib/src/models/suspicious_app_info.dart @@ -1,5 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:freerasp/src/models/package_info.dart'; +import 'package:meta/meta.dart'; /// An application reported by the malware detection. @immutable @@ -38,8 +38,8 @@ class SuspiciousAppInfo { bool operator ==(Object other) => other is SuspiciousAppInfo && other.packageInfo == packageInfo && - listEquals(other.reasons, reasons) && - listEquals(other.permissions, permissions); + _listEquals(other.reasons, reasons) && + _listEquals(other.permissions, permissions); @override int get hashCode => Object.hash( @@ -52,3 +52,12 @@ class SuspiciousAppInfo { String toString() => 'SuspiciousAppInfo(packageInfo: $packageInfo, ' 'reasons: $reasons, permissions: $permissions)'; } + +bool _listEquals(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null || a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +}