update dependencies

This commit is contained in:
otsmr 2026-02-12 22:01:59 +01:00
parent 3a3a7e5a63
commit 785dccdf9c
35 changed files with 2489 additions and 424 deletions

View file

@ -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

View file

@ -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) {
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() {
Executors.newSingleThreadExecutor().execute {
val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false)
if (isSecure) {
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),
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()
))
SCREENSHOT_TAKEN to screenshotData.isNotEmpty(),
IS_SCREEN_RECORDING to isScreenRecording
)
)
if (lastSharedPreferencesState != jsonString) {
hasSharedPreferencesChanged = true
lastSharedPreferencesState = jsonString
}
}, 100)
}
private fun convertMapToJsonString(map: Map<String, Any>): String {

View file

@ -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,6 +172,9 @@ public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandle
case "screenshotOn":
shotOn()
result(true)
case "toggleScreenshotWithImage":
let isActive = toggleScreenshotWithImage()
result(isActive)
case "toggleScreenshot":
IOSNoScreenshotPlugin.preventScreenShot ? shotOn() : shotOff()
result(true)
@ -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
}
}

View file

@ -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.

View file

@ -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";

View file

@ -32,6 +32,11 @@ class NoScreenshot implements NoScreenshotPlatform {
return _instancePlatform.screenshotOn();
}
@override
Future<bool> 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<void> startScreenRecordingListening() {
return _instancePlatform.startScreenRecordingListening();
}
/// Stop listening to screen recording activities
@override
Future<void> stopScreenRecordingListening() {
return _instancePlatform.stopScreenRecordingListening();
}
@override
bool operator ==(Object other) {
return identical(this, other) ||

View file

@ -40,6 +40,12 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
return result ?? false;
}
@override
Future<bool> toggleScreenshotWithImage() async {
final result = await methodChannel.invokeMethod<bool>(screenSetImage);
return result ?? false;
}
@override
Future<void> startScreenshotListening() {
return methodChannel.invokeMethod<void>(startScreenshotListeningConst);
@ -49,4 +55,14 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
Future<void> stopScreenshotListening() {
return methodChannel.invokeMethod<void>(stopScreenshotListeningConst);
}
@override
Future<void> startScreenRecordingListening() {
return methodChannel.invokeMethod<void>(startScreenRecordingListeningConst);
}
@override
Future<void> stopScreenRecordingListening() {
return methodChannel.invokeMethod<void>(stopScreenRecordingListeningConst);
}
}

View file

@ -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<bool> 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<void> startScreenRecordingListening() {
throw UnimplementedError(
'startScreenRecordingListening has not been implemented.');
}
/// Stop listening to screen recording activities
Future<void> stopScreenRecordingListening() {
throw UnimplementedError(
'stopScreenRecordingListening has not been implemented.');
}
}

View file

@ -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<String, dynamic> 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;
}
}

View file

@ -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

View file

@ -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 = <String, dynamic>{
'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)');
});
});
}

View file

@ -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<bool> screenshotOff() async {
@ -29,10 +33,25 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform {
return;
}
@override
Future<bool> toggleScreenshotWithImage() async {
return true;
}
@override
Future<void> stopScreenshotListening() async {
return;
}
@override
Future<void> startScreenRecordingListening() async {
return;
}
@override
Future<void> 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);
});
});
}

View file

@ -20,6 +20,11 @@ class MockNoScreenshotPlatform
return Future.value(true);
}
@override
Future<bool> toggleScreenshotWithImage() async {
return Future.value(true);
}
@override
Future<bool> toggleScreenshot() async {
// Mock implementation or return a fixed value
@ -38,6 +43,16 @@ class MockNoScreenshotPlatform
Future<void> stopScreenshotListening() {
return Future.value();
}
@override
Future<void> startScreenRecordingListening() {
return Future.value();
}
@override
Future<void> 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<NoScreenshot>());
});
}

View file

@ -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';

View file

@ -1,34 +1,78 @@
import 'dart:collection';
class QrBitBuffer extends Object with ListMixin<bool> {
final List<int> _buffer;
/// A growable sequence of bits.
///
/// Used internally to construct the data bit stream for a QR code.
class QrBitBuffer extends Iterable<bool> {
final _buffer = <int>[];
int _length = 0;
QrBitBuffer() : _buffer = <int>[];
@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<bool> 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<bool> {
_length++;
}
List<bool> getRange(int start, int end) {
final list = <bool>[];
for (var i = start; i < end; i++) {
list.add(this[i]);
}
return list;
}
}
class _QrBitBufferIterator implements Iterator<bool> {
final QrBitBuffer _buffer;
int _currentIndex = -1;
_QrBitBufferIterator(this._buffer);
@override
bool get current => _buffer[_currentIndex];
@override
bool moveNext() {
_currentIndex++;
return _currentIndex < _buffer.length;
}
}

View file

@ -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<QrDatum> 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) {

40
qr/lib/src/eci.dart Normal file
View file

@ -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);
}
}
}

87
qr/lib/src/ecivalue.dart Normal file
View file

@ -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);
}

View file

@ -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<int> 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);
}

View file

@ -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;

View file

@ -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];

View file

@ -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,
};
}
}
}

View file

