From e85f273684a5971f060fe591a25b39c6ee3964f1 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:58:28 +0200 Subject: [PATCH 01/15] chore: remove redundant folders --- CHANGELOG.md | 2 + example/pubspec.lock | 2 +- ios/.gitignore | 38 ------------------- ios/Assets/.gitkeep | 0 .../SensitiveContentAnalysisPlugin.swift | 1 - ios/sensitive_content_analysis.podspec | 1 - .../SensitiveContentAnalysisPlugin.swift | 1 - macos/sensitive_content_analysis.podspec | 1 - 8 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 ios/.gitignore delete mode 100644 ios/Assets/.gitkeep delete mode 120000 ios/Classes/SensitiveContentAnalysisPlugin.swift delete mode 120000 ios/sensitive_content_analysis.podspec delete mode 120000 macos/Classes/SensitiveContentAnalysisPlugin.swift delete mode 120000 macos/sensitive_content_analysis.podspec diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8c35c..a5c11c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.0.2 +- Fix: Data Race & Main Thread Violation ## 2.0.0 - Support for Swift Package Manager. diff --git a/example/pubspec.lock b/example/pubspec.lock index 2d3b29e..23c1d50 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -445,7 +445,7 @@ packages: path: ".." relative: true source: path - version: "2.0.1" + version: "2.0.2" sky_engine: dependency: transitive description: flutter 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/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 From 50db7d659216393ff912eaca1829cd1f4e99e6a4 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:36:42 +0200 Subject: [PATCH 02/15] fix(darwin): add backwards compatibility for cocoapods --- CHANGELOG.md | 4 +++ .../sensitive_content_analysis.podspec | 3 +- .../xcschemes/xcschememanagement.plist | 14 ++++++++++ .../sensitive_content_analysis/Package.swift | 11 ++++---- .../SensitiveContentAnalysisPlugin.h | 4 --- example/ios/Podfile.lock | 26 +++++++++++++++++ example/ios/Runner.xcodeproj/project.pbxproj | 28 ------------------- example/pubspec.yaml | 5 +++- pubspec.yaml | 4 +-- 9 files changed, 57 insertions(+), 42 deletions(-) rename darwin/{sensitive_content_analysis => }/sensitive_content_analysis.podspec (89%) create mode 100644 darwin/sensitive_content_analysis/.swiftpm/xcode/xcuserdata/jonafeucht.xcuserdatad/xcschemes/xcschememanagement.plist delete mode 100644 darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.h diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c11c9..5b00a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ +## 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/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..5ca3b98 --- /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 + 9 + + + + diff --git a/darwin/sensitive_content_analysis/Package.swift b/darwin/sensitive_content_analysis/Package.swift index c142b34..2c28c85 100644 --- a/darwin/sensitive_content_analysis/Package.swift +++ b/darwin/sensitive_content_analysis/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "sensitive_content_analysis", platforms: [ .iOS("13.0"), - .macOS("12.0") + .macOS("12.0"), ], products: [ .library( @@ -14,20 +14,21 @@ let package = Package( ) ], dependencies: [ - .package(name: "FlutterFramework", path: "../FlutterFramework"), + .package(path: "../FlutterFramework") ], targets: [ .target( name: "sensitive_content_analysis", dependencies: [ - .product(name: "FlutterFramework", package: "FlutterFramework"), + .product(name: "FlutterFramework", package: "FlutterFramework") ], + path: ".", resources: [], linkerSettings: [ .linkedFramework("Flutter", .when(platforms: [.iOS])), .linkedFramework("FlutterMacOS", .when(platforms: [.macOS])), - .linkedFramework("SensitiveContentAnalysis", .when(platforms: [.iOS, .macOS])) + .linkedFramework("SensitiveContentAnalysis", .when(platforms: [.iOS, .macOS])), ] ) ] -) \ No newline at end of file +) 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/example/ios/Podfile.lock b/example/ios/Podfile.lock index c4ae4f7..0052c97 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,22 +1,48 @@ PODS: + - file_picker (0.0.1): + - Flutter + - FlutterMacOS - Flutter (1.0.0) + - image_picker_ios (0.0.1): + - Flutter + - integration_test (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - sensitive_content_analysis (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/darwin`) - Flutter (from `Flutter`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - sensitive_content_analysis (from `.symlinks/plugins/sensitive_content_analysis/darwin`) EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/darwin" Flutter: :path: Flutter + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + sensitive_content_analysis: + :path: ".symlinks/plugins/sensitive_content_analysis/darwin" SPEC CHECKSUMS: + file_picker: 70164d9778c42c47218d6cd79ce435de0856b11a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 + sensitive_content_analysis: ed23804e800acb5554078f1584f24da9c6184b87 PODFILE CHECKSUM: 9c46fd01abff66081b39f5fa5767b3f1d0b11d76 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 71dbb3c..9238175 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 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 */; }; @@ -55,9 +54,6 @@ 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 = ""; }; - 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 = ""; }; @@ -85,7 +81,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, BA71F0ED51A027C7FB44BBF6 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -126,9 +121,6 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 78DABEA22ED26510000E7860 /* sensitive_content_analysis */, - 784666492D4C4C64000A1A5F /* FlutterFramework */, - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -214,9 +206,6 @@ dependencies = ( ); name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -250,9 +239,6 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -741,20 +727,6 @@ 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 = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ddf81ce..e7d33c4 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -26,4 +26,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true + + config: + enable-swift-package-manager: false \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 9771e5d..2bb61f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ 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 +version: 2.0.3 homepage: https://github.com/doppeltilde/sensitive_content_analysis environment: sdk: '>=3.1.0 <4.0.0' - flutter: '>=3.3.0' + flutter: '>=3.41.0' dependencies: flutter: From 457aee8789a52fd6db62ea3d7a5fb7231f2cf544 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:43:29 +0200 Subject: [PATCH 03/15] chore: formatting --- .../sensitive_content_analysis/Package.swift | 56 +-- .../SensitiveContentAnalysisPlugin.swift | 454 +++++++++--------- 2 files changed, 255 insertions(+), 255 deletions(-) diff --git a/darwin/sensitive_content_analysis/Package.swift b/darwin/sensitive_content_analysis/Package.swift index 2c28c85..0055371 100644 --- a/darwin/sensitive_content_analysis/Package.swift +++ b/darwin/sensitive_content_analysis/Package.swift @@ -3,32 +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(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])), - ] - ) - ] + 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.swift b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift index 6963c45..6f7d3f2 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,257 @@ #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 static func register(with registrar: FlutterPluginRegistrar) { - #if os(iOS) - let messenger = registrar.messenger() - #else - let messenger = registrar.messenger - #endif + 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() - registrar.addMethodCallDelegate(instance, channel: channel) - } + let channel = FlutterMethodChannel( + name: "sensitive_content_analysis", + binaryMessenger: messenger + ) - private var _analyzer: Any? + let instance = SensitiveContentAnalysisPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } - @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 } - 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 + let newAnalyzer = SCSensitivityAnalyzer() + _analyzer = newAnalyzer + return newAnalyzer + } + + 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 17.0, macOS 14.0, *) + private func analyzeImage( + image: FlutterStandardTypedData, + result: @escaping FlutterResult + ) { + let analyzer = self.analyzer + + Task(priority: .userInitiated) { + guard analyzer.analysisPolicy != .disabled else { + await MainActor.run { result(nil) } + return + } + + 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(analysisResult.isSensitive) + } + } catch { + await MainActor.run { + result( + FlutterError( + code: "analysis_error", + message: "Failed to analyze image", + details: error.localizedDescription ) - #endif - } - - @available(iOS 17.0, macOS 14.0, *) - private func analyzeImage( - image: FlutterStandardTypedData, - result: @escaping FlutterResult - ) { - let analyzer = self.analyzer - - Task(priority: .userInitiated) { - guard analyzer.analysisPolicy != .disabled else { - await MainActor.run { result(nil) } - return - } - - 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(analysisResult.isSensitive) - } - } 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 - ) { - let analyzer = self.analyzer - - Task(priority: .userInitiated) { - 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(analysisResult.isSensitive) - } - } 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 analyzeVideo( + at fileURL: URL, + result: @escaping FlutterResult + ) { + let analyzer = self.analyzer + + Task(priority: .userInitiated) { + 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(analysisResult.isSensitive) } - } - - @available(iOS 17.0, macOS 14.0, *) - private func analyzeNetworkImage( - at url: URL, - result: @escaping FlutterResult - ) { - let analyzer = self.analyzer - - Task(priority: .userInitiated) { - 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 - } - - let analysisResult = try await analyzer.analyzeImage(cgImage) - - await MainActor.run { - result(analysisResult.isSensitive) - } - } catch { - await MainActor.run { - result( - FlutterError( - code: "analysis_error", - message: "Failed to analyze network image", - details: error.localizedDescription - ) - ) - } - } + } 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 + ) { + let analyzer = self.analyzer + + Task(priority: .userInitiated) { + 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 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 - ) + let analysisResult = try await analyzer.analyzeImage(cgImage) + + await MainActor.run { + result(analysisResult.isSensitive) + } + } 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) { + 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 + ) + ) + } + } + + public func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard #available(iOS 17.0, macOS 14.0, *) else { + result(FlutterMethodNotImplemented) + return } - public func handle( - _ call: FlutterMethodCall, - result: @escaping FlutterResult - ) { - guard #available(iOS 17.0, macOS 14.0, *) else { - result(FlutterMethodNotImplemented) - return - } + switch call.method { - 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) - - case "checkPolicy": - checkPolicy(result: result) - - default: - result(FlutterMethodNotImplemented) - } + 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) + + case "checkPolicy": + checkPolicy(result: result) + + default: + result(FlutterMethodNotImplemented) } + } } From 791d9450a25f09409e0ca00769e7d0224b3c48a0 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:05:34 +0200 Subject: [PATCH 04/15] fix --- .../xcschemes/xcschememanagement.plist | 2 +- .../SensitiveContentAnalysisPlugin.swift | 46 ++++++++++--------- example/macos/Podfile.lock | 12 ----- .../macos/Runner.xcodeproj/project.pbxproj | 34 ++++++++++++-- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++++++++ example/pubspec.lock | 4 +- example/pubspec.yaml | 4 +- 7 files changed, 78 insertions(+), 42 deletions(-) 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 index 5ca3b98..53c5146 100644 --- 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 @@ -7,7 +7,7 @@ sensitive-content-analysis.xcscheme_^#shared#^_ orderHint - 9 + 10 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 6f7d3f2..d175023 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -10,8 +10,7 @@ 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) @@ -62,9 +61,9 @@ image: FlutterStandardTypedData, result: @escaping FlutterResult ) { - let analyzer = self.analyzer - Task(priority: .userInitiated) { + let analyzer = await MainActor.run { self.analyzer } + guard analyzer.analysisPolicy != .disabled else { await MainActor.run { result(nil) } return @@ -100,9 +99,9 @@ at fileURL: URL, result: @escaping FlutterResult ) { - let analyzer = self.analyzer - Task(priority: .userInitiated) { + let analyzer = await MainActor.run { self.analyzer } + guard analyzer.analysisPolicy != .disabled else { await MainActor.run { result(nil) } return @@ -134,9 +133,9 @@ at url: URL, result: @escaping FlutterResult ) { - let analyzer = self.analyzer - Task(priority: .userInitiated) { + let analyzer = await MainActor.run { self.analyzer } + guard analyzer.analysisPolicy != .disabled else { await MainActor.run { result(nil) } return @@ -171,21 +170,24 @@ @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 + 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 + ) ) - ) + } } } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 68938d9..bbea909 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -1,34 +1,22 @@ 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 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 0e13e25..49be747 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 78FA66B7C2A45709C4128F28 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8F111450786C92DF977678C /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -83,6 +84,9 @@ 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 = ""; }; @@ -103,6 +107,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 5F8E2129349DD2ADB44CA281 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -164,6 +169,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 */, @@ -247,6 +255,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 +301,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -576,7 +590,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -705,7 +719,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 +742,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -794,6 +808,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" + flutter: ">=3.41.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e7d33c4..3724744 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -28,5 +28,5 @@ dev_dependencies: flutter: uses-material-design: true - config: - enable-swift-package-manager: false \ No newline at end of file + # config: + # enable-swift-package-manager: false \ No newline at end of file From 21c4a786f5ba319df888d723bdd3062fbcd7d508 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:24:10 +0200 Subject: [PATCH 05/15] Release 2.1.0 --- .github/workflows/test.yml | 42 ++ CHANGELOG.md | 11 + README.md | 96 ++-- .../SensitiveContentAnalysisPlugin.swift | 72 ++- example/ios/Flutter/Debug.xcconfig | 1 - example/ios/Flutter/Release.xcconfig | 1 - example/ios/Podfile | 44 -- example/ios/Podfile.lock | 49 -- example/ios/Runner.xcodeproj/project.pbxproj | 123 +--- example/lib/home_feed.dart | 253 +++++++++ example/lib/main.dart | 149 ++++- example/macos/Flutter/Flutter-Debug.xcconfig | 1 - .../macos/Flutter/Flutter-Release.xcconfig | 1 - .../Flutter/GeneratedPluginRegistrant.swift | 4 +- example/macos/Podfile | 43 -- example/macos/Podfile.lock | 23 - .../macos/Runner.xcodeproj/project.pbxproj | 95 ---- example/pubspec.lock | 528 ++++++++++++++++-- example/pubspec.yaml | 9 +- example/test/widget_test.dart | 271 ++++++++- example/test/widget_test.mocks.dart | 80 +++ lib/sensitive_content_analysis.dart | 54 +- pubspec.yaml | 2 +- 23 files changed, 1431 insertions(+), 521 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 example/ios/Podfile delete mode 100644 example/ios/Podfile.lock create mode 100644 example/lib/home_feed.dart delete mode 100644 example/macos/Podfile delete mode 100644 example/macos/Podfile.lock create mode 100644 example/test/widget_test.mocks.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ef5b46d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +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: 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 5b00a69..9d006e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 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. diff --git a/README.md b/README.md index 1844db0..87e2373 100644 --- a/README.md +++ b/README.md @@ -93,50 +93,41 @@ if (policy != null) { #### 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 +135,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 +145,50 @@ 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()); } - } ``` +### Result + +All `analyze` methods return a `SensitivityAnalysisResult` object: + +```dart +class SensitivityAnalysisResult { + final bool isSensitive; + final List detectedTypes; // Available on iOS 27.0+ / macOS 27.0+ +} +``` + +- `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.0 / macOS 27.0. + --- ### Caveats 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 d175023..47dde2e 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -1,3 +1,5 @@ +import ImageIO + #if canImport(SensitiveContentAnalysis) import SensitiveContentAnalysis #endif @@ -42,20 +44,60 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { } 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 - ) + 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 + + return [ + "isSensitive": analysisResult.isSensitive, + "detectedTypes": detectedTypesArray, + ] } + 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) + } + } + + func release() { + if !waiters.isEmpty { + let next = waiters.removeFirst() + next.resume() + } else { + running = max(0, running - 1) + } + } + } + + private let queue = AnalysisQueue() + @available(iOS 17.0, macOS 14.0, *) private func analyzeImage( image: FlutterStandardTypedData, @@ -78,7 +120,7 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { let analysisResult = try await analyzer.analyzeImage(cgImage) await MainActor.run { - result(analysisResult.isSensitive) + result(self.formatAnalysisResult(analysisResult)) } } catch { await MainActor.run { @@ -112,7 +154,7 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { let analysisResult = try await handler.hasSensitiveContent() await MainActor.run { - result(analysisResult.isSensitive) + result(self.formatAnalysisResult(analysisResult)) } } catch { await MainActor.run { @@ -134,6 +176,8 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { 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 { @@ -152,7 +196,7 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { let analysisResult = try await analyzer.analyzeImage(cgImage) await MainActor.run { - result(analysisResult.isSensitive) + result(self.formatAnalysisResult(analysisResult)) } } catch { await MainActor.run { 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 0052c97..0000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,49 +0,0 @@ -PODS: - - file_picker (0.0.1): - - Flutter - - FlutterMacOS - - Flutter (1.0.0) - - image_picker_ios (0.0.1): - - Flutter - - integration_test (0.0.1): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sensitive_content_analysis (0.0.1): - - Flutter - - FlutterMacOS - -DEPENDENCIES: - - file_picker (from `.symlinks/plugins/file_picker/darwin`) - - Flutter (from `Flutter`) - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - sensitive_content_analysis (from `.symlinks/plugins/sensitive_content_analysis/darwin`) - -EXTERNAL SOURCES: - file_picker: - :path: ".symlinks/plugins/file_picker/darwin" - Flutter: - :path: Flutter - image_picker_ios: - :path: ".symlinks/plugins/image_picker_ios/ios" - integration_test: - :path: ".symlinks/plugins/integration_test/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - sensitive_content_analysis: - :path: ".symlinks/plugins/sensitive_content_analysis/darwin" - -SPEC CHECKSUMS: - file_picker: 70164d9778c42c47218d6cd79ce435de0856b11a - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 - sensitive_content_analysis: ed23804e800acb5554078f1584f24da9c6184b87 - -PODFILE CHECKSUM: 9c46fd01abff66081b39f5fa5767b3f1d0b11d76 - -COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 9238175..b8e980c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,11 +11,10 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 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 */ @@ -44,20 +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 = ../../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; }; @@ -73,7 +67,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B52200AC350B45639D4A8255 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -81,7 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BA71F0ED51A027C7FB44BBF6 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -96,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 = ""; @@ -121,6 +99,9 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* sensitive_content_analysis */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -137,7 +118,6 @@ 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 87FE9446974C5F6AE975833C /* Pods */, - 3BE7B3DD1495D20D149F214B /* Frameworks */, ); sourceTree = ""; }; @@ -173,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 */, @@ -192,20 +171,21 @@ 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 = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -239,6 +219,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -271,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; @@ -309,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; @@ -346,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 */ @@ -489,7 +411,6 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 56D9EEAC50ABB2CA2200B2F0 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -507,7 +428,6 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7EB008A721187624F840D95F /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -523,7 +443,6 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 830E9F5EECBEBE4BE89C5025 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -727,6 +646,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 = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/lib/home_feed.dart b/example/lib/home_feed.dart new file mode 100644 index 0000000..8e7bf93 --- /dev/null +++ b/example/lib/home_feed.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; +import 'dart:ui'; +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: 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 == 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: 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}); + + // Fixed type signature to accept dynamic map values + 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: 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: FontWeight.bold)), + ], + ), + ), + GestureDetector( + onTap: () { + if (!_isNsfw) return; + setState(() => _isBlurred = !_isBlurred); + }, + child: Stack( + alignment: Alignment.center, + children: [ + _hasLoaded + ? AspectRatio( + aspectRatio: 1, + child: CachedNetworkImage( + imageUrl: imageUrl, + width: double.infinity, + height: 440, + fit: BoxFit.cover, + )) + : Center(child: CircularProgressIndicator()), + if (_isBlurred) ...[ + Positioned.fill( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.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: 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: TextAlign.center, + style: TextStyle( + color: Colors.white, fontWeight: 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..5bf1c57 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,8 +7,12 @@ 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'; + +late final SensitiveContentAnalysis sca; void main() { + sca = SensitiveContentAnalysis(); runApp(MaterialApp(theme: ThemeData.dark(), home: MyApp())); } @@ -20,8 +24,6 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final sca = SensitiveContentAnalysis(); - Future analyzeImage() async { try { final ImagePicker picker = ImagePicker(); @@ -33,8 +35,10 @@ class _MyAppState extends State { 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 +51,10 @@ 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()); } @@ -65,8 +71,10 @@ class _MyAppState extends State { final response = await dio.download(url, file.path); 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) { @@ -81,9 +89,10 @@ class _MyAppState extends State { type: FileType.video, ); if (selectedFile != null) { - bool? isSensitive = + SensitivityAnalysisResult? isSensitive = await sca.analyzeVideo(url: selectedFile.files.first.path!); - _showResultDialog("Analysis Result", "SENSITIVE: $isSensitive"); + _showResultDialog( + "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); } } catch (e) { _showResultDialog("Error", e.toString()); @@ -129,6 +138,7 @@ class _MyAppState extends State { onPressed: () async => await checkPolicy(), child: const Text("Check Policy."), ), + warningWidget(), ]), ), ); @@ -151,4 +161,123 @@ class _MyAppState extends State { }, ); } + + TextButton warningWidget() { + return TextButton( + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.0), + ), + elevation: 10, + backgroundColor: Theme.of(context).cardColor, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, // Wrap content tightly + children: [ + // 18+ Icon Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Text( + "18+", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + ), + const SizedBox(height: 20), + + // Title + const Text( + "Age Verification Required", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + + // Content Description + Text( + "The home feed contains content intended for mature audiences. Please verify that you are 18 years or older to proceed.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + height: 1.4, + ), + ), + const SizedBox(height: 28), + + // Actions (Stacked for a cleaner, full-bleed button look) + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const FeedScreen(), + ), + ); + }, + child: const Text( + "I am 18 or older", + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 44, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.grey[600], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => Navigator.pop(context), // Close dialog + child: const Text( + "Go Back", + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + child: const Text("Home feed example."), + ); + } } 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 bbea909..0000000 --- a/example/macos/Podfile.lock +++ /dev/null @@ -1,23 +0,0 @@ -PODS: - - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - -DEPENDENCIES: - - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - -EXTERNAL SOURCES: - FlutterMacOS: - :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - -SPEC CHECKSUMS: - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 - -PODFILE CHECKSUM: 6acf97521436d16fc31cd5e1a02000905acdb3ae - -COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 49be747..215a4c5 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,9 +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 */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - 78FA66B7C2A45709C4128F28 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8F111450786C92DF977678C /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,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 = ""; }; @@ -81,17 +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 */ @@ -99,7 +89,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78FA66B7C2A45709C4128F28 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -108,7 +97,6 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - 5F8E2129349DD2ADB44CA281 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -141,7 +129,6 @@ 33CEB47122A05771004F2AC0 /* Flutter */, 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, FE0095FE3310AF736E67F430 /* Pods */, ); sourceTree = ""; @@ -193,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 = ""; @@ -222,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 */, @@ -241,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 = ( ); @@ -335,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; @@ -395,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 */ @@ -485,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; @@ -500,7 +407,6 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E4519D8E12DBAC847FE32D5E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -515,7 +421,6 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7F890A81D151036D48996D76 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/example/pubspec.lock b/example/pubspec.lock index 0e506f0..9bdafff 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" args: dependency: transitive description: @@ -13,18 +29,90 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -33,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -41,6 +137,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -49,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -57,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -65,14 +193,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + url: "https://pub.dev" + source: hosted + version: "3.1.9" dbus: dependency: transitive description: name: dbus - sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.13" + version: "0.7.14" dio: dependency: "direct main" description: @@ -85,10 +221,10 @@ packages: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" fake_async: dependency: transitive description: @@ -117,10 +253,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" file_picker: dependency: "direct main" description: @@ -157,15 +293,31 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_driver: dependency: transitive description: flutter @@ -202,22 +354,54 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: transitive description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image_picker: dependency: "direct main" description: @@ -230,26 +414,26 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "8dfe08ea7fcf7467dbaf6889e72eebd5e0d6711caae201fdac780eb45232cd02" + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+19" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 url: "https://pub.dev" source: hosted - version: "0.8.13" + version: "0.8.13+6" image_picker_linux: dependency: transitive description: @@ -262,18 +446,18 @@ packages: dependency: transitive description: name: image_picker_macos - sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.2+1" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.11.1" image_picker_windows: dependency: transitive description: @@ -287,6 +471,38 @@ packages: description: flutter source: sdk version: "0.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -319,6 +535,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -347,12 +571,44 @@ packages: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" - path: + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: c8d040d754367108fbe482dcb79dd72b8fe60ac6727abd15b4783c5560297ee6 + url: "https://pub.dev" + source: hosted + version: "5.7.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -371,18 +627,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.18" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -419,10 +675,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -431,34 +687,138 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" process: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sensitive_content_analysis: dependency: "direct main" description: path: ".." relative: true source: path - version: "2.0.3" + version: "2.1.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014 + url: "https://pub.dev" + source: hosted + version: "2.5.9" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -475,14 +835,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -491,14 +859,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: @@ -511,10 +887,18 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "4.5.3" vector_math: dependency: transitive description: @@ -527,10 +911,18 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -539,14 +931,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.1.0" win32: dependency: transitive description: @@ -559,18 +967,26 @@ packages: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.41.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 3724744..ef07b4a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -16,17 +16,18 @@ 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 dev_dependencies: integration_test: sdk: flutter flutter_test: sdk: flutter + mockito: ^5.4.4 + build_runner: ^2.4.9 flutter_lints: ^6.0.0 flutter: - uses-material-design: true - - # config: - # enable-swift-package-manager: false \ No newline at end of file + uses-material-design: true \ No newline at end of file diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 330c48c..82d5936 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,27 +1,260 @@ -// 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: []), + ); + + 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: []), + ); + + 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: []), + ); + + 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: []), + ); + + 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: []), + ); + + 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 => 0); + + final policy = await mockSca.checkPolicy(); + + expect(policy, equals(0)); + verify(mockSca.checkPolicy()).called(1); + }); + + test('returns policy code 1 (restricted)', () async { + when(mockSca.checkPolicy()).thenAnswer((_) async => 1); + + final policy = await mockSca.checkPolicy(); + + expect(policy, equals(1)); + }); + + 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(() => mockSca.checkPolicy(), throwsException); + }); + }); + + group('SensitivityAnalysisResult model', () { + test('constructs with isSensitive=true', () { + final result = + SensitivityAnalysisResult(isSensitive: true, detectedTypes: []); + expect(result.isSensitive, isTrue); + }); + + test('constructs with isSensitive=false', () { + final result = + SensitivityAnalysisResult(isSensitive: false, detectedTypes: []); + 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..c28e8f0 --- /dev/null +++ b/example/test/widget_test.mocks.dart @@ -0,0 +1,80 @@ +// 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 + +/// 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 checkPolicy() => (super.noSuchMethod( + Invocation.method( + #checkPolicy, + [], + ), + returnValue: _i3.Future.value(), + ) as _i3.Future); +} diff --git a/lib/sensitive_content_analysis.dart b/lib/sensitive_content_analysis.dart index 2ac5331..05bc248 100644 --- a/lib/sensitive_content_analysis.dart +++ b/lib/sensitive_content_analysis.dart @@ -5,12 +5,13 @@ 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 { + /// Returns a [SensitivityAnalysisResult] with sensitivity status and detected content types. + Future analyzeImage(Uint8List file) async { try { - final dynamic isSensitive = - await methodChannel.invokeMethod('analyzeImage', file); - return isSensitive; + final Map? result = + await methodChannel.invokeMapMethod('analyzeImage', file); + if (result == null) return null; + return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -18,12 +19,14 @@ class SensitiveContentAnalysis { /// Analyzes an network image for sensitive content. /// - /// Returns a `bool` indicating whether the image is sensitive. - Future analyzeNetworkImage({required String url}) async { + /// Returns a [SensitivityAnalysisResult] with sensitivity status and detected content types. + Future analyzeNetworkImage( + {required String url}) async { try { - final dynamic isSensitive = - await methodChannel.invokeMethod('analyzeNetworkImage', {"url": url}); - return isSensitive; + final Map? result = await methodChannel + .invokeMapMethod('analyzeNetworkImage', {"url": url}); + if (result == null) return null; + return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -31,12 +34,13 @@ class SensitiveContentAnalysis { /// Analyzes an video for sensitive content. /// - /// Returns a `bool` indicating whether the video is sensitive. - Future analyzeVideo({required String url}) async { + /// Returns a [SensitivityAnalysisResult] with sensitivity status and detected content types. + Future analyzeVideo({required String url}) async { try { - final dynamic isSensitive = await methodChannel - .invokeMethod('analyzeVideo', {"url": Uri.file(url).toString()}); - return isSensitive; + final Map? result = await methodChannel + .invokeMapMethod('analyzeVideo', {"url": Uri.file(url).toString()}); + if (result == null) return null; + return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -54,3 +58,23 @@ class SensitiveContentAnalysis { } } } + +class SensitivityAnalysisResult { + final bool isSensitive; + final List detectedTypes; + + const SensitivityAnalysisResult({ + required this.isSensitive, + required this.detectedTypes, + }); + + factory SensitivityAnalysisResult.fromMap(Map map) { + return SensitivityAnalysisResult( + isSensitive: map['isSensitive'] as bool? ?? false, + detectedTypes: (map['detectedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2bb61f1..7ce42f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ 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.3 +version: 2.1.0 homepage: https://github.com/doppeltilde/sensitive_content_analysis environment: From 749ae24b27857629b9f6081287c51a115bfd31c2 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:28:15 +0200 Subject: [PATCH 06/15] Update test.yml --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef5b46d..9906c20 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,10 @@ jobs: - 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 . From 2a22f76a237ce951d8364d1fa1c71b70999df367 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:31:39 +0200 Subject: [PATCH 07/15] Update main.dart --- example/lib/main.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5bf1c57..abc8c2d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -178,13 +178,13 @@ class _MyAppState extends State { child: Padding( padding: const EdgeInsets.all(24.0), child: Column( - mainAxisSize: MainAxisSize.min, // Wrap content tightly + mainAxisSize: MainAxisSize.min, children: [ // 18+ Icon Header Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), + color: Colors.red.withValues(alpha: .1), shape: BoxShape.circle, ), child: const Text( @@ -210,7 +210,6 @@ class _MyAppState extends State { ), const SizedBox(height: 12), - // Content Description Text( "The home feed contains content intended for mature audiences. Please verify that you are 18 years or older to proceed.", textAlign: TextAlign.center, @@ -222,7 +221,6 @@ class _MyAppState extends State { ), const SizedBox(height: 28), - // Actions (Stacked for a cleaner, full-bleed button look) SizedBox( width: double.infinity, height: 48, @@ -236,7 +234,7 @@ class _MyAppState extends State { elevation: 0, ), onPressed: () { - Navigator.pop(context); // Close dialog + Navigator.pop(context); Navigator.push( context, MaterialPageRoute( @@ -262,7 +260,7 @@ class _MyAppState extends State { borderRadius: BorderRadius.circular(12), ), ), - onPressed: () => Navigator.pop(context), // Close dialog + onPressed: () => Navigator.pop(context), child: const Text( "Go Back", style: TextStyle( From 6e7527763a37f48dcdbf770cbcee7eed41dc0e8e Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:19:46 +0200 Subject: [PATCH 08/15] feat: video stream analyzer --- README.md | 5 +- .../SensitiveContentAnalysisPlugin.swift | 366 ++++++++++++++++++ example/lib/main.dart | 14 +- example/lib/video_analyzer.dart | 254 ++++++++++++ example/pubspec.lock | 42 +- example/pubspec.yaml | 1 + example/test/widget_test.dart | 62 ++- example/test/widget_test.mocks.dart | 45 ++- lib/sensitive_content_analysis.dart | 247 ++++++++++-- pubspec.yaml | 15 +- 10 files changed, 995 insertions(+), 56 deletions(-) create mode 100644 example/lib/video_analyzer.dart diff --git a/README.md b/README.md index 87e2373..50e2038 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 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 47dde2e..211c0c6 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -1,3 +1,4 @@ +import CoreVideo import ImageIO #if canImport(SensitiveContentAnalysis) @@ -14,6 +15,8 @@ import ImageIO public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { + private static var _cachedMessenger: FlutterBinaryMessenger? + public static func register(with registrar: FlutterPluginRegistrar) { #if os(iOS) let messenger = registrar.messenger() @@ -21,6 +24,8 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { let messenger = registrar.messenger #endif + Self._cachedMessenger = messenger + let channel = FlutterMethodChannel( name: "sensitive_content_analysis", binaryMessenger: messenger @@ -30,6 +35,8 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) } + // MARK: - SCSensitivityAnalyzer + private var _analyzer: Any? @available(iOS 17.0, macOS 14.0, *) @@ -43,6 +50,21 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { return newAnalyzer } + // MARK: - SCVideoStreamAnalyzer + + #if os(iOS) + private var _streamAnalyzers: [String: Any] = [:] + private var _streamTasks: [String: Any] = [:] + private var _streamChannels: [String: FlutterEventChannel] = [:] + + @available(iOS 26.0, *) + private func streamAnalyzer(for uuid: String) -> SCVideoStreamAnalyzer? { + return _streamAnalyzers[uuid] as? SCVideoStreamAnalyzer + } + #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) @@ -65,9 +87,24 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { } #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, ] } @@ -235,6 +272,298 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { } } + #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, + messenger: FlutterBinaryMessenger + ) { + 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 + ) + _streamAnalyzers[participantUUID] = streamAnalyzer + + 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: 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 + )) + } + } + + /// 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 + ) { + 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? + + init( + streamAnalyzer: SCVideoStreamAnalyzer, + formatResult: @escaping (SCSensitivityAnalysis) -> [String: Any]? + ) { + self.streamAnalyzer = streamAnalyzer + self.formatResult = formatResult + } + + func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink + ) -> FlutterError? { + monitorTask?.cancel() + + let analyzer = self.streamAnalyzer + let formatter = self.formatResult + + print("🔄 [Native] Starting analysisChanges listener...") + + monitorTask = Task { [weak self] in + guard let self else { return } + + print("▶️ [Native] Task started, waiting for analysisChanges...") + + do { + for try await analysis in self.streamAnalyzer.analysisChanges { + print("📡 [Native] >>> analysisChanges EMITTED! isSensitive: \(analysis.isSensitive)") + guard !Task.isCancelled else { + print("⏹️ [Native] Task cancelled during analysisChanges") + break + } + + var detectedStr = "[]" + #if compiler(>=6.4) + if #available(iOS 27.0, *) { + detectedStr = "\(analysis.detectedTypes)" + } + #endif + + print( + """ + 📡 [Native] Received analysis event → + isSensitive: \(analysis.isSensitive), + shouldInterrupt: \(analysis.shouldInterruptVideo), + detectedTypes: \(detectedStr) + """) + + if let formatted = formatter(analysis) { + await MainActor.run { + events(formatted) + } + } + } + + print("🔚 [Native] analysisChanges stream ended normally") + await MainActor.run { events(FlutterEndOfEventStream) } + } catch { + print("❌ [Native] analysisChanges error: \(error.localizedDescription)") + await MainActor.run { + events( + FlutterError( + code: "stream_analysis_error", + message: "Error in analysisChanges stream", + details: error.localizedDescription + )) + } + } + } + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + print("🛑 [Native] onCancel called for stream analyzer") + monitorTask?.cancel() + monitorTask = nil + return nil + } + } + + #endif + + // MARK: - FlutterPlugin handle + public func handle( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -296,6 +625,43 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { 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 + } + + let messenger = SensitiveContentAnalysisPlugin._cachedMessenger! + + switch call.method { + case "createVideoStreamAnalyzer": + createVideoStreamAnalyzer(args: args, result: result, messenger: messenger) + 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/lib/main.dart b/example/lib/main.dart index abc8c2d..f45c143 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,6 +8,7 @@ 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_analyzer.dart'; late final SensitiveContentAnalysis sca; @@ -101,8 +102,8 @@ 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}"); } catch (e) { _showResultDialog("Error", e.toString()); } @@ -134,6 +135,15 @@ class _MyAppState extends State { 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."), diff --git a/example/lib/video_analyzer.dart b/example/lib/video_analyzer.dart new file mode 100644 index 0000000..6cc08b6 --- /dev/null +++ b/example/lib/video_analyzer.dart @@ -0,0 +1,254 @@ +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; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _initializeSystem(); + } + + Future _initializeSystem() async { + final policy = await SensitiveContentAnalysis().checkPolicy(); + print("Analysis Policy: $policy"); + + if (policy == AnalysisPolicy.disabled) { + print( + "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings."); + } + try { + _analyzer = await sca.createVideoStreamAnalyzer( + participantUUID: widget.participantUUID, + streamDirection: StreamDirection.outgoing, + ); + + _subscribeToAnalysisChanges(); + + final cameras = await availableCameras(); + if (cameras.isEmpty) { + throw Exception("No physical cameras detected on this device."); + } + + _cameraController = CameraController( + cameras.first, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.bgra8888, + ); + + await _cameraController!.initialize(); + + await Future.delayed(const Duration(milliseconds: 100)); + 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 { + print("🔍 Full Analysis Result: $result"); + setState(() { + _isStreamInterrupted = result.shouldInterruptVideo; + }); + }, + onError: (error) { + debugPrint('Error caught from analysis EventChannel: $error'); + }, + ); + } + + DateTime? _lastAnalysisTime; + final int _throttleMs = 400; + + 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; + + print( + "📤 Sending frame: ${image.width}x ${image.height}, bytes: ${plane.bytes.length}"); + + 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 EdgeInsets.all(24.0), + child: Text( + _errorMessage, + style: const TextStyle(color: Colors.red, fontSize: 16), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text("Sensitive Content Stream")), + body: Stack( + children: [ + if (_cameraController != null && + _cameraController!.value.isInitialized) + Positioned.fill( + child: CameraPreview(_cameraController!), + ) + else + const Center(child: Text("Camera view unavailable.")), + 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: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.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: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isStreamInterrupted ? "NOT SAFE" : "SAFE", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: 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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 9bdafff..167f0eb 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -113,6 +113,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777" + url: "https://pub.dev" + source: hosted + version: "0.12.0+1" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: e20c1e92ce6797d9ae9b1db1e09a4c1039a04827d0b24985f5da3840b96948ac + url: "https://pub.dev" + source: hosted + version: "0.7.2+1" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: @@ -741,7 +781,7 @@ packages: path: ".." relative: true source: path - version: "2.1.0" + version: "2.1.1" shelf: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ef07b4a..7f81821 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: dio: ^5.9.2 path: ^1.9.1 cached_network_image: ^3.4.1 + camera: ^0.12.0+1 dev_dependencies: integration_test: diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 82d5936..92e559c 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -20,8 +20,12 @@ void main() { 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: []), + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), ); final result = await mockSca.analyzeImage(fakeBytes); @@ -34,8 +38,12 @@ void main() { 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: []), + (_) async => SensitivityAnalysisResult( + isSensitive: true, + detectedTypes: [], + shouldIndicateSensitivity: true, + shouldInterruptVideo: true, + shouldMuteAudio: true), ); final result = await mockSca.analyzeImage(fakeBytes); @@ -68,8 +76,12 @@ void main() { test('returns isSensitive=false for safe network image', () async { when(mockSca.analyzeNetworkImage(url: safeUrl)).thenAnswer( - (_) async => - SensitivityAnalysisResult(isSensitive: false, detectedTypes: []), + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), ); final result = await mockSca.analyzeNetworkImage(url: safeUrl); @@ -106,8 +118,12 @@ void main() { test('returns isSensitive=false for safe video', () async { when(mockSca.analyzeVideo(url: videoPath)).thenAnswer( - (_) async => - SensitivityAnalysisResult(isSensitive: false, detectedTypes: []), + (_) async => SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false), ); final result = await mockSca.analyzeVideo(url: videoPath); @@ -119,8 +135,12 @@ void main() { test('returns isSensitive=true for flagged video', () async { when(mockSca.analyzeVideo(url: videoPath)).thenAnswer( - (_) async => - SensitivityAnalysisResult(isSensitive: true, detectedTypes: []), + (_) async => SensitivityAnalysisResult( + isSensitive: true, + detectedTypes: [], + shouldIndicateSensitivity: true, + shouldInterruptVideo: true, + shouldMuteAudio: true), ); final result = await mockSca.analyzeVideo(url: videoPath); @@ -142,7 +162,8 @@ void main() { group('SensitiveContentAnalysis - checkPolicy', () { test('returns policy code 0 (no restrictions)', () async { - when(mockSca.checkPolicy()).thenAnswer((_) async => 0); + when(mockSca.checkPolicy()) + .thenAnswer((_) async => AnalysisPolicy.fromInt(0)); final policy = await mockSca.checkPolicy(); @@ -151,7 +172,8 @@ void main() { }); test('returns policy code 1 (restricted)', () async { - when(mockSca.checkPolicy()).thenAnswer((_) async => 1); + when(mockSca.checkPolicy()) + .thenAnswer((_) async => AnalysisPolicy.fromInt(1)); final policy = await mockSca.checkPolicy(); @@ -175,14 +197,22 @@ void main() { group('SensitivityAnalysisResult model', () { test('constructs with isSensitive=true', () { - final result = - SensitivityAnalysisResult(isSensitive: true, detectedTypes: []); + 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: []); + final result = SensitivityAnalysisResult( + isSensitive: false, + detectedTypes: [], + shouldIndicateSensitivity: false, + shouldInterruptVideo: false, + shouldMuteAudio: false); expect(result.isSensitive, isFalse); }); }); diff --git a/example/test/widget_test.mocks.dart b/example/test/widget_test.mocks.dart index c28e8f0..f79a9c8 100644 --- a/example/test/widget_test.mocks.dart +++ b/example/test/widget_test.mocks.dart @@ -25,6 +25,17 @@ import 'package:sensitive_content_analysis/sensitive_content_analysis.dart' // 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. @@ -70,11 +81,39 @@ class MockSensitiveContentAnalysis extends _i1.Mock ) as _i3.Future<_i2.SensitivityAnalysisResult?>); @override - _i3.Future checkPolicy() => (super.noSuchMethod( + _i3.Future<_i2.AnalysisPolicy?> checkPolicy() => (super.noSuchMethod( Invocation.method( #checkPolicy, [], ), - returnValue: _i3.Future.value(), - ) as _i3.Future); + 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/lib/sensitive_content_analysis.dart b/lib/sensitive_content_analysis.dart index 05bc248..21cff68 100644 --- a/lib/sensitive_content_analysis.dart +++ b/lib/sensitive_content_analysis.dart @@ -1,15 +1,13 @@ 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 [SensitivityAnalysisResult] with sensitivity status and detected content types. + /// Analyzes a local image for sensitive content. Future analyzeImage(Uint8List file) async { try { final Map? result = - await methodChannel.invokeMapMethod('analyzeImage', file); + await _methodChannel.invokeMapMethod('analyzeImage', file); if (result == null) return null; return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { @@ -17,14 +15,13 @@ class SensitiveContentAnalysis { } } - /// Analyzes an network image for sensitive content. - /// - /// Returns a [SensitivityAnalysisResult] with sensitivity status and detected content types. - Future analyzeNetworkImage( - {required String url}) async { + /// Analyzes a network image for sensitive content. + Future analyzeNetworkImage({ + required String url, + }) async { try { - final Map? result = await methodChannel - .invokeMapMethod('analyzeNetworkImage', {"url": url}); + final Map? result = await _methodChannel + .invokeMapMethod('analyzeNetworkImage', {'url': url}); if (result == null) return null; return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { @@ -32,13 +29,13 @@ class SensitiveContentAnalysis { } } - /// Analyzes an video for sensitive content. - /// - /// Returns a [SensitivityAnalysisResult] with sensitivity status and detected content types. - Future analyzeVideo({required String url}) async { + /// Analyzes a local video file for sensitive content. + Future analyzeVideo({ + required String url, + }) async { try { - final Map? result = await methodChannel - .invokeMapMethod('analyzeVideo', {"url": Uri.file(url).toString()}); + final Map? result = await _methodChannel + .invokeMapMethod('analyzeVideo', {'url': Uri.file(url).toString()}); if (result == null) return null; return SensitivityAnalysisResult.fromMap(result); } on PlatformException catch (e) { @@ -46,35 +43,225 @@ class SensitiveContentAnalysis { } } - /// Check Policy. + /// Returns the current [AnalysisPolicy]. + Future checkPolicy() async { + try { + final int? raw = await _methodChannel.invokeMethod('checkPolicy'); + if (raw == null) return null; + return AnalysisPolicy.fromInt(raw); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// Creates a [VideoStreamAnalyzer] for the given participant. /// - /// Returns a `int` indicating the policy value. - Future checkPolicy() 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 result = await methodChannel.invokeMethod('checkPolicy'); - return result; + await _methodChannel.invokeMethod('createVideoStreamAnalyzer', { + 'participantUUID': participantUUID, + 'streamDirection': streamDirection.index, + 'eventChannelName': eventChannelName, + }); } on PlatformException catch (e) { throw UnimplementedError(e.message); } + + return VideoStreamAnalyzer._( + participantUUID: participantUUID, + eventChannelName: eventChannelName, + ); } } -class SensitivityAnalysisResult { - final bool isSensitive; - final List detectedTypes; +// --------------------------------------------------------------------------- +// VideoStreamAnalyzer +// --------------------------------------------------------------------------- + +/// 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 String eventChannelName, + }) : _eventChannel = EventChannel(eventChannelName); + + final String participantUUID; + final EventChannel _eventChannel; + /// A stream of [SensitivityAnalysisResult] pushed whenever the framework + /// detects a change in the video stream's sensitivity status. + /// + /// 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) => + SensitivityAnalysisResult.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 { + await _methodChannel.invokeMethod('analyzeVideoStreamFrame', { + 'participantUUID': participantUUID, + 'bytes': bytes, + 'width': width, + 'height': height, + 'bytesPerRow': bytesPerRow, + }); + } on PlatformException catch (e) { + throw UnimplementedError(e.message); + } + } + + /// 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. + /// + /// After calling this, [analysisChanges] will close and this instance + /// should be discarded. + Future endAnalysis() async { + try { + 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 26+ / macOS 26+; always false on older OS versions. + final bool shouldIndicateSensitivity; + + /// App should interrupt video playback. + /// Available on iOS 26+ / macOS 26+; always false on older OS versions. + final bool shouldInterruptVideo; + + /// App should mute the audio of the current video stream. + /// Available on iOS 26+ / macOS 26+; always false on older OS versions. + final bool shouldMuteAudio; + factory SensitivityAnalysisResult.fromMap(Map map) { + final rawTypes = (map['detectedTypes'] as List?) + ?.map((e) => DetectedContentType.fromString(e as String? ?? '')) + .whereType() + .toList() ?? + []; + return SensitivityAnalysisResult( isSensitive: map['isSensitive'] as bool? ?? false, - detectedTypes: (map['detectedTypes'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], + detectedTypes: rawTypes, + shouldIndicateSensitivity: + map['shouldIndicateSensitivity'] as bool? ?? false, + shouldInterruptVideo: map['shouldInterruptVideo'] as bool? ?? false, + shouldMuteAudio: map['shouldMuteAudio'] as bool? ?? false, ); } + + @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' => DetectedContentType.sexuallyExplicit, + 'goreOrViolence' => DetectedContentType.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 => AnalysisPolicy.disabled, + 1 => AnalysisPolicy.simpleInterventions, + 2 => AnalysisPolicy.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/pubspec.yaml b/pubspec.yaml index 7ce42f5..b8edaca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,19 @@ 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.1.0 +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.1 homepage: https://github.com/doppeltilde/sensitive_content_analysis +topics: + - nsfw + - nsfw-detection + - content-moderation + - moderation + - sensitive-content + - sensitive-content-analysis + - video + - image + - photo + environment: sdk: '>=3.1.0 <4.0.0' flutter: '>=3.41.0' From 7ac534e71e3b7da9acc806c23a5f6d70bb8a5da1 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:22:16 +0200 Subject: [PATCH 09/15] Update video_analyzer.dart --- example/lib/video_analyzer.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/example/lib/video_analyzer.dart b/example/lib/video_analyzer.dart index 6cc08b6..207f1b7 100644 --- a/example/lib/video_analyzer.dart +++ b/example/lib/video_analyzer.dart @@ -37,10 +37,10 @@ class _CameraSensitiveAnalysisScreenState Future _initializeSystem() async { final policy = await SensitiveContentAnalysis().checkPolicy(); - print("Analysis Policy: $policy"); + debugPrint("Analysis Policy: $policy"); if (policy == AnalysisPolicy.disabled) { - print( + debugPrint( "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings."); } try { @@ -86,7 +86,7 @@ class _CameraSensitiveAnalysisScreenState _analysisSubscription = _analyzer!.analysisChanges.listen( (SensitivityAnalysisResult result) async { - print("🔍 Full Analysis Result: $result"); + debugPrint("🔍 Full Analysis Result: $result"); setState(() { _isStreamInterrupted = result.shouldInterruptVideo; }); @@ -122,9 +122,6 @@ class _CameraSensitiveAnalysisScreenState final Uint8List bytes = plane.bytes; final int bytesPerRow = plane.bytesPerRow; - print( - "📤 Sending frame: ${image.width}x ${image.height}, bytes: ${plane.bytes.length}"); - await _analyzer!.analyzeFrame( bytes: bytes, width: image.width, From 77e08c444f32a239f6c76d3b39cef795d03a048b Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:27:18 +0200 Subject: [PATCH 10/15] Update widget_test.dart --- example/test/widget_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 92e559c..4c48928 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -163,7 +163,7 @@ void main() { group('SensitiveContentAnalysis - checkPolicy', () { test('returns policy code 0 (no restrictions)', () async { when(mockSca.checkPolicy()) - .thenAnswer((_) async => AnalysisPolicy.fromInt(0)); + .thenAnswer((_) async => AnalysisPolicy.disabled); final policy = await mockSca.checkPolicy(); @@ -173,7 +173,7 @@ void main() { test('returns policy code 1 (restricted)', () async { when(mockSca.checkPolicy()) - .thenAnswer((_) async => AnalysisPolicy.fromInt(1)); + .thenAnswer((_) async => AnalysisPolicy.simpleInterventions); final policy = await mockSca.checkPolicy(); From b04d94cc4be1ec32bf8f22eafb7934df4b1d74d5 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:04:34 +0200 Subject: [PATCH 11/15] fix --- .../SensitiveContentAnalysisPlugin.swift | 52 ++--- example/lib/video_analyzer.dart | 207 +++++++++++++----- 2 files changed, 178 insertions(+), 81 deletions(-) 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 211c0c6..6a9f63a 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -15,7 +15,12 @@ import ImageIO public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { - private static var _cachedMessenger: FlutterBinaryMessenger? + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } public static func register(with registrar: FlutterPluginRegistrar) { #if os(iOS) @@ -24,14 +29,12 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { let messenger = registrar.messenger #endif - Self._cachedMessenger = messenger - let channel = FlutterMethodChannel( name: "sensitive_content_analysis", binaryMessenger: messenger ) - let instance = SensitiveContentAnalysisPlugin() + let instance = SensitiveContentAnalysisPlugin(messenger: messenger) registrar.addMethodCallDelegate(instance, channel: channel) } @@ -287,7 +290,6 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { private func createVideoStreamAnalyzer( args: [String: Any], result: @escaping FlutterResult, - messenger: FlutterBinaryMessenger ) { guard let participantUUID = args["participantUUID"] as? String, @@ -323,7 +325,7 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { ) let eventChannel = FlutterEventChannel( name: eventChannelName, - binaryMessenger: messenger + binaryMessenger: self.messenger ) eventChannel.setStreamHandler(handler) @@ -489,27 +491,27 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { self.formatResult = formatResult } - func onListen( - withArguments arguments: Any?, - eventSink events: @escaping FlutterEventSink - ) -> FlutterError? { + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { monitorTask?.cancel() - let analyzer = self.streamAnalyzer - let formatter = self.formatResult + var iterator = streamAnalyzer.analysisChanges.makeAsyncIterator() - print("🔄 [Native] Starting analysisChanges listener...") + if let current = streamAnalyzer.analysis, let formatted = formatResult(current) { + print("📡 [Native] Emitting initial analysis state: isSensitive=\(current.isSensitive)") + events(formatted) + } - monitorTask = Task { [weak self] in - guard let self else { return } + print("🔄 [Native] Starting analysisChanges listener...") - print("▶️ [Native] Task started, waiting for analysisChanges...") + monitorTask = Task { + print("▶️ [Native] Task started, driving pre-registered iterator...") do { - for try await analysis in self.streamAnalyzer.analysisChanges { - print("📡 [Native] >>> analysisChanges EMITTED! isSensitive: \(analysis.isSensitive)") + while let analysis = try await iterator.next() { guard !Task.isCancelled else { - print("⏹️ [Native] Task cancelled during analysisChanges") + print("⏹️ [Native] Task cancelled") break } @@ -522,13 +524,13 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { print( """ - 📡 [Native] Received analysis event → - isSensitive: \(analysis.isSensitive), - shouldInterrupt: \(analysis.shouldInterruptVideo), + 📡 [Native] analysisChanges EMITTED → + isSensitive: \(analysis.isSensitive) + shouldInterrupt: \(analysis.shouldInterruptVideo) detectedTypes: \(detectedStr) """) - if let formatted = formatter(analysis) { + if let formatted = formatResult(analysis) { await MainActor.run { events(formatted) } @@ -641,11 +643,9 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { return } - let messenger = SensitiveContentAnalysisPlugin._cachedMessenger! - switch call.method { case "createVideoStreamAnalyzer": - createVideoStreamAnalyzer(args: args, result: result, messenger: messenger) + createVideoStreamAnalyzer(args: args, result: result) case "analyzeVideoStreamFrame": analyzeVideoStreamFrame(args: args, result: result) case "continueVideoStream": diff --git a/example/lib/video_analyzer.dart b/example/lib/video_analyzer.dart index 207f1b7..f0e750a 100644 --- a/example/lib/video_analyzer.dart +++ b/example/lib/video_analyzer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; import 'package:sensitive_content_analysis_example/main.dart'; import 'package:camera/camera.dart'; @@ -27,6 +28,7 @@ class _CameraSensitiveAnalysisScreenState bool _isInitializing = true; bool _isAnalyzingFrame = false; bool _isStreamInterrupted = false; + bool _isContinuing = false; String _errorMessage = ''; @override @@ -46,7 +48,7 @@ class _CameraSensitiveAnalysisScreenState try { _analyzer = await sca.createVideoStreamAnalyzer( participantUUID: widget.participantUUID, - streamDirection: StreamDirection.outgoing, + streamDirection: StreamDirection.incoming, ); _subscribeToAnalysisChanges(); @@ -65,7 +67,6 @@ class _CameraSensitiveAnalysisScreenState await _cameraController!.initialize(); - await Future.delayed(const Duration(milliseconds: 100)); await _analyzer!.continueStream(); _startFrameStreamingPipeline(); @@ -87,8 +88,17 @@ class _CameraSensitiveAnalysisScreenState _analysisSubscription = _analyzer!.analysisChanges.listen( (SensitivityAnalysisResult result) async { debugPrint("🔍 Full Analysis Result: $result"); + debugPrint("${result.isSensitive}"); + debugPrint("${result.shouldInterruptVideo}"); + if (result.shouldInterruptVideo) { + if (_cameraController != null && + _cameraController!.value.isStreamingImages) { + await _cameraController!.stopImageStream(); + } + } setState(() { - _isStreamInterrupted = result.shouldInterruptVideo; + _isStreamInterrupted = true; + _isContinuing = false; }); }, onError: (error) { @@ -97,8 +107,21 @@ class _CameraSensitiveAnalysisScreenState ); } + Future _onContinueTapped() async { + if (_analyzer == null || _isContinuing) return; + setState(() => _isContinuing = true); + try { + await _analyzer!.continueStream(); + setState(() => _isStreamInterrupted = false); + _startFrameStreamingPipeline(); + } catch (e) { + debugPrint("Failed to continue stream: $e"); + setState(() => _isContinuing = false); + } + } + DateTime? _lastAnalysisTime; - final int _throttleMs = 400; + final int _throttleMs = 300; void _startFrameStreamingPipeline() { if (_cameraController == null || !_cameraController!.value.isInitialized) { @@ -181,69 +204,143 @@ class _CameraSensitiveAnalysisScreenState if (_cameraController != null && _cameraController!.value.isInitialized) Positioned.fill( - child: CameraPreview(_cameraController!), + child: _isStreamInterrupted + ? SizedBox.shrink() + : CameraPreview(_cameraController!), ) else const Center(child: Text("Camera view unavailable.")), - 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: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.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: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isStreamInterrupted ? "NOT SAFE" : "SAFE", - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, + 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: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.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: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isStreamInterrupted ? "NOT SAFE" : "SAFE", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: 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: ImageFilter.blur(sigmaX: 50, sigmaY: 50), + child: Container( + color: Colors.black.withValues(alpha: 0.45), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.visibility_off_rounded, + color: Colors.white, + size: 56, + ), + const SizedBox(height: 16), + const Text( + "Sensitive Content Detected", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + "The Video is paused because the video\nmay be showing something sensitive.", + textAlign: 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", ), - const SizedBox(height: 2), - Text( - _isStreamInterrupted - ? "Sensitive content detected in frame." - : "No sensitive content detected.", - style: const TextStyle( - color: Colors.white70, - fontSize: 13, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric( + horizontal: 28, vertical: 14), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ], - ), + ), + ], ), - ], + ), ), ), ), ), - ), ], ), ); From c831226e175ec00b61ce84d0f2299799252ed127 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:07:35 +0200 Subject: [PATCH 12/15] Update widget_test.dart --- example/test/widget_test.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 4c48928..6e20f55 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -167,7 +167,7 @@ void main() { final policy = await mockSca.checkPolicy(); - expect(policy, equals(0)); + expect(policy, equals(AnalysisPolicy.disabled)); verify(mockSca.checkPolicy()).called(1); }); @@ -177,7 +177,7 @@ void main() { final policy = await mockSca.checkPolicy(); - expect(policy, equals(1)); + expect(policy, equals(AnalysisPolicy.simpleInterventions)); }); test('returns null when policy cannot be determined', () async { @@ -191,10 +191,9 @@ void main() { test('throws on policy fetch failure', () async { when(mockSca.checkPolicy()).thenThrow(Exception('Policy fetch failed')); - expect(() => mockSca.checkPolicy(), throwsException); + expect(() async => await mockSca.checkPolicy(), throwsException); }); }); - group('SensitivityAnalysisResult model', () { test('constructs with isSensitive=true', () { final result = SensitivityAnalysisResult( From 3ccc2cff3122f0ce388966dc2fa0d65bb4712117 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:13:23 +0200 Subject: [PATCH 13/15] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d006e9..c8c67d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 2.1.1 +- Feat: Video Analyzer. + ## 2.1.0 ### Breaking - `analyzeImage`, `analyzeNetworkImage`, and `analyzeVideo` now return `SensitivityAnalysisResult?` instead of `bool?`. From 02c9f3ab4aa1a918b04409af8e95c76aaf7589a1 Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:43:03 +0200 Subject: [PATCH 14/15] fix(iOS): re-initialize video stream analyzer on stream continue --- CHANGELOG.md | 5 +- README.md | 47 +++----- .../SensitiveContentAnalysisPlugin.swift | 106 +++++++++++------- example/lib/main.dart | 2 +- ...alyzer.dart => video_stream_analyzer.dart} | 29 +++-- pubspec.yaml | 6 +- 6 files changed, 106 insertions(+), 89 deletions(-) rename example/lib/{video_analyzer.dart => video_stream_analyzer.dart} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c67d3..991f512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ +## 2.1.2 +- Fix(iOS): Re-initialize video stream analyzer on stream continue. + ## 2.1.1 -- Feat: Video Analyzer. +- Feat(iOS): Video Stream Analyzer. ## 2.1.0 ### Breaking diff --git a/README.md b/README.md index 50e2038..bde5cd3 100644 --- a/README.md +++ b/README.md @@ -57,37 +57,22 @@ https://developer.apple.com/documentation/sensitivecontentanalysis/testing-your- ### Check Policy: ```dart -final sca = SensitiveContentAnalysis(); + try { + AnalysisPolicy? policy = await sca.checkPolicy(); + _showResultDialog("Policy Check", "Policy: ${policy?.name}"); + /// Analysis is disabled; always returns null from analysis methods. + /// .disabled -int? policy = await sca.checkPolicy(); -if (policy != null) { - return policy; -} + /// Simple interventions (e.g. blurring) are suggested. + /// .simpleInterventions + + /// Descriptive interventions with richer guidance are suggested. + /// .descriptiveInterventions + + } 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 @@ -176,6 +161,10 @@ if (policy != null) { } ``` +#### 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: 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 6a9f63a..4291cdd 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -56,13 +56,44 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { // MARK: - SCVideoStreamAnalyzer #if os(iOS) - private var _streamAnalyzers: [String: Any] = [:] + private var _streamAnalyzers: [String: (analyzer: Any, direction: Int)] = [:] private var _streamTasks: [String: Any] = [:] private var _streamChannels: [String: FlutterEventChannel] = [:] @available(iOS 26.0, *) private func streamAnalyzer(for uuid: String) -> SCVideoStreamAnalyzer? { - return _streamAnalyzers[uuid] as? SCVideoStreamAnalyzer + return _streamAnalyzers[uuid]?.analyzer as? SCVideoStreamAnalyzer + } + + @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 @@ -314,7 +345,8 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { participantUUID: participantUUID, streamDirection: direction ) - _streamAnalyzers[participantUUID] = streamAnalyzer + + _streamAnalyzers[participantUUID] = (analyzer: streamAnalyzer, direction: directionRaw) let handler = VideoStreamEventHandler( streamAnalyzer: streamAnalyzer, @@ -440,6 +472,9 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { } streamAnalyzer.continueStream() + + resetAndRecreateAnalyzer(for: participantUUID) + result(nil) } @@ -479,9 +514,10 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { @available(iOS 26.0, *) private class VideoStreamEventHandler: NSObject, FlutterStreamHandler { - private let streamAnalyzer: SCVideoStreamAnalyzer + private var streamAnalyzer: SCVideoStreamAnalyzer private let formatResult: (SCSensitivityAnalysis) -> [String: Any]? private var monitorTask: Task? + private var currentSink: FlutterEventSink? init( streamAnalyzer: SCVideoStreamAnalyzer, @@ -491,44 +527,39 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { 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() - if let current = streamAnalyzer.analysis, let formatted = formatResult(current) { - print("📡 [Native] Emitting initial analysis state: isSensitive=\(current.isSensitive)") - events(formatted) - } - - print("🔄 [Native] Starting analysisChanges listener...") + let safeState: [String: Any] = [ + "isSensitive": false, + "detectedTypes": [String](), + "shouldIndicateSensitivity": false, + "shouldInterruptVideo": false, + "shouldMuteAudio": false, + ] + events(safeState) monitorTask = Task { - print("▶️ [Native] Task started, driving pre-registered iterator...") - do { while let analysis = try await iterator.next() { - guard !Task.isCancelled else { - print("⏹️ [Native] Task cancelled") - break - } - - var detectedStr = "[]" - #if compiler(>=6.4) - if #available(iOS 27.0, *) { - detectedStr = "\(analysis.detectedTypes)" - } - #endif - - print( - """ - 📡 [Native] analysisChanges EMITTED → - isSensitive: \(analysis.isSensitive) - shouldInterrupt: \(analysis.shouldInterruptVideo) - detectedTypes: \(detectedStr) - """) + guard !Task.isCancelled else { break } if let formatted = formatResult(analysis) { await MainActor.run { @@ -536,28 +567,17 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { } } } - - print("🔚 [Native] analysisChanges stream ended normally") - await MainActor.run { events(FlutterEndOfEventStream) } } catch { print("❌ [Native] analysisChanges error: \(error.localizedDescription)") - await MainActor.run { - events( - FlutterError( - code: "stream_analysis_error", - message: "Error in analysisChanges stream", - details: error.localizedDescription - )) - } } } - return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { print("🛑 [Native] onCancel called for stream analyzer") monitorTask?.cancel() monitorTask = nil + currentSink = nil return nil } } diff --git a/example/lib/main.dart b/example/lib/main.dart index f45c143..c1879af 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,7 +8,7 @@ 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_analyzer.dart'; +import 'package:sensitive_content_analysis_example/video_stream_analyzer.dart'; late final SensitiveContentAnalysis sca; diff --git a/example/lib/video_analyzer.dart b/example/lib/video_stream_analyzer.dart similarity index 96% rename from example/lib/video_analyzer.dart rename to example/lib/video_stream_analyzer.dart index f0e750a..db41bda 100644 --- a/example/lib/video_analyzer.dart +++ b/example/lib/video_stream_analyzer.dart @@ -88,18 +88,16 @@ class _CameraSensitiveAnalysisScreenState _analysisSubscription = _analyzer!.analysisChanges.listen( (SensitivityAnalysisResult result) async { debugPrint("🔍 Full Analysis Result: $result"); - debugPrint("${result.isSensitive}"); - debugPrint("${result.shouldInterruptVideo}"); - if (result.shouldInterruptVideo) { + + if (result.shouldInterruptVideo && !_isContinuing) { if (_cameraController != null && _cameraController!.value.isStreamingImages) { await _cameraController!.stopImageStream(); } + setState(() { + _isStreamInterrupted = true; + }); } - setState(() { - _isStreamInterrupted = true; - _isContinuing = false; - }); }, onError: (error) { debugPrint('Error caught from analysis EventChannel: $error'); @@ -109,10 +107,21 @@ class _CameraSensitiveAnalysisScreenState Future _onContinueTapped() async { if (_analyzer == null || _isContinuing) return; - setState(() => _isContinuing = true); + + setState(() { + _isContinuing = true; + }); + try { await _analyzer!.continueStream(); - setState(() => _isStreamInterrupted = false); + + setState(() { + _isStreamInterrupted = false; + _isContinuing = false; + }); + + await Future.delayed(const Duration(milliseconds: 200)); + _startFrameStreamingPipeline(); } catch (e) { debugPrint("Failed to continue stream: $e"); @@ -121,7 +130,7 @@ class _CameraSensitiveAnalysisScreenState } DateTime? _lastAnalysisTime; - final int _throttleMs = 300; + final int _throttleMs = 200; void _startFrameStreamingPipeline() { if (_cameraController == null || !_cameraController!.value.isInitialized) { diff --git a/pubspec.yaml b/pubspec.yaml index b8edaca..77647aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,18 +1,14 @@ name: sensitive_content_analysis 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.1 +version: 2.1.2 homepage: https://github.com/doppeltilde/sensitive_content_analysis topics: - nsfw - nsfw-detection - content-moderation - - moderation - sensitive-content - sensitive-content-analysis - - video - - image - - photo environment: sdk: '>=3.1.0 <4.0.0' From dd6ba3cfba13bebd15cdbdaddfec8c98c79e534d Mon Sep 17 00:00:00 2001 From: "Jona T. Feucht" <14951074+jonafeucht@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:36:59 +0200 Subject: [PATCH 15/15] chore: Bump Dart to >=3.12, Flutter to >=3.44, and clean up code. --- CHANGELOG.md | 3 + README.md | 40 ++-- .../SensitiveContentAnalysisPlugin.swift | 5 +- example/lib/home_feed.dart | 72 +++---- example/lib/main.dart | 176 +++++++++++------- example/lib/video_stream_analyzer.dart | 55 +++--- example/pubspec.lock | 2 +- example/pubspec.yaml | 2 +- lib/sensitive_content_analysis.dart | 79 ++++---- pubspec.yaml | 6 +- 10 files changed, 253 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 991f512..0fa75df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 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. diff --git a/README.md b/README.md index bde5cd3..cf2b60b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - + [![Pub](https://img.shields.io/pub/v/sensitive_content_analysis.svg?style=popout&include_prereleases)](https://pub.dev/packages/sensitive_content_analysis) @@ -57,18 +57,26 @@ https://developer.apple.com/documentation/sensitivecontentanalysis/testing-your- ### Check Policy: ```dart - try { +try { AnalysisPolicy? policy = await sca.checkPolicy(); _showResultDialog("Policy Check", "Policy: ${policy?.name}"); - /// 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 + 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()); } @@ -172,12 +180,18 @@ All `analyze` methods return a `SensitivityAnalysisResult` object: ```dart class SensitivityAnalysisResult { final bool isSensitive; - final List detectedTypes; // Available on iOS 27.0+ / macOS 27.0+ + 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.0 / macOS 27.0. +- `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. --- 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 4291cdd..7c5dda3 100644 --- a/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift +++ b/darwin/sensitive_content_analysis/Sources/sensitive_content_analysis/SensitiveContentAnalysisPlugin.swift @@ -613,8 +613,7 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { case "analyzeVideo": guard let args = call.arguments as? [String: Any], - let urlString = args["url"] as? String, - let url = URL(string: urlString) + let filePath = args["url"] as? String else { result( FlutterError( @@ -625,6 +624,8 @@ public class SensitiveContentAnalysisPlugin: NSObject, FlutterPlugin { ) return } + + let url = URL(fileURLWithPath: filePath) analyzeVideo(at: url, result: result) case "analyzeNetworkImage": diff --git a/example/lib/home_feed.dart b/example/lib/home_feed.dart index 8e7bf93..266dfa4 100644 --- a/example/lib/home_feed.dart +++ b/example/lib/home_feed.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; // ignore: depend_on_referenced_packages @@ -14,10 +13,7 @@ class FeedScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text( - 'NSFW Feed', - style: TextStyle(fontWeight: FontWeight.bold), - ), + title: const Text('NSFW Feed', style: TextStyle(fontWeight: .bold)), ), body: AuthenticatedAlbumFeed(), ); @@ -43,7 +39,8 @@ class _AuthenticatedAlbumFeedState extends State { Future> fetchAlbumImages() async { try { final Uri url = Uri.parse( - 'https://api.waifu.im/images?isNsfw=All&orderBy=Random&page=1&pageSize=30'); + 'https://api.waifu.im/images?isNsfw=All&orderBy=Random&page=1&pageSize=30', + ); final response = await http.get(url); @@ -64,19 +61,20 @@ class _AuthenticatedAlbumFeedState extends State { return FutureBuilder>( future: _albumImagesFuture, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { + 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.')); + child: Text('No images found or configuration invalid.'), + ); } final imagesList = snapshot.data!; return Align( - alignment: Alignment.topCenter, + alignment: .topCenter, child: Container( constraints: const BoxConstraints(maxWidth: 500), child: ListView.builder( @@ -96,10 +94,12 @@ class _AuthenticatedAlbumFeedState extends State { class AlbumFeedPost extends StatefulWidget { final Map imageData; final int index; - const AlbumFeedPost( - {super.key, required this.imageData, required this.index}); + const AlbumFeedPost({ + super.key, + required this.imageData, + required this.index, + }); - // Fixed type signature to accept dynamic map values static final Map> _analysisCache = {}; @override @@ -138,8 +138,9 @@ class _AlbumFeedPostState extends State { } try { - SensitivityAnalysisResult? isSensitive = - await sca.analyzeNetworkImage(url: url); + SensitivityAnalysisResult? isSensitive = await sca.analyzeNetworkImage( + url: url, + ); debugPrint(isSensitive?.isSensitive.toString()); debugPrint(isSensitive?.detectedTypes.toString()); @@ -175,7 +176,7 @@ class _AlbumFeedPostState extends State { final String imageUrl = widget.imageData['url'] ?? ''; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), @@ -183,8 +184,7 @@ class _AlbumFeedPostState extends State { children: [ const CircleAvatar(radius: 16, backgroundColor: Colors.indigo), const SizedBox(width: 10), - Text('creator', - style: const TextStyle(fontWeight: FontWeight.bold)), + Text('creator', style: const TextStyle(fontWeight: .bold)), ], ), ), @@ -194,25 +194,27 @@ class _AlbumFeedPostState extends State { setState(() => _isBlurred = !_isBlurred); }, child: Stack( - alignment: Alignment.center, + alignment: .center, children: [ _hasLoaded ? AspectRatio( aspectRatio: 1, child: CachedNetworkImage( imageUrl: imageUrl, - width: double.infinity, + width: .infinity, height: 440, - fit: BoxFit.cover, - )) + fit: .cover, + ), + ) : Center(child: CircularProgressIndicator()), if (_isBlurred) ...[ Positioned.fill( child: ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), - child: - Container(color: Colors.black.withValues(alpha: 0.5)), + filter: .blur(sigmaX: 100, sigmaY: 100), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + ), ), ), ), @@ -220,30 +222,36 @@ class _AlbumFeedPostState extends State { child: Container( color: Colors.black.withValues(alpha: 0.4), child: const Column( - mainAxisAlignment: MainAxisAlignment.center, + 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: TextAlign.center, + textAlign: .center, style: TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ) + color: Colors.white, + fontWeight: .bold, + ), + ), ], ), ), - ) - ] + ), + ], ], ), ), Row( children: [ IconButton( - icon: const Icon(Icons.favorite_border), onPressed: () {}), + icon: const Icon(Icons.favorite_border), + onPressed: () {}, + ), IconButton( - icon: const Icon(Icons.bookmark_border), onPressed: () {}), + icon: const Icon(Icons.bookmark_border), + onPressed: () {}, + ), ], ), const SizedBox(height: 12), diff --git a/example/lib/main.dart b/example/lib/main.dart index c1879af..aef13d3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -30,16 +30,19 @@ class _MyAppState extends State { 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. - SensitivityAnalysisResult? isSensitive = - await sca.analyzeImage(imageData); + SensitivityAnalysisResult? isSensitive = await sca.analyzeImage( + imageData, + ); _showResultDialog( - "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } } catch (e) { _showResultDialog("Error", e.toString()); @@ -52,10 +55,13 @@ class _MyAppState extends State { "https://docs-assets.developer.apple.com/published/517e263450/rendered2x-1685188934.png"; // Analyze the image for sensitive content. - SensitivityAnalysisResult? isSensitive = - await sca.analyzeNetworkImage(url: url); + SensitivityAnalysisResult? isSensitive = await sca.analyzeNetworkImage( + url: url, + ); _showResultDialog( - "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } catch (e) { _showResultDialog("Error", e.toString()); } @@ -68,14 +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) { - SensitivityAnalysisResult? isSensitive = - await sca.analyzeVideo(url: file.path); + SensitivityAnalysisResult? isSensitive = await sca.analyzeVideo( + url: file.path, + ); + _showResultDialog( - "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); await file.delete(); } } catch (e) { @@ -87,13 +113,16 @@ class _MyAppState extends State { try { FilePickerResult? selectedFile = await FilePicker.pickFiles( allowMultiple: false, - type: FileType.video, + type: .video, ); if (selectedFile != null) { - SensitivityAnalysisResult? isSensitive = - await sca.analyzeVideo(url: selectedFile.files.first.path!); + SensitivityAnalysisResult? isSensitive = await sca.analyzeVideo( + url: selectedFile.files.first.path!, + ); _showResultDialog( - "Analysis Result", "SENSITIVE: ${isSensitive?.isSensitive}"); + "Analysis Result", + "SENSITIVE: ${isSensitive?.isSensitive}", + ); } } catch (e) { _showResultDialog("Error", e.toString()); @@ -104,6 +133,23 @@ class _MyAppState extends State { 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()); } @@ -118,38 +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: () => Navigator.push( + 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(), - ]), + participantUUID: "participantUUIDExample", + ), + ), + ), + child: const Text("Analyze Camera Stream."), + ), + TextButton( + onPressed: () async => await checkPolicy(), + child: const Text("Check Policy."), + ), + warningWidget(), + ], + ), ), ); } @@ -180,28 +231,25 @@ class _MyAppState extends State { barrierDismissible: false, builder: (BuildContext context) { return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24.0), - ), + shape: RoundedRectangleBorder(borderRadius: .circular(24.0)), elevation: 10, backgroundColor: Theme.of(context).cardColor, child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const .all(24.0), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ - // 18+ Icon Header Container( - padding: const EdgeInsets.all(16), + padding: const .all(16), decoration: BoxDecoration( color: Colors.red.withValues(alpha: .1), - shape: BoxShape.circle, + shape: .circle, ), child: const Text( "18+", style: TextStyle( fontSize: 28, - fontWeight: FontWeight.bold, + fontWeight: .bold, color: Colors.redAccent, ), ), @@ -211,10 +259,10 @@ class _MyAppState extends State { // Title const Text( "Age Verification Required", - textAlign: TextAlign.center, + textAlign: .center, style: TextStyle( fontSize: 20, - fontWeight: FontWeight.bold, + fontWeight: .bold, letterSpacing: 0.5, ), ), @@ -222,7 +270,7 @@ class _MyAppState extends State { Text( "The home feed contains content intended for mature audiences. Please verify that you are 18 years or older to proceed.", - textAlign: TextAlign.center, + textAlign: .center, style: TextStyle( fontSize: 14, color: Colors.grey[600], @@ -232,14 +280,14 @@ class _MyAppState extends State { const SizedBox(height: 28), SizedBox( - width: double.infinity, + width: .infinity, height: 48, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), elevation: 0, ), @@ -254,27 +302,25 @@ class _MyAppState extends State { }, child: const Text( "I am 18 or older", - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), + style: TextStyle(fontSize: 16, fontWeight: .w600), ), ), ), const SizedBox(height: 8), SizedBox( - width: double.infinity, + width: .infinity, height: 44, child: TextButton( style: TextButton.styleFrom( foregroundColor: Colors.grey[600], shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), ), onPressed: () => Navigator.pop(context), child: const Text( "Go Back", - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w500), + style: TextStyle(fontSize: 15, fontWeight: .w500), ), ), ), diff --git a/example/lib/video_stream_analyzer.dart b/example/lib/video_stream_analyzer.dart index db41bda..8229a3c 100644 --- a/example/lib/video_stream_analyzer.dart +++ b/example/lib/video_stream_analyzer.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui'; import 'package:sensitive_content_analysis/sensitive_content_analysis.dart'; import 'package:sensitive_content_analysis_example/main.dart'; import 'package:camera/camera.dart'; @@ -38,17 +37,18 @@ class _CameraSensitiveAnalysisScreenState } Future _initializeSystem() async { - final policy = await SensitiveContentAnalysis().checkPolicy(); + final policy = await sca.checkPolicy(); debugPrint("Analysis Policy: $policy"); - if (policy == AnalysisPolicy.disabled) { + if (policy == .disabled) { debugPrint( - "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings."); + "⚠️ Sensitive content analysis is DISABLED. Check entitlement + device settings.", + ); } try { _analyzer = await sca.createVideoStreamAnalyzer( participantUUID: widget.participantUUID, - streamDirection: StreamDirection.incoming, + streamDirection: .incoming, ); _subscribeToAnalysisChanges(); @@ -60,9 +60,9 @@ class _CameraSensitiveAnalysisScreenState _cameraController = CameraController( cameras.first, - ResolutionPreset.medium, - enableAudio: false, - imageFormatGroup: ImageFormatGroup.bgra8888, + .medium, + enableAudio: true, + imageFormatGroup: .bgra8888, ); await _cameraController!.initialize(); @@ -186,20 +186,18 @@ class _CameraSensitiveAnalysisScreenState @override Widget build(BuildContext context) { if (_isInitializing) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } if (_errorMessage.isNotEmpty) { return Scaffold( body: Center( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const .all(24.0), child: Text( _errorMessage, style: const TextStyle(color: Colors.red, fontSize: 16), - textAlign: TextAlign.center, + textAlign: .center, ), ), ), @@ -230,12 +228,9 @@ class _CameraSensitiveAnalysisScreenState color: _isStreamInterrupted ? Colors.red.withValues(alpha: 0.9) : Colors.green.withValues(alpha: 0.9), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: .circular(12)), child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16.0, horizontal: 20.0), + padding: const .symmetric(vertical: 16.0, horizontal: 20.0), child: Row( children: [ Icon( @@ -248,15 +243,15 @@ class _CameraSensitiveAnalysisScreenState const SizedBox(width: 16), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text( _isStreamInterrupted ? "NOT SAFE" : "SAFE", style: const TextStyle( color: Colors.white, fontSize: 18, - fontWeight: FontWeight.bold, + fontWeight: .bold, letterSpacing: 1.2, ), ), @@ -286,11 +281,11 @@ class _CameraSensitiveAnalysisScreenState duration: const Duration(milliseconds: 300), child: ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), + filter: .blur(sigmaX: 50, sigmaY: 50), child: Container( color: Colors.black.withValues(alpha: 0.45), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ const Icon( Icons.visibility_off_rounded, @@ -300,17 +295,17 @@ class _CameraSensitiveAnalysisScreenState const SizedBox(height: 16), const Text( "Sensitive Content Detected", - textAlign: TextAlign.center, + textAlign: .center, style: TextStyle( color: Colors.white, fontSize: 20, - fontWeight: FontWeight.bold, + fontWeight: .bold, ), ), const SizedBox(height: 8), const Text( "The Video is paused because the video\nmay be showing something sensitive.", - textAlign: TextAlign.center, + textAlign: .center, style: TextStyle( color: Colors.white70, fontSize: 14, @@ -335,11 +330,13 @@ class _CameraSensitiveAnalysisScreenState style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), + padding: const .symmetric( + horizontal: 28, + vertical: 14, + ), textStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w600, + fontWeight: .w600, ), ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 167f0eb..fae60f8 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -781,7 +781,7 @@ packages: path: ".." relative: true source: path - version: "2.1.1" + version: "2.1.2" shelf: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7f81821..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: diff --git a/lib/sensitive_content_analysis.dart b/lib/sensitive_content_analysis.dart index 21cff68..65e71b5 100644 --- a/lib/sensitive_content_analysis.dart +++ b/lib/sensitive_content_analysis.dart @@ -6,10 +6,10 @@ class SensitiveContentAnalysis { /// Analyzes a local image for sensitive content. Future analyzeImage(Uint8List file) async { try { - final Map? result = - await _methodChannel.invokeMapMethod('analyzeImage', file); + final Map? result = await _methodChannel + .invokeMapMethod('analyzeImage', file); if (result == null) return null; - return SensitivityAnalysisResult.fromMap(result); + return .fromMap(result); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -23,21 +23,19 @@ class SensitiveContentAnalysis { final Map? result = await _methodChannel .invokeMapMethod('analyzeNetworkImage', {'url': url}); if (result == null) return null; - return SensitivityAnalysisResult.fromMap(result); + 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 { + Future analyzeVideo({required String url}) async { try { final Map? result = await _methodChannel - .invokeMapMethod('analyzeVideo', {'url': Uri.file(url).toString()}); + .invokeMapMethod('analyzeVideo', {'url': url}); if (result == null) return null; - return SensitivityAnalysisResult.fromMap(result); + return .fromMap(result); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -48,7 +46,7 @@ class SensitiveContentAnalysis { try { final int? raw = await _methodChannel.invokeMethod('checkPolicy'); if (raw == null) return null; - return AnalysisPolicy.fromInt(raw); + return .fromInt(raw); } on PlatformException catch (e) { throw UnimplementedError(e.message); } @@ -75,9 +73,9 @@ class SensitiveContentAnalysis { throw UnimplementedError(e.message); } - return VideoStreamAnalyzer._( + return ._( participantUUID: participantUUID, - eventChannelName: eventChannelName, + eventChannel: EventChannel(eventChannelName), ); } } @@ -97,8 +95,8 @@ class SensitiveContentAnalysis { class VideoStreamAnalyzer { VideoStreamAnalyzer._({ required this.participantUUID, - required String eventChannelName, - }) : _eventChannel = EventChannel(eventChannelName); + required this._eventChannel, + }); final String participantUUID; final EventChannel _eventChannel; @@ -111,8 +109,7 @@ class VideoStreamAnalyzer { return _eventChannel .receiveBroadcastStream() .where((event) => event is Map) - .map((event) => - SensitivityAnalysisResult.fromMap(event as Map)); + .map((event) => .fromMap(event as Map)); } /// Passes a raw video frame to the analyzer. @@ -188,36 +185,36 @@ class SensitivityAnalysisResult { final List detectedTypes; /// App should indicate the presence of sensitive content to the user. - /// Available on iOS 26+ / macOS 26+; always false on older OS versions. + /// Available on iOS/ipadOS 26+ always false on older OS versions. final bool shouldIndicateSensitivity; /// App should interrupt video playback. - /// Available on iOS 26+ / macOS 26+; always false on older OS versions. + /// 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 26+ / macOS 26+; always false on older OS versions. + /// Available on iOS/ipadOS 26+ always false on older OS versions. final bool shouldMuteAudio; factory SensitivityAnalysisResult.fromMap(Map map) { - final rawTypes = (map['detectedTypes'] as List?) - ?.map((e) => DetectedContentType.fromString(e as String? ?? '')) - .whereType() - .toList() ?? - []; + bool toBool(String key) => map[key] as bool? ?? false; return SensitivityAnalysisResult( - isSensitive: map['isSensitive'] as bool? ?? false, - detectedTypes: rawTypes, - shouldIndicateSensitivity: - map['shouldIndicateSensitivity'] as bool? ?? false, - shouldInterruptVideo: map['shouldInterruptVideo'] as bool? ?? false, - shouldMuteAudio: map['shouldMuteAudio'] as bool? ?? false, + 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(' + String toString() => + 'SensitivityAnalysisResult(' 'isSensitive: $isSensitive, ' 'detectedTypes: $detectedTypes, ' 'shouldIndicateSensitivity: $shouldIndicateSensitivity, ' @@ -234,10 +231,10 @@ enum DetectedContentType { goreOrViolence; static DetectedContentType? fromString(String value) => switch (value) { - 'sexuallyExplicit' => DetectedContentType.sexuallyExplicit, - 'goreOrViolence' => DetectedContentType.goreOrViolence, - _ => null, - }; + 'sexuallyExplicit' => .sexuallyExplicit, + 'goreOrViolence' => .goreOrViolence, + _ => null, + }; } enum AnalysisPolicy { @@ -251,11 +248,11 @@ enum AnalysisPolicy { descriptiveInterventions; static AnalysisPolicy fromInt(int value) => switch (value) { - 0 => AnalysisPolicy.disabled, - 1 => AnalysisPolicy.simpleInterventions, - 2 => AnalysisPolicy.descriptiveInterventions, - _ => throw ArgumentError('Unknown AnalysisPolicy value: $value'), - }; + 0 => .disabled, + 1 => .simpleInterventions, + 2 => .descriptiveInterventions, + _ => throw ArgumentError('Unknown AnalysisPolicy value: $value'), + }; } enum StreamDirection { @@ -263,5 +260,5 @@ enum StreamDirection { outgoing, /// An incoming stream from a remote participant. - incoming; + incoming, } diff --git a/pubspec.yaml b/pubspec.yaml index 77647aa..89b9730 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: sensitive_content_analysis 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.2 +version: 2.1.3 homepage: https://github.com/doppeltilde/sensitive_content_analysis topics: @@ -11,8 +11,8 @@ topics: - sensitive-content-analysis environment: - sdk: '>=3.1.0 <4.0.0' - flutter: '>=3.41.0' + sdk: '>=3.12.0 <4.0.0' + flutter: '>=3.44.0' dependencies: flutter: