diff --git a/config.lock.yaml b/config.lock.yaml index cc14be3..39e937c 100644 --- a/config.lock.yaml +++ b/config.lock.yaml @@ -9,11 +9,11 @@ introduction_screen: 4a90e557630b28834479ed9c64a9d2d0185d8e48 libsignal_protocol_dart: 618f0c0b49534245a640a31d204265440cbac9ee lottie: 4f1a5a52bdf1e1c1e12fa97c96174dcb05419e19 mutex: 84ca903a3ac863735e3228c75a212133621f680f -no_screenshot: 57b4a072e9193b4fa1257a6f1acb13ef307625e7 +no_screenshot: 9ca2a492ff12e5179583a1fa015bf0843382b866 optional: 71c638891ce4f2aff35c7387727989f31f9d877d photo_view: a13ca2fc387a3fb1276126959e092c44d0029987 pointycastle: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd -qr: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e +qr: 5fa01fcccd6121b906dc7df4fffa9fa22ca94f75 qr_flutter: d5e7206396105d643113618290bbcc755d05f492 restart_app: 12339f63bf8e9631e619c4f9f6b4e013fa324715 x25519: ecb1d357714537bba6e276ef45f093846d4beaee diff --git a/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt b/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt index 9ccb112..c01a389 100644 --- a/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt +++ b/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt @@ -1,15 +1,20 @@ package com.flutterplaza.no_screenshot import android.app.Activity +import android.app.Application import android.content.Context import android.content.SharedPreferences import android.database.ContentObserver import android.net.Uri +import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore import android.util.Log +import android.view.ViewGroup import android.view.WindowManager.LayoutParams +import android.widget.FrameLayout +import android.widget.ImageView import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -17,6 +22,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors import org.json.JSONObject const val SCREENSHOT_ON_CONST = "screenshotOn" @@ -28,72 +34,122 @@ const val STOP_SCREENSHOT_LISTENING_CONST = "stopScreenshotListening" const val SCREENSHOT_PATH = "screenshot_path" const val PREF_KEY_SCREENSHOT = "is_screenshot_on" const val SCREENSHOT_TAKEN = "was_screenshot_taken" +const val SET_IMAGE_CONST = "toggleScreenshotWithImage" +const val PREF_KEY_IMAGE_OVERLAY = "is_image_overlay_mode_enabled" +const val IS_SCREEN_RECORDING = "is_screen_recording" +const val START_SCREEN_RECORDING_LISTENING_CONST = "startScreenRecordingListening" +const val STOP_SCREEN_RECORDING_LISTENING_CONST = "stopScreenRecordingListening" const val SCREENSHOT_METHOD_CHANNEL = "com.flutterplaza.no_screenshot_methods" const val SCREENSHOT_EVENT_CHANNEL = "com.flutterplaza.no_screenshot_streams" -class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, EventChannel.StreamHandler { +class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, + EventChannel.StreamHandler { private lateinit var methodChannel: MethodChannel private lateinit var eventChannel: EventChannel private lateinit var context: Context private var activity: Activity? = null - private lateinit var preferences: SharedPreferences + private val preferences: SharedPreferences by lazy { + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + } private var screenshotObserver: ContentObserver? = null private val handler = Handler(Looper.getMainLooper()) private var eventSink: EventChannel.EventSink? = null private var lastSharedPreferencesState: String = "" private var hasSharedPreferencesChanged: Boolean = false + private var isImageOverlayModeEnabled: Boolean = false + private var overlayImageView: ImageView? = null + private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + private var isScreenRecording: Boolean = false + private var isRecordingListening: Boolean = false + private var screenCaptureCallback: Any? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext - preferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, SCREENSHOT_METHOD_CHANNEL) + methodChannel = + MethodChannel(flutterPluginBinding.binaryMessenger, SCREENSHOT_METHOD_CHANNEL) methodChannel.setMethodCallHandler(this) eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, SCREENSHOT_EVENT_CHANNEL) eventChannel.setStreamHandler(this) initScreenshotObserver() + registerLifecycleCallbacks() } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { methodChannel.setMethodCallHandler(null) screenshotObserver?.let { context.contentResolver.unregisterContentObserver(it) } + unregisterLifecycleCallbacks() } override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity restoreScreenshotState() + if (isRecordingListening) { + registerScreenCaptureCallback() + } + } + + override fun onDetachedFromActivityForConfigChanges() { + unregisterScreenCaptureCallback() + removeImageOverlay() + activity = null } - override fun onDetachedFromActivityForConfigChanges() {} override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity restoreScreenshotState() + if (isRecordingListening) { + registerScreenCaptureCallback() + } } - override fun onDetachedFromActivity() {} + override fun onDetachedFromActivity() { + unregisterScreenCaptureCallback() + removeImageOverlay() + activity = null + } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) { when (call.method) { SCREENSHOT_ON_CONST -> { result.success(screenshotOn().also { updateSharedPreferencesState("") }) } + SCREENSHOT_OFF_CONST -> { result.success(screenshotOff().also { updateSharedPreferencesState("") }) } + TOGGLE_SCREENSHOT_CONST -> { toggleScreenshot() result.success(true.also { updateSharedPreferencesState("") }) } + START_SCREENSHOT_LISTENING_CONST -> { startListening() result.success("Listening started") } + STOP_SCREENSHOT_LISTENING_CONST -> { stopListening() result.success("Listening stopped".also { updateSharedPreferencesState("") }) } + + SET_IMAGE_CONST -> { + result.success(toggleScreenshotWithImage()) + } + + START_SCREEN_RECORDING_LISTENING_CONST -> { + startRecordingListening() + result.success("Recording listening started") + } + + STOP_SCREEN_RECORDING_LISTENING_CONST -> { + stopRecordingListening() + result.success("Recording listening stopped".also { updateSharedPreferencesState("") }) + } + else -> result.notImplemented() } } @@ -108,12 +164,137 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ eventSink = null } + private fun registerLifecycleCallbacks() { + val app = context as? Application ?: return + lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityPaused(act: Activity) { + if (act == activity && isImageOverlayModeEnabled) { + act.window?.clearFlags(LayoutParams.FLAG_SECURE) + showImageOverlay(act) + + } + } + + override fun onActivityResumed(act: Activity) { + if (act == activity && isImageOverlayModeEnabled) { + removeImageOverlay() + act.window?.addFlags(LayoutParams.FLAG_SECURE) + } + } + + override fun onActivityCreated(act: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(act: Activity) {} + override fun onActivityStopped(act: Activity) {} + override fun onActivitySaveInstanceState(act: Activity, outState: Bundle) { + if (act == activity && isImageOverlayModeEnabled) { + showImageOverlay(act) + } + } + override fun onActivityDestroyed(act: Activity) {} + } + app.registerActivityLifecycleCallbacks(lifecycleCallbacks) + } + + private fun unregisterLifecycleCallbacks() { + val app = context as? Application ?: return + lifecycleCallbacks?.let { app.unregisterActivityLifecycleCallbacks(it) } + lifecycleCallbacks = null + } + + private fun showImageOverlay(activity: Activity) { + if (overlayImageView != null) return + val resId = activity.resources.getIdentifier("image", "drawable", activity.packageName) + if (resId == 0) return + activity.runOnUiThread { + val imageView = ImageView(activity).apply { + setImageResource(resId) + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + (activity.window.decorView as? ViewGroup)?.addView(imageView) + overlayImageView = imageView + } + } + + private fun removeImageOverlay() { + val imageView = overlayImageView ?: return + val act = activity + if (act != null) { + act.runOnUiThread { + (imageView.parent as? ViewGroup)?.removeView(imageView) + overlayImageView = null + } + } else { + (imageView.parent as? ViewGroup)?.removeView(imageView) + overlayImageView = null + } + } + + private fun toggleScreenshotWithImage(): Boolean { + isImageOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_IMAGE_OVERLAY, false) + saveImageOverlayState(isImageOverlayModeEnabled) + + if (isImageOverlayModeEnabled) { + screenshotOff() + } else { + screenshotOn() + removeImageOverlay() + } + updateSharedPreferencesState("") + return isImageOverlayModeEnabled + } + + // ── Screen Recording Detection ───────────────────────────────────── + + private fun startRecordingListening() { + if (isRecordingListening) return + isRecordingListening = true + registerScreenCaptureCallback() + updateSharedPreferencesState("") + } + + private fun stopRecordingListening() { + if (!isRecordingListening) return + isRecordingListening = false + unregisterScreenCaptureCallback() + isScreenRecording = false + updateSharedPreferencesState("") + } + + private fun registerScreenCaptureCallback() { + if (android.os.Build.VERSION.SDK_INT >= 34) { + val act = activity ?: return + if (screenCaptureCallback != null) return + + val callback = Activity.ScreenCaptureCallback { + isScreenRecording = true + updateSharedPreferencesState("") + } + act.registerScreenCaptureCallback(act.mainExecutor, callback) + screenCaptureCallback = callback + } + } + + private fun unregisterScreenCaptureCallback() { + if (android.os.Build.VERSION.SDK_INT >= 34) { + val act = activity ?: return + val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback ?: return + act.unregisterScreenCaptureCallback(callback) + screenCaptureCallback = null + } + } + private fun initScreenshotObserver() { screenshotObserver = object : ContentObserver(Handler()) { override fun onChange(selfChange: Boolean, uri: Uri?) { super.onChange(selfChange, uri) uri?.let { - if (it.toString().contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString())) { + if (it.toString() + .contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString()) + ) { Log.d("ScreenshotProtection", "Screenshot detected") updateSharedPreferencesState(it.path ?: "") } @@ -124,7 +305,11 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ private fun startListening() { screenshotObserver?.let { - context.contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, it) + context.contentResolver.registerContentObserver( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + true, + it + ) } } @@ -159,28 +344,50 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ } private fun saveScreenshotState(isSecure: Boolean) { - preferences.edit().putBoolean(PREF_KEY_SCREENSHOT, isSecure).apply() + Executors.newSingleThreadExecutor().execute { + preferences.edit().putBoolean(PREF_KEY_SCREENSHOT, isSecure).apply() + } + } + + private fun saveImageOverlayState(enabled: Boolean) { + Executors.newSingleThreadExecutor().execute { + preferences.edit().putBoolean(PREF_KEY_IMAGE_OVERLAY, enabled).apply() + } } private fun restoreScreenshotState() { - val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false) - if (isSecure) { - screenshotOff() - } else { - screenshotOn() + Executors.newSingleThreadExecutor().execute { + val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false) + val overlayEnabled = preferences.getBoolean(PREF_KEY_IMAGE_OVERLAY, false) + isImageOverlayModeEnabled = overlayEnabled + + activity?.runOnUiThread { + if (isImageOverlayModeEnabled || isSecure) { + screenshotOff() + } else { + screenshotOn() + } + } } } private fun updateSharedPreferencesState(screenshotData: String) { - val jsonString = convertMapToJsonString(mapOf( - PREF_KEY_SCREENSHOT to preferences.getBoolean(PREF_KEY_SCREENSHOT, false), - SCREENSHOT_PATH to screenshotData, - SCREENSHOT_TAKEN to screenshotData.isNotEmpty() - )) - if (lastSharedPreferencesState != jsonString) { - hasSharedPreferencesChanged = true - lastSharedPreferencesState = jsonString - } + Handler(Looper.getMainLooper()).postDelayed({ + val isSecure = + (activity?.window?.attributes?.flags ?: 0) and LayoutParams.FLAG_SECURE != 0 + val jsonString = convertMapToJsonString( + mapOf( + PREF_KEY_SCREENSHOT to isSecure, + SCREENSHOT_PATH to screenshotData, + SCREENSHOT_TAKEN to screenshotData.isNotEmpty(), + IS_SCREEN_RECORDING to isScreenRecording + ) + ) + if (lastSharedPreferencesState != jsonString) { + hasSharedPreferencesChanged = true + lastSharedPreferencesState = jsonString + } + }, 100) } private fun convertMapToJsonString(map: Map): String { @@ -196,4 +403,4 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ handler.postDelayed(this, 1000) } } -} \ No newline at end of file +} diff --git a/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift b/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift index ae360d6..b233626 100644 --- a/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift +++ b/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift @@ -1,30 +1,35 @@ import Flutter import UIKit -import ScreenProtectorKit public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - private var screenProtectorKit: ScreenProtectorKit? = nil + private var screenPrevent = UITextField() + private var screenImage: UIImageView? = nil + private weak var attachedWindow: UIWindow? = nil private static var methodChannel: FlutterMethodChannel? = nil private static var eventChannel: FlutterEventChannel? = nil private static var preventScreenShot: Bool = false private var eventSink: FlutterEventSink? = nil private var lastSharedPreferencesState: String = "" private var hasSharedPreferencesChanged: Bool = false + private var isImageOverlayModeEnabled: Bool = false + private var isScreenRecording: Bool = false + private var isRecordingListening: Bool = false private static let ENABLESCREENSHOT = false private static let DISABLESCREENSHOT = true private static let preventScreenShotKey = "preventScreenShot" + private static let imageOverlayModeKey = "imageOverlayMode" private static let methodChannelName = "com.flutterplaza.no_screenshot_methods" private static let eventChannelName = "com.flutterplaza.no_screenshot_streams" private static let screenshotPathPlaceholder = "screenshot_path_placeholder" - init(screenProtectorKit: ScreenProtectorKit) { - self.screenProtectorKit = screenProtectorKit + override init() { super.init() // Restore the saved state from UserDefaults - var fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey) + let fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey) + isImageOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.imageOverlayModeKey) updateScreenshotState(isScreenshotBlocked: fetchVal) } @@ -32,30 +37,111 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle methodChannel = FlutterMethodChannel(name: methodChannelName, binaryMessenger: registrar.messenger()) eventChannel = FlutterEventChannel(name: eventChannelName, binaryMessenger: registrar.messenger()) - let window = UIApplication.shared.delegate?.window - let screenProtectorKit = ScreenProtectorKit(window: window as? UIWindow) - screenProtectorKit.configurePreventionScreenshot() + let instance = IOSNoScreenshotPlugin() - let instance = IOSNoScreenshotPlugin(screenProtectorKit: screenProtectorKit) registrar.addMethodCallDelegate(instance, channel: methodChannel!) eventChannel?.setStreamHandler(instance) registrar.addApplicationDelegate(instance) } + // MARK: - Inline Screenshot Prevention (replaces ScreenProtectorKit) + + private func configurePreventionScreenshot(window: UIWindow) { + guard let rootLayer = window.layer.superlayer else { return } + guard screenPrevent.layer.superlayer == nil else { return } + + screenPrevent.semanticContentAttribute = .forceLeftToRight // RTL fix + screenPrevent.textAlignment = .left // RTL fix + + // Briefly add to the window so UIKit creates the text field's + // internal sublayer hierarchy, then force a layout pass and + // immediately remove so screenPrevent is NOT a subview of window. + // This avoids a circular view-hierarchy that causes EXC_BAD_ACCESS + // (stack overflow in _collectExistingTraitCollectionsForTraitTracking) + // on iOS 26+. + window.addSubview(screenPrevent) + screenPrevent.layoutIfNeeded() + screenPrevent.removeFromSuperview() + + // Keep the layer at the origin so reparenting window.layer + // does not shift the app content. + screenPrevent.layer.frame = .zero + + rootLayer.addSublayer(screenPrevent.layer) + if #available(iOS 17.0, *) { + screenPrevent.layer.sublayers?.last?.addSublayer(window.layer) + } else { + screenPrevent.layer.sublayers?.first?.addSublayer(window.layer) + } + } + + private func enablePreventScreenshot() { + screenPrevent.isSecureTextEntry = true + } + + private func disablePreventScreenshot() { + screenPrevent.isSecureTextEntry = false + } + + private func enableImageScreen(named: String) { + guard let window = attachedWindow else { return } + let imageView = UIImageView(frame: UIScreen.main.bounds) + imageView.image = UIImage(named: named) + imageView.isUserInteractionEnabled = false + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + window.addSubview(imageView) + screenImage = imageView + } + + private func disableImageScreen() { + screenImage?.removeFromSuperview() + screenImage = nil + } + + // MARK: - App Lifecycle + // + // Image overlay lifecycle is intentionally handled in exactly two places: + // SHOW: applicationWillResignActive (app is about to lose focus) + // HIDE: applicationDidBecomeActive (app is fully interactive again) + // + // willResignActive always fires before didEnterBackground, and + // didBecomeActive always fires after willEnterForeground, so a single + // show/hide pair covers both the app-switcher peek and the full + // background → foreground round-trip without double-showing the image. + public func applicationWillResignActive(_ application: UIApplication) { persistState() + + if isImageOverlayModeEnabled { + // Temporarily lift screenshot prevention so the overlay image is + // visible in the app switcher (otherwise the secure text field + // would show a blank screen). + disablePreventScreenshot() + enableImageScreen(named: "image") + } } public func applicationDidBecomeActive(_ application: UIApplication) { + // Remove the image overlay FIRST. + if isImageOverlayModeEnabled { + disableImageScreen() + } + + // Now restore screenshot protection (and re-attach the window if it + // changed while the app was in the background). fetchPersistedState() } public func applicationWillEnterForeground(_ application: UIApplication) { - fetchPersistedState() + // Image overlay removal is handled in applicationDidBecomeActive + // which always fires after this callback. } public func applicationDidEnterBackground(_ application: UIApplication) { persistState() + // Image overlay was already shown in applicationWillResignActive + // which always fires before this callback. } public func applicationWillTerminate(_ application: UIApplication) { @@ -65,15 +151,17 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle func persistState() { // Persist the state when changed UserDefaults.standard.set(IOSNoScreenshotPlugin.preventScreenShot, forKey: IOSNoScreenshotPlugin.preventScreenShotKey) - print("Persisted state: \(IOSNoScreenshotPlugin.preventScreenShot)") + UserDefaults.standard.set(isImageOverlayModeEnabled, forKey: IOSNoScreenshotPlugin.imageOverlayModeKey) + print("Persisted state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled)") updateSharedPreferencesState("") } func fetchPersistedState() { // Restore the saved state from UserDefaults - var fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey) ? IOSNoScreenshotPlugin.DISABLESCREENSHOT :IOSNoScreenshotPlugin.ENABLESCREENSHOT + let fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey) ? IOSNoScreenshotPlugin.DISABLESCREENSHOT : IOSNoScreenshotPlugin.ENABLESCREENSHOT + isImageOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.imageOverlayModeKey) updateScreenshotState(isScreenshotBlocked: fetchVal) - print("Fetched state: \(IOSNoScreenshotPlugin.preventScreenShot)") + print("Fetched state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled)") } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -84,8 +172,11 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle case "screenshotOn": shotOn() result(true) + case "toggleScreenshotWithImage": + let isActive = toggleScreenshotWithImage() + result(isActive) case "toggleScreenshot": - IOSNoScreenshotPlugin.preventScreenShot ? shotOn(): shotOff() + IOSNoScreenshotPlugin.preventScreenShot ? shotOn() : shotOff() result(true) case "startScreenshotListening": startListening() @@ -93,6 +184,12 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle case "stopScreenshotListening": stopListening() result("Listening stopped") + case "startScreenRecordingListening": + startRecordingListening() + result("Recording listening started") + case "stopScreenRecordingListening": + stopRecordingListening() + result("Recording listening stopped") default: result(FlutterMethodNotImplemented) } @@ -100,16 +197,35 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle private func shotOff() { IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT - screenProtectorKit?.enabledPreventScreenshot() + enablePreventScreenshot() persistState() } private func shotOn() { IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT - screenProtectorKit?.disablePreventScreenshot() + disablePreventScreenshot() persistState() } + private func toggleScreenshotWithImage() -> Bool { + // Toggle the image overlay mode state + isImageOverlayModeEnabled.toggle() + + if isImageOverlayModeEnabled { + // Mode is now active (true) - screenshot prevention should be ON (screenshots blocked) + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + } else { + // Mode is now inactive (false) - screenshot prevention should be OFF (screenshots allowed) + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT + disablePreventScreenshot() + disableImageScreen() + } + + persistState() + return isImageOverlayModeEnabled + } + private func startListening() { NotificationCenter.default.addObserver(self, selector: #selector(screenshotDetected), name: UIApplication.userDidTakeScreenshotNotification, object: nil) persistState() @@ -120,16 +236,60 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle persistState() } + // MARK: - Screen Recording Detection + + private func startRecordingListening() { + guard !isRecordingListening else { return } + isRecordingListening = true + + if #available(iOS 11.0, *) { + NotificationCenter.default.addObserver( + self, + selector: #selector(screenCapturedDidChange), + name: UIScreen.capturedDidChangeNotification, + object: nil + ) + // Check initial state + isScreenRecording = UIScreen.main.isCaptured + } + + updateSharedPreferencesState("") + } + + private func stopRecordingListening() { + guard isRecordingListening else { return } + isRecordingListening = false + + if #available(iOS 11.0, *) { + NotificationCenter.default.removeObserver( + self, + name: UIScreen.capturedDidChangeNotification, + object: nil + ) + } + + isScreenRecording = false + updateSharedPreferencesState("") + } + + @objc private func screenCapturedDidChange() { + if #available(iOS 11.0, *) { + isScreenRecording = UIScreen.main.isCaptured + } + updateSharedPreferencesState("") + } + @objc private func screenshotDetected() { print("Screenshot detected") updateSharedPreferencesState(IOSNoScreenshotPlugin.screenshotPathPlaceholder) } private func updateScreenshotState(isScreenshotBlocked: Bool) { + attachWindowIfNeeded() if isScreenshotBlocked { - screenProtectorKit?.enabledPreventScreenshot() + enablePreventScreenshot() } else { - screenProtectorKit?.disablePreventScreenshot() + disablePreventScreenshot() } } @@ -137,7 +297,8 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle let map: [String: Any] = [ "is_screenshot_on": IOSNoScreenshotPlugin.preventScreenShot, "screenshot_path": screenshotData, - "was_screenshot_taken": !screenshotData.isEmpty + "was_screenshot_taken": !screenshotData.isEmpty, + "is_screen_recording": isScreenRecording ] let jsonString = convertMapToJsonString(map) if lastSharedPreferencesState != jsonString { @@ -176,7 +337,47 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle } } - deinit { - screenProtectorKit?.removeAllObserver() + private func attachWindowIfNeeded() { + var activeWindow: UIWindow? + + if #available(iOS 13.0, *) { + if let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let active = windowScene.windows.first(where: { $0.isKeyWindow }) { + activeWindow = active + } + } else { + activeWindow = UIApplication.shared.windows.filter {$0.isKeyWindow}.first + } + + guard let window = activeWindow else { + print("❗️No active window found.") + return + } + + // Skip re-configuration if already attached to this window. + if window === attachedWindow { + return + } + + // Clean up old state before re-attaching to a new window. + if isImageOverlayModeEnabled { + disableImageScreen() + } + disablePreventScreenshot() + + // Undo previous layer reparenting: move the old window's layer + // back to the root layer and detach the text field's layer. + if let oldWindow = attachedWindow, + let rootLayer = screenPrevent.layer.superlayer { + rootLayer.addSublayer(oldWindow.layer) + screenPrevent.layer.removeFromSuperlayer() + } + + // Use a fresh UITextField to avoid stale layer state. + screenPrevent = UITextField() + + configurePreventionScreenshot(window: window) + self.attachedWindow = window } } diff --git a/no_screenshot/ios/no_screenshot.podspec b/no_screenshot/ios/no_screenshot.podspec index 7d9027e..930aad2 100644 --- a/no_screenshot/ios/no_screenshot.podspec +++ b/no_screenshot/ios/no_screenshot.podspec @@ -15,8 +15,6 @@ A new Flutter plugin project. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - # Updated the dependency version to remove the wildcard and use a specific version range - s.dependency 'ScreenProtectorKit', '~> 1.3.1' s.platform = :ios, '10.0' # Flutter.framework does not contain a i386 slice. diff --git a/no_screenshot/lib/constants.dart b/no_screenshot/lib/constants.dart index f2e5e59..f378f61 100644 --- a/no_screenshot/lib/constants.dart +++ b/no_screenshot/lib/constants.dart @@ -1,7 +1,10 @@ const screenShotOnConst = "screenshotOn"; const screenShotOffConst = "screenshotOff"; +const screenSetImage = "toggleScreenshotWithImage"; const toggleScreenShotConst = "toggleScreenshot"; const startScreenshotListeningConst = 'startScreenshotListening'; const stopScreenshotListeningConst = 'stopScreenshotListening'; +const startScreenRecordingListeningConst = 'startScreenRecordingListening'; +const stopScreenRecordingListeningConst = 'stopScreenRecordingListening'; const screenshotMethodChannel = "com.flutterplaza.no_screenshot_methods"; const screenshotEventChannel = "com.flutterplaza.no_screenshot_streams"; diff --git a/no_screenshot/lib/no_screenshot.dart b/no_screenshot/lib/no_screenshot.dart index f9469ad..00ccc64 100644 --- a/no_screenshot/lib/no_screenshot.dart +++ b/no_screenshot/lib/no_screenshot.dart @@ -32,6 +32,11 @@ class NoScreenshot implements NoScreenshotPlatform { return _instancePlatform.screenshotOn(); } + @override + Future toggleScreenshotWithImage() { + return _instancePlatform.toggleScreenshotWithImage(); + } + /// Return `true` if screenshot capabilities has been /// successfully toggle from it previous state and `false` if the attempt /// to toggle failed. @@ -61,6 +66,18 @@ class NoScreenshot implements NoScreenshotPlatform { return _instancePlatform.stopScreenshotListening(); } + /// Start listening to screen recording activities + @override + Future startScreenRecordingListening() { + return _instancePlatform.startScreenRecordingListening(); + } + + /// Stop listening to screen recording activities + @override + Future stopScreenRecordingListening() { + return _instancePlatform.stopScreenRecordingListening(); + } + @override bool operator ==(Object other) { return identical(this, other) || diff --git a/no_screenshot/lib/no_screenshot_method_channel.dart b/no_screenshot/lib/no_screenshot_method_channel.dart index 6633075..94615ed 100644 --- a/no_screenshot/lib/no_screenshot_method_channel.dart +++ b/no_screenshot/lib/no_screenshot_method_channel.dart @@ -40,6 +40,12 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform { return result ?? false; } + @override + Future toggleScreenshotWithImage() async { + final result = await methodChannel.invokeMethod(screenSetImage); + return result ?? false; + } + @override Future startScreenshotListening() { return methodChannel.invokeMethod(startScreenshotListeningConst); @@ -49,4 +55,14 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform { Future stopScreenshotListening() { return methodChannel.invokeMethod(stopScreenshotListeningConst); } + + @override + Future startScreenRecordingListening() { + return methodChannel.invokeMethod(startScreenRecordingListeningConst); + } + + @override + Future stopScreenRecordingListening() { + return methodChannel.invokeMethod(stopScreenRecordingListeningConst); + } } diff --git a/no_screenshot/lib/no_screenshot_platform_interface.dart b/no_screenshot/lib/no_screenshot_platform_interface.dart index eac1732..6808efa 100644 --- a/no_screenshot/lib/no_screenshot_platform_interface.dart +++ b/no_screenshot/lib/no_screenshot_platform_interface.dart @@ -38,6 +38,14 @@ abstract class NoScreenshotPlatform extends PlatformInterface { throw UnimplementedError('screenshotOn() has not been implemented.'); } + /// Return `true` if screenshot capabilities has been + /// successfully enabled or is currently enabled and `false` otherwise. + /// throw `UnmimplementedError` if not implement + Future toggleScreenshotWithImage() { + throw UnimplementedError( + 'toggleScreenshotWithImage() has not been implemented.'); + } + /// Return `true` if screenshot capabilities has been /// successfully toggle from it previous state and `false` if the attempt /// to toggle failed. @@ -65,4 +73,16 @@ abstract class NoScreenshotPlatform extends PlatformInterface { throw UnimplementedError( 'stopScreenshotListening has not been implemented.'); } + + /// Start listening to screen recording activities + Future startScreenRecordingListening() { + throw UnimplementedError( + 'startScreenRecordingListening has not been implemented.'); + } + + /// Stop listening to screen recording activities + Future stopScreenRecordingListening() { + throw UnimplementedError( + 'stopScreenRecordingListening has not been implemented.'); + } } diff --git a/no_screenshot/lib/screenshot_snapshot.dart b/no_screenshot/lib/screenshot_snapshot.dart index 6aa20e8..fa62c21 100644 --- a/no_screenshot/lib/screenshot_snapshot.dart +++ b/no_screenshot/lib/screenshot_snapshot.dart @@ -1,12 +1,22 @@ class ScreenshotSnapshot { + /// File path of the captured screenshot. + /// + /// Only available on **macOS** (via Spotlight / `NSMetadataQuery`) and + /// **Linux** (via GFileMonitor / inotify). + /// On Android and iOS the OS does not expose the screenshot file path — + /// this field will contain a placeholder string. + /// Use [wasScreenshotTaken] to detect screenshot events on all platforms. final String screenshotPath; + final bool isScreenshotProtectionOn; final bool wasScreenshotTaken; + final bool isScreenRecording; ScreenshotSnapshot({ required this.screenshotPath, required this.isScreenshotProtectionOn, required this.wasScreenshotTaken, + this.isScreenRecording = false, }); factory ScreenshotSnapshot.fromMap(Map map) { @@ -14,6 +24,7 @@ class ScreenshotSnapshot { screenshotPath: map['screenshot_path'] as String? ?? '', isScreenshotProtectionOn: map['is_screenshot_on'] as bool? ?? false, wasScreenshotTaken: map['was_screenshot_taken'] as bool? ?? false, + isScreenRecording: map['is_screen_recording'] as bool? ?? false, ); } @@ -22,12 +33,13 @@ class ScreenshotSnapshot { 'screenshot_path': screenshotPath, 'is_screenshot_on': isScreenshotProtectionOn, 'was_screenshot_taken': wasScreenshotTaken, + 'is_screen_recording': isScreenRecording, }; } @override String toString() { - return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken\n)'; + return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken, \nisScreenRecording: $isScreenRecording\n)'; } @override @@ -37,13 +49,15 @@ class ScreenshotSnapshot { return other is ScreenshotSnapshot && other.screenshotPath == screenshotPath && other.isScreenshotProtectionOn == isScreenshotProtectionOn && - other.wasScreenshotTaken == wasScreenshotTaken; + other.wasScreenshotTaken == wasScreenshotTaken && + other.isScreenRecording == isScreenRecording; } @override int get hashCode { return screenshotPath.hashCode ^ isScreenshotProtectionOn.hashCode ^ - wasScreenshotTaken.hashCode; + wasScreenshotTaken.hashCode ^ + isScreenRecording.hashCode; } } diff --git a/no_screenshot/pubspec.yaml b/no_screenshot/pubspec.yaml index 26f4e5a..622334c 100644 --- a/no_screenshot/pubspec.yaml +++ b/no_screenshot/pubspec.yaml @@ -1,8 +1,8 @@ name: no_screenshot -description: Flutter plugin to enable, disable, toggle or stream screenshot activities in your application. -version: 0.3.2 +description: Flutter plugin to enable, disable, toggle or stream screenshot and screen recording activities in your application. +version: 0.4.0 homepage: https://flutterplaza.com -repository: https://github.com/FlutterPlaza/no_screenshot/releases/tag/v0.3.2 +repository: https://github.com/FlutterPlaza/no_screenshot/releases/tag/v0.4.0 environment: sdk: '>=3.0.0 <4.0.0' @@ -30,3 +30,5 @@ flutter: pluginClass: NoScreenshotPlugin macos: pluginClass: MacOSNoScreenshotPlugin + linux: + pluginClass: NoScreenshotPlugin diff --git a/no_screenshot/test/no_screenshot_method_channel_test.dart b/no_screenshot/test/no_screenshot_method_channel_test.dart index 1033ec8..cdf1991 100644 --- a/no_screenshot/test/no_screenshot_method_channel_test.dart +++ b/no_screenshot/test/no_screenshot_method_channel_test.dart @@ -83,6 +83,87 @@ void main() { await platform.stopScreenshotListening(); expect(true, true); // Add more specific expectations if needed }); + + test('toggleScreenshotWithImage', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenSetImage) { + return expected; + } + return null; + }); + + final result = await platform.toggleScreenshotWithImage(); + expect(result, expected); + }); + + test('toggleScreenshotWithImage returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.toggleScreenshotWithImage(); + expect(result, false); + }); + + test('screenshotOn returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.screenshotOn(); + expect(result, false); + }); + + test('screenshotOff returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.screenshotOff(); + expect(result, false); + }); + + test('toggleScreenshot returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.toggleScreenshot(); + expect(result, false); + }); + + test('startScreenRecordingListening', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == startScreenRecordingListeningConst) { + return null; + } + return null; + }); + + await platform.startScreenRecordingListening(); + expect(true, true); + }); + + test('stopScreenRecordingListening', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == stopScreenRecordingListeningConst) { + return null; + } + return null; + }); + + await platform.stopScreenRecordingListening(); + expect(true, true); + }); }); group('ScreenshotSnapshot', () { @@ -96,6 +177,31 @@ void main() { expect(snapshot.screenshotPath, '/example/path'); expect(snapshot.isScreenshotProtectionOn, true); expect(snapshot.wasScreenshotTaken, true); + expect(snapshot.isScreenRecording, false); + }); + + test('fromMap with is_screen_recording', () { + final map = { + 'screenshot_path': '/example/path', + 'is_screenshot_on': true, + 'was_screenshot_taken': false, + 'is_screen_recording': true, + }; + final snapshot = ScreenshotSnapshot.fromMap(map); + expect(snapshot.screenshotPath, '/example/path'); + expect(snapshot.isScreenshotProtectionOn, true); + expect(snapshot.wasScreenshotTaken, false); + expect(snapshot.isScreenRecording, true); + }); + + test('fromMap without is_screen_recording defaults to false', () { + final map = { + 'screenshot_path': '/example/path', + 'is_screenshot_on': true, + 'was_screenshot_taken': true, + }; + final snapshot = ScreenshotSnapshot.fromMap(map); + expect(snapshot.isScreenRecording, false); }); test('toMap', () { @@ -103,11 +209,23 @@ void main() { screenshotPath: '/example/path', isScreenshotProtectionOn: true, wasScreenshotTaken: true, + isScreenRecording: true, ); final map = snapshot.toMap(); expect(map['screenshot_path'], '/example/path'); expect(map['is_screenshot_on'], true); expect(map['was_screenshot_taken'], true); + expect(map['is_screen_recording'], true); + }); + + test('toMap with default isScreenRecording', () { + final snapshot = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + ); + final map = snapshot.toMap(); + expect(map['is_screen_recording'], false); }); test('equality operator', () { @@ -126,9 +244,16 @@ void main() { isScreenshotProtectionOn: false, wasScreenshotTaken: false, ); + final snapshot4 = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + isScreenRecording: true, + ); expect(snapshot1 == snapshot2, true); expect(snapshot1 == snapshot3, false); + expect(snapshot1 == snapshot4, false); }); test('hashCode', () { @@ -152,6 +277,28 @@ void main() { expect(snapshot1.hashCode, isNot(snapshot3.hashCode)); }); + test('fromMap with empty map uses defaults', () { + final snapshot = ScreenshotSnapshot.fromMap({}); + expect(snapshot.screenshotPath, ''); + expect(snapshot.isScreenshotProtectionOn, false); + expect(snapshot.wasScreenshotTaken, false); + expect(snapshot.isScreenRecording, false); + }); + + test('fromMap with null values uses defaults', () { + final map = { + 'screenshot_path': null, + 'is_screenshot_on': null, + 'was_screenshot_taken': null, + 'is_screen_recording': null, + }; + final snapshot = ScreenshotSnapshot.fromMap(map); + expect(snapshot.screenshotPath, ''); + expect(snapshot.isScreenshotProtectionOn, false); + expect(snapshot.wasScreenshotTaken, false); + expect(snapshot.isScreenRecording, false); + }); + test('toString', () { final snapshot = ScreenshotSnapshot( screenshotPath: '/example/path', @@ -160,7 +307,19 @@ void main() { ); final string = snapshot.toString(); expect(string, - 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true\n)'); + 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false\n)'); + }); + + test('toString with isScreenRecording true', () { + final snapshot = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + isScreenRecording: true, + ); + final string = snapshot.toString(); + expect(string, + 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true\n)'); }); }); } diff --git a/no_screenshot/test/no_screenshot_platform_interface_test.dart b/no_screenshot/test/no_screenshot_platform_interface_test.dart index ab0a9d4..6a2ea85 100644 --- a/no_screenshot/test/no_screenshot_platform_interface_test.dart +++ b/no_screenshot/test/no_screenshot_platform_interface_test.dart @@ -3,6 +3,10 @@ import 'package:no_screenshot/no_screenshot_method_channel.dart'; import 'package:no_screenshot/no_screenshot_platform_interface.dart'; import 'package:no_screenshot/screenshot_snapshot.dart'; +/// A minimal subclass that does NOT override toggleScreenshotWithImage, +/// so we can verify the base class throws UnimplementedError. +class BaseNoScreenshotPlatform extends NoScreenshotPlatform {} + class MockNoScreenshotPlatform extends NoScreenshotPlatform { @override Future screenshotOff() async { @@ -29,10 +33,25 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform { return; } + @override + Future toggleScreenshotWithImage() async { + return true; + } + @override Future stopScreenshotListening() async { return; } + + @override + Future startScreenRecordingListening() async { + return; + } + + @override + Future stopScreenRecordingListening() async { + return; + } } void main() { @@ -71,5 +90,86 @@ void main() { () async { expect(platform.stopScreenshotListening(), completes); }); + + test('toggleScreenshotWithImage should return true when called', () async { + expect(await platform.toggleScreenshotWithImage(), isTrue); + }); + + test( + 'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.toggleScreenshotWithImage(), + throwsUnimplementedError); + }); + + test('base NoScreenshotPlatform.screenshotOff() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotOff(), throwsUnimplementedError); + }); + + test('base NoScreenshotPlatform.screenshotOn() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotOn(), throwsUnimplementedError); + }); + + test( + 'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError); + }); + + test('base NoScreenshotPlatform.screenshotStream throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotStream, throwsUnimplementedError); + }); + + test( + 'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.startScreenshotListening(), + throwsUnimplementedError); + }); + + test( + 'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.stopScreenshotListening(), + throwsUnimplementedError); + }); + + test( + 'startScreenRecordingListening should not throw UnimplementedError when called', + () async { + expect(platform.startScreenRecordingListening(), completes); + }); + + test( + 'stopScreenRecordingListening should not throw UnimplementedError when called', + () async { + expect(platform.stopScreenRecordingListening(), completes); + }); + + test( + 'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.startScreenRecordingListening(), + throwsUnimplementedError); + }); + + test( + 'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.stopScreenRecordingListening(), + throwsUnimplementedError); + }); }); } diff --git a/no_screenshot/test/no_screenshot_test.dart b/no_screenshot/test/no_screenshot_test.dart index 86396bd..650a070 100644 --- a/no_screenshot/test/no_screenshot_test.dart +++ b/no_screenshot/test/no_screenshot_test.dart @@ -20,6 +20,11 @@ class MockNoScreenshotPlatform return Future.value(true); } + @override + Future toggleScreenshotWithImage() async { + return Future.value(true); + } + @override Future toggleScreenshot() async { // Mock implementation or return a fixed value @@ -38,6 +43,16 @@ class MockNoScreenshotPlatform Future stopScreenshotListening() { return Future.value(); } + + @override + Future startScreenRecordingListening() { + return Future.value(); + } + + @override + Future stopScreenRecordingListening() { + return Future.value(); + } } void main() { @@ -86,10 +101,27 @@ void main() { expect(NoScreenshot.instance.stopScreenshotListening(), completes); }); + test('toggleScreenshotWithImage', () async { + expect(await NoScreenshot.instance.toggleScreenshotWithImage(), true); + }); + test('NoScreenshot equality operator', () { final instance1 = NoScreenshot.instance; final instance2 = NoScreenshot.instance; expect(instance1 == instance2, true, reason: 'Instances should be equal'); }); + + test('NoScreenshot hashCode consistency', () { + final instance1 = NoScreenshot.instance; + final instance2 = NoScreenshot.instance; + + expect(instance1.hashCode, instance2.hashCode); + }); + + test('deprecated constructor still works', () { + // ignore: deprecated_member_use + final instance = NoScreenshot(); + expect(instance, isA()); + }); } diff --git a/qr/lib/qr.dart b/qr/lib/qr.dart index 0565eb5..db99aa2 100644 --- a/qr/lib/qr.dart +++ b/qr/lib/qr.dart @@ -1,5 +1,9 @@ export 'src/bit_buffer.dart'; +export 'src/byte.dart'; +export 'src/eci.dart'; +export 'src/ecivalue.dart'; export 'src/error_correct_level.dart'; export 'src/input_too_long_exception.dart'; +export 'src/mode.dart'; export 'src/qr_code.dart'; export 'src/qr_image.dart'; diff --git a/qr/lib/src/bit_buffer.dart b/qr/lib/src/bit_buffer.dart index 3848f4e..9248490 100644 --- a/qr/lib/src/bit_buffer.dart +++ b/qr/lib/src/bit_buffer.dart @@ -1,34 +1,78 @@ -import 'dart:collection'; - -class QrBitBuffer extends Object with ListMixin { - final List _buffer; +/// A growable sequence of bits. +/// +/// Used internally to construct the data bit stream for a QR code. +class QrBitBuffer extends Iterable { + final _buffer = []; int _length = 0; - QrBitBuffer() : _buffer = []; - - @override - void operator []=(int index, bool value) => - throw UnsupportedError('cannot change'); - - @override - bool operator [](int index) { - final bufIndex = index ~/ 8; - return ((_buffer[bufIndex] >> (7 - index % 8)) & 1) == 1; - } + QrBitBuffer(); @override int get length => _length; @override - set length(int value) => throw UnsupportedError('Cannot change'); + Iterator get iterator => _QrBitBufferIterator(this); + + bool operator [](int index) { + final bufIndex = index ~/ 8; + return ((_buffer[bufIndex] >> (7 - index % 8)) & 1) == 1; + } int getByte(int index) => _buffer[index]; void put(int number, int length) { - for (var i = 0; i < length; i++) { - final bit = ((number >> (length - i - 1)) & 1) == 1; - putBit(bit); + if (length == 0) return; + + var bitIndex = _length; + final endBitIndex = bitIndex + length; + + // Ensure capacity + final neededBytes = (endBitIndex + 7) >> 3; // (endBitIndex + 7) ~/ 8 + while (_buffer.length < neededBytes) { + _buffer.add(0); } + + // Optimization for byte-aligned writes of 8 bits (common case) + if (length == 8 && (bitIndex & 7) == 0 && number >= 0 && number <= 255) { + _buffer[bitIndex >> 3] = number; + _length = endBitIndex; + return; + } + + // Generic chunked write + var bitsLeft = length; + + while (bitsLeft > 0) { + final bufIndex = bitIndex >> 3; + final leftBitIndex = bitIndex & 7; + final available = 8 - leftBitIndex; + final bitsToWrite = bitsLeft < available ? bitsLeft : available; + + // Extract the 'bitsToWrite' most significant bits from 'number' + // Shift number right to move target bits to bottom + // Mask them + // Then allocate them to the byte buffer + + final shift = bitsLeft - bitsToWrite; + final bits = (number >> shift) & ((1 << bitsToWrite) - 1); + + // Setup position in byte. + // We want to write 'bits' starting at 'leftBitIndex'. + // So we shift 'bits' left by (available - bitsToWrite)? + // No, `leftBitIndex` is 0-7. 0 is MSB (0x80). + // If leftBitIndex is 0, we write starting at 0x80. + // If bitsToWrite is 8, we write 0xFF. + // If 4 bits, we write 0xF0. + // formula: bits << (8 - leftBitIndex - bitsToWrite) + + final posShift = 8 - leftBitIndex - bitsToWrite; + _buffer[bufIndex] |= bits << posShift; + + bitsLeft -= bitsToWrite; + bitIndex += bitsToWrite; + } + + _length = endBitIndex; } void putBit(bool bit) { @@ -43,4 +87,28 @@ class QrBitBuffer extends Object with ListMixin { _length++; } + + List getRange(int start, int end) { + final list = []; + for (var i = start; i < end; i++) { + list.add(this[i]); + } + return list; + } +} + +class _QrBitBufferIterator implements Iterator { + final QrBitBuffer _buffer; + int _currentIndex = -1; + + _QrBitBufferIterator(this._buffer); + + @override + bool get current => _buffer[_currentIndex]; + + @override + bool moveNext() { + _currentIndex++; + return _currentIndex < _buffer.length; + } } diff --git a/qr/lib/src/byte.dart b/qr/lib/src/byte.dart index 560644a..27aeddf 100644 --- a/qr/lib/src/byte.dart +++ b/qr/lib/src/byte.dart @@ -2,17 +2,47 @@ import 'dart:convert'; import 'dart:typed_data'; import 'bit_buffer.dart'; -import 'mode.dart' as qr_mode; +import 'eci.dart'; +import 'mode.dart'; +/// A piece of data to be encoded in a QR code. +/// +/// Use [toDatums] to parse a string into optimal segments. abstract class QrDatum { - int get mode; + QrMode get mode; int get length; void write(QrBitBuffer buffer); + + /// Parses [data] into a list of [QrDatum] segments, optimizing for the + /// most efficient encoding modes (Numeric, Alphanumeric, Byte). + /// + /// Automatically handles UTF-8 characters by using [QrEci] and [QrByte] + /// segments if necessary. + static List toDatums(String data) { + if (QrNumeric.validationRegex.hasMatch(data)) { + return [QrNumeric.fromString(data)]; + } + if (QrAlphaNumeric.validationRegex.hasMatch(data)) { + return [QrAlphaNumeric.fromString(data)]; + } + // Default to byte mode for other characters + // Check if we need ECI (if there are chars > 255) + // Actually, standard ISO-8859-1 is 0-255. + // Emojis and other UTF-8 chars will definitely trigger this. + final hasNonLatin1 = data.codeUnits.any((c) => c > 255); + if (hasNonLatin1) { + return [QrEci(26), QrByte(data)]; // UTF-8 + } + return [QrByte(data)]; + } } +/// Represents data encoded in Byte mode (8-bit). +/// +/// Supports ISO-8859-1 and UTF-8 (when preceded by an ECI segment). class QrByte implements QrDatum { @override - final int mode = qr_mode.mode8bitByte; + final QrMode mode = QrMode.byte; final Uint8List _data; factory QrByte(String input) => @@ -34,13 +64,20 @@ class QrByte implements QrDatum { } } -/// Encodes numbers (0-9) 10 bits per 3 digits. +/// Encodes numeric data (digits 0-9). +/// +/// Compresses 3 digits into 10 bits. +/// Most efficient mode for decimal numbers. class QrNumeric implements QrDatum { static final RegExp validationRegex = RegExp(r'^[0-9]+$'); factory QrNumeric.fromString(String numberString) { if (!validationRegex.hasMatch(numberString)) { - throw ArgumentError('string can only contain digits 0-9'); + throw ArgumentError.value( + numberString, + 'numberString', + 'string can only contain digits 0-9', + ); } final newList = Uint8List(numberString.length); var count = 0; @@ -55,7 +92,7 @@ class QrNumeric implements QrDatum { final Uint8List _data; @override - final int mode = qr_mode.modeNumber; + final QrMode mode = QrMode.numeric; @override void write(QrBitBuffer buffer) { @@ -82,7 +119,10 @@ class QrNumeric implements QrDatum { int get length => _data.length; } -/// Encodes numbers (0-9) 10 bits per 3 digits. +/// Encodes alphanumeric data (uppercase letters, digits, and specific symbols). +/// +/// Supported characters: 0-9, A-Z, space, $, %, *, +, -, ., /, : +/// Compresses 2 characters into 11 bits. class QrAlphaNumeric implements QrDatum { static const alphaNumTable = r'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; // Note: '-' anywhere in this string is a range character. @@ -102,9 +142,10 @@ class QrAlphaNumeric implements QrDatum { factory QrAlphaNumeric.fromString(String alphaNumeric) { if (!alphaNumeric.contains(validationRegex)) { - throw ArgumentError( - 'String does not contain valid ALPHA-NUM ' - 'character set: $alphaNumeric', + throw ArgumentError.value( + alphaNumeric, + 'alphaNumeric', + 'String does not contain valid ALPHA-NUM character set', ); } return QrAlphaNumeric._(alphaNumeric); @@ -113,7 +154,7 @@ class QrAlphaNumeric implements QrDatum { QrAlphaNumeric._(this._string); @override - final int mode = qr_mode.modeAlphaNum; + final QrMode mode = QrMode.alphaNumeric; @override void write(QrBitBuffer buffer) { diff --git a/qr/lib/src/eci.dart b/qr/lib/src/eci.dart new file mode 100644 index 0000000..bb939c9 --- /dev/null +++ b/qr/lib/src/eci.dart @@ -0,0 +1,40 @@ +import 'bit_buffer.dart'; +import 'byte.dart'; + +import 'mode.dart'; + +/// Extended Channel Interpretation (ECI) mode data. +/// +/// Use this to specify a different character encoding for the following data. +class QrEci implements QrDatum { + final int value; + + factory QrEci(int value) { + if (value < 0 || value > 999999) { + throw RangeError.range(value, 0, 999999, 'value'); + } + return QrEci._(value); + } + + QrEci._(this.value); + + @override + QrMode get mode => QrMode.eci; + + @override + int get length => 0; // ECI segments do not have a length field + + @override + void write(QrBitBuffer buffer) { + if (value < 128) { + // 0xxxxxxx + buffer.put(value, 8); + } else if (value < 16384) { + // 10xxxxxx xxxxxxxx + buffer.put(0x8000 | value, 16); + } else { + // 110xxxxx xxxxxxxx xxxxxxxx + buffer.put(0xC00000 | value, 24); + } + } +} diff --git a/qr/lib/src/ecivalue.dart b/qr/lib/src/ecivalue.dart new file mode 100644 index 0000000..b5e9e89 --- /dev/null +++ b/qr/lib/src/ecivalue.dart @@ -0,0 +1,87 @@ +/// ECI value for QR Codes. +/// +/// This extension type provides constants for common ECI values. +/// +/// See: https://github.com/zxing/zxing/blob/master/core/src/main/java/com/google/zxing/common/CharacterSetECI.java +extension type const QrEciValue(int value) implements int { + /// ISO-8859-1 (Latin-1). Default encoding. + static const iso8859_1 = QrEciValue(3); + + /// ISO-8859-2 (Latin-2). + static const iso8859_2 = QrEciValue(4); + + /// ISO-8859-3 (Latin-3). + static const iso8859_3 = QrEciValue(5); + + /// ISO-8859-4 (Latin-4). + static const iso8859_4 = QrEciValue(6); + + /// ISO-8859-5 (Latin/Cyrillic). + static const iso8859_5 = QrEciValue(7); + + /// ISO-8859-6 (Latin/Arabic). + static const iso8859_6 = QrEciValue(8); + + /// ISO-8859-7 (Latin/Greek). + static const iso8859_7 = QrEciValue(9); + + /// ISO-8859-8 (Latin/Hebrew). + static const iso8859_8 = QrEciValue(10); + + /// ISO-8859-9 (Latin-5). + static const iso8859_9 = QrEciValue(11); + + /// ISO-8859-10 (Latin-6). + static const iso8859_10 = QrEciValue(12); + + /// ISO-8859-11 (Latin/Thai). + static const iso8859_11 = QrEciValue(13); + + /// ISO-8859-13 (Latin-7). + static const iso8859_13 = QrEciValue(15); + + /// ISO-8859-14 (Latin-8). + static const iso8859_14 = QrEciValue(16); + + /// ISO-8859-15 (Latin-9). + static const iso8859_15 = QrEciValue(17); + + /// ISO-8859-16 (Latin-10). + static const iso8859_16 = QrEciValue(18); + + /// Shift JIS. + static const shiftJis = QrEciValue(20); + + /// Windows-1250 (Latin-2). + static const windows1250 = QrEciValue(21); + + /// Windows-1251 (Cyrillic). + static const windows1251 = QrEciValue(22); + + /// Windows-1252 (Latin-1). + static const windows1252 = QrEciValue(23); + + /// Windows-1256 (Arabic). + static const windows1256 = QrEciValue(24); + + /// UTF-16 (Big Endian). + static const utf16BE = QrEciValue(25); + + /// UTF-8. + static const utf8 = QrEciValue(26); + + /// US-ASCII. + static const ascii = QrEciValue(27); + + /// Big5. + static const big5 = QrEciValue(28); + + /// GB 2312. + static const gb2312 = QrEciValue(29); + + /// EUC-KR. + static const eucKr = QrEciValue(30); + + /// GBK. + static const gbk = QrEciValue(31); +} diff --git a/qr/lib/src/error_correct_level.dart b/qr/lib/src/error_correct_level.dart index 79c6f11..8652a29 100644 --- a/qr/lib/src/error_correct_level.dart +++ b/qr/lib/src/error_correct_level.dart @@ -1,20 +1,27 @@ -// ignore: avoid_classes_with_only_static_members -class QrErrorCorrectLevel { - static const int L = 1; - static const int M = 0; - static const int Q = 3; - static const int H = 2; +/// QR Code error correction level. +/// +/// Recover capacity: +/// * [low] : ~7% +/// * [medium] : ~15% +/// * [quartile] : ~25% +/// * [high] : ~30% +enum QrErrorCorrectLevel { + // NOTE: the order here MATTERS. + // The index maps to the QR standard. - // thesee *are* in order of lowest to highest quality...I think - // all I know for sure: you can create longer messages w/ item N than N+1 - // I assume this correcsponds to more error correction for N+1 - static const List levels = [L, M, Q, H]; + /// Level M (Medium) ~15% error correction. + medium(15), - static String getName(int level) => switch (level) { - L => 'Low', - M => 'Medium', - Q => 'Quartile', - H => 'High', - _ => throw ArgumentError('level $level not supported'), - }; + /// Level L (Low) ~7% error correction. + low(7), + + /// Level H (High) ~30% error correction. + high(30), + + /// Level Q (Quartile) ~25% error correction. + quartile(25); + + final int recoveryRate; + + const QrErrorCorrectLevel(this.recoveryRate); } diff --git a/qr/lib/src/mask_pattern.dart b/qr/lib/src/mask_pattern.dart index f6a42f8..a20637f 100644 --- a/qr/lib/src/mask_pattern.dart +++ b/qr/lib/src/mask_pattern.dart @@ -1,8 +1,25 @@ -const int pattern000 = 0; -const int pattern001 = 1; -const int pattern010 = 2; -const int pattern011 = 3; -const int pattern100 = 4; -const int pattern101 = 5; -const int pattern110 = 6; -const int pattern111 = 7; +enum QrMaskPattern { + pattern000(_check000), + pattern001(_check001), + pattern010(_check010), + pattern011(_check011), + pattern100(_check100), + pattern101(_check101), + pattern110(_check110), + pattern111(_check111); + + final bool Function(int i, int j) _check; + + const QrMaskPattern(this._check); + + bool check(int i, int j) => _check(i, j); +} + +bool _check000(int i, int j) => (i + j).isEven; +bool _check001(int i, int j) => i.isEven; +bool _check010(int i, int j) => j % 3 == 0; +bool _check011(int i, int j) => (i + j) % 3 == 0; +bool _check100(int i, int j) => ((i ~/ 2) + (j ~/ 3)).isEven; +bool _check101(int i, int j) => ((i * j) % 2 + (i * j) % 3) == 0; +bool _check110(int i, int j) => (((i * j) % 2) + ((i * j) % 3)).isEven; +bool _check111(int i, int j) => (((i * j) % 3) + ((i + j) % 2)).isEven; diff --git a/qr/lib/src/math.dart b/qr/lib/src/math.dart index a5ab7bc..6ff4c19 100644 --- a/qr/lib/src/math.dart +++ b/qr/lib/src/math.dart @@ -3,7 +3,8 @@ import 'dart:typed_data'; final Uint8List _logTable = _createLogTable(); final Uint8List _expTable = _createExpTable(); -int glog(int n) => (n >= 1) ? _logTable[n] : throw ArgumentError('glog($n)'); +int glog(int n) => + (n >= 1) ? _logTable[n] : throw ArgumentError.value(n, 'n', 'must be >= 1'); int gexp(int n) => _expTable[n % 255]; diff --git a/qr/lib/src/mode.dart b/qr/lib/src/mode.dart index 00efc50..c380f31 100644 --- a/qr/lib/src/mode.dart +++ b/qr/lib/src/mode.dart @@ -1,4 +1,55 @@ -const int modeNumber = 1 << 0; -const int modeAlphaNum = 1 << 1; -const int mode8bitByte = 1 << 2; -const int modeKanji = 1 << 3; +/// The encoding mode of a QR code segment. +enum QrMode { + /// Numeric mode (0-9). Most efficient. + numeric(1), + + /// Alphanumeric mode (0-9, A-Z, space, %, *, +, -, ., /, :). + alphaNumeric(2), + + /// Byte mode (8-bit data). + byte(4), + + /// Kanji mode (Shift-JIS). + kanji(8), + + /// Extended Channel Interpretation (ECI) mode. + eci(7); + + final int value; + + const QrMode(this.value); + + int getLengthBits(int type) { + if (this == eci) return 0; + if (type < 1 || type > 40) throw RangeError.range(type, 1, 40, 'type'); + + if (type < 10) { + // 1 - 9 + return switch (this) { + numeric => 10, + alphaNumeric => 9, + byte => 8, + kanji => 8, + eci => 0, + }; + } else if (type < 27) { + // 10 - 26 + return switch (this) { + numeric => 12, + alphaNumeric => 11, + byte => 16, + kanji => 10, + eci => 0, + }; + } else { + // 27 - 40 + return switch (this) { + numeric => 14, + alphaNumeric => 13, + byte => 16, + kanji => 12, + eci => 0, + }; + } + } +} diff --git a/qr/lib/src/polynomial.dart b/qr/lib/src/polynomial.dart index 282ede9..7a3641d 100644 --- a/qr/lib/src/polynomial.dart +++ b/qr/lib/src/polynomial.dart @@ -28,36 +28,75 @@ class QrPolynomial { int get length => _values.length; QrPolynomial multiply(QrPolynomial e) { - final List foo = Uint8List(length + e.length - 1); + final foo = Uint8List(length + e.length - 1); for (var i = 0; i < length; i++) { + final v1 = _values[i]; + if (v1 == 0) continue; + final log1 = qr_math.glog(v1); for (var j = 0; j < e.length; j++) { - foo[i + j] ^= qr_math.gexp(qr_math.glog(this[i]) + qr_math.glog(e[j])); + final v2 = e[j]; + if (v2 == 0) continue; + foo[i + j] ^= qr_math.gexp(log1 + qr_math.glog(v2)); } } - return QrPolynomial(foo, 0); + return QrPolynomial._internal(foo); } QrPolynomial mod(QrPolynomial e) { if (length - e.length < 0) { - // ignore: avoid_returning_this return this; } - final ratio = qr_math.glog(this[0]) - qr_math.glog(e[0]); + // Use a copy of _values that we will mutate + // We only need the part that will remain after modulo? + // Actually, standard polynomial division. + // We can work on a copy of `this._values` and zero out leading terms. - final value = Uint8List(length); + final values = Uint8List.fromList(_values); - for (var i = 0; i < length; i++) { - value[i] = this[i]; + for (var i = 0; i < values.length - e.length + 1; i++) { + final v = values[i]; + if (v == 0) continue; + + final ratio = qr_math.glog(v) - qr_math.glog(e[0]); + + for (var j = 0; j < e.length; j++) { + final eVal = e[j]; + if (eVal == 0) continue; + values[i + j] ^= qr_math.gexp(qr_math.glog(eVal) + ratio); + } } - for (var i = 0; i < e.length; i++) { - value[i] ^= qr_math.gexp(qr_math.glog(e[i]) + ratio); - } + // The result is the remainder, which is the last e.length - 1 coefficients? + // Wait, the degree of remainder is less than degree of divisor (e). + // e.length is e.degree + 1. + // So remainder length is e.length - 1. - // recursive call - return QrPolynomial(value, 0).mod(e); + // Find where the remainder starts. + // In the loop above, we zeroed out terms from 0 to + // `values.length - e.length`. + // So the remainder starts at values.length - e.length + 1? + // No, we iterated i from 0 to diff. + // The loop eliminates the term at `i`. + // The last `i` is `values.length - e.length`. + // After that, the terms from `0` to `values.length - e.length` should be 0. + // The remainder is at the end. + + // Note: The original implementation used `offset` to skip leading zeros. + // `offset` increased when `values[offset] == 0`. + // My loop enforces `values[i]` becomes 0 (arithmetically, though likely not + // exactly 0 due to XOR, wait XOR equal things is 0). + + // Let's manually increment offset to match original logic if needed, + // or just slice the end. + // The remainder should fit in e.length - 1. + + // We can just return the tail. + // But we need to handle leading zeros in the result too? + // `QrPolynomial` constructor handles leading zeros. + + return QrPolynomial(values.sublist(values.length - e.length + 1), 0); } } diff --git a/qr/lib/src/qr_code.dart b/qr/lib/src/qr_code.dart index 3449a65..afc8a2c 100644 --- a/qr/lib/src/qr_code.dart +++ b/qr/lib/src/qr_code.dart @@ -5,66 +5,61 @@ import 'package:meta/meta.dart'; import 'bit_buffer.dart'; import 'byte.dart'; +import 'eci.dart'; import 'error_correct_level.dart'; import 'input_too_long_exception.dart'; import 'math.dart' as qr_math; -import 'mode.dart' as qr_mode; + import 'polynomial.dart'; import 'rs_block.dart'; class QrCode { final int typeNumber; - final int errorCorrectLevel; + final QrErrorCorrectLevel errorCorrectLevel; final int moduleCount; List? _dataCache; final _dataList = []; QrCode(this.typeNumber, this.errorCorrectLevel) : moduleCount = typeNumber * 4 + 17 { + // The typeNumber is now calculated internally by the factories, + // so this check is only needed if QrCode is instantiated directly. + // However, the factories ensure a valid typeNumber is passed. + // Keeping it for direct instantiation safety. RangeError.checkValueInInterval(typeNumber, 1, 40, 'typeNumber'); - RangeError.checkValidIndex( - errorCorrectLevel, - QrErrorCorrectLevel.levels, - 'errorCorrectLevel', - ); } factory QrCode.fromData({ required String data, - required int errorCorrectLevel, + required QrErrorCorrectLevel errorCorrectLevel, }) { - final QrDatum datum; - // Automatically determine mode here - if (QrNumeric.validationRegex.hasMatch(data)) { - // Numeric mode for numbers only - datum = QrNumeric.fromString(data); - } else if (QrAlphaNumeric.validationRegex.hasMatch(data)) { - // Alphanumeric mode for alphanumeric characters only - datum = QrAlphaNumeric.fromString(data); - } else { - // Default to byte mode for other characters - datum = QrByte(data); + final datumList = QrDatum.toDatums(data); + + final typeNumber = _calculateTypeNumberFromData( + errorCorrectLevel, + datumList, + ); + + final qrCode = QrCode(typeNumber, errorCorrectLevel); + for (final datum in datumList) { + qrCode._addToList(datum); } - - final typeNumber = _calculateTypeNumberFromData(errorCorrectLevel, datum); - - final qrCode = QrCode(typeNumber, errorCorrectLevel).._addToList(datum); return qrCode; } factory QrCode.fromUint8List({ required Uint8List data, - required int errorCorrectLevel, + required QrErrorCorrectLevel errorCorrectLevel, }) { - final typeNumber = _calculateTypeNumberFromData( - errorCorrectLevel, - QrByte.fromUint8List(data), - ); - return QrCode(typeNumber, errorCorrectLevel) - .._addToList(QrByte.fromUint8List(data)); + final datum = QrByte.fromUint8List(data); + final typeNumber = _calculateTypeNumberFromData(errorCorrectLevel, [datum]); + return QrCode(typeNumber, errorCorrectLevel).._addToList(datum); } - static int _calculateTotalDataBits(int typeNumber, int errorCorrectLevel) { + static int _calculateTotalDataBits( + int typeNumber, + QrErrorCorrectLevel errorCorrectLevel, + ) { final rsBlocks = QrRsBlock.getRSBlocks(typeNumber, errorCorrectLevel); var totalDataBits = 0; for (var rsBlock in rsBlocks) { @@ -73,26 +68,35 @@ class QrCode { return totalDataBits; } - static int _calculateTypeNumberFromData(int errorCorrectLevel, QrDatum data) { + static int _calculateTypeNumberFromData( + QrErrorCorrectLevel errorCorrectLevel, + List data, + ) { for (var typeNumber = 1; typeNumber <= 40; typeNumber++) { final totalDataBits = _calculateTotalDataBits( typeNumber, errorCorrectLevel, ); - final buffer = QrBitBuffer() - ..put(data.mode, 4) - ..put(data.length, _lengthInBits(data.mode, typeNumber)); - data.write(buffer); + final buffer = QrBitBuffer(); + for (final datum in data) { + buffer + ..put(datum.mode.value, 4) + ..put(datum.length, datum.mode.getLengthBits(typeNumber)); + datum.write(buffer); + } if (buffer.length <= totalDataBits) return typeNumber; } // If we reach here, the data is too long for any QR Code version. - final buffer = QrBitBuffer() - ..put(data.mode, 4) - ..put(data.length, _lengthInBits(data.mode, 40)); - data.write(buffer); + final buffer = QrBitBuffer(); + for (final datum in data) { + buffer + ..put(datum.mode.value, 4) + ..put(datum.length, datum.mode.getLengthBits(40)); + datum.write(buffer); + } final maxBits = _calculateTotalDataBits(40, errorCorrectLevel); @@ -100,19 +104,9 @@ class QrCode { } void addData(String data) { - final QrDatum datum; - // Automatically determine mode here, just like QrCode.fromData - if (QrNumeric.validationRegex.hasMatch(data)) { - // Numeric mode for numbers only - datum = QrNumeric.fromString(data); - } else if (QrAlphaNumeric.validationRegex.hasMatch(data)) { - // Alphanumeric mode for alphanumeric characters only - datum = QrAlphaNumeric.fromString(data); - } else { - // Default to byte mode for other characters - datum = QrByte(data); + for (final datum in QrDatum.toDatums(data)) { + _addToList(datum); } - _addToList(datum); } void addByteData(ByteData data) => _addToList(QrByte.fromByteData(data)); @@ -127,6 +121,8 @@ class QrCode { void addAlphaNumeric(String alphaNumeric) => _addToList(QrAlphaNumeric.fromString(alphaNumeric)); + void addECI(int eciValue) => _addToList(QrEci(eciValue)); + void _addToList(QrDatum data) { _dataList.add(data); _dataCache = null; @@ -142,7 +138,7 @@ const int _pad1 = 0x11; List _createData( int typeNumber, - int errorCorrectLevel, + QrErrorCorrectLevel errorCorrectLevel, List dataList, ) { final rsBlocks = QrRsBlock.getRSBlocks(typeNumber, errorCorrectLevel); @@ -152,8 +148,8 @@ List _createData( for (var i = 0; i < dataList.length; i++) { final data = dataList[i]; buffer - ..put(data.mode, 4) - ..put(data.length, _lengthInBits(data.mode, typeNumber)); + ..put(data.mode.value, 4) + ..put(data.length, data.mode.getLengthBits(typeNumber)); data.write(buffer); } @@ -164,6 +160,10 @@ List _createData( errorCorrectLevel, ); + if (buffer.length > totalDataBits) { + throw InputTooLongException(buffer.length, totalDataBits); + } + // HUH? // èIí[ÉRÅ[Éh if (buffer.length + 4 <= totalDataBits) { @@ -244,39 +244,6 @@ List _createBytes(QrBitBuffer buffer, List rsBlocks) { return data; } -int _lengthInBits(int mode, int type) { - if (1 <= type && type < 10) { - // 1 - 9 - return switch (mode) { - qr_mode.modeNumber => 10, - qr_mode.modeAlphaNum => 9, - qr_mode.mode8bitByte => 8, - qr_mode.modeKanji => 8, - _ => throw ArgumentError('mode:$mode'), - }; - } else if (type < 27) { - // 10 - 26 - return switch (mode) { - qr_mode.modeNumber => 12, - qr_mode.modeAlphaNum => 11, - qr_mode.mode8bitByte => 16, - qr_mode.modeKanji => 10, - _ => throw ArgumentError('mode:$mode'), - }; - } else if (type < 41) { - // 27 - 40 - return switch (mode) { - qr_mode.modeNumber => 14, - qr_mode.modeAlphaNum => 13, - qr_mode.mode8bitByte => 16, - qr_mode.modeKanji => 12, - _ => throw ArgumentError('mode:$mode'), - }; - } else { - throw ArgumentError('type:$type'); - } -} - QrPolynomial _errorCorrectPolynomial(int errorCorrectLength) { var a = QrPolynomial([1], 0); diff --git a/qr/lib/src/qr_image.dart b/qr/lib/src/qr_image.dart index beb09e2..cbba6d1 100644 --- a/qr/lib/src/qr_image.dart +++ b/qr/lib/src/qr_image.dart @@ -1,34 +1,72 @@ +import 'dart:typed_data'; + import 'package:meta/meta.dart'; -import 'mask_pattern.dart' as qr_mask_pattern; +import 'error_correct_level.dart'; +import 'mask_pattern.dart'; import 'qr_code.dart'; import 'util.dart' as qr_util; /// Renders the encoded data from a [QrCode] in a portable format. class QrImage { + static const _pixelUnassigned = 0; + static const _pixelLight = 1; + static const _pixelDark = 2; + final int moduleCount; final int typeNumber; - final int errorCorrectLevel; + final QrErrorCorrectLevel errorCorrectLevel; final int maskPattern; - final _modules = >[]; + final Uint8List _data; /// Generates a QrImage with the best mask pattern encoding [qrCode]. factory QrImage(QrCode qrCode) { - var minLostPoint = 0.0; - QrImage? bestImage; + // Create a template with invariant patterns + final template = QrImage._template(qrCode); + final moduleCount = template.moduleCount; + final dataSize = moduleCount * moduleCount; + // Step 1: Clone template to working buffer and place data (no mask) + final dataMap = Uint8List(dataSize)..setRange(0, dataSize, template._data); + + // Create a temporary QrImage to use its _placeData method + // We pass 0 as maskPattern, but we will modify _placeData to NOT mask. + QrImage._fromData(qrCode, 0, dataMap)._placeData(qrCode.dataCache); + + final workingBuffer = Uint8List(dataSize); + var minLostPoint = double.maxFinite; + var bestMaskPattern = 0; + Uint8List? bestData; // We need to store the best result. + + // Step 2: Try all 8 masks for (var i = 0; i < 8; i++) { - final testImage = QrImage._test(qrCode, i); + // Copy pre-placed data to working buffer + workingBuffer.setRange(0, dataSize, dataMap); + + final testImage = QrImage._fromData(qrCode, i, workingBuffer) + // Apply mask (XOR) + .._applyMask(QrMaskPattern.values[i], template._data); + final lostPoint = _lostPoint(testImage); - if (i == 0 || minLostPoint > lostPoint) { + if (lostPoint < minLostPoint) { minLostPoint = lostPoint; - bestImage = testImage; + bestMaskPattern = i; + // Copy working buffer to bestData + bestData ??= Uint8List(dataSize); + bestData.setRange(0, dataSize, workingBuffer); } } - return QrImage.withMaskPattern(qrCode, bestImage!.maskPattern); + final finalImage = QrImage._fromData(qrCode, bestMaskPattern, bestData!) + // Final setup with correct format info (not test, so actual pixels) + .._setupTypeInfo(bestMaskPattern, false); + if (finalImage.typeNumber >= 7) { + finalImage._setupTypeNumber(false); + } + + return finalImage; } /// Generates a specific image for the [qrCode] and [maskPattern]. @@ -36,35 +74,75 @@ class QrImage { : assert(maskPattern >= 0 && maskPattern <= 7), moduleCount = qrCode.moduleCount, typeNumber = qrCode.typeNumber, - errorCorrectLevel = qrCode.errorCorrectLevel { + errorCorrectLevel = qrCode.errorCorrectLevel, + _data = Uint8List(qrCode.moduleCount * qrCode.moduleCount) { _makeImpl(maskPattern, qrCode.dataCache, false); } - QrImage._test(QrCode qrCode, this.maskPattern) + /// Internal constructor for template creation + QrImage._template(QrCode qrCode) : moduleCount = qrCode.moduleCount, typeNumber = qrCode.typeNumber, - errorCorrectLevel = qrCode.errorCorrectLevel { - _makeImpl(maskPattern, qrCode.dataCache, true); + errorCorrectLevel = qrCode.errorCorrectLevel, + maskPattern = 0, // Irrelevant + _data = Uint8List(qrCode.moduleCount * qrCode.moduleCount) { + // Setup invariant parts with test=true (reserving space) + _resetModules(); + _setupPositionProbePattern(0, 0); + _setupPositionProbePattern(moduleCount - 7, 0); + _setupPositionProbePattern(0, moduleCount - 7); + _setupPositionAdjustPattern(); + _setupTimingPattern(); + // Type info and Type number are invariant if test=true (all light) + _setupTypeInfo(0, true); + if (typeNumber >= 7) { + _setupTypeNumber(true); + } } + /// Internal constructor for testing phase + QrImage._fromData(QrCode qrCode, this.maskPattern, this._data) + : moduleCount = qrCode.moduleCount, + typeNumber = qrCode.typeNumber, + errorCorrectLevel = qrCode.errorCorrectLevel; + @visibleForTesting - List> get qrModules => _modules; + List> get qrModules { + final list = >[]; + for (var r = 0; r < moduleCount; r++) { + final row = List.filled(moduleCount, null); + for (var c = 0; c < moduleCount; c++) { + final v = _data[r * moduleCount + c]; + row[c] = v == _pixelUnassigned ? null : (v == _pixelDark); + } + list.add(row); + } + return list; + } void _resetModules() { - _modules.clear(); - for (var row = 0; row < moduleCount; row++) { - _modules.add(List.filled(moduleCount, null)); - } + _data.fillRange(0, _data.length, _pixelUnassigned); } bool isDark(int row, int col) { - if (row < 0 || moduleCount <= row || col < 0 || moduleCount <= col) { - throw ArgumentError('$row , $col'); + if (row < 0 || moduleCount <= row) { + throw RangeError.range(row, 0, moduleCount - 1, 'row'); } - return _modules[row][col]!; + if (col < 0 || moduleCount <= col) { + throw RangeError.range(col, 0, moduleCount - 1, 'col'); + } + return _data[row * moduleCount + col] == _pixelDark; + } + + void _set(int row, int col, bool value) { + _data[row * moduleCount + col] = value ? _pixelDark : _pixelLight; } void _makeImpl(int maskPattern, List dataCache, bool test) { + // If not testing, we do full setup. + // If testing (template), this method is NOT called directly, but manually + // in _template. + // However, withMaskPattern calls this. _resetModules(); _setupPositionProbePattern(0, 0); _setupPositionProbePattern(moduleCount - 7, 0); @@ -80,6 +158,13 @@ class QrImage { _mapData(dataCache, maskPattern); } + // ... (existing constructors) + + // Refactored _mapData to JUST call _placeData then _applyMask? + // No, original _mapData did both. + + // Implemented below... + void _setupPositionProbePattern(int row, int col) { for (var r = -1; r <= 7; r++) { if (row + r <= -1 || moduleCount <= row + r) continue; @@ -90,9 +175,9 @@ class QrImage { if ((0 <= r && r <= 6 && (c == 0 || c == 6)) || (0 <= c && c <= 6 && (r == 0 || r == 6)) || (2 <= r && r <= 4 && 2 <= c && c <= 4)) { - _modules[row + r][col + c] = true; + _set(row + r, col + c, true); } else { - _modules[row + r][col + c] = false; + _set(row + r, col + c, false); } } } @@ -106,16 +191,16 @@ class QrImage { final row = pos[i]; final col = pos[j]; - if (_modules[row][col] != null) { + if (_data[row * moduleCount + col] != _pixelUnassigned) { continue; } for (var r = -2; r <= 2; r++) { for (var c = -2; c <= 2; c++) { if (r == -2 || r == 2 || c == -2 || c == 2 || (r == 0 && c == 0)) { - _modules[row + r][col + c] = true; + _set(row + r, col + c, true); } else { - _modules[row + r][col + c] = false; + _set(row + r, col + c, false); } } } @@ -125,22 +210,22 @@ class QrImage { void _setupTimingPattern() { for (var r = 8; r < moduleCount - 8; r++) { - if (_modules[r][6] != null) { + if (_data[r * moduleCount + 6] != _pixelUnassigned) { continue; } - _modules[r][6] = r.isEven; + _set(r, 6, r.isEven); } for (var c = 8; c < moduleCount - 8; c++) { - if (_modules[6][c] != null) { + if (_data[6 * moduleCount + c] != _pixelUnassigned) { continue; } - _modules[6][c] = c.isEven; + _set(6, c, c.isEven); } } void _setupTypeInfo(int maskPattern, bool test) { - final data = (errorCorrectLevel << 3) | maskPattern; + final data = (errorCorrectLevel.index << 3) | maskPattern; final bits = qr_util.bchTypeInfo(data); int i; @@ -151,11 +236,11 @@ class QrImage { mod = !test && ((bits >> i) & 1) == 1; if (i < 6) { - _modules[i][8] = mod; + _set(i, 8, mod); } else if (i < 8) { - _modules[i + 1][8] = mod; + _set(i + 1, 8, mod); } else { - _modules[moduleCount - 15 + i][8] = mod; + _set(moduleCount - 15 + i, 8, mod); } } @@ -164,16 +249,16 @@ class QrImage { mod = !test && ((bits >> i) & 1) == 1; if (i < 8) { - _modules[8][moduleCount - i - 1] = mod; + _set(8, moduleCount - i - 1, mod); } else if (i < 9) { - _modules[8][15 - i - 1 + 1] = mod; + _set(8, 15 - i - 1 + 1, mod); } else { - _modules[8][15 - i - 1] = mod; + _set(8, 15 - i - 1, mod); } } // fixed module - _modules[moduleCount - 8][8] = !test; + _set(moduleCount - 8, 8, !test); } void _setupTypeNumber(bool test) { @@ -181,12 +266,12 @@ class QrImage { for (var i = 0; i < 18; i++) { final mod = !test && ((bits >> i) & 1) == 1; - _modules[i ~/ 3][i % 3 + moduleCount - 8 - 3] = mod; + _set(i ~/ 3, i % 3 + moduleCount - 8 - 3, mod); } for (var i = 0; i < 18; i++) { final mod = !test && ((bits >> i) & 1) == 1; - _modules[i % 3 + moduleCount - 8 - 3][i ~/ 3] = mod; + _set(i % 3 + moduleCount - 8 - 3, i ~/ 3, mod); } } @@ -201,20 +286,20 @@ class QrImage { for (;;) { for (var c = 0; c < 2; c++) { - if (_modules[row][col - c] == null) { + if (_data[row * moduleCount + (col - c)] == _pixelUnassigned) { var dark = false; if (byteIndex < data.length) { dark = ((data[byteIndex] >> bitIndex) & 1) == 1; } - final mask = _mask(maskPattern, row, col - c); + final mask = QrMaskPattern.values[maskPattern].check(row, col - c); if (mask) { dark = !dark; } - _modules[row][col - c] = dark; + _set(row, col - c, dark); bitIndex--; if (bitIndex == -1) { @@ -234,49 +319,123 @@ class QrImage { } } } -} -bool _mask(int maskPattern, int i, int j) => switch (maskPattern) { - qr_mask_pattern.pattern000 => (i + j).isEven, - qr_mask_pattern.pattern001 => i.isEven, - qr_mask_pattern.pattern010 => j % 3 == 0, - qr_mask_pattern.pattern011 => (i + j) % 3 == 0, - qr_mask_pattern.pattern100 => ((i ~/ 2) + (j ~/ 3)).isEven, - qr_mask_pattern.pattern101 => (i * j) % 2 + (i * j) % 3 == 0, - qr_mask_pattern.pattern110 => ((i * j) % 2 + (i * j) % 3).isEven, - qr_mask_pattern.pattern111 => ((i * j) % 3 + (i + j) % 2).isEven, - _ => throw ArgumentError('bad maskPattern:$maskPattern'), -}; + void _placeData(List data) { + var inc = -1; + var row = moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + + for (var col = moduleCount - 1; col > 0; col -= 2) { + if (col == 6) col--; + + for (;;) { + for (var c = 0; c < 2; c++) { + if (_data[row * moduleCount + (col - c)] == _pixelUnassigned) { + var dark = false; + + if (byteIndex < data.length) { + dark = ((data[byteIndex] >> bitIndex) & 1) == 1; + } + + _set(row, col - c, dark); + bitIndex--; + + if (bitIndex == -1) { + byteIndex++; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + } + + void _applyMask(QrMaskPattern maskPattern, Uint8List templateData) { + var inc = -1; + var row = moduleCount - 1; + + for (var col = moduleCount - 1; col > 0; col -= 2) { + if (col == 6) col--; + + for (;;) { + for (var c = 0; c < 2; c++) { + if (templateData[row * moduleCount + (col - c)] == _pixelUnassigned) { + final mask = maskPattern.check(row, col - c); + if (mask) { + _data[row * moduleCount + (col - c)] ^= _pixelDark ^ _pixelLight; + } + } + } + + row += inc; + + if (row < 0 || moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + } +} double _lostPoint(QrImage qrImage) { final moduleCount = qrImage.moduleCount; - + final data = qrImage._data; var lostPoint = 0.0; - int row, col; - // LEVEL1 - for (row = 0; row < moduleCount; row++) { - for (col = 0; col < moduleCount; col++) { + // Cache data length for faster access (though it's final) + // Accessing local vars is faster. + + // Level 1 + for (var row = 0; row < moduleCount; row++) { + for (var col = 0; col < moduleCount; col++) { var sameCount = 0; - final dark = qrImage.isDark(row, col); + final currentIdx = row * moduleCount + col; + final isDark = data[currentIdx] == QrImage._pixelDark; - for (var r = -1; r <= 1; r++) { - if (row + r < 0 || moduleCount <= row + r) { - continue; + // Check all 8 neighbors + // Top row + if (row > 0) { + final upIdx = currentIdx - moduleCount; + if (col > 0 && (data[upIdx - 1] == QrImage._pixelDark) == isDark) { + sameCount++; } + if ((data[upIdx] == QrImage._pixelDark) == isDark) sameCount++; + if (col < moduleCount - 1 && + (data[upIdx + 1] == QrImage._pixelDark) == isDark) { + sameCount++; + } + } - for (var c = -1; c <= 1; c++) { - if (col + c < 0 || moduleCount <= col + c) { - continue; - } + // Middle row (left/right) + if (col > 0 && (data[currentIdx - 1] == QrImage._pixelDark) == isDark) { + sameCount++; + } + if (col < moduleCount - 1 && + (data[currentIdx + 1] == QrImage._pixelDark) == isDark) { + sameCount++; + } - if (r == 0 && c == 0) { - continue; - } - - if (dark == qrImage.isDark(row + r, col + c)) { - sameCount++; - } + // Bottom row + if (row < moduleCount - 1) { + final downIdx = currentIdx + moduleCount; + if (col > 0 && (data[downIdx - 1] == QrImage._pixelDark) == isDark) { + sameCount++; + } + if ((data[downIdx] == QrImage._pixelDark) == isDark) sameCount++; + if (col < moduleCount - 1 && + (data[downIdx + 1] == QrImage._pixelDark) == isDark) { + sameCount++; } } @@ -286,58 +445,58 @@ double _lostPoint(QrImage qrImage) { } } - // LEVEL2 - for (row = 0; row < moduleCount - 1; row++) { - for (col = 0; col < moduleCount - 1; col++) { - var count = 0; - if (qrImage.isDark(row, col)) count++; - if (qrImage.isDark(row + 1, col)) count++; - if (qrImage.isDark(row, col + 1)) count++; - if (qrImage.isDark(row + 1, col + 1)) count++; - if (count == 0 || count == 4) { + // Level 2: 2x2 blocks of same color + for (var row = 0; row < moduleCount - 1; row++) { + for (var col = 0; col < moduleCount - 1; col++) { + final idx = row * moduleCount + col; + final p00 = data[idx]; + final p01 = data[idx + 1]; + final p10 = data[idx + moduleCount]; + final p11 = data[idx + moduleCount + 1]; + + if (p00 == p01 && p00 == p10 && p00 == p11) { lostPoint += 3; } } } - // LEVEL3 - for (row = 0; row < moduleCount; row++) { - for (col = 0; col < moduleCount - 6; col++) { - if (qrImage.isDark(row, col) && - !qrImage.isDark(row, col + 1) && - qrImage.isDark(row, col + 2) && - qrImage.isDark(row, col + 3) && - qrImage.isDark(row, col + 4) && - !qrImage.isDark(row, col + 5) && - qrImage.isDark(row, col + 6)) { + // Level 3: 1:1:3:1:1 pattern + // Dark, Light, Dark, Dark, Dark, Light, Dark + for (var row = 0; row < moduleCount; row++) { + for (var col = 0; col < moduleCount - 6; col++) { + final idx = row * moduleCount + col; + if (data[idx] == QrImage._pixelDark && + data[idx + 1] == QrImage._pixelLight && + data[idx + 2] == QrImage._pixelDark && + data[idx + 3] == QrImage._pixelDark && + data[idx + 4] == QrImage._pixelDark && + data[idx + 5] == QrImage._pixelLight && + data[idx + 6] == QrImage._pixelDark) { lostPoint += 40; } } } - for (col = 0; col < moduleCount; col++) { - for (row = 0; row < moduleCount - 6; row++) { - if (qrImage.isDark(row, col) && - !qrImage.isDark(row + 1, col) && - qrImage.isDark(row + 2, col) && - qrImage.isDark(row + 3, col) && - qrImage.isDark(row + 4, col) && - !qrImage.isDark(row + 5, col) && - qrImage.isDark(row + 6, col)) { + // Check cols + for (var col = 0; col < moduleCount; col++) { + for (var row = 0; row < moduleCount - 6; row++) { + final idx = row * moduleCount + col; + if (data[idx] == QrImage._pixelDark && + data[idx + moduleCount] == QrImage._pixelLight && + data[idx + 2 * moduleCount] == QrImage._pixelDark && + data[idx + 3 * moduleCount] == QrImage._pixelDark && + data[idx + 4 * moduleCount] == QrImage._pixelDark && + data[idx + 5 * moduleCount] == QrImage._pixelLight && + data[idx + 6 * moduleCount] == QrImage._pixelDark) { lostPoint += 40; } } } - // LEVEL4 + // Level 4: Dark ratio var darkCount = 0; - - for (col = 0; col < moduleCount; col++) { - for (row = 0; row < moduleCount; row++) { - if (qrImage.isDark(row, col)) { - darkCount++; - } - } + for (var i = 0; i < data.length; i++) { + if (data[i] == QrImage._pixelDark) darkCount++; } final ratio = (100 * darkCount / moduleCount / moduleCount - 50).abs() / 5; diff --git a/qr/lib/src/rs_block.dart b/qr/lib/src/rs_block.dart index 206a016..62b3e0a 100644 --- a/qr/lib/src/rs_block.dart +++ b/qr/lib/src/rs_block.dart @@ -6,7 +6,10 @@ class QrRsBlock { QrRsBlock._(this.totalCount, this.dataCount); - static List getRSBlocks(int typeNumber, int errorCorrectLevel) { + static List getRSBlocks( + int typeNumber, + QrErrorCorrectLevel errorCorrectLevel, + ) { final rsBlock = _getRsBlockTable(typeNumber, errorCorrectLevel); final length = rsBlock.length ~/ 3; @@ -29,15 +32,12 @@ class QrRsBlock { List _getRsBlockTable( int typeNumber, - int errorCorrectLevel, + QrErrorCorrectLevel errorCorrectLevel, ) => switch (errorCorrectLevel) { - QrErrorCorrectLevel.L => _rsBlockTable[(typeNumber - 1) * 4 + 0], - QrErrorCorrectLevel.M => _rsBlockTable[(typeNumber - 1) * 4 + 1], - QrErrorCorrectLevel.Q => _rsBlockTable[(typeNumber - 1) * 4 + 2], - QrErrorCorrectLevel.H => _rsBlockTable[(typeNumber - 1) * 4 + 3], - _ => throw ArgumentError( - 'bad rs block @ typeNumber: $typeNumber/errorCorrectLevel:$errorCorrectLevel', - ), + QrErrorCorrectLevel.low => _rsBlockTable[(typeNumber - 1) * 4 + 0], + QrErrorCorrectLevel.medium => _rsBlockTable[(typeNumber - 1) * 4 + 1], + QrErrorCorrectLevel.quartile => _rsBlockTable[(typeNumber - 1) * 4 + 2], + QrErrorCorrectLevel.high => _rsBlockTable[(typeNumber - 1) * 4 + 3], }; const List> _rsBlockTable = [ diff --git a/qr/pubspec.yaml b/qr/pubspec.yaml index 48a112e..6ba3673 100644 --- a/qr/pubspec.yaml +++ b/qr/pubspec.yaml @@ -12,9 +12,13 @@ dependencies: meta: ^1.7.0 dev_dependencies: + args: ^2.1.0 + benchmark_harness: ^2.0.0 build_runner: ^2.2.1 build_web_compilers: ^4.1.4 dart_flutter_team_lints: ^3.0.0 + path: ^1.9.1 stream_transform: ^2.0.0 test: ^1.21.6 + test_process: ^2.1.1 web: ^1.1.0 diff --git a/qr/test/eci_test.dart b/qr/test/eci_test.dart new file mode 100644 index 0000000..ef14925 --- /dev/null +++ b/qr/test/eci_test.dart @@ -0,0 +1,564 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:qr/qr.dart'; +import 'package:qr/src/mode.dart' as qr_mode; +import 'package:test/test.dart'; + +void main() { + group('QrEci', () { + test('validates value range', () { + expect(() => QrEci(-1), throwsArgumentError); + expect(() => QrEci(1000000), throwsArgumentError); + expect(QrEci(0).value, 0); + expect(QrEci(999999).value, 999999); + }); + + test('constants', () { + expect(QrEciValue.iso8859_1, 3); + expect(QrEciValue.iso8859_2, 4); + expect(QrEciValue.iso8859_3, 5); + expect(QrEciValue.iso8859_4, 6); + expect(QrEciValue.iso8859_5, 7); + expect(QrEciValue.iso8859_6, 8); + expect(QrEciValue.iso8859_7, 9); + expect(QrEciValue.iso8859_8, 10); + expect(QrEciValue.iso8859_9, 11); + expect(QrEciValue.iso8859_10, 12); + expect(QrEciValue.iso8859_11, 13); + expect(QrEciValue.iso8859_13, 15); + expect(QrEciValue.iso8859_14, 16); + expect(QrEciValue.iso8859_15, 17); + expect(QrEciValue.iso8859_16, 18); + expect(QrEciValue.shiftJis, 20); + expect(QrEciValue.windows1250, 21); + expect(QrEciValue.windows1251, 22); + expect(QrEciValue.windows1252, 23); + expect(QrEciValue.windows1256, 24); + expect(QrEciValue.utf16BE, 25); + expect(QrEciValue.utf8, 26); + expect(QrEciValue.ascii, 27); + expect(QrEciValue.big5, 28); + expect(QrEciValue.gb2312, 29); + expect(QrEciValue.eucKr, 30); + expect(QrEciValue.gbk, 31); + }); + + test('properties', () { + final eci = QrEci(123); + expect(eci.mode, qr_mode.QrMode.eci); + expect(eci.length, 0); + }); + + test('encodes 0-127 (8 bits)', () { + _testEci(0, [0x00]); // 00000000 + _testEci(65, [0x41]); // 01000001 + _testEci(127, [0x7F]); // 01111111 + }); + + test('encodes 128-16383 (16 bits)', () { + // 128 -> 10 000000 10000000 -> 0x80 0x80 + _testEci(128, [0x80, 0x80]); + // 16383 -> 10 111111 11111111 -> 0xBF 0xFF + _testEci(16383, [0xBF, 0xFF]); + }); + + test('encodes 16384-999999 (24 bits)', () { + // 16384 -> 110 00000 01000000 00000000 -> 0xC0 0x40 0x00 + _testEci(16384, [0xC0, 0x40, 0x00]); + // 999999 -> 11110100001000111111 -> 0F 42 3F + // 999999 = 0xF423F + // 110 01111 01000010 00111111 -> 0xCF 0x42 0x3F + _testEci(999999, [0xCF, 0x42, 0x3F]); + }); + }); + + test('validates emoji', () { + final code = QrCode(1, QrErrorCorrectLevel.low)..addData('🙃'); + + // Validate bitstream structure: + // Header: Mode 7 (0111) + Value 26 (00011010) + Mode 4 (0100) + Length 4 (00000100) + // 0111 0001 1010 0100 0000 0100 -> 0x71 0xA4 0x04 + // Data: F0 9F 99 83 + // Terminator: 0000 + // Padding to byte: 0000 (since 60 bits + 4 bits = 64 bits = 8 bytes) + // Pad Bytes: 0xEC, 0x11... (to fill 19 bytes) + final expectedData = [ + 0x71, 0xA4, 0x04, // Header + 0xF0, 0x9F, 0x99, 0x83, // '🙃' in UTF-8 + 0x00, // Terminator + Bit Padding to byte boundary + // Padding Codewords (0xEC, 0x11 alternating) to fill 19 bytes capacity + 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, + ]; + + // Verify the full data cache (19 Data Codewords for Version 1-L) + expect(code.dataCache.sublist(0, 19), expectedData); + + final image = QrImage(code); + expect(image.moduleCount, 21); // Version 1 is 21x21 + expect(_getModules(image), _expectedEmojiModules); + }); +} + +void _testEci(int value, List expectedBytes) { + final buffer = QrBitBuffer(); + QrEci(value).write(buffer); + + expect(buffer, hasLength(expectedBytes.length * 8)); + for (var i = 0; i < expectedBytes.length; i++) { + expect(buffer.getByte(i), expectedBytes[i], reason: 'Byte $i mismatch'); + } +} + +List _getModules(QrImage image) { + final modules = []; + for (var i = 0; i < image.moduleCount; i++) { + for (var j = 0; j < image.moduleCount; j++) { + modules.add(image.isDark(i, j)); + } + } + return modules; +} + +const _expectedEmojiModules = [ + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + false, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, // Row 0 + true, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + true, // Row 1 + true, + false, + true, + true, + true, + false, + true, + false, + false, + true, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, // Row 2 + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + true, + false, + false, + true, + false, + true, + true, + true, + false, + true, // Row 3 + true, + false, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + true, + false, + true, + false, + true, + true, + true, + false, + true, // Row 4 + true, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + false, + false, + true, // Row 5 + true, + true, + true, + true, + true, + true, + true, + false, + true, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, // Row 6 + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, // Row 7 + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + false, + false, + true, + true, + false, + true, + false, + true, + false, + true, + false, // Row 8 + false, + true, + true, + true, + false, + false, + false, + true, + true, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + false, // Row 9 + true, + true, + false, + false, + false, + true, + true, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + true, + false, // Row 10 + true, + false, + true, + true, + true, + false, + false, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + false, + true, + false, + false, // Row 11 + false, + true, + false, + false, + true, + true, + true, + true, + false, + true, + false, + true, + false, + true, + false, + false, + true, + false, + true, + false, + false, // Row 12 + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, // Row 13 + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + false, + false, + true, + false, + true, + true, + false, + true, + true, + true, + false, // Row 14 + true, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + true, // Row 15 + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false, + true, // Row 16 + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + true, + false, // Row 17 + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + true, + true, + false, + true, + false, + true, + false, + true, + true, + false, + false, // Row 18 + true, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, // Row 19 + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + false, + true, + false, + true, + true, + true, + true, + true, + false, // Row 20 +]; diff --git a/qr/test/qr_alphanumeric_test.dart b/qr/test/qr_alphanumeric_test.dart index 68da9de..9af0781 100644 --- a/qr/test/qr_alphanumeric_test.dart +++ b/qr/test/qr_alphanumeric_test.dart @@ -1,5 +1,4 @@ import 'package:qr/qr.dart'; -import 'package:qr/src/byte.dart'; import 'package:test/test.dart'; void main() { @@ -7,11 +6,11 @@ void main() { final qr = QrAlphaNumeric.fromString( r'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:', ); - expect(qr.mode, 2); + expect(qr.mode, QrMode.alphaNumeric); expect(qr.length, 45); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 248); + expect(buffer, hasLength(248)); expect( buffer.map((e) => e ? '1' : '0').join(), '00000000001' @@ -42,21 +41,21 @@ void main() { test('single alphanumeric', () { final qr = QrAlphaNumeric.fromString(r'$'); - expect(qr.mode, 2); + expect(qr.mode, QrMode.alphaNumeric); expect(qr.length, 1); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 6); + expect(buffer, hasLength(6)); expect(buffer.map((e) => e ? '1' : '0').join(), '100101'); }); test('double (even) alphanumeric', () { final qr = QrAlphaNumeric.fromString('3Z'); - expect(qr.mode, 2); + expect(qr.mode, QrMode.alphaNumeric); expect(qr.length, 2); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 11, reason: 'n*5+1 = 11'); + expect(buffer, hasLength(11), reason: 'n*5+1 = 11'); expect( buffer .getRange(0, 11) diff --git a/qr/test/qr_byte_test.dart b/qr/test/qr_byte_test.dart index 9c922cd..6b18f8f 100644 --- a/qr/test/qr_byte_test.dart +++ b/qr/test/qr_byte_test.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import 'package:qr/qr.dart'; -import 'package:qr/src/byte.dart'; import 'package:test/test.dart'; void main() { diff --git a/qr/test/qr_code_test.dart b/qr/test/qr_code_test.dart index b75b7e0..6186e61 100644 --- a/qr/test/qr_code_test.dart +++ b/qr/test/qr_code_test.dart @@ -11,36 +11,32 @@ import 'qr_code_test_data_with_mask.dart'; void main() { test('simple', () { for (var typeNumber = 1; typeNumber <= 40; typeNumber++) { - for (var quality in QrErrorCorrectLevel.levels) { + for (var quality in QrErrorCorrectLevel.values) { final qr = QrImage(QrCode(typeNumber, quality)..addData('shanna!')); final modules = qr.qrModules; - for (var i = 0; i < modules.length; i++) { - expect( - _encodeBoolListToString(modules[i]), - qrCodeTestData[typeNumber.toString()][quality.toString()][i], - ); - } - } - } - }); - - test('fromData', () { - for (var quality in QrErrorCorrectLevel.levels) { - final qr = QrImage( - QrCode.fromData(data: 'shanna!', errorCorrectLevel: quality), - ); - final modules = qr.qrModules; - for (var i = 0; i < modules.length; i++) { expect( - _encodeBoolListToString(modules[i]), - qrCodeTestData['1'][quality.toString()][i], + modules.map(_encodeBoolListToString), + qrCodeTestData[typeNumber.toString()][quality.index.toString()], ); } } }); + test('fromData', () { + for (var quality in QrErrorCorrectLevel.values) { + final qr = QrImage( + QrCode.fromData(data: 'shanna!', errorCorrectLevel: quality), + ); + final modules = qr.qrModules; + expect( + modules.map(_encodeBoolListToString), + qrCodeTestData['1'][quality.index.toString()], + ); + } + }); + test('fromUint8List', () { - for (var quality in QrErrorCorrectLevel.levels) { + for (var quality in QrErrorCorrectLevel.values) { final qr = QrImage( QrCode.fromUint8List( data: Uint8List.fromList([115, 104, 97, 110, 110, 97, 33]), @@ -48,67 +44,53 @@ void main() { ), ); final modules = qr.qrModules; - for (var i = 0; i < modules.length; i++) { - expect( - _encodeBoolListToString(modules[i]), - qrCodeTestData['1'][quality.toString()][i], - ); - } + expect( + modules.map(_encodeBoolListToString), + qrCodeTestData['1'][quality.index.toString()], + ); } }); test('WHEN mask pattern is provided, SHOULD make a masked QR Code', () { for (var mask = 0; mask <= 7; mask++) { final qr = QrImage.withMaskPattern( - QrCode(1, QrErrorCorrectLevel.L)..addData('shanna!'), + QrCode(1, QrErrorCorrectLevel.low)..addData('shanna!'), mask, ); final modules = qr.qrModules; - for (var i = 0; i < modules.length; i++) { - expect( - _encodeBoolListToString(modules[i]), - qrCodeTestDataWithMask[mask.toString()][i], - ); - } + expect( + modules.map(_encodeBoolListToString), + qrCodeTestDataWithMask[mask.toString()], + ); } }); - test( - ''' - WHEN provided mask pattern is smaller than 0, - SHOULD throw an AssertionError - ''', - () { - expect(() { - QrImage.withMaskPattern( - QrCode(1, QrErrorCorrectLevel.L)..addData('shanna!'), - -1, - ); - }, throwsA(isA())); - }, - ); + test('WHEN provided mask pattern is smaller than 0, ' + 'SHOULD throw an AssertionError', () { + expect(() { + QrImage.withMaskPattern( + QrCode(1, QrErrorCorrectLevel.low)..addData('shanna!'), + -1, + ); + }, throwsA(isA())); + }); - test( - ''' - WHEN provided mask pattern is bigger than 7, - SHOULD throw an AssertionError - ''', - () { - expect(() { - QrImage.withMaskPattern( - QrCode(1, QrErrorCorrectLevel.L)..addData('shanna!'), - 8, - ); - }, throwsA(isA())); - }, - ); + test('WHEN provided mask pattern is bigger than 7, ' + 'SHOULD throw an AssertionError', () { + expect(() { + QrImage.withMaskPattern( + QrCode(1, QrErrorCorrectLevel.high)..addData('shanna!'), + 8, + ); + }, throwsA(isA())); + }); group('QrCode.fromData Automatic Mode Detection', () { // Numeric Mode test('should use Numeric Mode for numbers', () { // 9 numeric chars fit version 1 (H level). final qr = QrCode.fromData( data: '123456789', - errorCorrectLevel: QrErrorCorrectLevel.H, + errorCorrectLevel: QrErrorCorrectLevel.high, ); expect(qr.typeNumber, 1); }); @@ -119,7 +101,7 @@ void main() { // version 2 (H level, 16 chars). final qr = QrCode.fromData( data: 'HELLO WORLD A', - errorCorrectLevel: QrErrorCorrectLevel.H, + errorCorrectLevel: QrErrorCorrectLevel.high, ); expect(qr.typeNumber, 2); }); @@ -130,7 +112,7 @@ void main() { // '機械学習' (12 bytes) fits version 2 (H level, 16 bytes). final qr = QrCode.fromData( data: '機械学習', - errorCorrectLevel: QrErrorCorrectLevel.H, + errorCorrectLevel: QrErrorCorrectLevel.high, ); expect(qr.typeNumber, 2); }); @@ -140,7 +122,7 @@ void main() { // Numeric Mode test('should use Numeric Mode for numbers', () { // 9 numeric characters fit version 1 (H level). - final qr = QrCode(1, QrErrorCorrectLevel.H)..addData('123456789'); + final qr = QrCode(1, QrErrorCorrectLevel.low)..addData('123456789'); expect(qr.typeNumber, 1); }); @@ -148,7 +130,7 @@ void main() { test('should use Alphanumeric Mode', () { // 13 alphanumeric characters exceed version 1 (7 chars) but fit // version 2 (H level, 16 chars). - final qr = QrCode(2, QrErrorCorrectLevel.H)..addData('HELLO WORLD A'); + final qr = QrCode(2, QrErrorCorrectLevel.high)..addData('HELLO WORLD A'); expect(qr.typeNumber, 2); }); @@ -156,7 +138,7 @@ void main() { test('should use Byte Mode for non-alphanumeric characters', () { // Kanji characters are UTF-8 encoded. // '機械学習' (12 bytes) fits version 2 (H level, 16 bytes). - final qr = QrCode(2, QrErrorCorrectLevel.H)..addData('機械学習'); + final qr = QrCode(2, QrErrorCorrectLevel.high)..addData('機械学習'); expect(qr.typeNumber, 2); }); }); @@ -168,7 +150,7 @@ void main() { final qrCode = QrCode.fromData( data: largeData, - errorCorrectLevel: QrErrorCorrectLevel.L, + errorCorrectLevel: QrErrorCorrectLevel.low, ); expect(qrCode.typeNumber, 40); @@ -182,12 +164,20 @@ void main() { expect( () => QrCode.fromData( data: excessivelyLargeData, - errorCorrectLevel: QrErrorCorrectLevel.L, + errorCorrectLevel: QrErrorCorrectLevel.low, ), throwsA(isA()), ); }); }); + + group('QrCode.addData size checks', () { + test('should throw if data exceeds capacity for fixed version', () { + final code = QrCode(1, QrErrorCorrectLevel.low)..addData('|' * 30); + + expect(() => code.dataCache, throwsA(isA())); + }); + }); } String _encodeBoolListToString(List source) => diff --git a/qr/test/qr_datum_test.dart b/qr/test/qr_datum_test.dart new file mode 100644 index 0000000..2a55ffb --- /dev/null +++ b/qr/test/qr_datum_test.dart @@ -0,0 +1,46 @@ +import 'package:qr/src/byte.dart'; +import 'package:qr/src/eci.dart'; +import 'package:test/test.dart'; + +void main() { + group('QrDatum.toDatums', () { + test('Numeric', () { + final datums = QrDatum.toDatums('123456'); + expect(datums, hasLength(1)); + expect(datums.first, isA()); + }); + + test('AlphaNumeric', () { + final datums = QrDatum.toDatums('HELLO WORLD'); + expect(datums, hasLength(1)); + expect(datums.first, isA()); + }); + + test('Byte (Latin-1)', () { + final datums = QrDatum.toDatums('Hello World!'); + expect(datums, hasLength(1)); + expect(datums.first, isA()); + }); + + test('Byte (UTF-8 with ECI)', () { + final datums = QrDatum.toDatums('Hello 🌍'); + expect(datums, hasLength(2)); + expect(datums[0], isA()); + expect((datums[0] as QrEci).value, 26); + expect(datums[1], isA()); + }); + + test('Complex Emoji (UTF-8 with ECI)', () { + // Woman + Medium Skin Tone + ZWJ + Heart + VS16 + ZWJ + Kiss Mark + ZWJ + // + Man + Dark Brown Skin Tone + const complexEmoji = + '\u{1F469}\u{1F3FD}\u{200D}\u{2764}\u{FE0F}\u{200D}' + '\u{1F48B}\u{200D}\u{1F468}\u{1F3FE}'; + final datums = QrDatum.toDatums(complexEmoji); + expect(datums, hasLength(2)); + expect(datums[0], isA()); + expect((datums[0] as QrEci).value, 26); + expect(datums[1], isA()); + }); + }); +} diff --git a/qr/test/qr_numeric_test.dart b/qr/test/qr_numeric_test.dart index 2d53439..7ad72c9 100644 --- a/qr/test/qr_numeric_test.dart +++ b/qr/test/qr_numeric_test.dart @@ -1,15 +1,14 @@ import 'package:qr/qr.dart'; -import 'package:qr/src/byte.dart'; import 'package:test/test.dart'; void main() { test('all digits 1 through 0', () { final qr = QrNumeric.fromString('1234567890'); - expect(qr.mode, 1); + expect(qr.mode, QrMode.numeric); expect(qr.length, 10); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 34); + expect(buffer, hasLength(34)); expect( buffer .getRange(0, 10) @@ -54,11 +53,11 @@ void main() { test('single numeric', () { final qr = QrNumeric.fromString('5'); - expect(qr.mode, 1); + expect(qr.mode, QrMode.numeric); expect(qr.length, 1); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 4); + expect(buffer, hasLength(4)); expect( buffer .getRange(0, 4) @@ -73,11 +72,11 @@ void main() { test('double numeric', () { final qr = QrNumeric.fromString('37'); - expect(qr.mode, 1); + expect(qr.mode, QrMode.numeric); expect(qr.length, 2); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 7, reason: 'n*3+1 = 7'); + expect(buffer, hasLength(7), reason: 'n*3+1 = 7'); expect( buffer .getRange(0, 7) @@ -92,11 +91,11 @@ void main() { test('triple (even) numeric', () { final qr = QrNumeric.fromString('371'); - expect(qr.mode, 1); + expect(qr.mode, QrMode.numeric); expect(qr.length, 3); final buffer = QrBitBuffer(); qr.write(buffer); - expect(buffer.length, 10, reason: 'n*3+1 = 10'); + expect(buffer, hasLength(10), reason: 'n*3+1 = 10'); expect( buffer .getRange(0, 10) diff --git a/qr/test/verify_emoji_test.dart b/qr/test/verify_emoji_test.dart new file mode 100644 index 0000000..2ca2db0 --- /dev/null +++ b/qr/test/verify_emoji_test.dart @@ -0,0 +1,41 @@ +import 'package:qr/qr.dart'; +import 'package:test/test.dart'; + +void main() { + test('Generate QR with Emoji', () { + const emojiString = '👩🏽❤️💋👨🏾'; + final qr = QrCode.fromData( + data: emojiString, + errorCorrectLevel: QrErrorCorrectLevel.low, + ); + expect(qr.typeNumber, 2); + expect(qr.typeNumber, greaterThan(0)); + // Verify we have multiple segments (ECI + Byte) + // iterate over modules or check internal structure if possible + // (but it's private) + }); + + test('Generate QR with Complex Emoji (ZWJ support)', () { + // Woman + Medium Skin Tone + ZWJ + Heart + VS16 + ZWJ + Kiss Mark + ZWJ + // + Man + Dark Brown Skin Tone + const complexEmoji = + '\u{1F469}\u{1F3FD}\u{200D}\u{2764}\u{FE0F}\u{200D}' + '\u{1F48B}\u{200D}\u{1F468}\u{1F3FE}'; + + final qr = QrCode.fromData( + data: complexEmoji, + errorCorrectLevel: QrErrorCorrectLevel.low, + ); + expect(qr.typeNumber, greaterThan(0)); + // Verify it didn't throw and created a valid QR structure + // The exact type number depends on the overhead of ECI + Byte mode + + // 4 segments: + // 1. ECI (26 for UTF-8) + // 2. Byte Data (the emoji bytes) + + // We can't easily peek into _dataList, but we can verify the module count + // implies it's not empty + expect(qr.moduleCount, greaterThan(21)); + }); +} diff --git a/qr/test/verify_qr_tool_test.dart b/qr/test/verify_qr_tool_test.dart new file mode 100644 index 0000000..f0dc120 --- /dev/null +++ b/qr/test/verify_qr_tool_test.dart @@ -0,0 +1,173 @@ +@Tags(['require-zbar']) +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; + +void main() { + late Directory tempDir; + + setUpAll(() { + tempDir = Directory.systemTemp.createTempSync('qr_tool_test'); + }); + + tearDownAll(() { + tempDir.deleteSync(recursive: true); + }); + + final configurations = [ + (version: null, correction: null), + (version: 40, correction: 'H'), + ]; + + final inputs = [ + '123456', + 'HELLO WORLD', + 'Hello 👋 World 🌍', + '👩🏽❤️💋👨🏾', + '👩🏽‍❤️‍💋‍👨🏾', + ]; + + for (final config in configurations) { + for (final input in inputs) { + test( + 'Generate QR with config $config and input "$input"', + () async { + final bmpPath = p.join( + tempDir.path, + 'test_${config.hashCode}_${input.hashCode}.bmp', + ); + final args = [ + 'tool/write_qr.dart', + '-o', + bmpPath, + if (config.version != null) ...['-v', config.version.toString()], + if (config.correction != null) ...['-c', config.correction!], + '--scale', + '10', + input, + ]; + + final process = await TestProcess.start('dart', args); + await process.shouldExit(0); + + expect( + File(bmpPath).existsSync(), + isTrue, + reason: 'BMP file should be created', + ); + + // Validate with zbarimg + // zbarimg output format: QR-Code:content + final zbar = await TestProcess.start('zbarimg', ['--quiet', bmpPath]); + await zbar.shouldExit(0); + final output = (await zbar.stdout.rest.toList()).join('\n').trim(); + + if (output != 'QR-Code:$input') { + print('zbarimg failed to match input.'); + print('Input: $input'); + print('Output: "$output"'); + } + expect(output, 'QR-Code:$input'); + }, + timeout: const Timeout(Duration(seconds: 20)), + ); + } + } + + test('Generate QR with Version 1 (numeric input)', () async { + const input = '123456'; + final bmpPath = p.join(tempDir.path, 'test_v1_numeric.bmp'); + final args = [ + 'tool/write_qr.dart', + '-o', + bmpPath, + '-v', + '1', + '-c', + 'L', + '--scale', + '10', + input, + ]; + + final process = await TestProcess.start('dart', args); + await process.shouldExit(0); + expect( + File(bmpPath).existsSync(), + isTrue, + reason: 'BMP file should be created', + ); + + final zbar = await TestProcess.start('zbarimg', ['--quiet', bmpPath]); + await zbar.shouldExit(0); + final output = (await zbar.stdout.rest.toList()).join('\n').trim(); + + if (output != 'QR-Code:$input') { + print('zbarimg failed to match input.'); + print('Input: $input'); + print('Output: "$output"'); + } + expect(output, 'QR-Code:$input'); + }); + + test('Error case: Missing output argument', () async { + final process = await TestProcess.start('dart', [ + 'tool/write_qr.dart', + 'content', + ]); + await process.shouldExit(1); + final output = await process.stdout.next; + expect( + output, + contains('Error: Invalid argument(s): Option output is mandatory.'), + ); + }); + + test('Error case: Invalid version', () async { + final bmpPath = p.join(tempDir.path, 'invalid_version.bmp'); + final process = await TestProcess.start('dart', [ + 'tool/write_qr.dart', + '-o', + bmpPath, + '-v', + '41', + 'content', + ]); + await process.shouldExit(1); + }); + + test('Error case: Invalid correction', () async { + final bmpPath = p.join(tempDir.path, 'invalid_correction.bmp'); + final process = await TestProcess.start('dart', [ + 'tool/write_qr.dart', + '-o', + bmpPath, + '-c', + 'X', + 'content', + ]); + await process.shouldExit(1); // ArgParser error + }); + + test('Error case: Input too long for version (explicit version)', () async { + const input = + 'This string is definitely too long for Version 1 with ' + 'High error correction level.'; + final bmpPath = p.join(tempDir.path, 'too_long.bmp'); + final process = await TestProcess.start('dart', [ + 'tool/write_qr.dart', + '-o', + bmpPath, + '-v', + '1', + '-c', + 'H', // High error correction reduces capacity + input, + ]); + await process.shouldExit(1); + }); +}