@ -28,36 +28,75 @@ class QrPolynomial {
int get length => _values.length;
QrPolynomial multiply(QrPolynomial e) {
final List<int> 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;
for (var i = 0; i < e.length; i++) {
value[i] ^= qr_math.gexp(qr_math.glog(e[i]) + ratio);
}
final ratio = qr_math.glog(v) - qr_math.glog(e[0]);
// recursive call
return QrPolynomial(value, 0).mod(e);
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);
}
}
// 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.
// 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);
}
}

View file

@ -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<int>? _dataCache;
final _dataList = <QrDatum>[];
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<QrDatum> 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,20 +104,10 @@ 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);
}
}
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<int> _createData(
int typeNumber,
int errorCorrectLevel,
QrErrorCorrectLevel errorCorrectLevel,
List<QrDatum> dataList,
) {
final rsBlocks = QrRsBlock.getRSBlocks(typeNumber, errorCorrectLevel);
@ -152,8 +148,8 @@ List<int> _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<int> _createData(
errorCorrectLevel,
);
if (buffer.length > totalDataBits) {
throw InputTooLongException(buffer.length, totalDataBits);
}
// HUH?
// è[É[Éh
if (buffer.length + 4 <= totalDataBits) {
@ -244,39 +244,6 @@ List<int> _createBytes(QrBitBuffer buffer, List<QrRsBlock> 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);

View file

@ -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 = <List<bool?>>[];
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<List<bool?>> get qrModules => _modules;
List<List<bool?>> get qrModules {
final list = <List<bool?>>[];
for (var r = 0; r < moduleCount; r++) {
final row = List<bool?>.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<bool?>.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<int> 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 {
}
}
}
void _placeData(List<int> 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;
}
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'),
};
_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;
}
for (var c = -1; c <= 1; c++) {
if (col + c < 0 || moduleCount <= col + c) {
continue;
}
if (r == 0 && c == 0) {
continue;
}
if (dark == qrImage.isDark(row + r, col + c)) {
// 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++;
}
}
// 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++;
}
// 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;

View file

@ -6,7 +6,10 @@ class QrRsBlock {
QrRsBlock._(this.totalCount, this.dataCount);
static List<QrRsBlock> getRSBlocks(int typeNumber, int errorCorrectLevel) {
static List<QrRsBlock> getRSBlocks(
int typeNumber,
QrErrorCorrectLevel errorCorrectLevel,
) {
final rsBlock = _getRsBlockTable(typeNumber, errorCorrectLevel);
final length = rsBlock.length ~/ 3;
@ -29,15 +32,12 @@ class QrRsBlock {
List<int> _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<List<int>> _rsBlockTable = [

View file

@ -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

564
qr/test/eci_test.dart Normal file
View file

@ -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<int> 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<bool> _getModules(QrImage image) {
final modules = <bool>[];
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
];

View file

@ -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<String>((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<String>((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)

View file

@ -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() {

View file

@ -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],
modules.map(_encodeBoolListToString),
qrCodeTestData[typeNumber.toString()][quality.index.toString()],
);
}
}
}
});
test('fromData', () {
for (var quality in QrErrorCorrectLevel.levels) {
for (var quality in QrErrorCorrectLevel.values) {
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['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],
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],
modules.map(_encodeBoolListToString),
qrCodeTestDataWithMask[mask.toString()],
);
}
}
});
test(
'''
WHEN provided mask pattern is smaller than 0,
SHOULD throw an AssertionError
''',
() {
test('WHEN provided mask pattern is smaller than 0, '
'SHOULD throw an AssertionError', () {
expect(() {
QrImage.withMaskPattern(
QrCode(1, QrErrorCorrectLevel.L)..addData('shanna!'),
QrCode(1, QrErrorCorrectLevel.low)..addData('shanna!'),
-1,
);
}, throwsA(isA<AssertionError>()));
},
);
});
test(
'''
WHEN provided mask pattern is bigger than 7,
SHOULD throw an AssertionError
''',
() {
test('WHEN provided mask pattern is bigger than 7, '
'SHOULD throw an AssertionError', () {
expect(() {
QrImage.withMaskPattern(
QrCode(1, QrErrorCorrectLevel.L)..addData('shanna!'),
QrCode(1, QrErrorCorrectLevel.high)..addData('shanna!'),
8,
);
}, throwsA(isA<AssertionError>()));
},
);
});
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<InputTooLongException>()),
);
});
});
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<InputTooLongException>()));
});
});
}
String _encodeBoolListToString(List<bool?> source) =>

View file

@ -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<QrNumeric>());
});
test('AlphaNumeric', () {
final datums = QrDatum.toDatums('HELLO WORLD');
expect(datums, hasLength(1));
expect(datums.first, isA<QrAlphaNumeric>());
});
test('Byte (Latin-1)', () {
final datums = QrDatum.toDatums('Hello World!');
expect(datums, hasLength(1));
expect(datums.first, isA<QrByte>());
});
test('Byte (UTF-8 with ECI)', () {
final datums = QrDatum.toDatums('Hello 🌍');
expect(datums, hasLength(2));
expect(datums[0], isA<QrEci>());
expect((datums[0] as QrEci).value, 26);
expect(datums[1], isA<QrByte>());
});
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<QrEci>());
expect((datums[0] as QrEci).value, 26);
expect(datums[1], isA<QrByte>());
});
});
}

View file

@ -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)

View file

@ -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));
});
}

View file

@ -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);
});
}