diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9906c20 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Flutter CI + +on: + push: + branches: ["main", "master", "dev"] + pull_request: + branches: ["main", "master", "dev"] + +jobs: + test: + name: Unit & Widget Tests + runs-on: macos-latest + + steps: + - name: Checkout 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 + working-directory: example + + - name: Generate mocks (build_runner) + run: dart run build_runner build --delete-conflicting-outputs + working-directory: example + + - name: Auto-format code + run: dart format . + working-directory: example + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + working-directory: example + + - name: Analyze code (dart analyze) + run: flutter analyze + working-directory: example + + - name: Run tests with coverage + run: flutter test --coverage + working-directory: example \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8c35c..0fa75df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 2.1.3 +- Chore: Bump Dart to >=3.12, Flutter to >=3.44, and clean up code. + +## 2.1.2 +- Fix(iOS): Re-initialize video stream analyzer on stream continue. + +## 2.1.1 +- Feat(iOS): Video Stream Analyzer. + +## 2.1.0 +### Breaking +- `analyzeImage`, `analyzeNetworkImage`, and `analyzeVideo` now return `SensitivityAnalysisResult?` instead of `bool?`. + +### Added +- `SensitivityAnalysisResult` with `isSensitive` (bool) and `detectedTypes` (List). +- `detectedTypes` returns detected content categories (e.g. `sexuallyExplicit`, `goreOrViolence`) on iOS 27.0+ / macOS 27.0+. Empty list on older OS versions. + +### Fix +- Added a queue to `analyzeNetworkImage` preventing SCA from crashing and returning `null`. + +## 2.0.3 +- Fix: Add backwards compatibility for cocoapods. + +## 2.0.2 +- Fix: Data Race & Main Thread Violation + ## 2.0.0 - Support for Swift Package Manager. diff --git a/README.md b/README.md index 1844db0..cf2b60b 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ #### Provide a safer experience in your app by detecting and alerting users to nudity in images and videos before displaying them onscreen. - + + [![Pub](https://img.shields.io/pub/v/sensitive_content_analysis.svg?style=popout&include_prereleases)](https://pub.dev/packages/sensitive_content_analysis) -Dart package for interacting with Apple's +Flutter package for interacting with Apple's [SensitiveContentAnalysis Framework](https://developer.apple.com/documentation/sensitivecontentanalysis). #### Minimum requirements @@ -56,87 +57,71 @@ https://developer.apple.com/documentation/sensitivecontentanalysis/testing-your- ### Check Policy: ```dart -final sca = SensitiveContentAnalysis(); - -int? policy = await sca.checkPolicy(); -if (policy != null) { - return policy; -} +try { + AnalysisPolicy? policy = await sca.checkPolicy(); + _showResultDialog("Policy Check", "Policy: ${policy?.name}"); + + switch (policy) { + case .descriptiveInterventions: + debugPrint( + "⚠️ Descriptive interventions with richer guidance are suggested.", + ); + break; + case .simpleInterventions: + debugPrint("⚠️ Simple interventions (e.g. blurring) are suggested."); + break; + case .disabled: + default: + debugPrint( + "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings.", + ); + break; + } + } catch (e) { + _showResultDialog("Error", e.toString()); + } ``` -> **case disabled = 0** -> If disabled the framework doesn’t detect nudity. The system disables sensitive content analysis under any of the following conditions: -> - The app lacks the necessary com.apple.developer.sensitivecontentanalysis.client entitlement. -> - Neither the Sensitive Content Warning user preference nor the Communication Safety parental control in Screen Time are active. -> - The user disables the Sensitive Content Warnings toggle in your app’s Settings. - -> **case simpleInterventions = 1** -> simpleInterventions indicates that the user enables both of the following: -> - Sensitive Content Warnings user preference -> - Sensitive Content Warnings in your app’s settings -> -> When your app detects nudity under this policy, your app needs to: -> - Keep the intervention minimal by describing the issue briefly and updating your app’s UI unobstructively. For example, consider blurring and annotating the area that otherwise presents the sensitive content versus raising a new fullscreen alert. -> - Intervene on the receipt of sensitve content over the network but allow the app to transmit content over the network unchecked. - -> **case descriptiveInterventions = 2** -> descriptiveInterventions indicates that the user enables both of the following: -> - Communication Safety parental control in Screen Time -> - Sensitive Content Warnings in your app’s settings -> -> When your app detects nudity under this policy, your app needs to: -> - Use child-appropriate language, such as broadly understood vocabulary -> - Present an alert that fills the full screen. -> - Intervene on the receipt of sensitve content over a network and before transmitting sensitive content over a network. ### Analyze Image #### File Image: ```dart - try { - final sca = SensitiveContentAnalysis(); - final ImagePicker picker = ImagePicker(); + try { + final ImagePicker picker = ImagePicker(); - // Pick an image. - final XFile? image = await picker.pickImage(source: ImageSource.gallery); + // Pick an image. + final XFile? image = await picker.pickImage(source: ImageSource.gallery); - if (image != null) { + if (image != null) { Uint8List imageData = await image.readAsBytes(); // Analyze the image for sensitive content. - final bool? isSensitive = await sca.analyzeImage(imageData); - if (isSensitive != null) { - return isSensitive; - } else { - debugPrint("Enable ”Sensitive Content Warning” in Settings -> Privacy & Security."); - return null; - } + SensitivityAnalysisResult? isSensitive = + await sca.analyzeImage(imageData); + _showResultDialog( + "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + } + } catch (e) { + _showResultDialog("Error", e.toString()); } - } catch (e) { - return null; - } ``` #### Network Image: ```dart -final String? analyzeUrl = "https://docs-assets.developer.apple.com/published/517e263450/rendered2x-1685188934.png"; - try { - final sca = SensitiveContentAnalysis(); - - if (analyzeUrl != null) { - final bool? isSensitive = await sca.analyzeNetworkImage(url: analyzeUrl); - if (isSensitive != null) { - return isSensitive; - } else { - debugPrint("Enable ”Sensitive Content Warning” in Settings -> Privacy & Security."); - return null; - } + const url = + "https://docs-assets.developer.apple.com/published/517e263450/rendered2x-1685188934.png"; + + // Analyze the image for sensitive content. + SensitivityAnalysisResult? isSensitive = + await sca.analyzeNetworkImage(url: url); + _showResultDialog( + "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + } catch (e) { + _showResultDialog("Error", e.toString()); } - } catch (e) { - return null; - } ``` ### Analyze Video @@ -144,7 +129,6 @@ final String? analyzeUrl = "https://docs-assets.developer.apple.com/published/51 #### Network Video: ```dart - Future analyzeNetworkVideo() async { try { Dio dio = Dio(); Directory tempDir = await getTemporaryDirectory(); @@ -155,38 +139,60 @@ final String? analyzeUrl = "https://docs-assets.developer.apple.com/published/51 final response = await dio.download(url, file.path); if (response.statusCode == 200) { - bool? isSensitive = await sca.analyzeVideo(url: file.path); - debugPrint("SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = + await sca.analyzeVideo(url: file.path); + _showResultDialog( + "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); await file.delete(); } } catch (e) { - debugPrint(e.toString()); + _showResultDialog("Error", e.toString()); } - } ``` #### Local Video: ```dart - Future analyzeLocalVideo() async { try { - const XTypeGroup typeGroup = XTypeGroup( - label: 'video', - extensions: ['mp4', 'mkv', 'avi', 'mov'], + FilePickerResult? selectedFile = await FilePicker.pickFiles( + allowMultiple: false, + type: FileType.video, ); - final XFile? selectedFile = - await openFile(acceptedTypeGroups: [typeGroup]); - if (selectedFile != null) { - bool? isSensitive = await sca.analyzeVideo(url: selectedFile.path); - debugPrint("SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = + await sca.analyzeVideo(url: selectedFile.files.first.path!); + _showResultDialog( + "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); } } catch (e) { - debugPrint(e.toString()); + _showResultDialog("Error", e.toString()); } - } ``` +#### Video Stream: +> [!NOTE] +> Please refer to the [`video_stream_analyzer`](example/lib/video_stream_analyzer.dart) example. + +### Result + +All `analyze` methods return a `SensitivityAnalysisResult` object: + +```dart +class SensitivityAnalysisResult { + final bool isSensitive; + final List detectedTypes; + final bool shouldIndicateSensitivity; + final bool shouldInterruptVideo; + final bool shouldMuteAudio; +} +``` + +- `isSensitive`: whether the content was flagged as sensitive +- `detectedTypes`: list of detected content categories, e.g. `"sexuallyExplicit"` or `"goreOrViolence"`. Empty on devices running below iOS 27+/ipadOS 27+/macOS 27+. +- `shouldIndicateSensitivity`: App should indicate the presence of sensitive content to the user. Available on iOS/ipadOS 26+ always false on older OS versions +- `shouldInterruptVideo`: App should interrupt video playback. Available on iOS/ipadOS 26+ always false on older OS versions. +- `shouldMuteAudio`: App should mute the audio of the current video stream. Available on iOS/ipadOS 26+ always false on older OS versions. + --- ### Caveats diff --git a/darwin/sensitive_content_analysis/sensitive_content_analysis.podspec b/darwin/sensitive_content_analysis.podspec similarity index 89% rename from darwin/sensitive_content_analysis/sensitive_content_analysis.podspec rename to darwin/sensitive_content_analysis.podspec index ed02976..7ea18a0 100644 --- a/darwin/sensitive_content_analysis/sensitive_content_analysis.podspec +++ b/darwin/sensitive_content_analysis.podspec @@ -14,8 +14,7 @@ A new Flutter plugin project supporting Sensitive Content Analysis. s.author = { 'Your Company' => 'email@example.com' } s.source = { :path => '.' } - s.source_files = 'Sources/sensitive_content_analysis/**/*' - s.public_header_files = 'Sources/sensitive_content_analysis/**/*.h' + s.source_files = 'sensitive_content_analysis/Sources/sensitive_content_analysis/**/*' s.swift_version = '5.5' s.ios.deployment_target = '13.0' diff --git a/darwin/sensitive_content_analysis/.swiftpm/xcode/xcuserdata/jonafeucht.xcuserdatad/xcschemes/xcschememanagement.plist b/darwin/sensitive_content_analysis/.swiftpm/xcode/xcuserdata/jonafeucht.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..53c5146 --- /dev/null +++ b/darwin/sensitive_content_analysis/.swiftpm/xcode/xcuserdata/jonafeucht.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + sensitive-content-analysis.xcscheme_^#shared#^_ + + orderHint + 10 + + + + diff --git a/darwin/sensitive_content_analysis/Package.swift b/darwin/sensitive_content_analysis/Package.swift index c142b34..0055371 100644 --- a/darwin/sensitive_content_analysis/Package.swift +++ b/darwin/sensitive_content_analysis/Package.swift @@ -3,31 +3,32 @@ import PackageDescription let package = Package( - name: "sensitive_content_analysis", - platforms: [ - .iOS("13.0"), - .macOS("12.0") - ], - products: [ - .library( - name: "sensitive-content-analysis", targets: ["sensitive_content_analysis"] - ) - ], - dependencies: [ - .package(name: "FlutterFramework", path: "../FlutterFramework"), - ], - targets: [ - .target( - name: "sensitive_content_analysis", - dependencies: [ - .product(name: "FlutterFramework", package: "FlutterFramework"), - ], - resources: [], - linkerSettings: [ - .linkedFramework("Flutter", .when(platforms: [.iOS])), - .linkedFramework("FlutterMacOS", .when(platforms: [.macOS])), - .linkedFramework("SensitiveContentAnalysis", .when(platforms: [.iOS, .macOS])) - ] - ) - ] -) \ No newline at end of file + name: "sensitive_content_analysis", + platforms: [ + .iOS("13.0"), + .macOS("12.0"), + ], + products: [ + .library( + name: "sensitive-content-analysis", targets: ["sensitive_content_analysis"] + ) + ], + dependencies: [ + .package(path: "../FlutterFramework") + ], + targets: [ + .target( + name: "sensitive_content_analysis", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + path: ".", + resources: [], + linkerSettings: [ + .linkedFramework("Flutter", .when(platforms: [.iOS])), + .linkedFramework("FlutterMacOS", .when(platforms: [.macOS])), + .linkedFramework("SensitiveContentAnalysis", .when(platforms: [.iOS, .macOS])), + ] + ) + ] +) diff --git a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.h b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.h deleted file mode 100644 index 49d4ac6..0000000 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface SensitiveContentAnalysisPlugin : NSObject -@end \ No newline at end of file diff --git a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift index 6963c45..7c5dda3 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -1,257 +1,690 @@ +import CoreVideo +import ImageIO + #if canImport(SensitiveContentAnalysis) - import SensitiveContentAnalysis + import SensitiveContentAnalysis #endif #if os(iOS) - import Flutter - import UIKit + import Flutter + import UIKit #else - import FlutterMacOS - import AppKit + import FlutterMacOS + import AppKit #endif -@MainActor -@objc public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { +public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - #if os(iOS) - let messenger = registrar.messenger() - #else - let messenger = registrar.messenger - #endif + private let messenger: FlutterBinaryMessenger - let channel = FlutterMethodChannel( - name: "sensitive_content_analysis", - binaryMessenger: messenger - ) + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + public static func register(with registrar: FlutterPluginRegistrar) { + #if os(iOS) + let messenger = registrar.messenger() + #else + let messenger = registrar.messenger + #endif + + let channel = FlutterMethodChannel( + name: "sensitive_content_analysis", + binaryMessenger: messenger + ) + + let instance = SensitiveContentAnalysisPlugin(messenger: messenger) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + // MARK: - SCSensitivityAnalyzer - let instance = SensitiveContentAnalysisPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) + private var _analyzer: Any? + + @available(iOS 17.0, macOS 14.0, *) + private var analyzer: SCSensitivityAnalyzer { + if let existing = _analyzer as? SCSensitivityAnalyzer { + return existing } - private var _analyzer: Any? + let newAnalyzer = SCSensitivityAnalyzer() + _analyzer = newAnalyzer + return newAnalyzer + } - @available(iOS 17.0, macOS 14.0, *) - private var analyzer: SCSensitivityAnalyzer { - if let existing = _analyzer as? SCSensitivityAnalyzer { - return existing - } + // MARK: - SCVideoStreamAnalyzer + + #if os(iOS) + private var _streamAnalyzers: [String: (analyzer: Any, direction: Int)] = [:] + private var _streamTasks: [String: Any] = [:] + private var _streamChannels: [String: FlutterEventChannel] = [:] - let newAnalyzer = SCSensitivityAnalyzer() - _analyzer = newAnalyzer - return newAnalyzer + @available(iOS 26.0, *) + private func streamAnalyzer(for uuid: String) -> SCVideoStreamAnalyzer? { + return _streamAnalyzers[uuid]?.analyzer as? SCVideoStreamAnalyzer } - private func cgImage(from data: Data) -> CGImage? { - #if os(iOS) - return UIImage(data: data)?.cgImage - #elseif os(macOS) - guard let nsImage = NSImage(data: data) else { return nil } - var imageRect = CGRect( - x: 0, y: 0, width: nsImage.size.width, height: nsImage.size.height) - return nsImage.cgImage( - forProposedRect: &imageRect, - context: nil, - hints: nil - ) - #endif + @available(iOS 26.0, *) + private func resetAndRecreateAnalyzer(for participantUUID: String) { + guard let config = _streamAnalyzers[participantUUID], + let oldAnalyzer = config.analyzer as? SCVideoStreamAnalyzer + else { + return + } + + let directionRaw = config.direction + let direction: SCVideoStreamAnalyzer.StreamDirection = + directionRaw == 0 ? .outgoing : .incoming + + print("🔄 [Native] Hard resetting SCVideoStreamAnalyzer to clear internal frame cache...") + oldAnalyzer.endAnalysis() + + do { + let newAnalyzer = try SCVideoStreamAnalyzer( + participantUUID: participantUUID, + streamDirection: direction + ) + + _streamAnalyzers[participantUUID] = (analyzer: newAnalyzer, direction: directionRaw) + + if let handler = _streamTasks[participantUUID] as? VideoStreamEventHandler { + handler.updateAnalyzer(newAnalyzer) + } + } catch { + print("❌ [Native] Failed to recreate analyzer: \(error.localizedDescription)") + } + } + #endif + + // MARK: - Helpers + + private func cgImage(from data: Data) -> CGImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + @available(iOS 17.0, macOS 14.0, *) + private func formatAnalysisResult(_ analysisResult: SCSensitivityAnalysis) -> [String: Any] { + var detectedTypesArray: [String] = [] + + #if compiler(>=6.4) + if #available(iOS 27.0, macOS 27.0, *) { + if analysisResult.isSensitive { + if analysisResult.detectedTypes.contains(.sexuallyExplicit) { + detectedTypesArray.append("sexuallyExplicit") + } + if analysisResult.detectedTypes.contains(.goreOrViolence) { + detectedTypesArray.append("goreOrViolence") + } + } + } + #endif + + var shouldIndicateSensitivity = false + var shouldInterruptVideo = false + var shouldMuteAudio = false + + #if compiler(>=6.2) + if #available(iOS 26.0, macOS 26.0, *) { + shouldIndicateSensitivity = analysisResult.shouldIndicateSensitivity + shouldInterruptVideo = analysisResult.shouldInterruptVideo + shouldMuteAudio = analysisResult.shouldMuteAudio + } + #endif + + return [ + "isSensitive": analysisResult.isSensitive, + "detectedTypes": detectedTypesArray, + "shouldIndicateSensitivity": shouldIndicateSensitivity, + "shouldInterruptVideo": shouldInterruptVideo, + "shouldMuteAudio": shouldMuteAudio, + ] + } + + private actor AnalysisQueue { + private var running = 0 + private let maxConcurrent = 3 + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if running < maxConcurrent { + running += 1 + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } } - @available(iOS 17.0, macOS 14.0, *) - private func analyzeImage( - image: FlutterStandardTypedData, - result: @escaping FlutterResult - ) { - let analyzer = self.analyzer + func release() { + if !waiters.isEmpty { + let next = waiters.removeFirst() + next.resume() + } else { + running = max(0, running - 1) + } + } + } - Task(priority: .userInitiated) { - guard analyzer.analysisPolicy != .disabled else { - await MainActor.run { result(nil) } - return - } + private let queue = AnalysisQueue() - guard let cgImage = cgImage(from: image.data) else { - await MainActor.run { result(nil) } - return - } + @available(iOS 17.0, macOS 14.0, *) + private func analyzeImage( + image: FlutterStandardTypedData, + result: @escaping FlutterResult + ) { + Task(priority: .userInitiated) { + let analyzer = await MainActor.run { self.analyzer } - do { - let analysisResult = try await analyzer.analyzeImage(cgImage) + guard analyzer.analysisPolicy != .disabled else { + await MainActor.run { result(nil) } + return + } - await MainActor.run { - result(analysisResult.isSensitive) - } - } catch { - await MainActor.run { - result( - FlutterError( - code: "analysis_error", - message: "Failed to analyze image", - details: error.localizedDescription - ) - ) - } - } + guard let cgImage = cgImage(from: image.data) else { + await MainActor.run { result(nil) } + return + } + + do { + let analysisResult = try await analyzer.analyzeImage(cgImage) + + await MainActor.run { + result(self.formatAnalysisResult(analysisResult)) + } + } catch { + await MainActor.run { + result( + FlutterError( + code: "analysis_error", + message: "Failed to analyze image", + details: error.localizedDescription + ) + ) + } + } + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func analyzeVideo( + at fileURL: URL, + result: @escaping FlutterResult + ) { + Task(priority: .userInitiated) { + let analyzer = await MainActor.run { self.analyzer } + + guard analyzer.analysisPolicy != .disabled else { + await MainActor.run { result(nil) } + return + } + + do { + let handler = analyzer.videoAnalysis(forFileAt: fileURL) + let analysisResult = try await handler.hasSensitiveContent() + + await MainActor.run { + result(self.formatAnalysisResult(analysisResult)) } + } catch { + await MainActor.run { + result( + FlutterError( + code: "analysis_error", + message: "Failed to analyze video", + details: error.localizedDescription + ) + ) + } + } } + } + + @available(iOS 17.0, macOS 14.0, *) + private func analyzeNetworkImage( + at url: URL, + result: @escaping FlutterResult + ) { + Task(priority: .userInitiated) { + await queue.acquire() + defer { Task { await queue.release() } } + let analyzer = await MainActor.run { self.analyzer } + + guard analyzer.analysisPolicy != .disabled else { + await MainActor.run { result(nil) } + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + guard let cgImage = cgImage(from: data) else { + await MainActor.run { result(nil) } + return + } - @available(iOS 17.0, macOS 14.0, *) - private func analyzeVideo( - at fileURL: URL, - result: @escaping FlutterResult - ) { - let analyzer = self.analyzer + let analysisResult = try await analyzer.analyzeImage(cgImage) - Task(priority: .userInitiated) { - guard analyzer.analysisPolicy != .disabled else { - await MainActor.run { result(nil) } - return - } + await MainActor.run { + result(self.formatAnalysisResult(analysisResult)) + } + } catch { + await MainActor.run { + result( + FlutterError( + code: "analysis_error", + message: "Failed to analyze network image", + details: error.localizedDescription + ) + ) + } + } + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func checkPolicy(result: @escaping FlutterResult) { + Task { @MainActor in + let analyzer = self.analyzer + switch analyzer.analysisPolicy { + case .disabled: + result(0) + case .simpleInterventions: + result(1) + case .descriptiveInterventions: + result(2) + @unknown default: + result( + FlutterError( + code: "unknown_policy", + message: "Unrecognized AnalysisPolicy value", + details: nil + ) + ) + } + } + } + + #if os(iOS) + // MARK: - SCVideoStreamAnalyzer lifecycle + + /// Creates an SCVideoStreamAnalyzer for a participant and begins + /// forwarding `analysisChanges` events back to Dart via the event sink. + /// + /// Arguments (Map): + /// "participantUUID" : String – stable ID for this stream + /// "streamDirection" : Int – 0 = outgoing, 1 = incoming + /// "eventChannelName": String – name of the FlutterEventChannel to send updates on + + @available(iOS 26.0, *) + private func createVideoStreamAnalyzer( + args: [String: Any], + result: @escaping FlutterResult, + ) { + guard + let participantUUID = args["participantUUID"] as? String, + let directionRaw = args["streamDirection"] as? Int, + let eventChannelName = args["eventChannelName"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: + "Expected participantUUID (String), streamDirection (Int), eventChannelName (String)", + details: nil + )) + return + } + + let direction: SCVideoStreamAnalyzer.StreamDirection = + directionRaw == 0 ? .outgoing : .incoming + + do { + let streamAnalyzer = try SCVideoStreamAnalyzer( + participantUUID: participantUUID, + streamDirection: direction + ) - do { - let handler = analyzer.videoAnalysis(forFileAt: fileURL) - let analysisResult = try await handler.hasSensitiveContent() + _streamAnalyzers[participantUUID] = (analyzer: streamAnalyzer, direction: directionRaw) - await MainActor.run { - result(analysisResult.isSensitive) - } - } catch { - await MainActor.run { - result( - FlutterError( - code: "analysis_error", - message: "Failed to analyze video", - details: error.localizedDescription - ) - ) - } - } - } + let handler = VideoStreamEventHandler( + streamAnalyzer: streamAnalyzer, + formatResult: { [weak self] analysis -> [String: Any]? in + guard let self else { return nil } + return self.formatAnalysisResult(analysis) + } + ) + let eventChannel = FlutterEventChannel( + name: eventChannelName, + binaryMessenger: self.messenger + ) + eventChannel.setStreamHandler(handler) + + _streamTasks[participantUUID] = handler + _streamChannels[participantUUID] = eventChannel + + result(nil) + } catch { + result( + FlutterError( + code: "stream_analyzer_init_error", + message: + "Failed to create SCVideoStreamAnalyzer — ensure Communication Safety or Sensitive Content Warnings is enabled", + details: error.localizedDescription + )) + } } - @available(iOS 17.0, macOS 14.0, *) - private func analyzeNetworkImage( - at url: URL, - result: @escaping FlutterResult + /// Passes a raw CVPixelBuffer (sent as BGRA bytes + width + height) to + /// `analyze(_:)` for apps that decode their own video stream. + /// + /// Arguments (Map): + /// "participantUUID": String + /// "bytes" : FlutterStandardTypedData – BGRA pixel data + /// "width" : Int + /// "height" : Int + @available(iOS 26.0, *) + private func analyzeVideoStreamFrame( + args: [String: Any], + result: @escaping FlutterResult ) { - let analyzer = self.analyzer + guard + let participantUUID = args["participantUUID"] as? String, + let typedData = args["bytes"] as? FlutterStandardTypedData, + let width = args["width"] as? Int, + let height = args["height"] as? Int, + let bytesPerRow = args["bytesPerRow"] as? Int, + let streamAnalyzer = streamAnalyzer(for: participantUUID) + else { + result( + FlutterError( + code: "invalid_arguments", message: "Missing required arguments", details: nil)) + return + } + + let data = typedData.data + + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_32BGRA, + nil, + &pixelBuffer + ) + + guard status == kCVReturnSuccess, let buffer = pixelBuffer else { + result( + FlutterError( + code: "pixel_buffer_error", message: "Failed to create CVPixelBuffer", + details: "Status: \(status)")) + return + } + + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + + if let dest = CVPixelBufferGetBaseAddress(buffer) { + let destBytesPerRow = CVPixelBufferGetBytesPerRow(buffer) + let srcBytesPerRow = bytesPerRow + + data.withUnsafeBytes { srcPtr in + guard let src = srcPtr.baseAddress else { return } + for y in 0.. [String: Any]? + private var monitorTask: Task? + private var currentSink: FlutterEventSink? + + init( + streamAnalyzer: SCVideoStreamAnalyzer, + formatResult: @escaping (SCSensitivityAnalysis) -> [String: Any]? + ) { + self.streamAnalyzer = streamAnalyzer + self.formatResult = formatResult + } + + func updateAnalyzer(_ newAnalyzer: SCVideoStreamAnalyzer) { + self.streamAnalyzer = newAnalyzer + if let sink = currentSink { + startMonitoring(events: sink) + } + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + self.currentSink = events + startMonitoring(events: events) + return nil + } + + private func startMonitoring(events: @escaping FlutterEventSink) { + monitorTask?.cancel() + + var iterator = streamAnalyzer.analysisChanges.makeAsyncIterator() + + let safeState: [String: Any] = [ + "isSensitive": false, + "detectedTypes": [String](), + "shouldIndicateSensitivity": false, + "shouldInterruptVideo": false, + "shouldMuteAudio": false, + ] + events(safeState) + + monitorTask = Task { + do { + while let analysis = try await iterator.next() { + guard !Task.isCancelled else { break } + + if let formatted = formatResult(analysis) { await MainActor.run { - result(analysisResult.isSensitive) - } - } catch { - await MainActor.run { - result( - FlutterError( - code: "analysis_error", - message: "Failed to analyze network image", - details: error.localizedDescription - ) - ) + events(formatted) } + } } + } catch { + print("❌ [Native] analysisChanges error: \(error.localizedDescription)") + } } + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + print("🛑 [Native] onCancel called for stream analyzer") + monitorTask?.cancel() + monitorTask = nil + currentSink = nil + return nil + } } - @available(iOS 17.0, macOS 14.0, *) - private func checkPolicy(result: @escaping FlutterResult) { - switch analyzer.analysisPolicy { - case .disabled: - result(0) - case .simpleInterventions: - result(1) - case .descriptiveInterventions: - result(2) - @unknown default: - result( - FlutterError( - code: "unknown_policy", - message: "Unrecognized AnalysisPolicy value", - details: nil - ) - ) - } - } + #endif - public func handle( - _ call: FlutterMethodCall, - result: @escaping FlutterResult - ) { - guard #available(iOS 17.0, macOS 14.0, *) else { - result(FlutterMethodNotImplemented) - return - } + // MARK: - FlutterPlugin handle - switch call.method { - - case "analyzeImage": - guard let image = call.arguments as? FlutterStandardTypedData else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected FlutterStandardTypedData for image", - details: nil - ) - ) - return - } - analyzeImage(image: image, result: result) - - case "analyzeVideo": - guard - let args = call.arguments as? [String: Any], - let urlString = args["url"] as? String, - let url = URL(string: urlString) - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected a valid 'url' string argument", - details: nil - ) - ) - return - } - analyzeVideo(at: url, result: result) - - case "analyzeNetworkImage": - guard - let args = call.arguments as? [String: Any], - let urlString = args["url"] as? String, - let url = URL(string: urlString) - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected a valid 'url' string argument", - details: nil - ) - ) - return - } - analyzeNetworkImage(at: url, result: result) + public func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard #available(iOS 17.0, macOS 14.0, *) else { + result(FlutterMethodNotImplemented) + return + } - case "checkPolicy": - checkPolicy(result: result) + switch call.method { - default: + case "analyzeImage": + guard let image = call.arguments as? FlutterStandardTypedData else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected FlutterStandardTypedData for image", + details: nil + ) + ) + return + } + analyzeImage(image: image, result: result) + + case "analyzeVideo": + guard + let args = call.arguments as? [String: Any], + let filePath = args["url"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a valid 'url' string argument", + details: nil + ) + ) + return + } + + let url = URL(fileURLWithPath: filePath) + analyzeVideo(at: url, result: result) + + case "analyzeNetworkImage": + guard + let args = call.arguments as? [String: Any], + let urlString = args["url"] as? String, + let url = URL(string: urlString) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a valid 'url' string argument", + details: nil + ) + ) + return + } + analyzeNetworkImage(at: url, result: result) + + case "checkPolicy": + checkPolicy(result: result) + + case "createVideoStreamAnalyzer", + "analyzeVideoStreamFrame", + "continueVideoStream", + "endVideoStreamAnalysis": + #if compiler(>=6.2) && os(iOS) + if #available(iOS 26.0, *) { + guard let args = call.arguments as? [String: Any] else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a Map argument", + details: nil + )) + return + } + + switch call.method { + case "createVideoStreamAnalyzer": + createVideoStreamAnalyzer(args: args, result: result) + case "analyzeVideoStreamFrame": + analyzeVideoStreamFrame(args: args, result: result) + case "continueVideoStream": + continueVideoStream(args: args, result: result) + case "endVideoStreamAnalysis": + endVideoStreamAnalysis(args: args, result: result) + default: result(FlutterMethodNotImplemented) + } + } else { + result(FlutterMethodNotImplemented) } + #else + result(FlutterMethodNotImplemented) + #endif + + default: + result(FlutterMethodNotImplemented) } + } } diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index ec97fc6..592ceee 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig index c4855bf..592ceee 100644 --- a/example/ios/Flutter/Release.xcconfig +++ b/example/ios/Flutter/Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile deleted file mode 100644 index 4871635..0000000 --- a/example/ios/Podfile +++ /dev/null @@ -1,44 +0,0 @@ -# Uncomment this line to define a global platform for your project -platform :ios, '15.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock deleted file mode 100644 index c4ae4f7..0000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,23 +0,0 @@ -PODS: - - Flutter (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - -DEPENDENCIES: - - Flutter (from `Flutter`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - -SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 - -PODFILE CHECKSUM: 9c46fd01abff66081b39f5fa5767b3f1d0b11d76 - -COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 71dbb3c..b8e980c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -15,8 +15,6 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B52200AC350B45639D4A8255 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 240C9B7B164EFAC11FD12018 /* Pods_RunnerTests.framework */; }; - BA71F0ED51A027C7FB44BBF6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244CED211C858B5AA8AC3E14 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,23 +43,15 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 240C9B7B164EFAC11FD12018 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 244CED211C858B5AA8AC3E14 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 418085546E28C88484BE7028 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 56D9EEAC50ABB2CA2200B2F0 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 6655F3E930AD6D7B22732ABA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; - 78DABEA22ED26510000E7860 /* sensitive_content_analysis */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = sensitive_content_analysis; path = ../../ios/sensitive_content_analysis; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* sensitive_content_analysis */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = sensitive_content_analysis; path = ../../darwin/sensitive_content_analysis; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7EB008A721187624F840D95F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 830E9F5EECBEBE4BE89C5025 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 8BDFD6F2399343487621EC2E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -77,7 +67,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B52200AC350B45639D4A8255 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -86,7 +75,6 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - BA71F0ED51A027C7FB44BBF6 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -101,24 +89,9 @@ path = RunnerTests; sourceTree = ""; }; - 3BE7B3DD1495D20D149F214B /* Frameworks */ = { - isa = PBXGroup; - children = ( - 244CED211C858B5AA8AC3E14 /* Pods_Runner.framework */, - 240C9B7B164EFAC11FD12018 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 87FE9446974C5F6AE975833C /* Pods */ = { isa = PBXGroup; children = ( - 418085546E28C88484BE7028 /* Pods-Runner.debug.xcconfig */, - 8BDFD6F2399343487621EC2E /* Pods-Runner.release.xcconfig */, - 6655F3E930AD6D7B22732ABA /* Pods-Runner.profile.xcconfig */, - 56D9EEAC50ABB2CA2200B2F0 /* Pods-RunnerTests.debug.xcconfig */, - 7EB008A721187624F840D95F /* Pods-RunnerTests.release.xcconfig */, - 830E9F5EECBEBE4BE89C5025 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -145,7 +118,6 @@ 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 87FE9446974C5F6AE975833C /* Pods */, - 3BE7B3DD1495D20D149F214B /* Frameworks */, ); sourceTree = ""; }; @@ -181,7 +153,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 677C4B0E13348E208FD78788 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 05509ADE118F2DF4F289742C /* Frameworks */, @@ -200,14 +171,12 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 34FB7EF5107F14C31A66B3ED /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - F7356EA80015DDD1F19A10D5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -285,28 +254,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 34FB7EF5107F14C31A66B3ED /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -323,28 +270,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 677C4B0E13348E208FD78788 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -360,23 +285,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - F7356EA80015DDD1F19A10D5 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -503,7 +411,6 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 56D9EEAC50ABB2CA2200B2F0 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -521,7 +428,6 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7EB008A721187624F840D95F /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -537,7 +443,6 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 830E9F5EECBEBE4BE89C5025 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/example/lib/home_feed.dart b/example/lib/home_feed.dart new file mode 100644 index 0000000..266dfa4 --- /dev/null +++ b/example/lib/home_feed.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +// ignore: depend_on_referenced_packages +import 'package:http/http.dart' as http; +import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; +import 'package:sensitive_content_analysis_example/main.dart'; + +class FeedScreen extends StatelessWidget { + const FeedScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('NSFW Feed', style: TextStyle(fontWeight: .bold)), + ), + body: AuthenticatedAlbumFeed(), + ); + } +} + +class AuthenticatedAlbumFeed extends StatefulWidget { + const AuthenticatedAlbumFeed({super.key}); + + @override + State createState() => _AuthenticatedAlbumFeedState(); +} + +class _AuthenticatedAlbumFeedState extends State { + late Future> _albumImagesFuture; + + @override + void initState() { + super.initState(); + _albumImagesFuture = fetchAlbumImages(); + } + + Future> fetchAlbumImages() async { + try { + final Uri url = Uri.parse( + 'https://api.waifu.im/images?isNsfw=All&orderBy=Random&page=1&pageSize=30', + ); + + final response = await http.get(url); + + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body); + return data['items'] ?? []; + } else { + debugPrint('API Error: ${response.reasonPhrase}'); + } + } catch (e) { + debugPrint('Network error fetching album: $e'); + } + return []; + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _albumImagesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == .waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) { + return const Center( + child: Text('No images found or configuration invalid.'), + ); + } + + final imagesList = snapshot.data!; + + return Align( + alignment: .topCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 500), + child: ListView.builder( + itemCount: imagesList.length, + itemBuilder: (context, index) { + final imageItem = imagesList[index]; + return AlbumFeedPost(imageData: imageItem, index: index); + }, + ), + ), + ); + }, + ); + } +} + +class AlbumFeedPost extends StatefulWidget { + final Map imageData; + final int index; + const AlbumFeedPost({ + super.key, + required this.imageData, + required this.index, + }); + + static final Map> _analysisCache = {}; + + @override + State createState() => _AlbumFeedPostState(); +} + +class _AlbumFeedPostState extends State { + bool _isNsfw = false; + bool _isBlurred = false; + bool _hasLoaded = false; + + @override + void initState() { + super.initState(); + analyzeImage(); + } + + Future analyzeImage() async { + final String? url = widget.imageData["url"]; + if (url == null || url.isEmpty) { + if (mounted) setState(() => _hasLoaded = true); + return; + } + + if (AlbumFeedPost._analysisCache.containsKey(url)) { + debugPrint("Fetching $url from cache."); + final cachedData = AlbumFeedPost._analysisCache[url]!; + if (mounted) { + setState(() { + _isNsfw = cachedData['isNsfw'] ?? false; + _isBlurred = cachedData['isBlurred'] ?? false; + _hasLoaded = true; + }); + } + return; + } + + try { + SensitivityAnalysisResult? isSensitive = await sca.analyzeNetworkImage( + url: url, + ); + + debugPrint(isSensitive?.isSensitive.toString()); + debugPrint(isSensitive?.detectedTypes.toString()); + + final bool isNsfw = isSensitive?.isSensitive ?? false; + + AlbumFeedPost._analysisCache[url] = { + 'isNsfw': isNsfw, + 'isBlurred': isNsfw, + }; + + if (mounted) { + setState(() { + _isNsfw = isNsfw; + _isBlurred = isNsfw; + }); + } + } catch (e, st) { + debugPrint(e.toString()); + debugPrint(st.toString()); + + AlbumFeedPost._analysisCache[url] = { + 'isNsfw': false, + 'isBlurred': false, + }; + } finally { + if (mounted) setState(() => _hasLoaded = true); + } + } + + @override + Widget build(BuildContext context) { + final String imageUrl = widget.imageData['url'] ?? ''; + + return Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Row( + children: [ + const CircleAvatar(radius: 16, backgroundColor: Colors.indigo), + const SizedBox(width: 10), + Text('creator', style: const TextStyle(fontWeight: .bold)), + ], + ), + ), + GestureDetector( + onTap: () { + if (!_isNsfw) return; + setState(() => _isBlurred = !_isBlurred); + }, + child: Stack( + alignment: .center, + children: [ + _hasLoaded + ? AspectRatio( + aspectRatio: 1, + child: CachedNetworkImage( + imageUrl: imageUrl, + width: .infinity, + height: 440, + fit: .cover, + ), + ) + : Center(child: CircularProgressIndicator()), + if (_isBlurred) ...[ + Positioned.fill( + child: ClipRect( + child: BackdropFilter( + filter: .blur(sigmaX: 100, sigmaY: 100), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + ), + ), + ), + ), + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.4), + child: const Column( + mainAxisAlignment: .center, + children: [ + Icon(Icons.lock_outline, color: Colors.white, size: 40), + SizedBox(height: 10), + Text( + 'Image Content Restricted\nTap to show the image', + textAlign: .center, + style: TextStyle( + color: Colors.white, + fontWeight: .bold, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.favorite_border), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.bookmark_border), + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 12), + ], + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 70c7575..aef13d3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,8 +7,13 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; import 'package:path/path.dart' as p; +import 'package:sensitive_content_analysis_example/home_feed.dart'; +import 'package:sensitive_content_analysis_example/video_stream_analyzer.dart'; + +late final SensitiveContentAnalysis sca; void main() { + sca = SensitiveContentAnalysis(); runApp(MaterialApp(theme: ThemeData.dark(), home: MyApp())); } @@ -20,21 +25,24 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final sca = SensitiveContentAnalysis(); - Future analyzeImage() async { try { final ImagePicker picker = ImagePicker(); // Pick an image. - final XFile? image = await picker.pickImage(source: ImageSource.gallery); + final XFile? image = await picker.pickImage(source: .gallery); if (image != null) { Uint8List imageData = await image.readAsBytes(); // Analyze the image for sensitive content. - bool? isSensitive = await sca.analyzeImage(imageData); - _showResultDialog("Analysis Result", "SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = await sca.analyzeImage( + imageData, + ); + _showResultDialog( + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } } catch (e) { _showResultDialog("Error", e.toString()); @@ -47,8 +55,13 @@ class _MyAppState extends State { "https://docs-assets.developer.apple.com/published/517e263450/rendered2x-1685188934.png"; // Analyze the image for sensitive content. - bool? isSensitive = await sca.analyzeNetworkImage(url: url); - _showResultDialog("Analysis Result", "SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = await sca.analyzeNetworkImage( + url: url, + ); + _showResultDialog( + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } catch (e) { _showResultDialog("Error", e.toString()); } @@ -61,12 +74,34 @@ class _MyAppState extends State { const url = "https://developer.apple.com/sample-code/web/qr-sca.mov"; final videoName = p.basename(url); - final file = File("${tempDir.path}/$videoName"); - final response = await dio.download(url, file.path); + final file = File(p.join(tempDir.path, videoName)); + if (await file.exists()) await file.delete(); + debugPrint(file.path); + + final response = await dio.download( + url, + file.path, + onReceiveProgress: (received, total) { + if (total != -1) { + double progress = received / total; + int percentage = (progress * 100).toInt(); + + debugPrint("Download progress: $percentage%"); + } else { + debugPrint("Downloaded $received bytes (Total size unknown)"); + } + }, + ); if (response.statusCode == 200) { - bool? isSensitive = await sca.analyzeVideo(url: file.path); - _showResultDialog("Analysis Result", "SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = await sca.analyzeVideo( + url: file.path, + ); + + _showResultDialog( + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); await file.delete(); } } catch (e) { @@ -78,12 +113,16 @@ class _MyAppState extends State { try { FilePickerResult? selectedFile = await FilePicker.pickFiles( allowMultiple: false, - type: FileType.video, + type: .video, ); if (selectedFile != null) { - bool? isSensitive = - await sca.analyzeVideo(url: selectedFile.files.first.path!); - _showResultDialog("Analysis Result", "SENSITIVE: $isSensitive"); + SensitivityAnalysisResult? isSensitive = await sca.analyzeVideo( + url: selectedFile.files.first.path!, + ); + _showResultDialog( + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } } catch (e) { _showResultDialog("Error", e.toString()); @@ -92,8 +131,25 @@ class _MyAppState extends State { Future checkPolicy() async { try { - int? policy = await sca.checkPolicy(); - _showResultDialog("Policy Check", "Policy: $policy"); + AnalysisPolicy? policy = await sca.checkPolicy(); + _showResultDialog("Policy Check", "Policy: ${policy?.name}"); + + switch (policy) { + case .descriptiveInterventions: + debugPrint( + "⚠️ Descriptive interventions with richer guidance are suggested.", + ); + break; + case .simpleInterventions: + debugPrint("⚠️ Simple interventions (e.g. blurring) are suggested."); + break; + case .disabled: + default: + debugPrint( + "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings.", + ); + break; + } } catch (e) { _showResultDialog("Error", e.toString()); } @@ -108,28 +164,43 @@ class _MyAppState extends State { title: const Text('Plugin example app'), ), body: Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton( - onPressed: () async => await analyzeImage(), - child: const Text("Select Image."), - ), - TextButton( - onPressed: () async => await analyzeNetworkImage(), - child: const Text("Select Network Image."), - ), - TextButton( - onPressed: () async => await analyzeNetworkVideo(), - child: const Text("Analyze Downloaded Video."), - ), - TextButton( - onPressed: () async => await analyzeLocalVideo(), - child: const Text("Analyze Selected Video."), - ), - TextButton( - onPressed: () async => await checkPolicy(), - child: const Text("Check Policy."), - ), - ]), + child: Column( + mainAxisAlignment: .center, + children: [ + TextButton( + onPressed: () async => await analyzeImage(), + child: const Text("Select Image."), + ), + TextButton( + onPressed: () async => await analyzeNetworkImage(), + child: const Text("Select Network Image."), + ), + TextButton( + onPressed: () async => await analyzeNetworkVideo(), + child: const Text("Analyze Downloaded Video."), + ), + TextButton( + onPressed: () async => await analyzeLocalVideo(), + child: const Text("Analyze Selected Video."), + ), + TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CameraSensitiveAnalysisScreen( + participantUUID: "participantUUIDExample", + ), + ), + ), + child: const Text("Analyze Camera Stream."), + ), + TextButton( + onPressed: () async => await checkPolicy(), + child: const Text("Check Policy."), + ), + warningWidget(), + ], + ), ), ); } @@ -151,4 +222,116 @@ class _MyAppState extends State { }, ); } + + TextButton warningWidget() { + return TextButton( + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: .circular(24.0)), + elevation: 10, + backgroundColor: Theme.of(context).cardColor, + child: Padding( + padding: const .all(24.0), + child: Column( + mainAxisSize: .min, + children: [ + Container( + padding: const .all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: .1), + shape: .circle, + ), + child: const Text( + "18+", + style: TextStyle( + fontSize: 28, + fontWeight: .bold, + color: Colors.redAccent, + ), + ), + ), + const SizedBox(height: 20), + + // Title + const Text( + "Age Verification Required", + textAlign: .center, + style: TextStyle( + fontSize: 20, + fontWeight: .bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + + Text( + "The home feed contains content intended for mature audiences. Please verify that you are 18 years or older to proceed.", + textAlign: .center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + height: 1.4, + ), + ), + const SizedBox(height: 28), + + SizedBox( + width: .infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: .circular(12), + ), + elevation: 0, + ), + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const FeedScreen(), + ), + ); + }, + child: const Text( + "I am 18 or older", + style: TextStyle(fontSize: 16, fontWeight: .w600), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: .infinity, + height: 44, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.grey[600], + shape: RoundedRectangleBorder( + borderRadius: .circular(12), + ), + ), + onPressed: () => Navigator.pop(context), + child: const Text( + "Go Back", + style: TextStyle(fontSize: 15, fontWeight: .w500), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + child: const Text("Home feed example."), + ); + } } diff --git a/example/lib/video_stream_analyzer.dart b/example/lib/video_stream_analyzer.dart new file mode 100644 index 0000000..8229a3c --- /dev/null +++ b/example/lib/video_stream_analyzer.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; +import 'package:sensitive_content_analysis_example/main.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CameraSensitiveAnalysisScreen extends StatefulWidget { + final String participantUUID; + + const CameraSensitiveAnalysisScreen({ + super.key, + required this.participantUUID, + }); + + @override + State createState() => + _CameraSensitiveAnalysisScreenState(); +} + +class _CameraSensitiveAnalysisScreenState + extends State { + CameraController? _cameraController; + VideoStreamAnalyzer? _analyzer; + StreamSubscription? _analysisSubscription; + + bool _isInitializing = true; + bool _isAnalyzingFrame = false; + bool _isStreamInterrupted = false; + bool _isContinuing = false; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _initializeSystem(); + } + + Future _initializeSystem() async { + final policy = await sca.checkPolicy(); + debugPrint("Analysis Policy: $policy"); + + if (policy == .disabled) { + debugPrint( + "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings.", + ); + } + try { + _analyzer = await sca.createVideoStreamAnalyzer( + participantUUID: widget.participantUUID, + streamDirection: .incoming, + ); + + _subscribeToAnalysisChanges(); + + final cameras = await availableCameras(); + if (cameras.isEmpty) { + throw Exception("No physical cameras detected on this device."); + } + + _cameraController = CameraController( + cameras.first, + .medium, + enableAudio: true, + imageFormatGroup: .bgra8888, + ); + + await _cameraController!.initialize(); + + await _analyzer!.continueStream(); + + _startFrameStreamingPipeline(); + + setState(() { + _isInitializing = false; + }); + } catch (e) { + setState(() { + _isInitializing = false; + _errorMessage = e.toString(); + }); + } + } + + void _subscribeToAnalysisChanges() { + if (_analyzer == null) return; + + _analysisSubscription = _analyzer!.analysisChanges.listen( + (SensitivityAnalysisResult result) async { + debugPrint("🔍 Full Analysis Result: $result"); + + if (result.shouldInterruptVideo && !_isContinuing) { + if (_cameraController != null && + _cameraController!.value.isStreamingImages) { + await _cameraController!.stopImageStream(); + } + setState(() { + _isStreamInterrupted = true; + }); + } + }, + onError: (error) { + debugPrint('Error caught from analysis EventChannel: $error'); + }, + ); + } + + Future _onContinueTapped() async { + if (_analyzer == null || _isContinuing) return; + + setState(() { + _isContinuing = true; + }); + + try { + await _analyzer!.continueStream(); + + setState(() { + _isStreamInterrupted = false; + _isContinuing = false; + }); + + await Future.delayed(const Duration(milliseconds: 200)); + + _startFrameStreamingPipeline(); + } catch (e) { + debugPrint("Failed to continue stream: $e"); + setState(() => _isContinuing = false); + } + } + + DateTime? _lastAnalysisTime; + final int _throttleMs = 200; + + void _startFrameStreamingPipeline() { + if (_cameraController == null || !_cameraController!.value.isInitialized) { + return; + } + + _cameraController!.startImageStream((CameraImage image) async { + if (_analyzer == null || _isAnalyzingFrame) return; + + final now = DateTime.now(); + if (_lastAnalysisTime != null && + now.difference(_lastAnalysisTime!).inMilliseconds < _throttleMs) { + return; + } + _lastAnalysisTime = now; + + _isAnalyzingFrame = true; + + try { + final plane = image.planes[0]; + final Uint8List bytes = plane.bytes; + final int bytesPerRow = plane.bytesPerRow; + + await _analyzer!.analyzeFrame( + bytes: bytes, + width: image.width, + height: image.height, + bytesPerRow: bytesPerRow, + ); + } catch (e) { + debugPrint("Failed to parse or analyze frame: $e"); + } finally { + _isAnalyzingFrame = false; + } + }); + } + + @override + void dispose() { + if (_cameraController != null && + _cameraController!.value.isStreamingImages) { + _cameraController!.stopImageStream(); + } + _cameraController?.dispose(); + + _analysisSubscription?.cancel(); + + _analyzer?.endAnalysis(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isInitializing) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + if (_errorMessage.isNotEmpty) { + return Scaffold( + body: Center( + child: Padding( + padding: const .all(24.0), + child: Text( + _errorMessage, + style: const TextStyle(color: Colors.red, fontSize: 16), + textAlign: .center, + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text("Sensitive Content Stream")), + body: Stack( + children: [ + if (_cameraController != null && + _cameraController!.value.isInitialized) + Positioned.fill( + child: _isStreamInterrupted + ? SizedBox.shrink() + : CameraPreview(_cameraController!), + ) + else + const Center(child: Text("Camera view unavailable.")), + if (!_isStreamInterrupted) + Positioned( + top: 16, + left: 16, + right: 16, + child: SafeArea( + child: Card( + elevation: 8, + color: _isStreamInterrupted + ? Colors.red.withValues(alpha: 0.9) + : Colors.green.withValues(alpha: 0.9), + shape: RoundedRectangleBorder(borderRadius: .circular(12)), + child: Padding( + padding: const .symmetric(vertical: 16.0, horizontal: 20.0), + child: Row( + children: [ + Icon( + _isStreamInterrupted + ? Icons.warning_amber_rounded + : Icons.check_circle_outline, + color: Colors.white, + size: 28, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + _isStreamInterrupted ? "NOT SAFE" : "SAFE", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: .bold, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + _isStreamInterrupted + ? "Sensitive content detected in frame." + : "No sensitive content detected.", + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + if (_isStreamInterrupted) + Positioned.fill( + child: AnimatedOpacity( + opacity: _isStreamInterrupted ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: ClipRect( + child: BackdropFilter( + filter: .blur(sigmaX: 50, sigmaY: 50), + child: Container( + color: Colors.black.withValues(alpha: 0.45), + child: Column( + mainAxisAlignment: .center, + children: [ + const Icon( + Icons.visibility_off_rounded, + color: Colors.white, + size: 56, + ), + const SizedBox(height: 16), + const Text( + "Sensitive Content Detected", + textAlign: .center, + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: .bold, + ), + ), + const SizedBox(height: 8), + const Text( + "The Video is paused because the video\nmay be showing something sensitive.", + textAlign: .center, + style: TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: _isContinuing ? null : _onContinueTapped, + icon: _isContinuing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.play_arrow_rounded), + label: Text( + _isContinuing ? "Resuming…" : "Resume Video", + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + padding: const .symmetric( + horizontal: 28, + vertical: 14, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: .w600, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig index 4b81f9b..c2efd0b 100644 --- a/example/macos/Flutter/Flutter-Debug.xcconfig +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig index 5caa9d1..c2efd0b 100644 --- a/example/macos/Flutter/Flutter-Release.xcconfig +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 6a9f476..37a3275 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,12 @@ import Foundation import file_picker import file_selector_macos -import path_provider_foundation import sensitive_content_analysis +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SensitiveContentAnalysisPlugin.register(with: registry.registrar(forPlugin: "SensitiveContentAnalysisPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/example/macos/Podfile b/example/macos/Podfile deleted file mode 100644 index 1f811a9..0000000 --- a/example/macos/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -platform :osx, '14.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock deleted file mode 100644 index 68938d9..0000000 --- a/example/macos/Podfile.lock +++ /dev/null @@ -1,35 +0,0 @@ -PODS: - - file_selector_macos (0.0.1): - - FlutterMacOS - - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sensitive_content_analysis (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - sensitive_content_analysis (from `Flutter/ephemeral/.symlinks/plugins/sensitive_content_analysis/macos`) - -EXTERNAL SOURCES: - file_selector_macos: - :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos - FlutterMacOS: - :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - sensitive_content_analysis: - :path: Flutter/ephemeral/.symlinks/plugins/sensitive_content_analysis/macos - -SPEC CHECKSUMS: - file_selector_macos: 660f7672ee62dad6df1bb0cbdb90a10ce3f946df - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 - sensitive_content_analysis: 00182a342b1e6ee570373d73573f7bc9ee4fe690 - -PODFILE CHECKSUM: 6acf97521436d16fc31cd5e1a02000905acdb3ae - -COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 0e13e25..215a4c5 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,8 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 5F8E2129349DD2ADB44CA281 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4D5C9BE33EC2BDEF637609 /* Pods_Runner.framework */; }; - 78FA66B7C2A45709C4128F28 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8F111450786C92DF977678C /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,8 +61,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 1CDB1DEC3823320EA5F2E111 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 2DA548C834AAAB4734956DDF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -80,14 +77,11 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 4C4D5C9BE33EC2BDEF637609 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 669FDA171898C867DBAF4BE7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 68098A31A007FACF677C042E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* sensitive_content_analysis */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = sensitive_content_analysis; path = ../../../darwin/sensitive_content_analysis; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 7F890A81D151036D48996D76 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - D8F111450786C92DF977678C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - E4519D8E12DBAC847FE32D5E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,7 +89,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78FA66B7C2A45709C4128F28 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,7 +96,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5F8E2129349DD2ADB44CA281 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -136,7 +129,6 @@ 33CEB47122A05771004F2AC0 /* Flutter */, 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, FE0095FE3310AF736E67F430 /* Pods */, ); sourceTree = ""; @@ -164,6 +156,9 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* sensitive_content_analysis */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -185,24 +180,9 @@ path = Runner; sourceTree = ""; }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 4C4D5C9BE33EC2BDEF637609 /* Pods_Runner.framework */, - D8F111450786C92DF977678C /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; FE0095FE3310AF736E67F430 /* Pods */ = { isa = PBXGroup; children = ( - 669FDA171898C867DBAF4BE7 /* Pods-Runner.debug.xcconfig */, - 68098A31A007FACF677C042E /* Pods-Runner.release.xcconfig */, - 2DA548C834AAAB4734956DDF /* Pods-Runner.profile.xcconfig */, - 1CDB1DEC3823320EA5F2E111 /* Pods-RunnerTests.debug.xcconfig */, - E4519D8E12DBAC847FE32D5E /* Pods-RunnerTests.release.xcconfig */, - 7F890A81D151036D48996D76 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -214,7 +194,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 8ECA4C9ED57C573F7D3AD1F1 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -233,13 +212,11 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 200F0ECB42FD60E75A241A10 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - BAD8864925DB97FE97CBA973 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -247,6 +224,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* sensitive_content_analysis_example.app */; productType = "com.apple.product-type.application"; @@ -290,6 +270,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -321,28 +304,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 200F0ECB42FD60E75A241A10 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -381,45 +342,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 8ECA4C9ED57C573F7D3AD1F1 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - BAD8864925DB97FE97CBA973 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -471,7 +393,6 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1CDB1DEC3823320EA5F2E111 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -486,7 +407,6 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E4519D8E12DBAC847FE32D5E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -501,7 +421,6 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7F890A81D151036D48996D76 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -576,7 +495,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -705,7 +624,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -728,7 +647,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -794,6 +713,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 4b53e01..71448bf 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + =3.11.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ddf81ce..3b00b9e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -3,7 +3,7 @@ description: "Demonstrates how to use the sensitive_content_analysis plugin." publish_to: 'none' environment: - sdk: '>=3.1.0 <4.0.0' + sdk: '>=3.12.0 <4.0.0' dependencies: flutter: @@ -16,12 +16,17 @@ dependencies: file_picker: 12.0.0-beta.3 #^11.0.2 path_provider: ^2.1.5 dio: ^5.9.2 + path: ^1.9.1 + cached_network_image: ^3.4.1 + camera: ^0.12.0+1 dev_dependencies: integration_test: sdk: flutter flutter_test: sdk: flutter + mockito: ^5.4.4 + build_runner: ^2.4.9 flutter_lints: ^6.0.0 diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 330c48c..6e20f55 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,27 +1,289 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; -import 'package:sensitive_content_analysis_example/main.dart'; +import 'widget_test.mocks.dart'; +@GenerateMocks([SensitiveContentAnalysis]) void main() { - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that platform version is retrieved. - expect( - find.byWidgetPredicate( - (Widget widget) => widget is Text && - widget.data!.startsWith('Running on:'), - ), - findsOneWidget, - ); + late MockSensitiveContentAnalysis mockSca; + + setUp(() { + mockSca = MockSensitiveContentAnalysis(); + }); + + group('SensitiveContentAnalysis - analyzeImage', () { + test('returns isSensitive=false for safe image bytes', () async { + final fakeBytes = Uint8List.fromList([0, 1, 2, 3]); + when(mockSca.analyzeImage(fakeBytes)).thenAnswer( + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), + ); + + final result = await mockSca.analyzeImage(fakeBytes); + + expect(result, isNotNull); + expect(result!.isSensitive, isFalse); + verify(mockSca.analyzeImage(fakeBytes)).called(1); + }); + + test('returns isSensitive=true for sensitive image bytes', () async { + final fakeBytes = Uint8List.fromList([255, 254, 253]); + when(mockSca.analyzeImage(fakeBytes)).thenAnswer( + (_) async => SensitivityAnalysisResult( + isSensitive: true, + detectedTypes: [], + shouldIndicateSensitivity: true, + shouldInterruptVideo: true, + shouldMuteAudio: true), + ); + + final result = await mockSca.analyzeImage(fakeBytes); + + expect(result, isNotNull); + expect(result!.isSensitive, isTrue); + }); + + test('returns null when analysis cannot be performed', () async { + final fakeBytes = Uint8List(0); + when(mockSca.analyzeImage(fakeBytes)).thenAnswer((_) async => null); + + final result = await mockSca.analyzeImage(fakeBytes); + + expect(result, isNull); + }); + + test('propagates exception on analysis failure', () async { + final fakeBytes = Uint8List.fromList([1, 2, 3]); + when(mockSca.analyzeImage(fakeBytes)) + .thenThrow(Exception('Analysis failed')); + + expect(() => mockSca.analyzeImage(fakeBytes), throwsException); + }); + }); + + group('SensitiveContentAnalysis - analyzeNetworkImage', () { + const safeUrl = + 'https://docs-assets.developer.apple.com/published/517e263450/rendered2x-1685188934.png'; + + test('returns isSensitive=false for safe network image', () async { + when(mockSca.analyzeNetworkImage(url: safeUrl)).thenAnswer( + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), + ); + + final result = await mockSca.analyzeNetworkImage(url: safeUrl); + + expect(result, isNotNull); + expect(result!.isSensitive, isFalse); + verify(mockSca.analyzeNetworkImage(url: safeUrl)).called(1); + }); + + test('returns null for unreachable URL', () async { + const badUrl = 'https://invalid.example.com/image.png'; + when(mockSca.analyzeNetworkImage(url: badUrl)) + .thenAnswer((_) async => null); + + final result = await mockSca.analyzeNetworkImage(url: badUrl); + + expect(result, isNull); + }); + + test('throws on network error', () async { + const errorUrl = 'https://unreachable.example.com/img.jpg'; + when(mockSca.analyzeNetworkImage(url: errorUrl)) + .thenThrow(Exception('Network error')); + + expect( + () => mockSca.analyzeNetworkImage(url: errorUrl), + throwsException, + ); + }); + }); + + group('SensitiveContentAnalysis - analyzeVideo', () { + const videoPath = '/tmp/test_video.mov'; + + test('returns isSensitive=false for safe video', () async { + when(mockSca.analyzeVideo(url: videoPath)).thenAnswer( + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), + ); + + final result = await mockSca.analyzeVideo(url: videoPath); + + expect(result, isNotNull); + expect(result!.isSensitive, isFalse); + verify(mockSca.analyzeVideo(url: videoPath)).called(1); + }); + + test('returns isSensitive=true for flagged video', () async { + when(mockSca.analyzeVideo(url: videoPath)).thenAnswer( + (_) async => SensitivityAnalysisResult( + isSensitive: true, + detectedTypes: [], + shouldIndicateSensitivity: true, + shouldInterruptVideo: true, + shouldMuteAudio: true), + ); + + final result = await mockSca.analyzeVideo(url: videoPath); + + expect(result!.isSensitive, isTrue); + }); + + test('throws on missing video file', () async { + const missingPath = '/tmp/nonexistent.mov'; + when(mockSca.analyzeVideo(url: missingPath)) + .thenThrow(Exception('File not found')); + + expect( + () => mockSca.analyzeVideo(url: missingPath), + throwsException, + ); + }); + }); + + group('SensitiveContentAnalysis - checkPolicy', () { + test('returns policy code 0 (no restrictions)', () async { + when(mockSca.checkPolicy()) + .thenAnswer((_) async => AnalysisPolicy.disabled); + + final policy = await mockSca.checkPolicy(); + + expect(policy, equals(AnalysisPolicy.disabled)); + verify(mockSca.checkPolicy()).called(1); + }); + + test('returns policy code 1 (restricted)', () async { + when(mockSca.checkPolicy()) + .thenAnswer((_) async => AnalysisPolicy.simpleInterventions); + + final policy = await mockSca.checkPolicy(); + + expect(policy, equals(AnalysisPolicy.simpleInterventions)); + }); + + test('returns null when policy cannot be determined', () async { + when(mockSca.checkPolicy()).thenAnswer((_) async => null); + + final policy = await mockSca.checkPolicy(); + + expect(policy, isNull); + }); + + test('throws on policy fetch failure', () async { + when(mockSca.checkPolicy()).thenThrow(Exception('Policy fetch failed')); + + expect(() async => await mockSca.checkPolicy(), throwsException); + }); + }); + group('SensitivityAnalysisResult model', () { + test('constructs with isSensitive=true', () { + final result = SensitivityAnalysisResult( + isSensitive: true, + detectedTypes: [], + shouldIndicateSensitivity: true, + shouldInterruptVideo: true, + shouldMuteAudio: true); + expect(result.isSensitive, isTrue); + }); + + test('constructs with isSensitive=false', () { + final result = SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false); + expect(result.isSensitive, isFalse); + }); + }); + + group('UI - result dialog smoke test', () { + testWidgets('shows AlertDialog with title and message', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Analysis Result'), + content: const Text('SENSITIVE: false'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Analysis Result'), findsOneWidget); + expect(find.text('SENSITIVE: false'), findsOneWidget); + expect(find.text('OK'), findsOneWidget); + }); + + testWidgets('dialog dismisses on OK tap', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Policy Check'), + content: const Text('Policy: 0'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + expect(find.text('Policy: 0'), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Policy: 0'), findsNothing); + }); }); } diff --git a/example/test/widget_test.mocks.dart b/example/test/widget_test.mocks.dart new file mode 100644 index 0000000..f79a9c8 --- /dev/null +++ b/example/test/widget_test.mocks.dart @@ -0,0 +1,119 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in sensitive_content_analysis_example/test/widget_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:typed_data' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:sensitive_content_analysis/sensitive_content_analysis.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeVideoStreamAnalyzer_0 extends _i1.SmartFake + implements _i2.VideoStreamAnalyzer { + _FakeVideoStreamAnalyzer_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SensitiveContentAnalysis]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSensitiveContentAnalysis extends _i1.Mock + implements _i2.SensitiveContentAnalysis { + MockSensitiveContentAnalysis() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.SensitivityAnalysisResult?> analyzeImage( + _i4.Uint8List? file) => + (super.noSuchMethod( + Invocation.method( + #analyzeImage, + [file], + ), + returnValue: _i3.Future<_i2.SensitivityAnalysisResult?>.value(), + ) as _i3.Future<_i2.SensitivityAnalysisResult?>); + + @override + _i3.Future<_i2.SensitivityAnalysisResult?> analyzeNetworkImage( + {required String? url}) => + (super.noSuchMethod( + Invocation.method( + #analyzeNetworkImage, + [], + {#url: url}, + ), + returnValue: _i3.Future<_i2.SensitivityAnalysisResult?>.value(), + ) as _i3.Future<_i2.SensitivityAnalysisResult?>); + + @override + _i3.Future<_i2.SensitivityAnalysisResult?> analyzeVideo( + {required String? url}) => + (super.noSuchMethod( + Invocation.method( + #analyzeVideo, + [], + {#url: url}, + ), + returnValue: _i3.Future<_i2.SensitivityAnalysisResult?>.value(), + ) as _i3.Future<_i2.SensitivityAnalysisResult?>); + + @override + _i3.Future<_i2.AnalysisPolicy?> checkPolicy() => (super.noSuchMethod( + Invocation.method( + #checkPolicy, + [], + ), + returnValue: _i3.Future<_i2.AnalysisPolicy?>.value(), + ) as _i3.Future<_i2.AnalysisPolicy?>); + + @override + _i3.Future<_i2.VideoStreamAnalyzer> createVideoStreamAnalyzer({ + required String? participantUUID, + required _i2.StreamDirection? streamDirection, + }) => + (super.noSuchMethod( + Invocation.method( + #createVideoStreamAnalyzer, + [], + { + #participantUUID: participantUUID, + #streamDirection: streamDirection, + }, + ), + returnValue: _i3.Future<_i2.VideoStreamAnalyzer>.value( + _FakeVideoStreamAnalyzer_0( + this, + Invocation.method( + #createVideoStreamAnalyzer, + [], + { + #participantUUID: participantUUID, + #streamDirection: streamDirection, + }, + ), + )), + ) as _i3.Future<_i2.VideoStreamAnalyzer>); +} diff --git a/ios/.gitignore b/ios/.gitignore deleted file mode 100644 index 0c88507..0000000 --- a/ios/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig -/Flutter/ephemeral/ -/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/ios/Classes/SensitiveContentAnalysisPlugin.swift b/ios/Classes/SensitiveContentAnalysisPlugin.swift deleted file mode 120000 index 2369ff7..0000000 --- a/ios/Classes/SensitiveContentAnalysisPlugin.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift \ No newline at end of file diff --git a/ios/sensitive_content_analysis.podspec b/ios/sensitive_content_analysis.podspec deleted file mode 120000 index 3889939..0000000 --- a/ios/sensitive_content_analysis.podspec +++ /dev/null @@ -1 +0,0 @@ -../darwin/sensitive_content_analysis/sensitive_content_analysis.podspec \ No newline at end of file diff --git a/lib/sensitive_content_analysis.dart b/lib/sensitive_content_analysis.dart index 2ac5331..65e71b5 100644 --- a/lib/sensitive_content_analysis.dart +++ b/lib/sensitive_content_analysis.dart @@ -1,56 +1,264 @@ import 'package:flutter/services.dart'; -const methodChannel = MethodChannel('sensitive_content_analysis'); +const _methodChannel = MethodChannel('sensitive_content_analysis'); class SensitiveContentAnalysis { - /// Analyzes an image for sensitive content. - /// - /// Returns a `bool` indicating whether the image is sensitive. - Future analyzeImage(Uint8List file) async { + /// Analyzes a local image for sensitive content. + Future analyzeImage(Uint8List file) async { + try { + final Map? result = await _methodChannel + .invokeMapMethod('analyzeImage', file); + if (result == null) return null; + return .fromMap(result); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// Analyzes a network image for sensitive content. + Future analyzeNetworkImage({ + required String url, + }) async { + try { + final Map? result = await _methodChannel + .invokeMapMethod('analyzeNetworkImage', {'url': url}); + if (result == null) return null; + return .fromMap(result); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// Analyzes a local video file for sensitive content. + Future analyzeVideo({required String url}) async { + try { + final Map? result = await _methodChannel + .invokeMapMethod('analyzeVideo', {'url': url}); + if (result == null) return null; + return .fromMap(result); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// Returns the current [AnalysisPolicy]. + Future checkPolicy() async { try { - final dynamic isSensitive = - await methodChannel.invokeMethod('analyzeImage', file); - return isSensitive; + final int? raw = await _methodChannel.invokeMethod('checkPolicy'); + if (raw == null) return null; + return .fromInt(raw); } on PlatformException catch (e) { throw UnimplementedError(e.message); } } - /// Analyzes an network image for sensitive content. + /// Creates a [VideoStreamAnalyzer] for the given participant. /// - /// Returns a `bool` indicating whether the image is sensitive. - Future analyzeNetworkImage({required String url}) async { + /// Throws a [PlatformException] if Communication Safety or Sensitive + /// Content Warnings is not enabled on the device (iOS 26+ / macOS 26+). + Future createVideoStreamAnalyzer({ + required String participantUUID, + required StreamDirection streamDirection, + }) async { + final eventChannelName = + 'sensitive_content_analysis/stream/$participantUUID'; + try { - final dynamic isSensitive = - await methodChannel.invokeMethod('analyzeNetworkImage', {"url": url}); - return isSensitive; + await _methodChannel.invokeMethod('createVideoStreamAnalyzer', { + 'participantUUID': participantUUID, + 'streamDirection': streamDirection.index, + 'eventChannelName': eventChannelName, + }); } on PlatformException catch (e) { throw UnimplementedError(e.message); } + + return ._( + participantUUID: participantUUID, + eventChannel: EventChannel(eventChannelName), + ); } +} + +// --------------------------------------------------------------------------- +// VideoStreamAnalyzer +// --------------------------------------------------------------------------- - /// Analyzes an video for sensitive content. +/// Monitors a live video stream for sensitive content. +/// +/// Obtain an instance via [SensitiveContentAnalysis.createVideoStreamAnalyzer]. +/// Listen to [analysisChanges] to react to detections, call [continueStream] +/// after handling an interruption, and call [endAnalysis] when the stream ends. +/// +/// Available on iOS 26+ / macOS 26+ only. On older OS versions +/// [SensitiveContentAnalysis.createVideoStreamAnalyzer] will throw. +class VideoStreamAnalyzer { + VideoStreamAnalyzer._({ + required this.participantUUID, + required this._eventChannel, + }); + + final String participantUUID; + final EventChannel _eventChannel; + + /// A stream of [SensitivityAnalysisResult] pushed whenever the framework + /// detects a change in the video stream's sensitivity status. /// - /// Returns a `bool` indicating whether the video is sensitive. - Future analyzeVideo({required String url}) async { + /// The stream closes when [endAnalysis] is called or the native side ends. + Stream get analysisChanges { + return _eventChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) => .fromMap(event as Map)); + } + + /// Passes a raw video frame to the analyzer. + /// + /// Use this when your app decodes its own video stream. [bytes] must be + /// BGRA-formatted pixel data matching [width] × [height]. + /// + /// For AVCaptureDeviceInput or VTDecompressionSession, call + /// beginAnalysis on the native side directly instead. + Future analyzeFrame({ + required Uint8List bytes, + required int width, + required int height, + required int bytesPerRow, + }) async { try { - final dynamic isSensitive = await methodChannel - .invokeMethod('analyzeVideo', {"url": Uri.file(url).toString()}); - return isSensitive; + await _methodChannel.invokeMethod('analyzeVideoStreamFrame', { + 'participantUUID': participantUUID, + 'bytes': bytes, + 'width': width, + 'height': height, + 'bytesPerRow': bytesPerRow, + }); } on PlatformException catch (e) { throw UnimplementedError(e.message); } } - /// Check Policy. + /// Signals that the app is ready to resume after handling a + /// [SensitivityAnalysisResult.shouldInterruptVideo] event. + Future continueStream() async { + try { + await _methodChannel.invokeMethod('continueVideoStream', { + 'participantUUID': participantUUID, + }); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// Stops analysis and releases the native analyzer for this participant. /// - /// Returns a `int` indicating the policy value. - Future checkPolicy() async { + /// After calling this, [analysisChanges] will close and this instance + /// should be discarded. + Future endAnalysis() async { try { - final result = await methodChannel.invokeMethod('checkPolicy'); - return result; + await _methodChannel.invokeMethod('endVideoStreamAnalysis', { + 'participantUUID': participantUUID, + }); } on PlatformException catch (e) { throw UnimplementedError(e.message); } } } + +// --------------------------------------------------------------------------- +// SensitivityAnalysisResult +// --------------------------------------------------------------------------- + +class SensitivityAnalysisResult { + const SensitivityAnalysisResult({ + required this.isSensitive, + required this.detectedTypes, + required this.shouldIndicateSensitivity, + required this.shouldInterruptVideo, + required this.shouldMuteAudio, + }); + + /// Whether the content is considered sensitive. + final bool isSensitive; + + /// Which types of sensitive content were detected (iOS 26+ / macOS 26+). + final List detectedTypes; + + /// App should indicate the presence of sensitive content to the user. + /// Available on iOS/ipadOS 26+ always false on older OS versions. + final bool shouldIndicateSensitivity; + + /// App should interrupt video playback. + /// Available on iOS/ipadOS 26+ always false on older OS versions. + final bool shouldInterruptVideo; + + /// App should mute the audio of the current video stream. + /// Available on iOS/ipadOS 26+ always false on older OS versions. + final bool shouldMuteAudio; + + factory SensitivityAnalysisResult.fromMap(Map map) { + bool toBool(String key) => map[key] as bool? ?? false; + + return SensitivityAnalysisResult( + isSensitive: toBool('isSensitive'), + shouldIndicateSensitivity: toBool('shouldIndicateSensitivity'), + shouldInterruptVideo: toBool('shouldInterruptVideo'), + shouldMuteAudio: toBool('shouldMuteAudio'), + detectedTypes: (map['detectedTypes'] as List? ?? []) + .whereType() + .map(DetectedContentType.fromString) + .whereType() + .toList(), + ); + } + + @override + String toString() => + 'SensitivityAnalysisResult(' + 'isSensitive: $isSensitive, ' + 'detectedTypes: $detectedTypes, ' + 'shouldIndicateSensitivity: $shouldIndicateSensitivity, ' + 'shouldInterruptVideo: $shouldInterruptVideo, ' + 'shouldMuteAudio: $shouldMuteAudio)'; +} + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +enum DetectedContentType { + sexuallyExplicit, + goreOrViolence; + + static DetectedContentType? fromString(String value) => switch (value) { + 'sexuallyExplicit' => .sexuallyExplicit, + 'goreOrViolence' => .goreOrViolence, + _ => null, + }; +} + +enum AnalysisPolicy { + /// Analysis is disabled; always returns null from analysis methods. + disabled, + + /// Simple interventions (e.g. blurring) are suggested. + simpleInterventions, + + /// Descriptive interventions with richer guidance are suggested. + descriptiveInterventions; + + static AnalysisPolicy fromInt(int value) => switch (value) { + 0 => .disabled, + 1 => .simpleInterventions, + 2 => .descriptiveInterventions, + _ => throw ArgumentError('Unknown AnalysisPolicy value: $value'), + }; +} + +enum StreamDirection { + /// The local device's outgoing camera stream. + outgoing, + + /// An incoming stream from a remote participant. + incoming, +} diff --git a/macos/Classes/SensitiveContentAnalysisPlugin.swift b/macos/Classes/SensitiveContentAnalysisPlugin.swift deleted file mode 120000 index 2369ff7..0000000 --- a/macos/Classes/SensitiveContentAnalysisPlugin.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift \ No newline at end of file diff --git a/macos/sensitive_content_analysis.podspec b/macos/sensitive_content_analysis.podspec deleted file mode 120000 index 3889939..0000000 --- a/macos/sensitive_content_analysis.podspec +++ /dev/null @@ -1 +0,0 @@ -../darwin/sensitive_content_analysis/sensitive_content_analysis.podspec \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 9771e5d..89b9730 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,18 @@ name: sensitive_content_analysis -description: "Dart package for detecting and alerting users to nudity in images and videos before displaying them, utilizing the SensitiveContentAnalysis Framework by Apple." -version: 2.0.2 +description: "Flutter package for detecting and alerting users to nudity in images and videos before displaying them, utilizing the SensitiveContentAnalysis Framework by Apple." +version: 2.1.3 homepage: https://github.com/doppeltilde/sensitive_content_analysis +topics: + - nsfw + - nsfw-detection + - content-moderation + - sensitive-content + - sensitive-content-analysis + environment: - sdk: '>=3.1.0 <4.0.0' - flutter: '>=3.3.0' + sdk: '>=3.12.0 <4.0.0' + flutter: '>=3.44.0' dependencies: flutter: