Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
enum BinaryMeta {
|
||||
enum MetaError: Error {
|
||||
case invalidBuffer
|
||||
case invalidMeta
|
||||
}
|
||||
|
||||
struct FileHeaderMeta: Codable {
|
||||
var filename: String?
|
||||
var contentType: String?
|
||||
}
|
||||
|
||||
static func mergeDataArrays(_ arrays: [Data]) -> Data {
|
||||
var totalLength = 0
|
||||
for arr in arrays {
|
||||
totalLength += 4 + arr.count
|
||||
}
|
||||
|
||||
var merged = Data(capacity: totalLength)
|
||||
for arr in arrays {
|
||||
let length = UInt32(arr.count)
|
||||
var bytes: [UInt8] = [
|
||||
UInt8((length >> 24) & 0xFF),
|
||||
UInt8((length >> 16) & 0xFF),
|
||||
UInt8((length >> 8) & 0xFF),
|
||||
UInt8(length & 0xFF),
|
||||
]
|
||||
merged.append(contentsOf: bytes)
|
||||
merged.append(arr)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
static func splitDataArrays(_ merged: Data) throws -> [Data] {
|
||||
var arrays: [Data] = []
|
||||
var offset = 0
|
||||
|
||||
while offset < merged.count {
|
||||
guard offset + 4 <= merged.count else {
|
||||
throw MetaError.invalidBuffer
|
||||
}
|
||||
|
||||
let length = merged.withUnsafeBytes { ptr -> Int in
|
||||
let b0 = Int(ptr[offset]) << 24
|
||||
let b1 = Int(ptr[offset + 1]) << 16
|
||||
let b2 = Int(ptr[offset + 2]) << 8
|
||||
let b3 = Int(ptr[offset + 3])
|
||||
return b0 | b1 | b2 | b3
|
||||
}
|
||||
offset += 4
|
||||
|
||||
guard offset + length <= merged.count else {
|
||||
throw MetaError.invalidBuffer
|
||||
}
|
||||
|
||||
let arr = merged.subdata(in: offset ..< offset + length)
|
||||
arrays.append(arr)
|
||||
offset += length
|
||||
}
|
||||
|
||||
return arrays
|
||||
}
|
||||
|
||||
static func appendFileHeaderMeta(data: Data, filename: String?, contentType: String) -> Data {
|
||||
let meta = FileHeaderMeta(filename: filename, contentType: contentType)
|
||||
guard let metaData = try? JSONEncoder().encode(meta) else {
|
||||
return data
|
||||
}
|
||||
return mergeDataArrays([metaData, data])
|
||||
}
|
||||
|
||||
static func readFileHeaderMeta(buffer: Data) throws -> (data: Data, filename: String?, contentType: String) {
|
||||
let arrays = try splitDataArrays(buffer)
|
||||
guard arrays.count == 2 else {
|
||||
throw MetaError.invalidBuffer
|
||||
}
|
||||
|
||||
let metaData = arrays[0]
|
||||
let data = arrays[1]
|
||||
|
||||
guard let meta = try? JSONDecoder().decode(FileHeaderMeta.self, from: metaData) else {
|
||||
throw MetaError.invalidMeta
|
||||
}
|
||||
|
||||
return (data, meta.filename, meta.contentType ?? "application/octet-stream")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct EncodedBlock {
|
||||
static let qrsURLPrefix = "https://qrss.netlify.app/#"
|
||||
var indices: [Int]
|
||||
var data: Data
|
||||
let k: Int
|
||||
@@ -80,8 +81,38 @@ struct EncodedBlock {
|
||||
toBinary().base64EncodedString()
|
||||
}
|
||||
|
||||
func toQRSString() -> String {
|
||||
Self.qrsURLPrefix + toBase64()
|
||||
}
|
||||
|
||||
static func fromQRSString(_ string: String) -> EncodedBlock? {
|
||||
var content = string
|
||||
if content.hasPrefix("http"), let hashIndex = content.firstIndex(of: "#") {
|
||||
content = String(content[content.index(after: hashIndex)...])
|
||||
}
|
||||
return fromBase64(content)
|
||||
}
|
||||
|
||||
static func fromBase64(_ string: String) -> EncodedBlock? {
|
||||
guard let data = Data(base64Encoded: string) else { return nil }
|
||||
return fromBinary(data)
|
||||
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
|
||||
#if DEBUG
|
||||
print("[EncodedBlock] Base64 decode failed for string of length \(string.count)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
#if DEBUG
|
||||
print("[EncodedBlock] Base64 decoded: \(data.count) bytes")
|
||||
#endif
|
||||
guard let block = fromBinary(data) else {
|
||||
#if DEBUG
|
||||
print("[EncodedBlock] fromBinary failed")
|
||||
if data.count >= 4 {
|
||||
let degree = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self).littleEndian }
|
||||
print("[EncodedBlock] degree=\(degree), expected size=\(4 + Int(degree) * 4 + 12), actual=\(data.count)")
|
||||
}
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
return block
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import zlib
|
||||
|
||||
final class LubyTransformDecoder {
|
||||
private(set) var decodedData: [Data?] = []
|
||||
@@ -66,16 +66,29 @@ final class LubyTransformDecoder {
|
||||
indices.map(String.init).joined(separator: ",")
|
||||
}
|
||||
|
||||
private func xorData(_ a: Data, _ b: Data) -> Data {
|
||||
var result = a
|
||||
let count = min(a.count, b.count)
|
||||
for i in 0 ..< count {
|
||||
result[i] ^= b[i]
|
||||
private func xorDataInPlace(_ dest: inout Data, _ src: Data) {
|
||||
let count = min(dest.count, src.count)
|
||||
dest.withUnsafeMutableBytes { destPtr in
|
||||
src.withUnsafeBytes { srcPtr in
|
||||
let d = destPtr.bindMemory(to: UInt8.self).baseAddress!
|
||||
let s = srcPtr.bindMemory(to: UInt8.self).baseAddress!
|
||||
for i in 0 ..< count {
|
||||
d[i] ^= s[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func propagateDecoded(key: String, wrapper: BlockWrapper) {
|
||||
var queue: [(key: String, wrapper: BlockWrapper)] = [(key, wrapper)]
|
||||
|
||||
while !queue.isEmpty {
|
||||
let (currentKey, currentWrapper) = queue.removeFirst()
|
||||
processBlock(key: currentKey, wrapper: currentWrapper, queue: &queue)
|
||||
}
|
||||
}
|
||||
|
||||
private func processBlock(key: String, wrapper: BlockWrapper, queue: inout [(key: String, wrapper: BlockWrapper)]) {
|
||||
var block = wrapper.block
|
||||
var indices = block.indices
|
||||
var indicesSet = Set(indices)
|
||||
@@ -88,7 +101,7 @@ final class LubyTransformDecoder {
|
||||
if indices.count > 1 {
|
||||
for index in indices {
|
||||
if let decoded = decodedData[index] {
|
||||
block.data = xorData(block.data, decoded)
|
||||
xorDataInPlace(&block.data, decoded)
|
||||
indicesSet.remove(index)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +118,7 @@ final class LubyTransformDecoder {
|
||||
let subIndices = indices.filter { $0 != index }
|
||||
let subkey = indicesToKey(subIndices)
|
||||
if let subWrapper = encodedBlockKeyMap[subkey] {
|
||||
block.data = xorData(block.data, subWrapper.block.data)
|
||||
xorDataInPlace(&block.data, subWrapper.block.data)
|
||||
for i in subWrapper.block.indices {
|
||||
indicesSet.remove(i)
|
||||
}
|
||||
@@ -121,8 +134,8 @@ final class LubyTransformDecoder {
|
||||
// Store subkeys for future matching if still high degree
|
||||
if indicesSet.count > 1 {
|
||||
for (index, subkey) in subkeys {
|
||||
let dispose = { [weak self] in
|
||||
self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
||||
let dispose: () -> Void = { [weak self] in
|
||||
_ = self?.encodedBlockSubkeyMap[subkey]?.remove(wrapper)
|
||||
}
|
||||
if encodedBlockSubkeyMap[subkey] == nil {
|
||||
encodedBlockSubkeyMap[subkey] = []
|
||||
@@ -156,14 +169,14 @@ final class LubyTransformDecoder {
|
||||
encodedBlockSubkeyMap.removeValue(forKey: newKey)
|
||||
for superWrapper in superset {
|
||||
var superBlock = superWrapper.block
|
||||
superBlock.data = xorData(superBlock.data, block.data)
|
||||
xorDataInPlace(&superBlock.data, block.data)
|
||||
var superIndicesSet = Set(superBlock.indices)
|
||||
for i in indices {
|
||||
superIndicesSet.remove(i)
|
||||
}
|
||||
superBlock.indices = Array(superIndicesSet).sorted()
|
||||
superWrapper.block = superBlock
|
||||
propagateDecoded(key: indicesToKey(superBlock.indices), wrapper: superWrapper)
|
||||
queue.append((indicesToKey(superBlock.indices), superWrapper))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +193,7 @@ final class LubyTransformDecoder {
|
||||
for waiting in waitingBlocks {
|
||||
let waitingKey = indicesToKey(waiting.block.indices)
|
||||
encodedBlockKeyMap.removeValue(forKey: waitingKey)
|
||||
propagateDecoded(key: waitingKey, wrapper: waiting)
|
||||
queue.append((waitingKey, waiting))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,25 +240,48 @@ final class LubyTransformDecoder {
|
||||
}
|
||||
|
||||
private static func inflate(_ data: Data) -> Data? {
|
||||
let sourceSize = data.count
|
||||
let destinationSize = sourceSize * 10
|
||||
var stream = z_stream()
|
||||
|
||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||
defer { destinationBuffer.deallocate() }
|
||||
|
||||
let decompressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||
return compression_decode_buffer(
|
||||
destinationBuffer,
|
||||
destinationSize,
|
||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||
sourceSize,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
// Use 15 for zlib format (with header/trailer) to match pako's default
|
||||
guard inflateInit2_(
|
||||
&stream,
|
||||
15,
|
||||
ZLIB_VERSION,
|
||||
Int32(MemoryLayout<z_stream>.size)
|
||||
) == Z_OK else {
|
||||
return nil
|
||||
}
|
||||
defer { inflateEnd(&stream) }
|
||||
|
||||
guard decompressedSize > 0 else { return nil }
|
||||
return Data(bytes: destinationBuffer, count: decompressedSize)
|
||||
var destCapacity = data.count * 4
|
||||
var dest = Data(count: destCapacity)
|
||||
|
||||
return data.withUnsafeBytes { srcPtr -> Data? in
|
||||
stream.next_in = UnsafeMutablePointer(mutating: srcPtr.bindMemory(to: Bytef.self).baseAddress)
|
||||
stream.avail_in = uInt(data.count)
|
||||
|
||||
while true {
|
||||
dest.withUnsafeMutableBytes { destPtr in
|
||||
stream.next_out = destPtr.bindMemory(to: Bytef.self).baseAddress?.advanced(by: Int(stream.total_out))
|
||||
stream.avail_out = uInt(destCapacity - Int(stream.total_out))
|
||||
}
|
||||
|
||||
let result = zlib.inflate(&stream, Z_NO_FLUSH)
|
||||
|
||||
if result == Z_STREAM_END {
|
||||
dest.count = Int(stream.total_out)
|
||||
return dest
|
||||
}
|
||||
|
||||
if result != Z_OK {
|
||||
return nil
|
||||
}
|
||||
|
||||
if stream.avail_out == 0 {
|
||||
destCapacity *= 2
|
||||
dest.count = destCapacity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import zlib
|
||||
|
||||
final class LubyTransformEncoder {
|
||||
let k: Int
|
||||
@@ -8,7 +8,7 @@ final class LubyTransformEncoder {
|
||||
let bytes: Int
|
||||
private let sourceBlocks: [Data]
|
||||
|
||||
init(data: Data, sliceSize: Int = 500, compress: Bool = true) {
|
||||
init(data: Data, sliceSize: Int = 512, compress: Bool = true) {
|
||||
self.sliceSize = sliceSize
|
||||
|
||||
let compressed: Data
|
||||
@@ -25,26 +25,39 @@ final class LubyTransformEncoder {
|
||||
}
|
||||
|
||||
private static func deflateCompress(_ data: Data) -> Data? {
|
||||
let sourceSize = data.count
|
||||
let destinationSize = sourceSize + 1024
|
||||
var stream = z_stream()
|
||||
|
||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)
|
||||
defer { destinationBuffer.deallocate() }
|
||||
// Use 15 for zlib format (with header/trailer) to match pako's default
|
||||
guard deflateInit2_(
|
||||
&stream,
|
||||
Z_DEFAULT_COMPRESSION,
|
||||
Z_DEFLATED,
|
||||
15,
|
||||
8,
|
||||
Z_DEFAULT_STRATEGY,
|
||||
ZLIB_VERSION,
|
||||
Int32(MemoryLayout<z_stream>.size)
|
||||
) == Z_OK else {
|
||||
return nil
|
||||
}
|
||||
defer { deflateEnd(&stream) }
|
||||
|
||||
let compressedSize = data.withUnsafeBytes { sourcePtr -> Int in
|
||||
guard let baseAddress = sourcePtr.baseAddress else { return 0 }
|
||||
return compression_encode_buffer(
|
||||
destinationBuffer,
|
||||
destinationSize,
|
||||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||||
sourceSize,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
let destSize = Int(deflateBound(&stream, UInt(data.count)))
|
||||
var dest = Data(count: destSize)
|
||||
|
||||
let result = data.withUnsafeBytes { srcPtr -> Int32 in
|
||||
dest.withUnsafeMutableBytes { destPtr -> Int32 in
|
||||
stream.next_in = UnsafeMutablePointer(mutating: srcPtr.bindMemory(to: Bytef.self).baseAddress)
|
||||
stream.avail_in = uInt(data.count)
|
||||
stream.next_out = destPtr.bindMemory(to: Bytef.self).baseAddress
|
||||
stream.avail_out = uInt(destSize)
|
||||
return deflate(&stream, Z_FINISH)
|
||||
}
|
||||
}
|
||||
|
||||
guard compressedSize > 0 else { return nil }
|
||||
return Data(bytes: destinationBuffer, count: compressedSize)
|
||||
guard result == Z_STREAM_END else { return nil }
|
||||
dest.count = Int(stream.total_out)
|
||||
return dest
|
||||
}
|
||||
|
||||
private static func sliceData(_ data: Data, sliceSize: Int) -> [Data] {
|
||||
@@ -94,10 +107,8 @@ final class LubyTransformEncoder {
|
||||
}
|
||||
|
||||
let random = Double.random(in: 0 ... 1)
|
||||
for i in 0 ..< k {
|
||||
if random < cumulative[i] {
|
||||
return i + 1
|
||||
}
|
||||
if let i = cumulative.firstIndex(where: { random < $0 }) {
|
||||
return i + 1
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -117,4 +128,78 @@ final class LubyTransformEncoder {
|
||||
return self.createBlock(indices: indices)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func runSelfTest() -> Bool {
|
||||
// Test 1: Round-trip tests with various sizes
|
||||
let testCases: [(size: Int, sliceSize: Int)] = [
|
||||
(1, 100),
|
||||
(100, 100),
|
||||
(1000, 100),
|
||||
(1031, 100),
|
||||
]
|
||||
|
||||
for (size, sliceSize) in testCases {
|
||||
let data = Data((0 ..< size).map { UInt8($0 % 256) })
|
||||
let encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
|
||||
let decoder = LubyTransformDecoder()
|
||||
|
||||
var blockCount = 0
|
||||
for block in encoder.fountain() {
|
||||
blockCount += 1
|
||||
if blockCount > encoder.k * 3 {
|
||||
print("LubyTransform self-test FAILED: too many blocks for size=\(size)")
|
||||
return false
|
||||
}
|
||||
do {
|
||||
if try decoder.addBlock(block) { break }
|
||||
} catch {
|
||||
print("LubyTransform self-test FAILED: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try decoder.getDecoded()
|
||||
if decoded != data {
|
||||
print("LubyTransform self-test FAILED: data mismatch for size=\(size)")
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
print("LubyTransform self-test FAILED: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: TypeScript compatibility (decode blocks generated by TypeScript)
|
||||
// Test vector: 5 bytes [0xAB, 0xCD, 0xEF, 0x12, 0x34], uncompressed, sliceSize=10
|
||||
let tsBlockBase64 = "AQAAAAAAAAABAAAABQAAALt8DL6rze8SNAAAAAAA"
|
||||
let expectedData = Data([0xAB, 0xCD, 0xEF, 0x12, 0x34])
|
||||
|
||||
guard let blockData = Data(base64Encoded: tsBlockBase64),
|
||||
let block = EncodedBlock.fromBinary(blockData)
|
||||
else {
|
||||
print("LubyTransform self-test FAILED: cannot parse TypeScript block")
|
||||
return false
|
||||
}
|
||||
|
||||
let tsDecoder = LubyTransformDecoder()
|
||||
do {
|
||||
_ = try tsDecoder.addBlock(block)
|
||||
let decoded = try tsDecoder.getDecoded()
|
||||
if decoded != expectedData {
|
||||
print("LubyTransform self-test FAILED: TypeScript compatibility mismatch")
|
||||
print("Expected: \(expectedData.map { String(format: "%02X", $0) }.joined())")
|
||||
print("Got: \(decoded.map { String(format: "%02X", $0) }.joined())")
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
print("LubyTransform self-test FAILED: TypeScript decode error: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
print("LubyTransform self-test PASSED (including TypeScript compatibility)")
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import QRCode
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class QRSImageGenerator: ObservableObject {
|
||||
@Published private(set) var currentImage: CGImage?
|
||||
|
||||
private let bufferSize: Int
|
||||
private let foregroundColor: CGColor
|
||||
private let backgroundColor: CGColor
|
||||
private let imageDimension: Int
|
||||
|
||||
private var frames: [EncodedBlock] = []
|
||||
private var imageBuffer: [CGImage?]
|
||||
private var generatedUpTo: Int = -1
|
||||
private var currentFrameIndex: Int = 0
|
||||
private var expectedTotalFrames: Int = 0
|
||||
|
||||
init(
|
||||
foregroundColor: CGColor,
|
||||
backgroundColor: CGColor = CGColor(gray: 1.0, alpha: 1.0),
|
||||
bufferSize: Int = 30,
|
||||
imageDimension: Int = 512
|
||||
) {
|
||||
self.foregroundColor = foregroundColor
|
||||
self.backgroundColor = backgroundColor
|
||||
self.bufferSize = bufferSize
|
||||
self.imageDimension = imageDimension
|
||||
imageBuffer = Array(repeating: nil, count: bufferSize)
|
||||
}
|
||||
|
||||
func setExpectedFrames(_ count: Int) {
|
||||
expectedTotalFrames = count
|
||||
}
|
||||
|
||||
func addFrame(_ block: EncodedBlock) async {
|
||||
let image = await generateImage(for: block)
|
||||
|
||||
let index = frames.count
|
||||
frames.append(block)
|
||||
|
||||
let bufferIndex = index % bufferSize
|
||||
imageBuffer[bufferIndex] = image
|
||||
generatedUpTo = index
|
||||
|
||||
if index == 0 {
|
||||
currentImage = image
|
||||
}
|
||||
}
|
||||
|
||||
func advanceFrame() {
|
||||
guard generatedUpTo >= 0 else { return }
|
||||
|
||||
let totalFrames = expectedTotalFrames > 0 ? expectedTotalFrames : frames.count
|
||||
guard totalFrames > 0 else { return }
|
||||
|
||||
let nextIndex = (currentFrameIndex + 1) % totalFrames
|
||||
if nextIndex <= generatedUpTo || generatedUpTo == totalFrames - 1 {
|
||||
currentFrameIndex = nextIndex
|
||||
}
|
||||
|
||||
let bufferIndex = currentFrameIndex % bufferSize
|
||||
currentImage = imageBuffer[bufferIndex]
|
||||
}
|
||||
|
||||
func cancel() {}
|
||||
|
||||
private nonisolated func generateImage(for block: EncodedBlock) async -> CGImage? {
|
||||
let content = block.toQRSString()
|
||||
let foregroundColor = foregroundColor
|
||||
let backgroundColor = backgroundColor
|
||||
let dimension = imageDimension
|
||||
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let document = try QRCode.Document(
|
||||
utf8String: content,
|
||||
errorCorrection: .low
|
||||
)
|
||||
document.design.foregroundColor(foregroundColor)
|
||||
document.design.backgroundColor(backgroundColor)
|
||||
document.design.additionalQuietZonePixels = 4
|
||||
return try document.cgImage(dimension: dimension)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user