flutter_nsfw_scaner
Flutter plugin for NSFW detection on Android and iOS with TensorFlow Lite.
The plugin supports images, videos, mixed media batches, and full gallery scans with streamed progress/results.
Key capabilities
- Image scan:
scanImage(...) - Image batch scan:
scanBatch(...) - Mixed media batch scan:
scanMediaBatch(...) - Chunked mixed media scan:
scanMediaInChunks(...) - Video scan with frame sampling + early stop:
scanVideo(...) - Scan media directly from URL (download + scan):
scanMediaFromUrl(...) - Native full gallery scan with streaming:
scanWholeGallery(...)/scanGallery(...) - Persisted background job coordination for long whole-gallery scans
- Persisted upload queue with staged local files for retries/resume
- Scan cancellation:
cancelScan(scanId: ...)orcancelScan() - Event stream progress updates via
progressStream - On-demand image thumbnail loading for gallery assets:
loadImageThumbnail(...) - On-demand full image asset loading for preview:
loadImageAsset(...) - Compatibility aliases:
loadImageThumnbail(...),loadImageAssets(...)
Platform and requirements
- Flutter
>=3.3.0 - Dart
^3.9.0 - Android
minSdk 24 - iOS
13.0+
Install
dependencies:
flutter_nsfw_scaner:
path: ../flutter_nsfw_scaner
Built-in model
The plugin ships a built-in model and labels:
NsfwBuiltinModels.nsfwMobilenetV2140224NsfwBuiltinModels.nsfwMobilenetV2140224Labels
./tool/download_nsfw_model.sh
Quick start
import 'package:flutter_nsfw_scaner/flutter_nsfw_scaner.dart';
final scanner = FlutterNsfwScaner();
await scanner.initialize(
modelAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224,
labelsAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224Labels,
numThreads: 2,
inputNormalization: NsfwInputNormalization.minusOneToOne,
defaultThreshold: 0.7,
backgroundProcessing: const NsfwBackgroundProcessingConfig(
enabled: true,
continueUploadsInBackground: true,
continueGalleryScanInBackground: true,
preventConcurrentWholeGalleryScans: true,
autoResumeInterruptedJobs: true,
),
galleryScanCachePrefix: 'my_app',
galleryScanCacheTableName: 'gallery_scan_history',
);
final image = await scanner.scanImage(
imagePath: '/path/image.jpg',
threshold: 0.45,
);
final video = await scanner.scanVideo(
videoPath: '/path/video.mp4',
threshold: 0.45,
sampleRateFps: 0.3,
maxFrames: 300,
dynamicSampleRate: true,
);
final gallery = await scanner.scanWholeGallery(
includeImages: true,
includeVideos: true,
pageSize: 140,
scanChunkSize: 80,
maxRetainedResultItems: 4000,
includeCleanResults: false,
debugLogging: true,
settings: const NsfwMediaBatchSettings(
imageThreshold: 0.45,
videoThreshold: 0.45,
maxConcurrency: 2,
),
onLoadProgress: (p) {
print('load ${p.scannedAssets} (images=${p.imageCount}, videos=${p.videoCount})');
},
onScanProgress: (p) {
print('scan ${p.processed}/${p.total}');
},
onChunkResult: (chunk) {
print('chunk items=${chunk.items.length}');
},
);
print('flagged=${gallery.flaggedCount}, errors=${gallery.errorCount}');
print('skipped=${gallery.skippedCount}');
print('truncated=${gallery.didTruncateItems}');
await scanner.waitForPendingUploads();
Background processing and long-running jobs
initialize(...) now accepts backgroundProcessing, and the default is already tuned for long whole-gallery scans:
await scanner.initialize(
modelAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224,
backgroundProcessing: const NsfwBackgroundProcessingConfig(
enabled: true,
continueUploadsInBackground: true,
continueGalleryScanInBackground: true,
preventConcurrentWholeGalleryScans: true,
autoResumeInterruptedJobs: true,
prioritizeForegroundUploads: true,
backgroundResolveConcurrency: 1,
backgroundUploadConcurrency: 1,
backgroundMaxParallelVideoUploads: 1,
),
);
What this does:
- upload queue state is persisted per build version and resumed on next
initialize(...) - whole-gallery jobs are persisted and auto-resumed on next
initialize(...) - a second whole-gallery scan is blocked by default while one is already active
pauseWholeGalleryScan()cancels the native run and keeps the job resumable- when the app is in foreground, uploads use the configured full resolve/upload concurrency
- when the app leaves foreground, uploads stay alive but are automatically throttled to the background concurrency values
Public APIs:
final jobs = await scanner.getBackgroundJobs();
final running = await scanner.isWholeGalleryScanRunning();
await scanner.pauseWholeGalleryScan();
await scanner.resumeWholeGalleryScan();
await scanner.cancelWholeGalleryScan();
await scanner.clearFinishedBackgroundJobs();
await scanner.waitForBackgroundTasks();
Or via the controller:
final controller = scanner.backgroundController;
await controller.resumePendingJobs();
Important platform note:
- Android is better suited for prolonged background work.
- On iOS, uploads can keep progressing more reliably than a full gallery scan.
- If iOS suspends or the app is terminated, the plugin resumes persisted gallery jobs on the next app start; it does not claim unlimited post-termination execution.
- Upload queue and staged files are persisted in a durable application-support directory so queued uploads are more likely to survive app upgrades/restarts during TestFlight release flows.
Whole-gallery scan cache
If you want scanWholeGallery(...) / scanGallery(...) to skip assets that were already scanned in earlier runs, configure the native cache once during initialize(...):
await scanner.initialize(
modelAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224,
labelsAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224Labels,
galleryScanCachePrefix: 'my_app',
galleryScanCacheTableName: 'gallery_scan_history',
);
Notes:
- The cache is stored in native SQLite inside the app sandbox.
- The cache is automatically scoped to the current build version. A new app build creates a fresh scan cache namespace, so scans are not skipped across build updates unless you keep build version constant intentionally.
- Cache identity is based on the native gallery asset id.
- Only successful whole-gallery scan items are written to the cache.
processedincludes skipped items, andskippedCounttells you how many were avoided because they were already cached.- If either
galleryScanCachePrefixorgalleryScanCacheTableNameis omitted/empty, the cache is disabled. - For very large libraries, whole-gallery scans retain at most
maxRetainedResultItemsin the finalitemslist to avoid memory pressure. The full scan still runs, counters remain correct, andonChunkResultstill streams every chunk. - If retained items were capped,
didTruncateItemsistrue.
Default threshold
You can define the default scan threshold during initialize(...):
await scanner.initialize(
modelAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224,
defaultThreshold: 0.7,
);
Notes:
- The default is
0.7. scanImage(...),scanBatch(...), andscanVideo(...)use this initialized default when you do not passthreshold.- You can still override the threshold per scan call:
final image = await scanner.scanImage(
imagePath: '/path/image.jpg',
threshold: 0.45,
);
Normani/Harami upload device folder
If useDeviceFolder is enabled and you do not set deviceFolder, the plugin now generates a random device id once and reuses it across app restarts. This makes the auto upload prefix stable for the same app installation and distinct enough across devices.
Priority order:
- Explicit
NsfwNormaniConfig.deviceFolder - Compile-time define
NSFW_DEVICE_ID - Auto-generated persistent device id
Example:
final config = NsfwNormaniConfig(
objectPrefix: 'nsfw_hits',
useDeviceFolder: true,
haramiResolveConcurrency: 2,
haramiUploadConcurrency: 3,
haramiMaxParallelVideoUploads: 1,
);
Notes:
- The generated id is stored locally inside the app sandbox.
- It survives normal app restarts.
- Reinstalling the app or clearing app storage can generate a new id.
- Uploads are staged locally before transfer so Photos/iCloud assets do not need to be re-materialized for every retry.
- Images can upload in parallel while videos are throttled separately.
Reset the cache for the currently initialized scanner:
await scanner.resetGalleryScanCache();
Asset preview APIs (for gallery UIs)
Use these when you receive gallery item references (assetId, uri, path) from scan results:
final thumbPath = await scanner.loadImageThumbnail(
assetRef: 'ph://A-B-C', // iOS PH asset URI/local id or Android content/file ref
width: 160,
height: 160,
quality: 70,
);
final fullPath = await scanner.loadImageAsset(
assetRef: 'ph://A-B-C',
);
Notes:
- These APIs return local file paths.
- They do not send image byte arrays over platform channels.
- Intended for lazy UI preview loading in result lists.
UI widget kit (optional, individually stylable)
Import:
import 'package:flutter_nsfw_scaner/flutter_nsfw_scaner.dart';
Progress widgets:
NsfwBatchProgressCardNsfwGalleryLoadCard
Result widgets:
NsfwResultStatusChipNsfwResultTile
Navigation/control widgets:
NsfwPaginationControlsNsfwBottomActionBarNsfwScanWizardStepHeader
All widgets are optional and composable. They are not hard-wired into scan logic.
API summary
initialize(...)backgroundControllergetBackgroundJobs() -> Future<List<NsfwBackgroundJob>>isWholeGalleryScanRunning() -> Future<bool>resumePendingBackgroundJobs() -> Future<bool>resumeWholeGalleryScan() -> Future<bool>pauseWholeGalleryScan() -> Future<bool>cancelWholeGalleryScan() -> Future<bool>clearFinishedBackgroundJobs() -> Future<void>waitForBackgroundTasks() -> Future<void>scanImage(...) -> NsfwScanResultscanBatch(...) -> List<NsfwScanResult>scanMediaBatch(...) -> NsfwMediaBatchResultscanMediaInChunks(...) -> NsfwMediaBatchResultscanVideo(...) -> NsfwVideoScanResultscanMediaFromUrl(...) -> NsfwMediaBatchItemResultscanWholeGallery(...) -> NsfwMediaBatchResultscanGallery(...) -> NsfwMediaBatchResultscanMedia(...) -> NsfwMediaBatchItemResultscanMultipleMedia(...) -> NsfwMediaBatchResultresetGalleryScanCache()loadAsset(...) -> NsfwLoadedAsset?loadMultipleAssets(...) -> List<NsfwAssetRef>loadMultipleWithRange(...) -> NsfwAssetPageloadImageThumbnail(...) -> Future<String?>loadImageAsset(...) -> Future<String?>dispose()cancelScan({String? scanId})
Performance design
- Heavy scan work runs in native background workers (Android coroutines/IO + worker pools, iOS global queues/operation-style batching).
- Gallery scan is batched and streamed (
gallery_load_progress,gallery_scan_progress,gallery_result_batch). - Progress events are throttled.
- Image inference uses thumbnail-sized decode (
~224target for model path). - Upload processing is split into two stages: resolve/materialize first, upload second.
- Prepared upload files are cached locally so retries and resumed jobs do not repeatedly hit Photos/iCloud.
- Upload workers are parallelized, while large video uploads are throttled independently from images.
- Worker parallelism is bounded by CPU and explicit settings.
- No large image bytes are transferred over channels during scan streaming.
Permissions
- Android: media read permissions (
READ_MEDIA_IMAGES,READ_MEDIA_VIDEO, legacy fallback on older versions) - iOS: Photo Library usage descriptions
iOS CocoaPods note
If your host app uses this plugin and CocoaPods fails with a static/transitive binary error related to TensorFlow Lite, set static linkage in your iOS Podfile:
target 'Runner' do
use_frameworks! :linkage => :static
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
Then reinstall pods:
flutter clean
rm -rf ios/Pods ios/Podfile.lock
cd ios && pod repo update && pod install && cd ..
flutter run
Example app
See example/README.md.
For other LLMs / tooling
See LLM.md for architecture, event schemas, threading model, and integration notes.
License
This plugin is licensed under MIT (see LICENSE).
Bundled model/license attributions are listed in THIRD_PARTY_NOTICES.md.