From aca190f673c7dd6cd4bac6cc99fdc45e36cfdecb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 10:53:24 +0000 Subject: [PATCH 01/31] Improve fingerprinting with scattered LSB, claim keys, and structural HMAC Add scattered LSB embedding for image and audio watermarks with legacy sequential fallback on extract. Extend payloads with optional HMAC claim signatures (alg/kid/version) and ClaimStatus evaluation. PDF and video fingerprinting now store contentBytes, inject comment claims before EOF, append uuid boxes for video, and verify via ClaimCrypto structural signing with originalLength/contentBytes integrity checks. Co-authored-by: Wejdan Al Amri --- lib/core/audio_watermark.dart | 307 +++++++++++++++++++++--- lib/core/image_watermark.dart | 399 ++++++++++++++++++++++++++------ lib/core/pdf_fingerprint.dart | 340 +++++++++++++++++++++------ lib/core/video_fingerprint.dart | 378 +++++++++++++++++++++++++----- 4 files changed, 1198 insertions(+), 226 deletions(-) diff --git a/lib/core/audio_watermark.dart b/lib/core/audio_watermark.dart index b788a34..710508d 100644 --- a/lib/core/audio_watermark.dart +++ b/lib/core/audio_watermark.dart @@ -1,5 +1,5 @@ -/// Inaudible WAV LSB watermark — same ownership payload shape as the image -/// demo, encoded into the least-significant bit of each 16-bit PCM sample. +/// Inaudible WAV LSB watermark — ownership payload encoded into the +/// least-significant bit of each 16-bit PCM sample using scattered placement. library; import 'dart:convert'; @@ -7,7 +7,8 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart' show compute; -import 'fingerprint.dart'; +import 'claim_crypto.dart'; +import 'lsb_scatter.dart'; const String _magic = 'SGA1'; @@ -17,19 +18,31 @@ class AudioPayload { required this.asset, required this.issued, required this.signature, + this.alg, + this.kid, + this.version = 1, }); final String owner; final String asset; final String issued; final String signature; - - Map toJson() => { - 'owner': owner, - 'asset': asset, - 'issued': issued, - 'signature': signature, - }; + final String? alg; + final String? kid; + final int version; + + Map toJson() { + final map = { + 'owner': owner, + 'asset': asset, + 'issued': issued, + 'signature': signature, + }; + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + return map; + } static AudioPayload? tryParse(String? raw) { if (raw == null) return null; @@ -41,14 +54,36 @@ class AudioPayload { asset: map['asset'] as String? ?? '', issued: map['issued'] as String? ?? '', signature: map['signature'] as String? ?? '', + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, ); } catch (_) { return null; } } - bool get signatureValid => - signature == fingerprint('$owner|$asset|$issued'); + bool get signatureValid { + final legacy = ClaimCrypto.signMediaLegacy( + owner: owner, + asset: asset, + issued: issued, + ); + return signature == legacy.signature; + } + + bool get isAuthentic => + statusWith(null) == ClaimStatus.authenticated; + + ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( + owner: owner, + asset: asset, + issued: issued, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class AudioEmbedOutcome { @@ -60,6 +95,7 @@ class AudioEmbedOutcome { required this.bitsUsed, required this.capacityBits, required this.recoveredRaw, + required this.claimStatus, }); final Uint8List markedWav; @@ -69,6 +105,7 @@ class AudioEmbedOutcome { final int bitsUsed; final int capacityBits; final String? recoveredRaw; + final ClaimStatus claimStatus; AudioPayload? get recovered => AudioPayload.tryParse(recoveredRaw); @@ -86,15 +123,18 @@ class AudioExtractOutcome { required this.raw, required this.sampleRate, required this.channels, + this.claimStatus = ClaimStatus.missing, }); final String? raw; final int sampleRate; final int channels; + final ClaimStatus claimStatus; } class NotAWavException implements Exception { - const NotAWavException([this.message = 'That file is not a valid WAV audio file.']); + const NotAWavException( + [this.message = 'That file is not a valid WAV audio file.']); final String message; @override String toString() => message; @@ -184,7 +224,126 @@ _WavInfo _parseWav(Uint8List bytes) { ); } -({int bitsUsed, int capacityBits}) _embedBits(Uint8List pcm, String payload) { +Uint8List _magicSeedHeader() { + final header = Uint8List(8); + header.setAll(0, ascii.encode(_magic)); + return header; +} + +Uint8List _buildPacket(String payloadJson) { + final body = utf8.encode(payloadJson); + final header = ascii.encode(_magic); + final headerBlock = Uint8List(8); + headerBlock.setAll(0, header); + headerBlock[4] = (body.length >>> 24) & 0xff; + headerBlock[5] = (body.length >>> 16) & 0xff; + headerBlock[6] = (body.length >>> 8) & 0xff; + headerBlock[7] = body.length & 0xff; + + // Packet = header + redundant header + body. + final packet = Uint8List(16 + body.length); + packet.setAll(0, headerBlock); + packet.setAll(8, headerBlock); + packet.setAll(16, body); + return packet; +} + +void _writeScatteredBit( + Uint8List pcm, + LsbScatter scatter, + int bitIndex, + int value, +) { + final sampleIndex = scatter[bitIndex] * 2; + if (sampleIndex >= pcm.length) return; + pcm[sampleIndex] = (pcm[sampleIndex] & 0xfe) | (value & 1); +} + +int _readScatteredBit(Uint8List pcm, LsbScatter scatter, int bitIndex) { + final sampleIndex = scatter[bitIndex] * 2; + if (sampleIndex >= pcm.length) return 0; + return pcm[sampleIndex] & 1; +} + +Uint8List? _readScatteredBytes( + Uint8List pcm, + LsbScatter scatter, + int count, + int startBit, +) { + final out = Uint8List(count); + for (var b = 0; b < count; b++) { + var byte = 0; + for (var k = 0; k < 8; k++) { + final bit = startBit + b * 8 + k; + if (bit >= scatter.length) return null; + byte = (byte << 1) | _readScatteredBit(pcm, scatter, bit); + } + out[b] = byte; + } + return out; +} + +({int bitsUsed, int capacityBits}) _embedScattered( + Uint8List pcm, + Uint8List packet, +) { + final slotCount = pcm.length ~/ 2; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + final totalBits = packet.length * 8; + if (totalBits > slotCount) throw const AudioTooShortException(); + + for (var bit = 0; bit < totalBits; bit++) { + final byte = packet[bit >> 3]; + final value = (byte >> (7 - (bit & 7))) & 1; + _writeScatteredBit(pcm, scatter, bit, value); + } + return (bitsUsed: totalBits, capacityBits: slotCount); +} + +String? _extractScattered(Uint8List pcm) { + final slotCount = pcm.length ~/ 2; + if (slotCount < 128) return null; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + + final header0 = _readScatteredBytes(pcm, scatter, 8, 0); + final header1 = _readScatteredBytes(pcm, scatter, 8, 64); + + Uint8List? header; + if (header0 != null && + String.fromCharCodes(header0.sublist(0, 4)) == _magic) { + header = header0; + } else if (header1 != null && + String.fromCharCodes(header1.sublist(0, 4)) == _magic) { + header = header1; + } else { + return null; + } + + final length = + (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7]; + if (length <= 0 || length > 1000000) return null; + if (128 + length * 8 > scatter.length) return null; + + final body = _readScatteredBytes(pcm, scatter, length, 128); + if (body == null) return null; + try { + return utf8.decode(body); + } catch (_) { + return null; + } +} + +({int bitsUsed, int capacityBits}) _embedSequential( + Uint8List pcm, + String payload, +) { final body = utf8.encode(payload); final header = ascii.encode(_magic); final bytes = Uint8List(header.length + 4 + body.length); @@ -195,7 +354,6 @@ _WavInfo _parseWav(Uint8List bytes) { bytes[7] = body.length & 0xff; bytes.setAll(8, body); - // One bit per 16-bit sample (LSB of the low byte). final capacity = pcm.length ~/ 2; final totalBits = bytes.length * 8; if (totalBits > capacity) throw const AudioTooShortException(); @@ -209,7 +367,7 @@ _WavInfo _parseWav(Uint8List bytes) { return (bitsUsed: totalBits, capacityBits: capacity); } -String? _extractBits(Uint8List pcm) { +String? _extractSequential(Uint8List pcm) { Uint8List? readBytes(int count, int startBit) { final out = Uint8List(count); var bit = startBit; @@ -240,31 +398,90 @@ String? _extractBits(Uint8List pcm) { } } +String? _extractBits(Uint8List pcm) => + _extractScattered(pcm) ?? _extractSequential(pcm); + +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +AudioPayload _buildPayload({ + required String owner, + required String asset, + required String issued, + ClaimKey? claimKey, +}) { + final resolvedOwner = + owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); + if (claimKey != null) { + final signed = ClaimCrypto.signMedia( + claimKey, + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return AudioPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + ); + } + final legacy = ClaimCrypto.signMediaLegacy( + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return AudioPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: legacy.signature, + alg: legacy.alg, + kid: legacy.kid, + version: legacy.version, + ); +} + AudioEmbedOutcome _embedTask( - ({Uint8List fileBytes, String owner, String fileName, String issued}) - args) { - final (:fileBytes, :owner, :fileName, :issued) = args; + ({ + Uint8List fileBytes, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:fileBytes, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final wav = _parseWav(fileBytes); final marked = Uint8List.fromList(fileBytes); final pcm = marked.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize); - final resolvedOwner = - owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); final asset = '$fileName·${wav.sampleRate}Hz·${wav.channels}ch·${wav.dataSize}B'; - final payload = AudioPayload( - owner: resolvedOwner, + final payload = _buildPayload( + owner: owner, asset: asset, issued: issued, - signature: fingerprint('$resolvedOwner|$asset|$issued'), + claimKey: claimKey, ); - final stats = _embedBits(pcm, jsonEncode(payload.toJson())); + final packet = _buildPacket(jsonEncode(payload.toJson())); + final stats = _embedScattered(pcm, packet); marked.setRange(wav.dataOffset, wav.dataOffset + wav.dataSize, pcm); final recovered = _extractBits( marked.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize), ); + final recoveredPayload = AudioPayload.tryParse(recovered); + final claimStatus = + recoveredPayload?.statusWith(claimKey) ?? ClaimStatus.missing; return AudioEmbedOutcome( markedWav: marked, @@ -274,16 +491,24 @@ AudioEmbedOutcome _embedTask( bitsUsed: stats.bitsUsed, capacityBits: stats.capacityBits, recoveredRaw: recovered, + claimStatus: claimStatus, ); } -AudioExtractOutcome _extractTask(Uint8List fileBytes) { +AudioExtractOutcome _extractTask( + ({Uint8List fileBytes, Uint8List? keyBytes, String? kid}) args, +) { + final (:fileBytes, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final wav = _parseWav(fileBytes); final pcm = fileBytes.sublist(wav.dataOffset, wav.dataOffset + wav.dataSize); + final raw = _extractBits(pcm); + final payload = AudioPayload.tryParse(raw); return AudioExtractOutcome( - raw: _extractBits(pcm), + raw: raw, sampleRate: wav.sampleRate, channels: wav.channels, + claimStatus: payload?.statusWith(claimKey) ?? ClaimStatus.missing, ); } @@ -291,11 +516,31 @@ Future embedAudioWatermark({ required Uint8List fileBytes, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (fileBytes: fileBytes, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + fileBytes: fileBytes, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -Future extractAudioWatermark(Uint8List fileBytes) => - compute(_extractTask, fileBytes); +Future extractAudioWatermark( + Uint8List fileBytes, { + ClaimKey? claimKey, +}) => + compute( + _extractTask, + ( + fileBytes: fileBytes, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/image_watermark.dart b/lib/core/image_watermark.dart index b138158..75cf527 100644 --- a/lib/core/image_watermark.dart +++ b/lib/core/image_watermark.dart @@ -1,9 +1,8 @@ /// LSB watermark codec — Dart port of the website's canvas-based codec. /// -/// The bit layout uses a Signata magic header (`SGN1`, 4-byte -/// big-endian length, payload bits spread over the R/G/B least significant -/// bits of each pixel in row-major order), so a PNG watermarked in the app -/// verifies on the website and vice versa. +/// Scattered LSB embedding (seeded from the magic header) with sequential +/// legacy fallback on extract. Payloads may carry HMAC claim signatures +/// (`alg: hmac-sha256`) or legacy FNV self-consistent signatures. library; import 'dart:convert'; @@ -13,7 +12,8 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart' show compute; import 'package:image/image.dart' as img; -import 'fingerprint.dart'; +import 'claim_crypto.dart'; +import 'lsb_scatter.dart'; const String _magic = 'SGN1'; @@ -24,19 +24,31 @@ class WatermarkPayload { required this.asset, required this.issued, required this.signature, + this.alg, + this.kid, + this.version = 1, }); final String owner; final String asset; final String issued; final String signature; - - Map toJson() => { - 'owner': owner, - 'asset': asset, - 'issued': issued, - 'signature': signature, - }; + final String? alg; + final String? kid; + final int version; + + Map toJson() { + final map = { + 'owner': owner, + 'asset': asset, + 'issued': issued, + 'signature': signature, + }; + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + return map; + } static WatermarkPayload? tryParse(String? raw) { if (raw == null) return null; @@ -48,16 +60,37 @@ class WatermarkPayload { asset: map['asset'] as String? ?? '', issued: map['issued'] as String? ?? '', signature: map['signature'] as String? ?? '', + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, ); } catch (_) { return null; } } - /// A payload is genuine when its signature matches the fingerprint - /// recomputed from its own claim fields. - bool get signatureValid => - signature == fingerprint('$owner|$asset|$issued'); + /// Legacy FNV self-consistency (ignores HMAC alg). + bool get signatureValid { + final legacy = ClaimCrypto.signMediaLegacy( + owner: owner, + asset: asset, + issued: issued, + ); + return signature == legacy.signature; + } + + bool get isAuthentic => + statusWith(null) == ClaimStatus.authenticated; + + ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( + owner: owner, + asset: asset, + issued: issued, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class EmbedOutcome { @@ -70,6 +103,8 @@ class EmbedOutcome { required this.bitsUsed, required this.capacityBits, required this.recoveredRaw, + required this.resized, + required this.claimStatus, }); final Uint8List originalPng; @@ -79,9 +114,9 @@ class EmbedOutcome { final int height; final int bitsUsed; final int capacityBits; - - /// Raw payload extracted back out of the encoded PNG (round trip). final String? recoveredRaw; + final bool resized; + final ClaimStatus claimStatus; WatermarkPayload? get recovered => WatermarkPayload.tryParse(recoveredRaw); @@ -95,15 +130,122 @@ class EmbedOutcome { } class ExtractOutcome { - const ExtractOutcome({required this.raw, required this.previewPng}); + const ExtractOutcome({ + required this.raw, + required this.previewPng, + this.claimStatus = ClaimStatus.missing, + }); final String? raw; final Uint8List previewPng; + final ClaimStatus claimStatus; +} + +class ImageTooSmallException implements Exception { + const ImageTooSmallException(); + + @override + String toString() => + 'Image is too small to carry this fingerprint. Try a larger image.'; } /* ------------------------- bit-level codec ------------------------- */ -({int bitsUsed, int capacityBits}) _embedBits(Uint8List rgba, String payload) { +Uint8List _magicSeedHeader() { + final header = Uint8List(8); + header.setAll(0, ascii.encode(_magic)); + return header; +} + +int _rgbaSlotCount(int rgbaLength) => (rgbaLength ~/ 4) * 3; + +void _writeScatteredBit( + Uint8List rgba, + LsbScatter scatter, + int bitIndex, + int value, +) { + final slot = scatter[bitIndex]; + final pixel = (slot ~/ 3) * 4 + (slot % 3); + if (pixel >= rgba.length) return; + rgba[pixel] = (rgba[pixel] & 0xfe) | (value & 1); +} + +int _readScatteredBit(Uint8List rgba, LsbScatter scatter, int bitIndex) { + final slot = scatter[bitIndex]; + final pixel = (slot ~/ 3) * 4 + (slot % 3); + if (pixel >= rgba.length) return 0; + return rgba[pixel] & 1; +} + +Uint8List? _readScatteredBytes( + Uint8List rgba, + LsbScatter scatter, + int count, + int startBit, +) { + final out = Uint8List(count); + for (var b = 0; b < count; b++) { + var byte = 0; + for (var k = 0; k < 8; k++) { + final bit = startBit + b * 8 + k; + if (bit >= scatter.length) return null; + byte = (byte << 1) | _readScatteredBit(rgba, scatter, bit); + } + out[b] = byte; + } + return out; +} + +({int bitsUsed, int capacityBits}) _embedScattered( + Uint8List rgba, + Uint8List packet, +) { + final slotCount = _rgbaSlotCount(rgba.length); + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + final totalBits = packet.length * 8; + if (totalBits > slotCount) throw const ImageTooSmallException(); + + for (var bit = 0; bit < totalBits; bit++) { + final byte = packet[bit >> 3]; + final value = (byte >> (7 - (bit & 7))) & 1; + _writeScatteredBit(rgba, scatter, bit, value); + } + return (bitsUsed: totalBits, capacityBits: slotCount); +} + +String? _extractScattered(Uint8List rgba) { + final slotCount = _rgbaSlotCount(rgba.length); + if (slotCount < 64) return null; + final scatter = LsbScatter.seeded( + slotCount: slotCount, + seed: LsbScatter.seedFromHeader(_magicSeedHeader()), + ); + + final header = _readScatteredBytes(rgba, scatter, 8, 0); + if (header == null) return null; + if (String.fromCharCodes(header.sublist(0, 4)) != _magic) return null; + final length = + (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7]; + if (length <= 0 || length > 1000000) return null; + final bodyStartBit = 64; + if (bodyStartBit + length * 8 > scatter.length) return null; + final body = _readScatteredBytes(rgba, scatter, length, bodyStartBit); + if (body == null) return null; + try { + return utf8.decode(body); + } catch (_) { + return null; + } +} + +({int bitsUsed, int capacityBits}) _embedSequential( + Uint8List rgba, + String payload, +) { final body = utf8.encode(payload); final header = ascii.encode(_magic); final bytes = Uint8List(header.length + 4 + body.length); @@ -114,13 +256,11 @@ class ExtractOutcome { bytes[7] = body.length & 0xff; bytes.setAll(8, body); - final capacity = (rgba.length ~/ 4) * 3 ~/ 8; - if (bytes.length > capacity) { - throw const ImageTooSmallException(); - } + final capacity = _rgbaSlotCount(rgba.length); + final totalBits = bytes.length * 8; + if (bytes.length > capacity ~/ 8) throw const ImageTooSmallException(); var bit = 0; - final totalBits = bytes.length * 8; for (var i = 0; i < rgba.length && bit < totalBits; i += 4) { for (var c = 0; c < 3 && bit < totalBits; c++, bit++) { final byte = bytes[bit >> 3]; @@ -128,10 +268,10 @@ class ExtractOutcome { rgba[i + c] = (rgba[i + c] & 0xfe) | value; } } - return (bitsUsed: totalBits, capacityBits: capacity * 8); + return (bitsUsed: totalBits, capacityBits: capacity); } -String? _extractBits(Uint8List rgba) { +String? _extractSequential(Uint8List rgba) { Uint8List? readBytes(int count, int startBit) { final out = Uint8List(count); var bit = startBit; @@ -162,12 +302,24 @@ String? _extractBits(Uint8List rgba) { } } -class ImageTooSmallException implements Exception { - const ImageTooSmallException(); +String? _extractBits(Uint8List rgba) => + _extractScattered(rgba) ?? _extractSequential(rgba); - @override - String toString() => - 'Image is too small to carry this fingerprint. Try a larger image.'; +Uint8List _buildPacket(String payloadJson) { + final body = utf8.encode(payloadJson); + final header = ascii.encode(_magic); + final bytes = Uint8List(header.length + 4 + body.length); + bytes.setAll(0, header); + bytes[4] = (body.length >>> 24) & 0xff; + bytes[5] = (body.length >>> 16) & 0xff; + bytes[6] = (body.length >>> 8) & 0xff; + bytes[7] = body.length & 0xff; + bytes.setAll(8, body); + return bytes; +} + +bool _canEmbedScattered(Uint8List rgba, int packetBytes) { + return packetBytes * 8 <= _rgbaSlotCount(rgba.length); } /* --------------------------- isolate tasks --------------------------- */ @@ -177,30 +329,114 @@ Uint8List _rgbaOf(img.Image source) { return converted.getBytes(order: img.ChannelOrder.rgba); } +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +WatermarkPayload _buildPayload({ + required String owner, + required String asset, + required String issued, + ClaimKey? claimKey, +}) { + final resolvedOwner = + owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); + if (claimKey != null) { + final signed = ClaimCrypto.signMedia( + claimKey, + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return WatermarkPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + ); + } + final legacy = ClaimCrypto.signMediaLegacy( + owner: resolvedOwner, + asset: asset, + issued: issued, + ); + return WatermarkPayload( + owner: resolvedOwner, + asset: asset, + issued: issued, + signature: legacy.signature, + alg: legacy.alg, + kid: legacy.kid, + version: legacy.version, + ); +} + EmbedOutcome _embedTask( - ({Uint8List fileBytes, String owner, String fileName, String issued}) - args) { - final (:fileBytes, :owner, :fileName, :issued) = args; + ({ + Uint8List fileBytes, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:fileBytes, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + var decoded = img.decodeImage(fileBytes); if (decoded == null) { throw Exception('Could not decode this image. Try a PNG, JPG or WEBP.'); } - // Match the website: bound processing to 900px on the longest edge. - final maxDim = math.max(decoded.width, decoded.height); - if (maxDim > 900) { - final scale = 900 / maxDim; - decoded = img.copyResize( - decoded, - width: math.max(1, (decoded.width * scale).round()), - height: math.max(1, (decoded.height * scale).round()), - interpolation: img.Interpolation.average, - ); + var resized = false; + img.Image working = decoded; + + String assetFor(img.Image image) => + '$fileName\u00b7${image.width}x${image.height}'; + + // Probe payload size at native resolution; resize only if capacity is insufficient. + var probePayload = _buildPayload( + owner: owner, + asset: assetFor(working), + issued: issued, + claimKey: claimKey, + ); + var probePacket = _buildPacket(jsonEncode(probePayload.toJson())); + var probeRgba = _rgbaOf(working); + + if (!_canEmbedScattered(probeRgba, probePacket.length)) { + final maxDim = math.max(working.width, working.height); + if (maxDim > 900) { + final scale = 900 / maxDim; + working = img.copyResize( + working, + width: math.max(1, (working.width * scale).round()), + height: math.max(1, (working.height * scale).round()), + interpolation: img.Interpolation.average, + ); + resized = true; + probePayload = _buildPayload( + owner: owner, + asset: assetFor(working), + issued: issued, + claimKey: claimKey, + ); + probePacket = _buildPacket(jsonEncode(probePayload.toJson())); + probeRgba = _rgbaOf(working); + } + if (!_canEmbedScattered(probeRgba, probePacket.length)) { + throw const ImageTooSmallException(); + } } - final w = decoded.width; - final h = decoded.height; - final rgba = _rgbaOf(decoded); + final w = working.width; + final h = working.height; + final rgba = probeRgba; final originalPng = img.encodePng( img.Image.fromBytes( @@ -212,17 +448,10 @@ EmbedOutcome _embedTask( ), ); - final resolvedOwner = - owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); - final asset = '$fileName\u00b7${w}x$h'; - final payload = WatermarkPayload( - owner: resolvedOwner, - asset: asset, - issued: issued, - signature: fingerprint('$resolvedOwner|$asset|$issued'), - ); + final payload = probePayload; + final packet = _buildPacket(jsonEncode(payload.toJson())); + final stats = _embedScattered(rgba, packet); - final stats = _embedBits(rgba, jsonEncode(payload.toJson())); final markedPng = img.encodePng( img.Image.fromBytes( width: w, @@ -233,9 +462,13 @@ EmbedOutcome _embedTask( ), ); - // Round trip: extract from the delivered PNG, exactly as a verifier would. final delivered = img.decodePng(markedPng); - final recovered = delivered == null ? null : _extractBits(_rgbaOf(delivered)); + final recovered = + delivered == null ? null : _extractBits(_rgbaOf(delivered)); + + final recoveredPayload = WatermarkPayload.tryParse(recovered); + final claimStatus = recoveredPayload?.statusWith(claimKey) ?? + ClaimStatus.missing; return EmbedOutcome( originalPng: originalPng, @@ -246,15 +479,25 @@ EmbedOutcome _embedTask( bitsUsed: stats.bitsUsed, capacityBits: stats.capacityBits, recoveredRaw: recovered, + resized: resized, + claimStatus: claimStatus, ); } -ExtractOutcome _extractTask(Uint8List fileBytes) { +ExtractOutcome _extractTask( + ({Uint8List fileBytes, Uint8List? keyBytes, String? kid}) args, +) { + final (:fileBytes, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final decoded = img.decodeImage(fileBytes); if (decoded == null) { throw Exception('Could not decode this image. Try a PNG, JPG or WEBP.'); } final raw = _extractBits(_rgbaOf(decoded)); + final payload = WatermarkPayload.tryParse(raw); + final claimStatus = + payload?.statusWith(claimKey) ?? ClaimStatus.missing; var preview = decoded; final maxDim = math.max(decoded.width, decoded.height); @@ -266,7 +509,11 @@ ExtractOutcome _extractTask(Uint8List fileBytes) { height: math.max(1, (decoded.height * scale).round()), ); } - return ExtractOutcome(raw: raw, previewPng: img.encodePng(preview)); + return ExtractOutcome( + raw: raw, + previewPng: img.encodePng(preview), + claimStatus: claimStatus, + ); } /// Embeds an ownership payload into the image and round-trip verifies it. @@ -274,12 +521,32 @@ Future embedWatermark({ required Uint8List fileBytes, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (fileBytes: fileBytes, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + fileBytes: fileBytes, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } /// Extracts a watermark payload from any image file, if one is present. -Future extractWatermark(Uint8List fileBytes) => - compute(_extractTask, fileBytes); +Future extractWatermark( + Uint8List fileBytes, { + ClaimKey? claimKey, +}) => + compute( + _extractTask, + ( + fileBytes: fileBytes, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/pdf_fingerprint.dart b/lib/core/pdf_fingerprint.dart index a208f9a..4bd8252 100644 --- a/lib/core/pdf_fingerprint.dart +++ b/lib/core/pdf_fingerprint.dart @@ -1,8 +1,8 @@ /// PDF structural fingerprinting — Dart port of the website prototype. /// -/// The identifier format uses a trailing Signata marker (`%%Signata:` -/// trailing marker carrying base64 JSON), so documents fingerprinted in the -/// app verify on the website and vice versa. +/// Injects a comment claim before the last `%%EOF` and appends a trailing +/// Signata marker. Payloads carry HMAC structural signatures when an account +/// key is available. library; import 'dart:convert'; @@ -12,7 +12,10 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart' show compute; +import 'claim_crypto.dart'; + const String pdfMark = '%%Signata:'; +const String pdfCommentMark = '% Signata-Claim: '; String _bytesToText(Uint8List bytes) => latin1.decode(bytes); @@ -49,8 +52,6 @@ class PdfStructure { final bool linearized; final bool encrypted; - /// Key order matches the website's `JSON.stringify(structure)` so digests - /// computed on either side agree. Map toJson() => { 'version': version, 'bytes': bytes, @@ -79,7 +80,6 @@ class PdfStructure { } } -/// Derives a structural profile from the raw PDF text. PdfStructure analyzePdf(String text, int byteLength) { final head = text.substring(0, math.min(9, text.length)); final version = @@ -96,7 +96,6 @@ PdfStructure analyzePdf(String text, int byteLength) { ); } -/// SHA-256 hex digest (uppercase), byte-compatible with the website's digest. String pdfDigest(String input) => crypto.sha256.convert(_textToBytes(input)).toString().toUpperCase(); @@ -106,22 +105,46 @@ class PdfPayload { required this.document, required this.issued, required this.structure, - required this.identifier, + required this.contentBytes, + this.signature, + this.alg, + this.kid, + this.version = 1, + this.identifier, }); final String owner; final String document; final String issued; final PdfStructure structure; - final String identifier; - - Map toJson() => { - 'owner': owner, - 'document': document, - 'issued': issued, - 'structure': structure.toJson(), - 'identifier': identifier, - }; + final int contentBytes; + final String? signature; + final String? alg; + final String? kid; + final int version; + + /// Legacy structural digest (uppercase SHA-256 hex). + final String? identifier; + + Map toJson() { + final map = { + 'owner': owner, + 'document': document, + 'issued': issued, + 'structure': structure.toJson(), + 'contentBytes': contentBytes, + }; + if (signature != null && signature!.isNotEmpty) { + map['signature'] = signature; + } + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + if (identifier != null && identifier!.isNotEmpty) { + map['identifier'] = identifier; + } + return map; + } static PdfPayload? tryParse(String? raw) { if (raw == null) return null; @@ -135,20 +158,64 @@ class PdfPayload { document: map['document'] as String? ?? '', issued: map['issued'] as String? ?? '', structure: structure, - identifier: map['identifier'] as String? ?? '', + contentBytes: (map['contentBytes'] as num?)?.toInt() ?? + (map['originalLength'] as num?)?.toInt() ?? + structure.bytes, + signature: map['signature'] as String?, + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, + identifier: map['identifier'] as String?, ); } catch (_) { return null; } } - String digestInput(int originalTextLength) => - '$owner|$document|$issued|${structure.canonical}|$originalTextLength'; + String digestInput() => + '$owner|$document|$issued|${structure.canonical}|$contentBytes'; + + String structuralIdentifier() => identifier ?? pdfDigest(digestInput()); + + ClaimStatus statusWith(ClaimKey? key, { + required String recheckId, + required bool structureMatch, + }) => + ClaimCrypto.evaluateStructural( + owner: owner, + document: document, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: structuralIdentifier(), + recheckId: recheckId, + structureMatch: structureMatch, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } -/// Appends the identifier after %%EOF — trailing bytes are ignored by readers. +/// Injects a comment claim before the last `%%EOF`, then appends the trailing marker. Uint8List embedPdfIdentifier(Uint8List source, String payload) { final encoded = base64Encode(utf8.encode(payload)); + final text = _bytesToText(source); + final eofIndex = text.lastIndexOf('%%EOF'); + if (eofIndex >= 0) { + final comment = '$pdfCommentMark$encoded\n'; + final commentBytes = _textToBytes(comment); + final before = source.sublist(0, eofIndex); + final after = source.sublist(eofIndex); + final withComment = Uint8List( + before.length + commentBytes.length + after.length, + ); + withComment.setAll(0, before); + withComment.setAll(before.length, commentBytes); + withComment.setAll(before.length + commentBytes.length, after); + source = withComment; + } + final tail = _textToBytes('\n$pdfMark$encoded\n%%EOF\n'); final out = Uint8List(source.length + tail.length); out.setAll(0, source); @@ -156,7 +223,7 @@ Uint8List embedPdfIdentifier(Uint8List source, String payload) { return out; } -String? extractPdfIdentifier(String text) { +String? _extractTrailingMarker(String text) { final index = text.lastIndexOf(pdfMark); if (index == -1) return null; final start = index + pdfMark.length; @@ -169,7 +236,21 @@ String? extractPdfIdentifier(String text) { } } -/* --------------------------- high-level ops --------------------------- */ +String? _extractCommentMarker(String text) { + final index = text.lastIndexOf(pdfCommentMark); + if (index == -1) return null; + final start = index + pdfCommentMark.length; + final end = text.indexOf('\n', start); + final encoded = text.substring(start, end == -1 ? text.length : end).trim(); + try { + return utf8.decode(base64Decode(encoded)); + } catch (_) { + return null; + } +} + +String? extractPdfIdentifier(String text) => + _extractTrailingMarker(text) ?? _extractCommentMarker(text); class PdfEmbedOutcome { const PdfEmbedOutcome({ @@ -180,6 +261,8 @@ class PdfEmbedOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final Uint8List markedBytes; @@ -189,6 +272,8 @@ class PdfEmbedOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class PdfVerifyOutcome { @@ -198,6 +283,8 @@ class PdfVerifyOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final PdfPayload? recovered; @@ -205,6 +292,8 @@ class PdfVerifyOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class NotAPdfException implements Exception { @@ -214,54 +303,162 @@ class NotAPdfException implements Exception { String toString() => 'That file is not a valid PDF document.'; } -PdfEmbedOutcome _pdfEmbedTask( - ({Uint8List source, String owner, String fileName, String issued}) args) { - final (:source, :owner, :fileName, :issued) = args; - final text = _bytesToText(source); - if (!text.startsWith('%PDF-')) throw const NotAPdfException(); +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} - final structure = analyzePdf(text, source.length); +PdfPayload _buildPayload({ + required String owner, + required String fileName, + required String issued, + required PdfStructure structure, + required int contentBytes, + ClaimKey? claimKey, +}) { final resolvedOwner = owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); - final identifier = pdfDigest( - '$resolvedOwner|$fileName|$issued|${structure.canonical}|${text.length}', + '$resolvedOwner|$fileName|$issued|${structure.canonical}|$contentBytes', ); - final payload = PdfPayload( + + if (claimKey != null) { + final signed = ClaimCrypto.signStructural( + claimKey, + owner: resolvedOwner, + document: fileName, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: identifier, + ); + return PdfPayload( + owner: resolvedOwner, + document: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + identifier: identifier, + ); + } + + return PdfPayload( owner: resolvedOwner, document: fileName, issued: issued, structure: structure, + contentBytes: contentBytes, identifier: identifier, + version: 1, ); +} - final marked = embedPdfIdentifier(source, jsonEncode(payload.toJson())); +({String recheckId, bool structureMatch, ClaimStatus claimStatus, bool verified}) + _evaluatePayload( + PdfPayload payload, + Uint8List source, + ClaimKey? claimKey, +) { + final originalLength = payload.contentBytes; + if (originalLength <= 0 || originalLength > source.length) { + return ( + recheckId: '—', + structureMatch: false, + claimStatus: ClaimStatus.present, + verified: false, + ); + } - // Read back from the delivered file, exactly as a verifier would. + final originalBytes = source.sublist(0, originalLength); + final originalText = _bytesToText(originalBytes); + final structure = analyzePdf(originalText, originalLength); + final recheckId = pdfDigest(payload.digestInput()); + final structureMatch = + structure.canonical == payload.structure.canonical; + final claimStatus = payload.statusWith( + claimKey, + recheckId: recheckId, + structureMatch: structureMatch, + ); + final verified = claimStatus == ClaimStatus.authenticated || + claimStatus == ClaimStatus.selfConsistent; + + return ( + recheckId: recheckId, + structureMatch: structureMatch, + claimStatus: claimStatus, + verified: verified, + ); +} + +PdfEmbedOutcome _pdfEmbedTask( + ({ + Uint8List source, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:source, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final text = _bytesToText(source); + if (!text.startsWith('%PDF-')) throw const NotAPdfException(); + + final contentBytes = source.length; + final structure = analyzePdf(text, contentBytes); + final payload = _buildPayload( + owner: owner, + fileName: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + claimKey: claimKey, + ); + + final marked = embedPdfIdentifier(source, jsonEncode(payload.toJson())); final deliveredText = _bytesToText(marked); final raw = extractPdfIdentifier(deliveredText); final recovered = PdfPayload.tryParse(raw); - final recheckId = - recovered == null ? '—' : pdfDigest(recovered.digestInput(text.length)); - final structureMatch = - recovered != null && recovered.structure.canonical == structure.canonical; - final verified = recovered != null && - recheckId == recovered.identifier && - structureMatch; + if (recovered == null) { + return PdfEmbedOutcome( + markedBytes: marked, + payload: payload, + recovered: null, + raw: raw, + recheckId: '—', + structureMatch: false, + verified: false, + claimStatus: ClaimStatus.missing, + originalLength: contentBytes, + ); + } + final eval = _evaluatePayload(recovered, marked, claimKey); return PdfEmbedOutcome( markedBytes: marked, payload: payload, recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: contentBytes, ); } -PdfVerifyOutcome _pdfVerifyTask(Uint8List source) { +PdfVerifyOutcome _pdfVerifyTask( + ({Uint8List source, Uint8List? keyBytes, String? kid}) args, +) { + final (:source, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); final text = _bytesToText(source); if (!text.startsWith('%PDF-')) throw const NotAPdfException(); @@ -274,41 +471,52 @@ PdfVerifyOutcome _pdfVerifyTask(Uint8List source) { recheckId: '—', structureMatch: false, verified: false, + claimStatus: ClaimStatus.missing, + originalLength: 0, ); } - // The marker tail starts with "\n%%Signata:", so everything before it is - // the original document as it existed when the identifier was derived. - final markIndex = text.lastIndexOf(pdfMark); - final originalLength = markIndex > 0 ? markIndex - 1 : text.length; - final originalText = text.substring(0, originalLength); - - final structure = analyzePdf(originalText, originalLength); - final recheckId = pdfDigest(recovered.digestInput(originalLength)); - final structureMatch = recovered.structure.canonical == structure.canonical; - final verified = recheckId == recovered.identifier && structureMatch; - + final eval = _evaluatePayload(recovered, source, claimKey); return PdfVerifyOutcome( recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: recovered.contentBytes, ); } -/// Fingerprints [source] and round-trip verifies the delivered file. Future embedPdfFingerprint({ required Uint8List source, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_pdfEmbedTask, - (source: source, owner: owner, fileName: fileName, issued: issued)); + return compute( + _pdfEmbedTask, + ( + source: source, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -/// Verifies a standalone PDF: recovers the identifier, recomputes the digest -/// and structural profile from the document itself, and compares. -Future verifyPdfFingerprint(Uint8List source) => - compute(_pdfVerifyTask, source); +Future verifyPdfFingerprint( + Uint8List source, { + ClaimKey? claimKey, +}) => + compute( + _pdfVerifyTask, + ( + source: source, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); diff --git a/lib/core/video_fingerprint.dart b/lib/core/video_fingerprint.dart index 696edc3..5565774 100644 --- a/lib/core/video_fingerprint.dart +++ b/lib/core/video_fingerprint.dart @@ -1,5 +1,5 @@ -/// Video container fingerprinting — profiles an MP4/MOV file and hides a -/// trailing Signata identifier (same pattern as the PDF prototype). +/// Video container fingerprinting — profiles an MP4/MOV file and embeds a +/// Signata claim in a custom `uuid` box plus a legacy trailing marker. library; import 'dart:convert'; @@ -8,9 +8,14 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/foundation.dart' show compute; +import 'claim_crypto.dart'; + const String videoMark = '%%SignataVideo:'; +final Uint8List signataVideoUuid = + Uint8List.fromList(ascii.encode('SignataClaimV2!!')); -String _bytesToLatin1(Uint8List bytes) => latin1.decode(bytes, allowInvalid: true); +String _bytesToLatin1(Uint8List bytes) => + latin1.decode(bytes, allowInvalid: true); Uint8List _latin1ToBytes(String text) { final out = Uint8List(text.length); @@ -71,22 +76,46 @@ class VideoPayload { required this.document, required this.issued, required this.structure, - required this.identifier, + required this.contentBytes, + this.signature, + this.alg, + this.kid, + this.version = 1, + this.identifier, }); final String owner; final String document; final String issued; final VideoStructure structure; - final String identifier; - - Map toJson() => { - 'owner': owner, - 'document': document, - 'issued': issued, - 'structure': structure.toJson(), - 'identifier': identifier, - }; + final int contentBytes; + final String? signature; + final String? alg; + final String? kid; + final int version; + + /// Legacy structural digest (uppercase SHA-256 hex). + final String? identifier; + + Map toJson() { + final map = { + 'owner': owner, + 'document': document, + 'issued': issued, + 'structure': structure.toJson(), + 'contentBytes': contentBytes, + }; + if (signature != null && signature!.isNotEmpty) { + map['signature'] = signature; + } + if (alg != null && alg!.isNotEmpty) map['alg'] = alg; + if (kid != null && kid!.isNotEmpty) map['kid'] = kid; + if (version > 1) map['v'] = version; + if (identifier != null && identifier!.isNotEmpty) { + map['identifier'] = identifier; + } + return map; + } static VideoPayload? tryParse(String? raw) { if (raw == null) return null; @@ -100,15 +129,43 @@ class VideoPayload { document: map['document'] as String? ?? '', issued: map['issued'] as String? ?? '', structure: structure, - identifier: map['identifier'] as String? ?? '', + contentBytes: (map['contentBytes'] as num?)?.toInt() ?? + (map['originalLength'] as num?)?.toInt() ?? + structure.bytes, + signature: map['signature'] as String?, + alg: map['alg'] as String?, + kid: map['kid'] as String?, + version: (map['v'] as num?)?.toInt() ?? 1, + identifier: map['identifier'] as String?, ); } catch (_) { return null; } } - String digestInput(int originalLength) => - '$owner|$document|$issued|${structure.canonical}|$originalLength'; + String digestInput() => + '$owner|$document|$issued|${structure.canonical}|$contentBytes'; + + String structuralIdentifier() => identifier ?? videoDigest(digestInput()); + + ClaimStatus statusWith(ClaimKey? key, { + required String recheckId, + required bool structureMatch, + }) => + ClaimCrypto.evaluateStructural( + owner: owner, + document: document, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: structuralIdentifier(), + recheckId: recheckId, + structureMatch: structureMatch, + signature: signature, + alg: alg, + kid: kid, + key: key, + ); } class NotAVideoException implements Exception { @@ -125,7 +182,6 @@ String videoDigest(String input) => VideoStructure analyzeVideo(Uint8List bytes) { if (bytes.length < 12) throw const NotAVideoException(); - // ISO BMFF: [size:4][type:4]… ftyp usually first. final firstType = String.fromCharCodes(bytes.sublist(4, 8)); if (firstType != 'ftyp' && firstType != 'wide' && firstType != 'mdat') { // Some files start with a free/skip atom; still accept if we later find moov. @@ -154,7 +210,6 @@ VideoStructure analyzeVideo(Uint8List bytes) { hasMdat = true; } - // Sample descriptions / handler hints inside this atom window. final end = size == 0 ? bytes.length : (size == 1 && offset + 16 <= bytes.length) @@ -164,10 +219,14 @@ VideoStructure analyzeVideo(Uint8List bytes) { : offset + size; final windowEnd = end.clamp(0, bytes.length); final slice = _bytesToLatin1(bytes.sublist(offset, windowEnd)); - if (slice.contains('vide') || slice.contains('avc1') || slice.contains('hvc1')) { + if (slice.contains('vide') || + slice.contains('avc1') || + slice.contains('hvc1')) { hasVideo = true; } - if (slice.contains('soun') || slice.contains('mp4a') || slice.contains('opus')) { + if (slice.contains('soun') || + slice.contains('mp4a') || + slice.contains('opus')) { hasAudio = true; } @@ -190,16 +249,76 @@ VideoStructure analyzeVideo(Uint8List bytes) { ); } +Uint8List _buildUuidBox(Uint8List payloadBytes) { + final boxSize = 8 + 16 + payloadBytes.length; + final box = Uint8List(boxSize); + final header = ByteData(8); + header.setUint32(0, boxSize, Endian.big); + box.setAll(0, header.buffer.asUint8List()); + box.setAll(4, 'uuid'.codeUnits); + box.setAll(8, signataVideoUuid); + box.setAll(24, payloadBytes); + return box; +} + Uint8List embedVideoIdentifier(Uint8List source, String payload) { + final payloadBytes = utf8.encode(payload); + final uuidBox = _buildUuidBox(payloadBytes); final encoded = base64Encode(utf8.encode(payload)); final tail = _latin1ToBytes('\n$videoMark$encoded\n'); - final out = Uint8List(source.length + tail.length); + final out = Uint8List(source.length + uuidBox.length + tail.length); out.setAll(0, source); - out.setAll(source.length, tail); + out.setAll(source.length, uuidBox); + out.setAll(source.length + uuidBox.length, tail); return out; } -String? extractVideoIdentifier(String text) { +String? extractVideoUuidPayload(Uint8List bytes) { + var offset = 0; + while (offset + 8 <= bytes.length) { + final size = ByteData.sublistView(bytes, offset, offset + 4) + .getUint32(0, Endian.big); + final type = String.fromCharCodes(bytes.sublist(offset + 4, offset + 8)); + if (size < 24 && size != 1) break; + + final end = size == 0 + ? bytes.length + : (size == 1 && offset + 16 <= bytes.length) + ? offset + + ByteData.sublistView(bytes, offset + 8, offset + 16) + .getUint64(0, Endian.big) + : offset + size; + final windowEnd = end.clamp(0, bytes.length); + + if (type == 'uuid' && offset + 24 <= windowEnd) { + final userType = bytes.sublist(offset + 8, offset + 24); + if (userType.length == signataVideoUuid.length) { + var matches = true; + for (var i = 0; i < signataVideoUuid.length; i++) { + if (userType[i] != signataVideoUuid[i]) { + matches = false; + break; + } + } + if (matches && windowEnd > offset + 24) { + try { + return utf8.decode(bytes.sublist(offset + 24, windowEnd)); + } catch (_) {} + } + } + } + + if (size == 0) break; + offset = windowEnd; + } + return null; +} + +String? extractVideoPayloadRaw(Uint8List bytes) { + final uuidPayload = extractVideoUuidPayload(bytes); + if (uuidPayload != null) return uuidPayload; + + final text = _bytesToLatin1(bytes); final index = text.lastIndexOf(videoMark); if (index == -1) return null; final start = index + videoMark.length; @@ -221,6 +340,8 @@ class VideoEmbedOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final Uint8List markedBytes; @@ -230,6 +351,8 @@ class VideoEmbedOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } class VideoVerifyOutcome { @@ -239,6 +362,8 @@ class VideoVerifyOutcome { required this.recheckId, required this.structureMatch, required this.verified, + required this.claimStatus, + required this.originalLength, }); final VideoPayload? recovered; @@ -246,77 +371,184 @@ class VideoVerifyOutcome { final String recheckId; final bool structureMatch; final bool verified; + final ClaimStatus claimStatus; + final int originalLength; } -VideoEmbedOutcome _embedTask( - ({Uint8List source, String owner, String fileName, String issued}) args) { - final (:source, :owner, :fileName, :issued) = args; - final structure = analyzeVideo(source); +ClaimKey? _claimKeyFromArgs(Uint8List? keyBytes, String? kid) { + if (keyBytes == null || keyBytes.length != 32) return null; + return ClaimKey(bytes: keyBytes, kid: kid ?? ClaimKey.kidFor(keyBytes)); +} + +VideoPayload _buildPayload({ + required String owner, + required String fileName, + required String issued, + required VideoStructure structure, + required int contentBytes, + ClaimKey? claimKey, +}) { final resolvedOwner = owner.trim().isEmpty ? 'Anonymous creator' : owner.trim(); final identifier = videoDigest( - '$resolvedOwner|$fileName|$issued|${structure.canonical}|${source.length}', + '$resolvedOwner|$fileName|$issued|${structure.canonical}|$contentBytes', ); - final payload = VideoPayload( + + if (claimKey != null) { + final signed = ClaimCrypto.signStructural( + claimKey, + owner: resolvedOwner, + document: fileName, + issued: issued, + structureCanonical: structure.canonical, + originalLength: contentBytes, + identifier: identifier, + ); + return VideoPayload( + owner: resolvedOwner, + document: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + version: signed.version, + identifier: identifier, + ); + } + + return VideoPayload( owner: resolvedOwner, document: fileName, issued: issued, structure: structure, + contentBytes: contentBytes, identifier: identifier, + version: 1, + ); +} + +({String recheckId, bool structureMatch, ClaimStatus claimStatus, bool verified}) + _evaluatePayload( + VideoPayload payload, + Uint8List source, + ClaimKey? claimKey, +) { + final originalLength = payload.contentBytes; + if (originalLength <= 0 || originalLength > source.length) { + return ( + recheckId: '—', + structureMatch: false, + claimStatus: ClaimStatus.present, + verified: false, + ); + } + + final original = source.sublist(0, originalLength); + final structure = analyzeVideo(original); + final recheckId = videoDigest(payload.digestInput()); + final structureMatch = structure.canonical == payload.structure.canonical; + final claimStatus = payload.statusWith( + claimKey, + recheckId: recheckId, + structureMatch: structureMatch, + ); + final verified = claimStatus == ClaimStatus.authenticated || + claimStatus == ClaimStatus.selfConsistent; + + return ( + recheckId: recheckId, + structureMatch: structureMatch, + claimStatus: claimStatus, + verified: verified, + ); +} + +VideoEmbedOutcome _embedTask( + ({ + Uint8List source, + String owner, + String fileName, + String issued, + Uint8List? keyBytes, + String? kid, + }) args, +) { + final (:source, :owner, :fileName, :issued, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final contentBytes = source.length; + final structure = analyzeVideo(source); + final payload = _buildPayload( + owner: owner, + fileName: fileName, + issued: issued, + structure: structure, + contentBytes: contentBytes, + claimKey: claimKey, ); - final marked = embedVideoIdentifier(source, jsonEncode(payload.toJson())); - final deliveredText = _bytesToLatin1(marked); - final raw = extractVideoIdentifier(deliveredText); + final marked = + embedVideoIdentifier(source, jsonEncode(payload.toJson())); + final raw = extractVideoPayloadRaw(marked); final recovered = VideoPayload.tryParse(raw); - final recheckId = recovered == null - ? '—' - : videoDigest(recovered.digestInput(source.length)); - final structureMatch = - recovered != null && recovered.structure.canonical == structure.canonical; - final verified = recovered != null && - recheckId == recovered.identifier && - structureMatch; + if (recovered == null) { + return VideoEmbedOutcome( + markedBytes: marked, + payload: payload, + recovered: null, + raw: raw, + recheckId: '—', + structureMatch: false, + verified: false, + claimStatus: ClaimStatus.missing, + originalLength: contentBytes, + ); + } + + final eval = _evaluatePayload(recovered, marked, claimKey); return VideoEmbedOutcome( markedBytes: marked, payload: payload, recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: contentBytes, ); } -VideoVerifyOutcome _verifyTask(Uint8List source) { - final text = _bytesToLatin1(source); - final raw = extractVideoIdentifier(text); +VideoVerifyOutcome _verifyTask( + ({Uint8List source, Uint8List? keyBytes, String? kid}) args, +) { + final (:source, :keyBytes, :kid) = args; + final claimKey = _claimKeyFromArgs(keyBytes, kid); + final raw = extractVideoPayloadRaw(source); final recovered = VideoPayload.tryParse(raw); if (recovered == null) { - return const VideoVerifyOutcome( + return VideoVerifyOutcome( recovered: null, - raw: null, + raw: raw, recheckId: '—', structureMatch: false, verified: false, + claimStatus: ClaimStatus.missing, + originalLength: 0, ); } - final markIndex = text.lastIndexOf(videoMark); - final originalLength = markIndex > 0 ? markIndex - 1 : source.length; - final original = source.sublist(0, originalLength); - final structure = analyzeVideo(original); - final recheckId = videoDigest(recovered.digestInput(originalLength)); - final structureMatch = recovered.structure.canonical == structure.canonical; - final verified = recheckId == recovered.identifier && structureMatch; - + final eval = _evaluatePayload(recovered, source, claimKey); return VideoVerifyOutcome( recovered: recovered, raw: raw, - recheckId: recheckId, - structureMatch: structureMatch, - verified: verified, + recheckId: eval.recheckId, + structureMatch: eval.structureMatch, + verified: eval.verified, + claimStatus: eval.claimStatus, + originalLength: recovered.contentBytes, ); } @@ -324,11 +556,31 @@ Future embedVideoFingerprint({ required Uint8List source, required String owner, required String fileName, + ClaimKey? claimKey, }) { final issued = DateTime.now().toUtc().toIso8601String(); - return compute(_embedTask, - (source: source, owner: owner, fileName: fileName, issued: issued)); + return compute( + _embedTask, + ( + source: source, + owner: owner, + fileName: fileName, + issued: issued, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); } -Future verifyVideoFingerprint(Uint8List source) => - compute(_verifyTask, source); +Future verifyVideoFingerprint( + Uint8List source, { + ClaimKey? claimKey, +}) => + compute( + _verifyTask, + ( + source: source, + keyBytes: claimKey?.bytes, + kid: claimKey?.kid, + ), + ); From f0eb2018f9995fc8f22f7d952f71cab93fa2f9fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 10:55:01 +0000 Subject: [PATCH 02/31] Wire tool screens to account-bound claim keys and status banners - Load AccountClaimKeys.current() before embed/verify on all four tools - Pass claimKey into embed/verify APIs; use claimBanner for StatusBanner - History verified flag uses claimCountsAsVerified - Show Key id detail row (kid or legacy) - Soften encrypted copy to signed/ownership language - Update prototype notes for scattered LSB, trailing PDF comment, uuid atom Co-authored-by: Wejdan Al Amri --- lib/core/claim_crypto.dart | 291 +++++++++++++++++++++++++++++ lib/core/claim_status_ui.dart | 42 +++++ lib/screens/audio_tool_screen.dart | 52 +++--- lib/screens/image_tool_screen.dart | 64 ++++--- lib/screens/pdf_tool_screen.dart | 45 +++-- lib/screens/video_tool_screen.dart | 45 +++-- 6 files changed, 461 insertions(+), 78 deletions(-) create mode 100644 lib/core/claim_crypto.dart create mode 100644 lib/core/claim_status_ui.dart diff --git a/lib/core/claim_crypto.dart b/lib/core/claim_crypto.dart new file mode 100644 index 0000000..06d40ec --- /dev/null +++ b/lib/core/claim_crypto.dart @@ -0,0 +1,291 @@ +/// Account-bound claim authentication for Signata fingerprints. +/// +/// New payloads carry `alg: hmac-sha256` + `kid` + `signature` (HMAC). +/// Legacy FNV-16 payloads (no `alg`) still verify as self-consistent only. +library; + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'auth.dart'; +import 'fingerprint.dart'; + +const String claimAlgHmac = 'hmac-sha256'; +const String claimAlgFnv16 = 'fnv16'; +const int claimPayloadVersion = 2; + +enum ClaimStatus { + /// No recoverable payload. + missing, + + /// Payload parsed but the signature/mac does not check out. + present, + + /// Legacy FNV or structural digest matches (no account authentication). + selfConsistent, + + /// HMAC verifies with the current account key. + authenticated, + + /// HMAC payload issued under a different key id. + foreignKey, +} + +class ClaimKey { + const ClaimKey({required this.bytes, required this.kid}); + + final Uint8List bytes; + final String kid; + + /// First 8 hex chars of SHA-256(key). + static String kidFor(Uint8List key) => + sha256.convert(key).toString().substring(0, 8).toUpperCase(); + + static ClaimKey generate() { + final rng = Random.secure(); + final bytes = Uint8List.fromList( + List.generate(32, (_) => rng.nextInt(256)), + ); + return ClaimKey(bytes: bytes, kid: kidFor(bytes)); + } + + /// Deterministic key for unit tests. + static ClaimKey fromSeed(String seed) { + final bytes = Uint8List.fromList(sha256.convert(utf8.encode(seed)).bytes); + return ClaimKey(bytes: bytes, kid: kidFor(bytes)); + } +} + +class SignedClaim { + const SignedClaim({ + required this.signature, + required this.alg, + required this.kid, + required this.version, + }); + + final String signature; + final String alg; + final String kid; + final int version; + + Map toFields() => { + 'alg': alg, + 'kid': kid, + 'v': version, + 'signature': signature, + }; +} + +class ClaimCrypto { + static String hmacHex(ClaimKey key, String canonical) => + Hmac(sha256, key.bytes) + .convert(utf8.encode(canonical)) + .toString() + .toUpperCase(); + + static String mediaCanonical({ + required String owner, + required String asset, + required String issued, + }) => + '$owner|$asset|$issued'; + + static String structuralCanonical({ + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + }) => + '$owner|$document|$issued|$structureCanonical|$originalLength|$identifier'; + + static SignedClaim signMedia( + ClaimKey key, { + required String owner, + required String asset, + required String issued, + }) { + final canonical = mediaCanonical(owner: owner, asset: asset, issued: issued); + return SignedClaim( + signature: hmacHex(key, canonical), + alg: claimAlgHmac, + kid: key.kid, + version: claimPayloadVersion, + ); + } + + static SignedClaim signStructural( + ClaimKey key, { + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + }) { + final canonical = structuralCanonical( + owner: owner, + document: document, + issued: issued, + structureCanonical: structureCanonical, + originalLength: originalLength, + identifier: identifier, + ); + return SignedClaim( + signature: hmacHex(key, canonical), + alg: claimAlgHmac, + kid: key.kid, + version: claimPayloadVersion, + ); + } + + /// Legacy FNV signature used when no account key is available. + static SignedClaim signMediaLegacy({ + required String owner, + required String asset, + required String issued, + }) => + SignedClaim( + signature: fingerprint(mediaCanonical( + owner: owner, + asset: asset, + issued: issued, + )), + alg: claimAlgFnv16, + kid: '', + version: 1, + ); + + static ClaimStatus evaluateMedia({ + required String owner, + required String asset, + required String issued, + required String signature, + String? alg, + String? kid, + ClaimKey? key, + }) { + final resolvedAlg = + (alg == null || alg.isEmpty) ? claimAlgFnv16 : alg; + final canonical = + mediaCanonical(owner: owner, asset: asset, issued: issued); + + if (resolvedAlg == claimAlgFnv16) { + return signature == fingerprint(canonical) + ? ClaimStatus.selfConsistent + : ClaimStatus.present; + } + + if (resolvedAlg != claimAlgHmac) return ClaimStatus.present; + if (key == null) { + return (kid != null && kid.isNotEmpty) + ? ClaimStatus.foreignKey + : ClaimStatus.present; + } + if (kid != null && kid.isNotEmpty && kid.toUpperCase() != key.kid) { + return ClaimStatus.foreignKey; + } + return hmacHex(key, canonical) == signature.toUpperCase() + ? ClaimStatus.authenticated + : ClaimStatus.present; + } + + static ClaimStatus evaluateStructural({ + required String owner, + required String document, + required String issued, + required String structureCanonical, + required int originalLength, + required String identifier, + required String recheckId, + required bool structureMatch, + String? signature, + String? alg, + String? kid, + ClaimKey? key, + }) { + final digestOk = identifier.isNotEmpty && + recheckId == identifier && + structureMatch; + if (!digestOk) { + return identifier.isEmpty ? ClaimStatus.missing : ClaimStatus.present; + } + + final resolvedAlg = + (alg == null || alg.isEmpty) ? claimAlgFnv16 : alg; + if (resolvedAlg != claimAlgHmac || signature == null || signature.isEmpty) { + return ClaimStatus.selfConsistent; + } + + if (key == null) return ClaimStatus.foreignKey; + if (kid != null && kid.isNotEmpty && kid.toUpperCase() != key.kid) { + return ClaimStatus.foreignKey; + } + + final canonical = structuralCanonical( + owner: owner, + document: document, + issued: issued, + structureCanonical: structureCanonical, + originalLength: originalLength, + identifier: identifier, + ); + return hmacHex(key, canonical) == signature.toUpperCase() + ? ClaimStatus.authenticated + : ClaimStatus.present; + } + + static String describe(ClaimStatus status) { + switch (status) { + case ClaimStatus.missing: + return 'No readable Signata fingerprint was found.'; + case ClaimStatus.present: + return 'A fingerprint was found but the claim signature is invalid.'; + case ClaimStatus.selfConsistent: + return 'Claim is self-consistent (legacy / unauthenticated).'; + case ClaimStatus.authenticated: + return 'Authenticated with your Signata key.'; + case ClaimStatus.foreignKey: + return 'Claim consistent but not signed with your Signata key.'; + } + } +} + +/// Loads or creates the per-account HMAC key in secure storage. +class AccountClaimKeys { + AccountClaimKeys._(); + + static const _secure = FlutterSecureStorage(); + + static String _storageKey(String userId) => 'signata_claim_key_$userId'; + + static Future forUser(String userId) async { + final existing = await _secure.read(key: _storageKey(userId)); + if (existing != null && existing.isNotEmpty) { + try { + final bytes = Uint8List.fromList(base64Url.decode(existing)); + if (bytes.length == 32) { + return ClaimKey(bytes: bytes, kid: ClaimKey.kidFor(bytes)); + } + } catch (_) {} + } + final key = ClaimKey.generate(); + await _secure.write( + key: _storageKey(userId), + value: base64UrlEncode(key.bytes), + ); + return key; + } + + static Future current() async { + final user = AuthService.instance.user; + if (user == null) return null; + return forUser(user.id); + } +} diff --git a/lib/core/claim_status_ui.dart b/lib/core/claim_status_ui.dart new file mode 100644 index 0000000..f5511f3 --- /dev/null +++ b/lib/core/claim_status_ui.dart @@ -0,0 +1,42 @@ +import 'claim_crypto.dart'; + +/// Shared verify banner copy for tool screens. +({bool ok, String title, String subtitle}) claimBanner(ClaimStatus status) { + switch (status) { + case ClaimStatus.authenticated: + return ( + ok: true, + title: 'Authenticated with your Signata key', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.selfConsistent: + return ( + ok: true, + title: 'Claim is self-consistent', + subtitle: + 'Legacy fingerprint checks out, but it is not bound to your account key.', + ); + case ClaimStatus.foreignKey: + return ( + ok: false, + title: 'Signed by another Signata key', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.present: + return ( + ok: false, + title: 'Fingerprint found but invalid', + subtitle: ClaimCrypto.describe(status), + ); + case ClaimStatus.missing: + return ( + ok: false, + title: 'No fingerprint found', + subtitle: ClaimCrypto.describe(status), + ); + } +} + +bool claimCountsAsVerified(ClaimStatus status) => + status == ClaimStatus.authenticated || + status == ClaimStatus.selfConsistent; diff --git a/lib/screens/audio_tool_screen.dart b/lib/screens/audio_tool_screen.dart index dcd5b6e..fedd955 100644 --- a/lib/screens/audio_tool_screen.dart +++ b/lib/screens/audio_tool_screen.dart @@ -4,6 +4,8 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../core/audio_watermark.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; @@ -68,12 +70,14 @@ class _AudioToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == AudioMode.embed) { setState(() => _workingStep = 'Embedding inaudible fingerprint'); final outcome = await embedAudioWatermark( fileBytes: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -87,13 +91,13 @@ class _AudioToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.signature, at: DateTime.now(), )); } else { setState(() => _workingStep = 'Scanning audio samples'); - final outcome = await extractAudioWatermark(bytes); + final outcome = await extractAudioWatermark(bytes, claimKey: claimKey); stopwatch.stop(); final payload = AudioPayload.tryParse(outcome.raw); if (!mounted) return; @@ -108,7 +112,7 @@ class _AudioToolScreenState extends State { action: 'verify', subject: file.name, owner: payload?.owner ?? 'Unknown', - verified: payload?.signatureValid ?? false, + verified: claimCountsAsVerified(outcome.claimStatus), reference: payload?.signature ?? '—', at: DateTime.now(), )); @@ -142,7 +146,7 @@ class _AudioToolScreenState extends State { medium: 'audio', subject: _fileName ?? outcome.payload.asset, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -180,7 +184,7 @@ class _AudioToolScreenState extends State { tag: 'Audio watermarking', title: 'Inaudible ownership in WAV', desc: - 'Signata flips the least-significant bit of 16-bit PCM samples to carry an encrypted ownership claim — inaudible, recoverable, processed on-device.', + 'Signata flips the least-significant bit of 16-bit PCM samples to carry a signed ownership claim — inaudible, recoverable, processed on-device.', ), const SizedBox(height: 20), SegmentedButton( @@ -302,21 +306,23 @@ class _AudioToolScreenState extends State { if (_mode == AudioMode.embed && _embedOutcome != null) { final outcome = _embedOutcome!; final recovered = outcome.recovered; + final banner = claimBanner(outcome.claimStatus); return [ StatusBanner( - ok: outcome.verified, - title: outcome.verified - ? 'Ownership verified' - : 'No valid watermark recovered', - subtitle: outcome.verified - ? 'Extracted signature matches the embedded fingerprint.' - : 'The payload could not be read back intact from this file.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ DetailRow('Owner', recovered?.owner ?? '—'), DetailRow('Asset', recovered?.asset ?? '—', mono: true), DetailRow('Signature', recovered?.signature ?? '—', mono: true), + DetailRow( + 'Key id', + _kidLabel(recovered?.alg ?? outcome.payload.alg, + recovered?.kid ?? outcome.payload.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), const SizedBox(height: 14), @@ -337,18 +343,12 @@ class _AudioToolScreenState extends State { if (_mode == AudioMode.verify && _verifyOutcome != null) { final payload = _verifyPayload; - final genuine = payload != null && payload.signatureValid; + final banner = claimBanner(_verifyOutcome!.claimStatus); return [ StatusBanner( - ok: genuine, - title: genuine - ? 'Signata audio watermark verified' - : payload != null - ? 'Watermark found but signature is invalid' - : 'No watermark found', - subtitle: genuine - ? 'The recovered signature matches the ownership claim.' - : 'This clip does not carry a readable Signata audio fingerprint.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), if (payload != null) ...[ const SizedBox(height: 14), @@ -356,6 +356,8 @@ class _AudioToolScreenState extends State { DetailRow('Owner', payload.owner), DetailRow('Asset', payload.asset, mono: true), DetailRow('Signature', payload.signature, mono: true), + DetailRow( + 'Key id', _kidLabel(payload.alg, payload.kid), mono: true), DetailRow( 'Format', '${_verifyOutcome!.sampleRate} Hz · ${_verifyOutcome!.channels} ch', @@ -375,4 +377,10 @@ class _AudioToolScreenState extends State { ), ]; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } diff --git a/lib/screens/image_tool_screen.dart b/lib/screens/image_tool_screen.dart index d8a86a7..a6634dd 100644 --- a/lib/screens/image_tool_screen.dart +++ b/lib/screens/image_tool_screen.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/image_watermark.dart'; import '../core/report.dart'; @@ -66,12 +68,14 @@ class _ImageToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == ToolMode.embed) { - setState(() => _workingStep = 'Embedding encrypted fingerprint'); + setState(() => _workingStep = 'Embedding signed ownership fingerprint'); final outcome = await embedWatermark( fileBytes: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -85,13 +89,13 @@ class _ImageToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.signature, at: DateTime.now(), )); } else { setState(() => _workingStep = 'Scanning for a watermark'); - final outcome = await extractWatermark(bytes); + final outcome = await extractWatermark(bytes, claimKey: claimKey); stopwatch.stop(); final payload = WatermarkPayload.tryParse(outcome.raw); if (!mounted) return; @@ -106,7 +110,7 @@ class _ImageToolScreenState extends State { action: 'verify', subject: file.name, owner: payload?.owner ?? 'Unknown', - verified: payload?.signatureValid ?? false, + verified: claimCountsAsVerified(outcome.claimStatus), reference: payload?.signature ?? '—', at: DateTime.now(), )); @@ -141,7 +145,7 @@ class _ImageToolScreenState extends State { medium: 'image', subject: _fileName ?? outcome.payload.asset, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -257,8 +261,8 @@ class _ImageToolScreenState extends State { const SizedBox(height: 14), Text( _mode == ToolMode.embed - ? 'Prototype note: this demo uses least-significant-bit encoding for illustration. The production engine adds encryption and transform-robust embedding server-side.' - : 'Verification recomputes the fingerprint from the recovered claim, so a forged or edited payload is detected. Lossy formats (JPG) destroy LSB watermarks by design.', + ? 'Prototype note: this demo uses scattered least-significant-bit encoding signed with your account key for illustration. The production engine adds transform-robust embedding server-side.' + : 'Verification recomputes the signed ownership claim from the recovered payload, so a forged or edited fingerprint is detected. Lossy formats (JPG) destroy LSB watermarks by design.', style: const TextStyle( fontSize: 11.5, height: 1.5, @@ -299,15 +303,16 @@ class _ImageToolScreenState extends State { final embed = _embedOutcome; if (_mode == ToolMode.embed && embed != null) { final recovered = embed.recovered; + final banner = claimBanner(embed.claimStatus); + var subtitle = banner.subtitle; + if (embed.resized) { + subtitle = '$subtitle Image was resized to fit the LSB carrier.'; + } return [ StatusBanner( - ok: embed.verified, - title: embed.verified - ? 'Ownership verified' - : 'No valid watermark recovered', - subtitle: embed.verified - ? 'Extracted signature matches the embedded fingerprint.' - : 'The payload could not be read back intact from this file.', + ok: banner.ok, + title: banner.title, + subtitle: subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -315,6 +320,11 @@ class _ImageToolScreenState extends State { DetailRow('Asset', recovered?.asset ?? '—', mono: true), DetailRow('Issued', _formatDate(recovered?.issued)), DetailRow('Signature', recovered?.signature ?? '—', mono: true), + DetailRow( + 'Key id', + _kidLabel(recovered?.alg ?? embed.payload.alg, + recovered?.kid ?? embed.payload.kid), + mono: true), DetailRow( 'Payload density', '${embed.bitsUsed} / ${embed.capacityBits} bits', @@ -330,20 +340,12 @@ class _ImageToolScreenState extends State { final verifyPayload = _verifyPayload; if (_mode == ToolMode.verify && _verifyOutcome != null) { - final genuine = verifyPayload != null && verifyPayload.signatureValid; + final banner = claimBanner(_verifyOutcome!.claimStatus); return [ StatusBanner( - ok: genuine, - title: genuine - ? 'Signata watermark verified' - : verifyPayload != null - ? 'Watermark found but signature is invalid' - : 'No watermark found', - subtitle: genuine - ? 'The recovered signature matches the ownership claim.' - : verifyPayload != null - ? 'The payload was recovered but fails the signature recheck.' - : 'This file does not carry a readable Signata fingerprint.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), if (verifyPayload != null) ...[ const SizedBox(height: 14), @@ -352,6 +354,10 @@ class _ImageToolScreenState extends State { DetailRow('Asset', verifyPayload.asset, mono: true), DetailRow('Issued', _formatDate(verifyPayload.issued)), DetailRow('Signature', verifyPayload.signature, mono: true), + DetailRow( + 'Key id', + _kidLabel(verifyPayload.alg, verifyPayload.kid), + mono: true), DetailRow('Scan time', '$_elapsedMs ms', mono: true), ]), const SizedBox(height: 14), @@ -395,6 +401,12 @@ class _ImageToolScreenState extends State { return '${parsed.year}-${two(parsed.month)}-${two(parsed.day)} ' '${two(parsed.hour)}:${two(parsed.minute)}'; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } class _ImagePair extends StatelessWidget { diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index 2f964a8..b0fe0d7 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/pdf_fingerprint.dart'; import '../core/report.dart'; @@ -67,12 +69,14 @@ class _PdfToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == PdfMode.embed) { setState(() => _workingStep = 'Deriving structural identifier'); final outcome = await embedPdfFingerprint( source: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -86,13 +90,13 @@ class _PdfToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.identifier, at: DateTime.now(), )); } else { setState(() => _workingStep = 'Extracting & verifying'); - final outcome = await verifyPdfFingerprint(bytes); + final outcome = await verifyPdfFingerprint(bytes, claimKey: claimKey); stopwatch.stop(); if (!mounted) return; setState(() { @@ -105,7 +109,7 @@ class _PdfToolScreenState extends State { action: 'verify', subject: file.name, owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.recovered?.identifier ?? '—', at: DateTime.now(), )); @@ -139,7 +143,7 @@ class _PdfToolScreenState extends State { medium: 'pdf', subject: outcome.recovered?.document ?? _fileName ?? 'document.pdf', owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -268,7 +272,7 @@ class _PdfToolScreenState extends State { ..._buildReport(), const SizedBox(height: 14), const Text( - 'Prototype note: the identifier is carried in a trailing marker for illustration. The production engine distributes it across object metadata and structural entropy so it survives re-saving and editing.', + 'Prototype note: the identifier is carried in a trailing marker comment and signed with your account key for illustration. Production distributes it across object metadata and structural entropy so it survives re-saving and editing.', style: TextStyle( fontSize: 11.5, height: 1.5, @@ -309,9 +313,9 @@ class _PdfToolScreenState extends State { final recovered = _mode == PdfMode.embed ? _embedOutcome?.recovered : _verifyOutcome?.recovered; - final verified = _mode == PdfMode.embed - ? _embedOutcome?.verified - : _verifyOutcome?.verified; + final claimStatus = _mode == PdfMode.embed + ? _embedOutcome?.claimStatus + : _verifyOutcome?.claimStatus; final structureMatch = _mode == PdfMode.embed ? _embedOutcome?.structureMatch : _verifyOutcome?.structureMatch; @@ -332,15 +336,16 @@ class _PdfToolScreenState extends State { ]; } + final banner = claimBanner(claimStatus ?? ClaimStatus.missing); + final payload = _mode == PdfMode.embed + ? _embedOutcome?.payload + : _verifyOutcome?.recovered; + return [ StatusBanner( - ok: verified ?? false, - title: (verified ?? false) - ? 'Document ownership verified' - : 'No valid fingerprint found', - subtitle: (verified ?? false) - ? 'Recovered identifier matches the recomputed structural digest.' - : 'The identifier could not be recovered or did not match the structure.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -353,6 +358,10 @@ class _PdfToolScreenState extends State { 'Structure match', (structureMatch ?? false) ? 'identical' : 'mismatch', mono: true), + DetailRow( + 'Key id', + _kidLabel(payload?.alg ?? recovered?.alg, payload?.kid ?? recovered?.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), if (_mode == PdfMode.embed) ...[ @@ -387,4 +396,10 @@ class _PdfToolScreenState extends State { return '${parsed.year}-${two(parsed.month)}-${two(parsed.day)} ' '${two(parsed.hour)}:${two(parsed.minute)}'; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index d842b89..9b9c74f 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../core/claim_crypto.dart'; +import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; @@ -66,12 +68,14 @@ class _VideoToolScreenState extends State { final stopwatch = Stopwatch()..start(); try { + final claimKey = await AccountClaimKeys.current(); if (_mode == VideoMode.embed) { setState(() => _workingStep = 'Profiling container structure'); final outcome = await embedVideoFingerprint( source: bytes, owner: _ownerController.text, fileName: file.name, + claimKey: claimKey, ); stopwatch.stop(); if (!mounted) return; @@ -85,13 +89,13 @@ class _VideoToolScreenState extends State { action: 'embed', subject: file.name, owner: outcome.payload.owner, - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.payload.identifier, at: DateTime.now(), )); } else { setState(() => _workingStep = 'Extracting & verifying'); - final outcome = await verifyVideoFingerprint(bytes); + final outcome = await verifyVideoFingerprint(bytes, claimKey: claimKey); stopwatch.stop(); if (!mounted) return; setState(() { @@ -104,7 +108,7 @@ class _VideoToolScreenState extends State { action: 'verify', subject: file.name, owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), reference: outcome.recovered?.identifier ?? '—', at: DateTime.now(), )); @@ -138,7 +142,7 @@ class _VideoToolScreenState extends State { medium: 'video', subject: outcome.recovered?.document ?? _fileName ?? 'video.mp4', owner: outcome.recovered?.owner ?? 'Unknown', - verified: outcome.verified, + verified: claimCountsAsVerified(outcome.claimStatus), issued: outcome.payload.issued, generated: DateTime.now().toUtc().toIso8601String(), evidence: { @@ -267,7 +271,7 @@ class _VideoToolScreenState extends State { ..._buildReport(), const SizedBox(height: 14), const Text( - 'Prototype note: the identifier is carried in a trailing marker for illustration. Production will distribute ownership across sample tables and metadata boxes so it survives remuxing.', + 'Prototype note: the identifier is carried in a uuid atom and signed with your account key for illustration. Production will distribute ownership across sample tables and metadata boxes so it survives remuxing.', style: TextStyle( fontSize: 11.5, height: 1.5, @@ -309,9 +313,9 @@ class _VideoToolScreenState extends State { final recovered = _mode == VideoMode.embed ? _embedOutcome?.recovered : _verifyOutcome?.recovered; - final verified = _mode == VideoMode.embed - ? _embedOutcome?.verified - : _verifyOutcome?.verified; + final claimStatus = _mode == VideoMode.embed + ? _embedOutcome?.claimStatus + : _verifyOutcome?.claimStatus; final structureMatch = _mode == VideoMode.embed ? _embedOutcome?.structureMatch : _verifyOutcome?.structureMatch; @@ -333,15 +337,16 @@ class _VideoToolScreenState extends State { ]; } + final banner = claimBanner(claimStatus ?? ClaimStatus.missing); + final payload = _mode == VideoMode.embed + ? _embedOutcome?.payload + : _verifyOutcome?.recovered; + return [ StatusBanner( - ok: verified ?? false, - title: (verified ?? false) - ? 'Video ownership verified' - : 'No valid fingerprint found', - subtitle: (verified ?? false) - ? 'Recovered identifier matches the recomputed structural digest.' - : 'The identifier could not be recovered or did not match the structure.', + ok: banner.ok, + title: banner.title, + subtitle: banner.subtitle, ), const SizedBox(height: 14), DetailList(rows: [ @@ -353,6 +358,10 @@ class _VideoToolScreenState extends State { 'Structure match', (structureMatch ?? false) ? 'identical' : 'mismatch', mono: true), + DetailRow( + 'Key id', + _kidLabel(payload?.alg ?? recovered?.alg, payload?.kid ?? recovered?.kid), + mono: true), DetailRow('Round trip', '$_elapsedMs ms', mono: true), ]), if (_mode == VideoMode.embed) ...[ @@ -372,4 +381,10 @@ class _VideoToolScreenState extends State { PayloadViewer(title: 'Extracted identifier payload', raw: raw), ]; } + + static String _kidLabel(String? alg, String? kid) { + if (alg == null || alg.isEmpty || alg == claimAlgFnv16) return 'legacy'; + if (kid != null && kid.isNotEmpty) return kid; + return 'legacy'; + } } From bbf21d8f3319ce844b79fd84f229e338d02606bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 10:56:27 +0000 Subject: [PATCH 03/31] Complete fingerprinting upgrade with scatter helper and HMAC tests. Add the missing LSB scatter module, fix claim authenticity helpers, and expand core tests for account-bound HMAC and hardened carriers. Co-authored-by: Wejdan Al Amri --- lib/core/audio_watermark.dart | 4 +- lib/core/image_watermark.dart | 4 +- lib/core/lsb_scatter.dart | 49 ++++++++ lib/core/video_fingerprint.dart | 12 +- test/core_test.dart | 216 +++++++++++++++++++++++++++----- 5 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 lib/core/lsb_scatter.dart diff --git a/lib/core/audio_watermark.dart b/lib/core/audio_watermark.dart index 710508d..0a54c4f 100644 --- a/lib/core/audio_watermark.dart +++ b/lib/core/audio_watermark.dart @@ -72,8 +72,8 @@ class AudioPayload { return signature == legacy.signature; } - bool get isAuthentic => - statusWith(null) == ClaimStatus.authenticated; + bool isAuthentic(ClaimKey? key) => + statusWith(key) == ClaimStatus.authenticated; ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( owner: owner, diff --git a/lib/core/image_watermark.dart b/lib/core/image_watermark.dart index 75cf527..dd8b22a 100644 --- a/lib/core/image_watermark.dart +++ b/lib/core/image_watermark.dart @@ -79,8 +79,8 @@ class WatermarkPayload { return signature == legacy.signature; } - bool get isAuthentic => - statusWith(null) == ClaimStatus.authenticated; + bool isAuthentic(ClaimKey? key) => + statusWith(key) == ClaimStatus.authenticated; ClaimStatus statusWith(ClaimKey? key) => ClaimCrypto.evaluateMedia( owner: owner, diff --git a/lib/core/lsb_scatter.dart b/lib/core/lsb_scatter.dart new file mode 100644 index 0000000..5dc956a --- /dev/null +++ b/lib/core/lsb_scatter.dart @@ -0,0 +1,49 @@ +/// Seeded bit-position scatter for LSB carriers (image RGB / audio PCM). +library; + +import 'dart:typed_data'; + +/// xorshift32 PRNG for deterministic bit-slot permutation. +class LsbScatter { + LsbScatter._(this._slots); + + final List _slots; + + /// [slotCount] addressable LSB positions (image: pixels*3, audio: samples). + factory LsbScatter.seeded({ + required int slotCount, + required int seed, + }) { + final slots = List.generate(slotCount, (i) => i); + var state = seed == 0 ? 0xA5A5A5A5 : seed; + int next() { + state ^= state << 13; + state &= 0xFFFFFFFF; + state ^= state >> 17; + state &= 0xFFFFFFFF; + state ^= state << 5; + state &= 0xFFFFFFFF; + return state; + } + + for (var i = slots.length - 1; i > 0; i--) { + final j = next() % (i + 1); + final tmp = slots[i]; + slots[i] = slots[j]; + slots[j] = tmp; + } + return LsbScatter._(slots); + } + + static int seedFromHeader(Uint8List header8) { + var h = 0x811c9dc5; + for (final b in header8) { + h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF; + } + return h == 0 ? 1 : h; + } + + int get length => _slots.length; + + int operator [](int bitIndex) => _slots[bitIndex]; +} diff --git a/lib/core/video_fingerprint.dart b/lib/core/video_fingerprint.dart index 5565774..0df69ea 100644 --- a/lib/core/video_fingerprint.dart +++ b/lib/core/video_fingerprint.dart @@ -314,11 +314,7 @@ String? extractVideoUuidPayload(Uint8List bytes) { return null; } -String? extractVideoPayloadRaw(Uint8List bytes) { - final uuidPayload = extractVideoUuidPayload(bytes); - if (uuidPayload != null) return uuidPayload; - - final text = _bytesToLatin1(bytes); +String? extractVideoIdentifier(String text) { final index = text.lastIndexOf(videoMark); if (index == -1) return null; final start = index + videoMark.length; @@ -331,6 +327,12 @@ String? extractVideoPayloadRaw(Uint8List bytes) { } } +String? extractVideoPayloadRaw(Uint8List bytes) { + final uuidPayload = extractVideoUuidPayload(bytes); + if (uuidPayload != null) return uuidPayload; + return extractVideoIdentifier(_bytesToLatin1(bytes)); +} + class VideoEmbedOutcome { const VideoEmbedOutcome({ required this.markedBytes, diff --git a/test/core_test.dart b/test/core_test.dart index 153de27..478a665 100644 --- a/test/core_test.dart +++ b/test/core_test.dart @@ -1,16 +1,20 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; import 'package:signata/core/audio_watermark.dart'; +import 'package:signata/core/claim_crypto.dart'; import 'package:signata/core/fingerprint.dart'; import 'package:signata/core/image_watermark.dart'; import 'package:signata/core/pdf_fingerprint.dart'; import 'package:signata/core/report.dart'; import 'package:signata/core/video_fingerprint.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image/image.dart' as img; void main() { + final alice = ClaimKey.fromSeed('alice-test-key'); + final bob = ClaimKey.fromSeed('bob-test-key'); + group('fingerprint', () { test('is deterministic and 16 hex chars', () { final a = fingerprint('Studio Nova|photo.png·900x600|2026-01-01'); @@ -24,38 +28,106 @@ void main() { }); }); + group('claim crypto', () { + test('HMAC authenticates with the same key', () { + final signed = ClaimCrypto.signMedia( + alice, + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + key: alice, + ), + ClaimStatus.authenticated, + ); + }); + + test('HMAC fails with a different key', () { + final signed = ClaimCrypto.signMedia( + alice, + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: signed.alg, + kid: signed.kid, + key: bob, + ), + ClaimStatus.foreignKey, + ); + }); + + test('legacy FNV is self-consistent only', () { + final signed = ClaimCrypto.signMediaLegacy( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + ); + expect( + ClaimCrypto.evaluateMedia( + owner: 'Alice', + asset: 'a.png·10x10', + issued: '2026-01-01T00:00:00Z', + signature: signed.signature, + alg: null, + kid: null, + key: alice, + ), + ClaimStatus.selfConsistent, + ); + }); + }); + group('image watermark', () { - Uint8List samplePng() { - final image = img.Image(width: 120, height: 90); + Uint8List samplePng({int width = 120, int height = 90}) { + final image = img.Image(width: width, height: height); for (final p in image) { p.setRgba((p.x * 2) % 256, (p.y * 3) % 256, (p.x + p.y) % 256, 255); } return img.encodePng(image); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedWatermark( fileBytes: samplePng(), owner: 'Studio Nova', fileName: 'sample.png', + claimKey: alice, ); expect(outcome.verified, isTrue); + expect(outcome.resized, isFalse); + expect(outcome.claimStatus, ClaimStatus.authenticated); expect(outcome.recovered?.owner, 'Studio Nova'); expect(outcome.recovered?.asset, 'sample.png\u00b7120x90'); - expect(outcome.recovered?.signatureValid, isTrue); + expect(outcome.recovered?.isAuthentic(alice), isTrue); + expect(outcome.recovered?.isAuthentic(bob), isFalse); }); - test('standalone extraction recovers a genuine payload', () async { + test('standalone extraction recovers an authenticated payload', () async { final outcome = await embedWatermark( fileBytes: samplePng(), owner: 'Creator X', fileName: 'art.png', + claimKey: alice, ); final extracted = await extractWatermark(outcome.markedPng); final payload = WatermarkPayload.tryParse(extracted.raw); expect(payload, isNotNull); - expect(payload!.owner, 'Creator X'); - expect(payload.signatureValid, isTrue); + expect(payload!.statusWith(alice), ClaimStatus.authenticated); }); test('unmarked image yields no payload', () async { @@ -72,6 +144,16 @@ void main() { })); expect(forged!.signatureValid, isFalse); }); + + test('legacy embed without key is self-consistent', () async { + final outcome = await embedWatermark( + fileBytes: samplePng(), + owner: 'Legacy', + fileName: 'old.png', + ); + expect(outcome.claimStatus, ClaimStatus.selfConsistent); + expect(outcome.recovered?.signatureValid, isTrue); + }); }); group('pdf fingerprint', () { @@ -85,14 +167,16 @@ void main() { 'startxref\n300\n%%EOF\n', )); - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedPdfFingerprint( source: samplePdf(), owner: 'Studio Nova', fileName: 'doc.pdf', + claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); + expect(outcome.claimStatus, ClaimStatus.authenticated); expect(outcome.recovered?.owner, 'Studio Nova'); expect(outcome.recovered?.structure.version, '1.4'); expect(outcome.recovered?.structure.pages, 1); @@ -103,16 +187,44 @@ void main() { source: samplePdf(), owner: 'Studio Nova', fileName: 'doc.pdf', + claimKey: alice, + ); + final verified = await verifyPdfFingerprint( + embedded.markedBytes, + claimKey: alice, ); - final verified = await verifyPdfFingerprint(embedded.markedBytes); expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); expect(verified.recovered?.identifier, embedded.payload.identifier); }); + test('redundant comment survives stripped trailing marker', () async { + final embedded = await embedPdfFingerprint( + source: samplePdf(), + owner: 'Studio Nova', + fileName: 'doc.pdf', + claimKey: alice, + ); + final text = latin1.decode(embedded.markedBytes); + final markIndex = text.lastIndexOf(pdfMark); + expect(markIndex, greaterThan(0)); + final withoutTrailing = + Uint8List.fromList(embedded.markedBytes.sublist(0, markIndex - 1)); + expect(latin1.decode(withoutTrailing).contains(pdfCommentMark), isTrue); + final verified = await verifyPdfFingerprint( + withoutTrailing, + claimKey: alice, + ); + expect(verified.recovered, isNotNull); + expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); + }); + test('unmarked pdf yields no fingerprint', () async { final outcome = await verifyPdfFingerprint(samplePdf()); expect(outcome.recovered, isNull); expect(outcome.verified, isFalse); + expect(outcome.claimStatus, ClaimStatus.missing); }); test('tampered identifier fails verification', () async { @@ -120,11 +232,8 @@ void main() { source: samplePdf(), owner: 'Studio Nova', fileName: 'doc.pdf', + claimKey: alice, ); - // Forge the payload: change the owner but keep the identifier. - final text = latin1.decode(embedded.markedBytes); - final markIndex = text.lastIndexOf(pdfMark); - final original = embedded.markedBytes.sublist(0, markIndex - 1); final payload = embedded.recovered!; final forged = jsonEncode({ 'owner': 'Thief', @@ -132,10 +241,14 @@ void main() { 'issued': payload.issued, 'structure': payload.structure.toJson(), 'identifier': payload.identifier, + 'contentBytes': payload.contentBytes, + 'signature': payload.signature, + 'alg': payload.alg, + 'kid': payload.kid, + 'v': payload.version, }); - final tampered = - embedPdfIdentifier(Uint8List.fromList(original), forged); - final outcome = await verifyPdfFingerprint(tampered); + final tampered = embedPdfIdentifier(samplePdf(), forged); + final outcome = await verifyPdfFingerprint(tampered, claimKey: alice); expect(outcome.verified, isFalse); }); @@ -150,7 +263,6 @@ void main() { group('audio watermark', () { Uint8List sampleWav({int samples = 8000}) { - // Minimal 16-bit mono PCM WAV. final dataSize = samples * 2; final bytes = BytesBuilder(); void writeString(String s) => bytes.add(s.codeUnits); @@ -169,8 +281,8 @@ void main() { writeString('WAVE'); writeString('fmt '); writeUint32(16); - writeUint16(1); // PCM - writeUint16(1); // mono + writeUint16(1); + writeUint16(1); writeUint32(44100); writeUint32(44100 * 2); writeUint16(2); @@ -187,38 +299,40 @@ void main() { return bytes.toBytes(); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC', () async { final outcome = await embedAudioWatermark( fileBytes: sampleWav(), owner: 'Studio Nova', fileName: 'clip.wav', + claimKey: alice, ); expect(outcome.verified, isTrue); + expect(outcome.claimStatus, ClaimStatus.authenticated); expect(outcome.recovered?.owner, 'Studio Nova'); - expect(outcome.recovered?.signatureValid, isTrue); + expect(outcome.recovered?.isAuthentic(alice), isTrue); }); - test('standalone extraction recovers a genuine payload', () async { + test('standalone extraction recovers an authenticated payload', () async { final embedded = await embedAudioWatermark( fileBytes: sampleWav(), owner: 'Creator X', fileName: 'voice.wav', + claimKey: alice, ); final extracted = await extractAudioWatermark(embedded.markedWav); final payload = AudioPayload.tryParse(extracted.raw); expect(payload, isNotNull); - expect(payload!.signatureValid, isTrue); + expect(payload!.statusWith(alice), ClaimStatus.authenticated); + expect(payload.statusWith(bob), ClaimStatus.foreignKey); }); }); group('video fingerprint', () { Uint8List sampleMp4() { - // Minimal ftyp + free-sized moov/mdat-like atoms for the profiler. final bytes = BytesBuilder(); void atom(String type, List body) { final size = 8 + body.length; - final header = ByteData(8) - ..setUint32(0, size, Endian.big); + final header = ByteData(8)..setUint32(0, size, Endian.big); final out = Uint8List(size); out.setAll(0, header.buffer.asUint8List()); out.setAll(4, type.codeUnits); @@ -232,15 +346,18 @@ void main() { return bytes.toBytes(); } - test('embed round trip verifies', () async { + test('embed round trip verifies with HMAC and uuid atom', () async { final outcome = await embedVideoFingerprint( source: sampleMp4(), owner: 'Studio Nova', fileName: 'reel.mp4', + claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); + expect(outcome.claimStatus, ClaimStatus.authenticated); expect(outcome.recovered?.owner, 'Studio Nova'); + expect(extractVideoUuidPayload(outcome.markedBytes), isNotNull); }); test('standalone verification of a delivered file passes', () async { @@ -248,11 +365,40 @@ void main() { source: sampleMp4(), owner: 'Studio Nova', fileName: 'reel.mp4', + claimKey: alice, + ); + final verified = await verifyVideoFingerprint( + embedded.markedBytes, + claimKey: alice, ); - final verified = await verifyVideoFingerprint(embedded.markedBytes); expect(verified.verified, isTrue); + expect(verified.claimStatus, ClaimStatus.authenticated); expect(verified.recovered?.identifier, embedded.payload.identifier); }); + + test('legacy trailer still extracts when uuid box is removed', () async { + final embedded = await embedVideoFingerprint( + source: sampleMp4(), + owner: 'Studio Nova', + fileName: 'reel.mp4', + claimKey: alice, + ); + final sourceLen = embedded.originalLength; + final trailerOnly = Uint8List.fromList([ + ...embedded.markedBytes.sublist(0, sourceLen), + ...latin1.encode( + '\n$videoMark${base64Encode(utf8.encode(embedded.raw!))}\n', + ), + ]); + expect(extractVideoUuidPayload(trailerOnly), isNull); + expect(extractVideoIdentifier(latin1.decode(trailerOnly)), isNotNull); + final verified = await verifyVideoFingerprint( + trailerOnly, + claimKey: alice, + ); + expect(verified.recovered, isNotNull); + expect(verified.verified, isTrue); + }); }); group('sealed report', () { @@ -269,7 +415,10 @@ void main() { test('seal verifies and has a fingerprint', () { final sealed = sealReport(body()); expect(verifySealedReport(sealed), isTrue); - expect(sealed.fingerprint, matches(RegExp(r'^[0-9A-F]{4}(-[0-9A-F]{4}){2}$'))); + expect( + sealed.fingerprint, + matches(RegExp(r'^[0-9A-F]{4}(-[0-9A-F]{4}){2}$')), + ); }); test('altered body invalidates the seal', () { @@ -291,7 +440,10 @@ void main() { test('canonicalize sorts keys recursively', () { expect( - canonicalize({'b': 1, 'a': {'d': true, 'c': null}}), + canonicalize({ + 'b': 1, + 'a': {'d': true, 'c': null} + }), '{"a":{"c":null,"d":true},"b":1}', ); }); From d220f102d4cdca69fb38be53916ddbe7c41e632f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 12:05:27 +0000 Subject: [PATCH 04/31] Add internet media tracing with URL scan and claim publishing. Creators can publish fingerprints after protect, scan public media URLs for Signata marks, keep a watchlist of sightings, and optionally sync claims to a remote registry. Co-authored-by: Wejdan Al Amri --- README.md | 11 +- android/app/src/main/AndroidManifest.xml | 1 + lib/core/claim_publisher.dart | 34 ++ lib/core/claim_registry.dart | 148 +++++++++ lib/core/trace_models.dart | 247 ++++++++++++++ lib/core/trace_store.dart | 102 ++++++ lib/core/url_tracer.dart | 302 +++++++++++++++++ lib/screens/audio_tool_screen.dart | 12 + lib/screens/image_tool_screen.dart | 12 + lib/screens/pdf_tool_screen.dart | 14 + lib/screens/shell.dart | 4 + lib/screens/tools_hub_screen.dart | 15 +- lib/screens/trace_screen.dart | 392 +++++++++++++++++++++++ lib/screens/video_tool_screen.dart | 14 + lib/widgets/publish_claim_button.dart | 94 ++++++ pubspec.yaml | 1 + test/trace_test.dart | 90 ++++++ tool/registry_server.example.md | 19 ++ 18 files changed, 1508 insertions(+), 4 deletions(-) create mode 100644 lib/core/claim_publisher.dart create mode 100644 lib/core/claim_registry.dart create mode 100644 lib/core/trace_models.dart create mode 100644 lib/core/trace_store.dart create mode 100644 lib/core/url_tracer.dart create mode 100644 lib/screens/trace_screen.dart create mode 100644 lib/widgets/publish_claim_button.dart create mode 100644 test/trace_test.dart create mode 100644 tool/registry_server.example.md diff --git a/README.md b/README.md index 6878c27..ab86f06 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Native Flutter app for digital watermarking — images, audio, video, and PDFs ## What's included - **Home** — full landing story (features, how it works, architecture, roadmap) -- **Tools hub** — Image, Audio, Video, and PDF protect/verify flows +- **Tools hub** — Image, Audio, Video, PDF protect/verify, plus **Trace online** +- **Internet tracing** — scan public media URLs for Signata fingerprints, watchlist re-scans, publish claims for catalog matching - **Auth** — email accounts + Google Sign-In gate - **History** — local on-device record of every embed/verify run - Sealed JSON verification reports (shareable) @@ -19,6 +20,14 @@ flutter pub get flutter run ``` +Optional remote claim registry (so other devices can look up fingerprints): + +```sh +flutter run --dart-define=SIGNATA_REGISTRY_URL=https://your-registry.example +``` + +See [`tool/registry_server.example.md`](tool/registry_server.example.md) for the expected HTTP API. Without it, tracing still works by reading fingerprints from downloaded files and matching your on-device published claims. + ## Test ```sh diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b2f1c46..0cfb11a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + publishProtected({ + required TraceMedium medium, + required String owner, + required String subject, + required String reference, + required String issued, + String? alg, + String? kid, + String? note, + }) async { + if (reference.trim().isEmpty || reference == '—') return null; + if (!AuthService.instance.isSignedIn) return null; + return ClaimRegistry.instance.publish( + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + alg: alg, + kid: kid, + note: note, + ); + } +} diff --git a/lib/core/claim_registry.dart b/lib/core/claim_registry.dart new file mode 100644 index 0000000..51332c7 --- /dev/null +++ b/lib/core/claim_registry.dart @@ -0,0 +1,148 @@ +/// Local + optional remote registry of published Signata claims. +/// +/// Local storage always works. When `SIGNATA_REGISTRY_URL` is set (dart-define +/// or [ClaimRegistry.configure]), claims are also POSTed so other devices can +/// look them up by fingerprint reference. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; +import 'trace_models.dart'; + +class ClaimRegistry { + ClaimRegistry._(); + static final ClaimRegistry instance = ClaimRegistry._(); + + static const _prefsKey = 'signata_published_claims_v1'; + + /// Optional remote base URL, e.g. `https://registry.example.com`. + static String remoteBaseUrl = const String.fromEnvironment( + 'SIGNATA_REGISTRY_URL', + defaultValue: '', + ); + + static void configure({String? remoteBaseUrl}) { + if (remoteBaseUrl != null) ClaimRegistry.remoteBaseUrl = remoteBaseUrl; + } + + static bool get hasRemote => remoteBaseUrl.trim().isNotEmpty; + + Future> listLocal() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null || raw.isEmpty) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list + .map(PublishedClaim.fromJson) + .whereType() + .toList() + ..sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + } catch (_) { + return const []; + } + } + + Future _saveLocal(List claims) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _prefsKey, + jsonEncode(claims.map((c) => c.toJson()).toList()), + ); + } + + Future findByReference(String reference) async { + final needle = reference.trim().toUpperCase(); + if (needle.isEmpty) return null; + for (final claim in await listLocal()) { + if (claim.reference.toUpperCase() == needle) return claim; + } + if (hasRemote) { + try { + final uri = Uri.parse('${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}' + '/claims/${Uri.encodeComponent(reference)}'); + final response = await http.get(uri).timeout(const Duration(seconds: 12)); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final map = decoded is Map && decoded['claim'] is Map + ? decoded['claim'] + : decoded; + return PublishedClaim.fromJson(map); + } + } catch (error) { + debugPrint('Remote claim lookup failed: $error'); + } + } + return null; + } + + Future publish({ + required TraceMedium medium, + required String owner, + required String subject, + required String reference, + required String issued, + String? alg, + String? kid, + String? note, + }) async { + final user = AuthService.instance.user; + final id = + 'claim_${DateTime.now().toUtc().millisecondsSinceEpoch}_$reference'; + var claim = PublishedClaim( + id: id, + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + publisherId: user?.id ?? 'anonymous', + publishedAt: DateTime.now().toUtc(), + alg: alg, + kid: kid, + note: note, + ); + + if (hasRemote) { + try { + final uri = Uri.parse( + '${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}/claims'); + final response = await http + .post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(claim.toJson()), + ) + .timeout(const Duration(seconds: 15)); + if (response.statusCode >= 200 && response.statusCode < 300) { + claim = claim.copyWith(remoteSynced: true); + } else { + debugPrint( + 'Remote publish failed: ${response.statusCode} ${response.body}', + ); + } + } catch (error) { + debugPrint('Remote publish error: $error'); + } + } + + final existing = await listLocal(); + final withoutDup = existing + .where((c) => c.reference.toUpperCase() != reference.toUpperCase()) + .toList(); + withoutDup.insert(0, claim); + await _saveLocal(withoutDup.take(200).toList()); + return claim; + } + + Future remove(String id) async { + final next = (await listLocal()).where((c) => c.id != id).toList(); + await _saveLocal(next); + } +} diff --git a/lib/core/trace_models.dart b/lib/core/trace_models.dart new file mode 100644 index 0000000..5df59f2 --- /dev/null +++ b/lib/core/trace_models.dart @@ -0,0 +1,247 @@ +/// Models for publishing claims and tracing media across the public web. +library; + +import 'claim_crypto.dart'; + +enum TraceMedium { image, audio, video, pdf, unknown } + +extension TraceMediumX on TraceMedium { + String get wire => name; + + static TraceMedium parse(String? value) { + switch (value) { + case 'image': + return TraceMedium.image; + case 'audio': + return TraceMedium.audio; + case 'video': + return TraceMedium.video; + case 'pdf': + return TraceMedium.pdf; + default: + return TraceMedium.unknown; + } + } +} + +/// A claim registered so copies found online can be linked to an owner. +class PublishedClaim { + const PublishedClaim({ + required this.id, + required this.medium, + required this.owner, + required this.subject, + required this.reference, + required this.issued, + required this.publisherId, + required this.publishedAt, + this.alg, + this.kid, + this.note, + this.remoteSynced = false, + }); + + final String id; + final TraceMedium medium; + final String owner; + final String subject; + + /// Signature or structural identifier embedded in the media. + final String reference; + final String issued; + final String publisherId; + final DateTime publishedAt; + final String? alg; + final String? kid; + final String? note; + final bool remoteSynced; + + Map toJson() => { + 'id': id, + 'medium': medium.wire, + 'owner': owner, + 'subject': subject, + 'reference': reference, + 'issued': issued, + 'publisherId': publisherId, + 'publishedAt': publishedAt.toIso8601String(), + if (alg != null) 'alg': alg, + if (kid != null) 'kid': kid, + if (note != null) 'note': note, + 'remoteSynced': remoteSynced, + }; + + static PublishedClaim? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return PublishedClaim( + id: map['id'] as String? ?? '', + medium: TraceMediumX.parse(map['medium'] as String?), + owner: map['owner'] as String? ?? '', + subject: map['subject'] as String? ?? '', + reference: map['reference'] as String? ?? '', + issued: map['issued'] as String? ?? '', + publisherId: map['publisherId'] as String? ?? '', + publishedAt: DateTime.tryParse(map['publishedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + alg: map['alg'] as String?, + kid: map['kid'] as String?, + note: map['note'] as String?, + remoteSynced: map['remoteSynced'] as bool? ?? false, + ); + } catch (_) { + return null; + } + } + + PublishedClaim copyWith({bool? remoteSynced, String? note}) => PublishedClaim( + id: id, + medium: medium, + owner: owner, + subject: subject, + reference: reference, + issued: issued, + publisherId: publisherId, + publishedAt: publishedAt, + alg: alg, + kid: kid, + note: note ?? this.note, + remoteSynced: remoteSynced ?? this.remoteSynced, + ); +} + +/// A URL the user wants re-scanned for Signata fingerprints. +class WatchTarget { + const WatchTarget({ + required this.id, + required this.url, + required this.addedAt, + this.label, + this.lastScannedAt, + this.lastReference, + }); + + final String id; + final String url; + final DateTime addedAt; + final String? label; + final DateTime? lastScannedAt; + final String? lastReference; + + Map toJson() => { + 'id': id, + 'url': url, + 'addedAt': addedAt.toIso8601String(), + if (label != null) 'label': label, + if (lastScannedAt != null) + 'lastScannedAt': lastScannedAt!.toIso8601String(), + if (lastReference != null) 'lastReference': lastReference, + }; + + static WatchTarget? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return WatchTarget( + id: map['id'] as String? ?? '', + url: map['url'] as String? ?? '', + addedAt: DateTime.tryParse(map['addedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + label: map['label'] as String?, + lastScannedAt: DateTime.tryParse(map['lastScannedAt'] as String? ?? ''), + lastReference: map['lastReference'] as String?, + ); + } catch (_) { + return null; + } + } + + WatchTarget copyWith({ + DateTime? lastScannedAt, + String? lastReference, + String? label, + }) => + WatchTarget( + id: id, + url: url, + addedAt: addedAt, + label: label ?? this.label, + lastScannedAt: lastScannedAt ?? this.lastScannedAt, + lastReference: lastReference ?? this.lastReference, + ); +} + +/// One online observation of a fingerprinted file. +class TraceSighting { + const TraceSighting({ + required this.id, + required this.url, + required this.medium, + required this.at, + required this.found, + required this.claimStatus, + this.owner, + this.subject, + this.reference, + this.matchedPublishedClaimId, + this.contentType, + this.error, + }); + + final String id; + final String url; + final TraceMedium medium; + final DateTime at; + final bool found; + final ClaimStatus claimStatus; + final String? owner; + final String? subject; + final String? reference; + final String? matchedPublishedClaimId; + final String? contentType; + final String? error; + + Map toJson() => { + 'id': id, + 'url': url, + 'medium': medium.wire, + 'at': at.toIso8601String(), + 'found': found, + 'claimStatus': claimStatus.name, + if (owner != null) 'owner': owner, + if (subject != null) 'subject': subject, + if (reference != null) 'reference': reference, + if (matchedPublishedClaimId != null) + 'matchedPublishedClaimId': matchedPublishedClaimId, + if (contentType != null) 'contentType': contentType, + if (error != null) 'error': error, + }; + + static TraceSighting? fromJson(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + try { + return TraceSighting( + id: map['id'] as String? ?? '', + url: map['url'] as String? ?? '', + medium: TraceMediumX.parse(map['medium'] as String?), + at: DateTime.tryParse(map['at'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + found: map['found'] as bool? ?? false, + claimStatus: ClaimStatus.values.firstWhere( + (s) => s.name == map['claimStatus'], + orElse: () => ClaimStatus.missing, + ), + owner: map['owner'] as String?, + subject: map['subject'] as String?, + reference: map['reference'] as String?, + matchedPublishedClaimId: map['matchedPublishedClaimId'] as String?, + contentType: map['contentType'] as String?, + error: map['error'] as String?, + ); + } catch (_) { + return null; + } + } +} diff --git a/lib/core/trace_store.dart b/lib/core/trace_store.dart new file mode 100644 index 0000000..b8b8b1d --- /dev/null +++ b/lib/core/trace_store.dart @@ -0,0 +1,102 @@ +/// Persistent watchlist and sighting log for internet media tracing. +library; + +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'trace_models.dart'; + +class TraceStore { + TraceStore._(); + static final TraceStore instance = TraceStore._(); + + static const _watchKey = 'signata_watch_targets_v1'; + static const _sightingsKey = 'signata_trace_sightings_v1'; + static const _maxSightings = 150; + + Future> listWatchTargets() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_watchKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.map(WatchTarget.fromJson).whereType().toList() + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + } catch (_) { + return const []; + } + } + + Future _saveWatch(List items) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _watchKey, + jsonEncode(items.map((e) => e.toJson()).toList()), + ); + } + + Future addWatchTarget(String url, {String? label}) async { + final normalized = url.trim(); + final existing = await listWatchTargets(); + final prior = existing.where((w) => w.url == normalized).toList(); + if (prior.isNotEmpty) return prior.first; + final target = WatchTarget( + id: 'watch_${DateTime.now().millisecondsSinceEpoch}', + url: normalized, + addedAt: DateTime.now().toUtc(), + label: label, + ); + existing.insert(0, target); + await _saveWatch(existing.take(100).toList()); + return target; + } + + Future removeWatchTarget(String id) async { + final next = (await listWatchTargets()).where((w) => w.id != id).toList(); + await _saveWatch(next); + } + + Future touchWatchTarget( + String id, { + required String? reference, + }) async { + final items = await listWatchTargets(); + final next = items + .map( + (w) => w.id == id + ? w.copyWith( + lastScannedAt: DateTime.now().toUtc(), + lastReference: reference, + ) + : w, + ) + .toList(); + await _saveWatch(next); + } + + Future> listSightings() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_sightingsKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw); + if (list is! List) return const []; + return list.map(TraceSighting.fromJson).whereType().toList() + ..sort((a, b) => b.at.compareTo(a.at)); + } catch (_) { + return const []; + } + } + + Future addSighting(TraceSighting sighting) async { + final items = await listSightings(); + items.insert(0, sighting); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _sightingsKey, + jsonEncode(items.take(_maxSightings).map((e) => e.toJson()).toList()), + ); + } +} diff --git a/lib/core/url_tracer.dart b/lib/core/url_tracer.dart new file mode 100644 index 0000000..a26f2d5 --- /dev/null +++ b/lib/core/url_tracer.dart @@ -0,0 +1,302 @@ +/// Fetches a public media URL and scans it for a Signata fingerprint. +library; + +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'audio_watermark.dart'; +import 'claim_crypto.dart'; +import 'claim_registry.dart'; +import 'image_watermark.dart'; +import 'pdf_fingerprint.dart'; +import 'trace_models.dart'; +import 'trace_store.dart'; +import 'video_fingerprint.dart'; + +class TraceScanResult { + const TraceScanResult({ + required this.sighting, + this.matchedClaim, + this.bytes, + }); + + final TraceSighting sighting; + final PublishedClaim? matchedClaim; + final Uint8List? bytes; +} + +class UrlTracer { + UrlTracer._(); + static final UrlTracer instance = UrlTracer._(); + + static const _maxBytes = 40 * 1024 * 1024; + + Future scanUrl( + String rawUrl, { + ClaimKey? claimKey, + bool persist = true, + bool addToWatchlist = false, + }) async { + final url = rawUrl.trim(); + if (!_looksLikeHttpUrl(url)) { + throw ArgumentError('Enter a valid http(s) URL to a media file.'); + } + + Uint8List bytes; + String? contentType; + try { + final response = await http.get(Uri.parse(url)).timeout( + const Duration(seconds: 45), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Download failed (${response.statusCode}).'); + } + if (response.bodyBytes.length > _maxBytes) { + throw Exception('File is larger than 40 MB; try a direct media link.'); + } + bytes = response.bodyBytes; + contentType = response.headers['content-type']; + } catch (error) { + final sighting = TraceSighting( + id: _id(), + url: url, + medium: TraceMedium.unknown, + at: DateTime.now().toUtc(), + found: false, + claimStatus: ClaimStatus.missing, + error: error.toString().replaceFirst('Exception: ', ''), + ); + if (persist) await TraceStore.instance.addSighting(sighting); + return TraceScanResult(sighting: sighting); + } + + final medium = _detectMedium(url: url, contentType: contentType, bytes: bytes); + final extracted = await _extract(medium, bytes, claimKey); + PublishedClaim? matched; + if (extracted.reference != null && extracted.reference!.isNotEmpty) { + matched = await ClaimRegistry.instance.findByReference(extracted.reference!); + } + + final sighting = TraceSighting( + id: _id(), + url: url, + medium: medium, + at: DateTime.now().toUtc(), + found: extracted.found, + claimStatus: extracted.status, + owner: extracted.owner ?? matched?.owner, + subject: extracted.subject ?? matched?.subject, + reference: extracted.reference ?? matched?.reference, + matchedPublishedClaimId: matched?.id, + contentType: contentType, + ); + + if (persist) await TraceStore.instance.addSighting(sighting); + if (addToWatchlist) { + final watch = await TraceStore.instance.addWatchTarget(url); + await TraceStore.instance.touchWatchTarget( + watch.id, + reference: sighting.reference, + ); + } + + return TraceScanResult( + sighting: sighting, + matchedClaim: matched, + bytes: bytes, + ); + } + + Future> scanWatchlist({ClaimKey? claimKey}) async { + final targets = await TraceStore.instance.listWatchTargets(); + final out = []; + for (final target in targets) { + final result = await scanUrl( + target.url, + claimKey: claimKey, + persist: true, + ); + await TraceStore.instance.touchWatchTarget( + target.id, + reference: result.sighting.reference, + ); + out.add(result); + } + return out; + } + + static bool _looksLikeHttpUrl(String url) { + final uri = Uri.tryParse(url); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + } + + static String _id() => 'sight_${DateTime.now().toUtc().microsecondsSinceEpoch}'; + + static TraceMedium _detectMedium({ + required String url, + required String? contentType, + required Uint8List bytes, + }) { + final ct = (contentType ?? '').toLowerCase(); + final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); + + if (ct.contains('pdf') || path.endsWith('.pdf') || _isPdf(bytes)) { + return TraceMedium.pdf; + } + if (ct.startsWith('image/') || + path.endsWith('.png') || + path.endsWith('.jpg') || + path.endsWith('.jpeg') || + path.endsWith('.webp') || + _isPng(bytes) || + _isJpeg(bytes)) { + return TraceMedium.image; + } + if (ct.contains('wav') || + ct.contains('audio/wave') || + path.endsWith('.wav') || + _isWav(bytes)) { + return TraceMedium.audio; + } + if (ct.startsWith('video/') || + path.endsWith('.mp4') || + path.endsWith('.mov') || + path.endsWith('.m4v') || + _isMp4(bytes)) { + return TraceMedium.video; + } + + // Fallbacks by sniffing when servers omit content-type. + if (_isPdf(bytes)) return TraceMedium.pdf; + if (_isPng(bytes) || _isJpeg(bytes)) return TraceMedium.image; + if (_isWav(bytes)) return TraceMedium.audio; + if (_isMp4(bytes)) return TraceMedium.video; + return TraceMedium.unknown; + } + + static bool _isPdf(Uint8List b) => + b.length >= 5 && + b[0] == 0x25 && + b[1] == 0x50 && + b[2] == 0x44 && + b[3] == 0x46; + + static bool _isPng(Uint8List b) => + b.length >= 8 && + b[0] == 0x89 && + b[1] == 0x50 && + b[2] == 0x4e && + b[3] == 0x47; + + static bool _isJpeg(Uint8List b) => + b.length >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff; + + static bool _isWav(Uint8List b) => + b.length >= 12 && + b[0] == 0x52 && + b[1] == 0x49 && + b[2] == 0x46 && + b[3] == 0x46 && + b[8] == 0x57 && + b[9] == 0x41 && + b[10] == 0x56 && + b[11] == 0x45; + + static bool _isMp4(Uint8List b) => + b.length >= 12 && + b[4] == 0x66 && + b[5] == 0x74 && + b[6] == 0x79 && + b[7] == 0x70; + + Future< + ({ + bool found, + ClaimStatus status, + String? owner, + String? subject, + String? reference, + })> _extract( + TraceMedium medium, + Uint8List bytes, + ClaimKey? claimKey, + ) async { + try { + switch (medium) { + case TraceMedium.image: + final outcome = await extractWatermark(bytes, claimKey: claimKey); + final payload = WatermarkPayload.tryParse(outcome.raw); + final status = outcome.claimStatus; + return ( + found: payload != null, + status: status, + owner: payload?.owner, + subject: payload?.asset, + reference: payload?.signature, + ); + case TraceMedium.audio: + final outcome = + await extractAudioWatermark(bytes, claimKey: claimKey); + final payload = AudioPayload.tryParse(outcome.raw); + return ( + found: payload != null, + status: outcome.claimStatus, + owner: payload?.owner, + subject: payload?.asset, + reference: payload?.signature, + ); + case TraceMedium.pdf: + final outcome = + await verifyPdfFingerprint(bytes, claimKey: claimKey); + return ( + found: outcome.recovered != null, + status: outcome.claimStatus, + owner: outcome.recovered?.owner, + subject: outcome.recovered?.document, + reference: outcome.recovered?.identifier ?? + outcome.recovered?.signature, + ); + case TraceMedium.video: + final outcome = + await verifyVideoFingerprint(bytes, claimKey: claimKey); + return ( + found: outcome.recovered != null, + status: outcome.claimStatus, + owner: outcome.recovered?.owner, + subject: outcome.recovered?.document, + reference: outcome.recovered?.identifier ?? + outcome.recovered?.signature, + ); + case TraceMedium.unknown: + // Try image then pdf then audio then video. + for (final probe in [ + TraceMedium.image, + TraceMedium.pdf, + TraceMedium.audio, + TraceMedium.video, + ]) { + final result = await _extract(probe, bytes, claimKey); + if (result.found) return result; + } + return ( + found: false, + status: ClaimStatus.missing, + owner: null, + subject: null, + reference: null, + ); + } + } catch (_) { + return ( + found: false, + status: ClaimStatus.missing, + owner: null, + subject: null, + reference: null, + ); + } + } +} diff --git a/lib/screens/audio_tool_screen.dart b/lib/screens/audio_tool_screen.dart index fedd955..c9026b4 100644 --- a/lib/screens/audio_tool_screen.dart +++ b/lib/screens/audio_tool_screen.dart @@ -9,8 +9,10 @@ import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; enum AudioMode { embed, verify } @@ -250,6 +252,16 @@ class _AudioToolScreenState extends State { icon: const Icon(Icons.ios_share, size: 18), label: const Text('Share watermarked WAV'), ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.audio, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.asset, + reference: _embedOutcome!.payload.signature, + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, + ), ], ], ), diff --git a/lib/screens/image_tool_screen.dart b/lib/screens/image_tool_screen.dart index a6634dd..0f6ba45 100644 --- a/lib/screens/image_tool_screen.dart +++ b/lib/screens/image_tool_screen.dart @@ -9,8 +9,10 @@ import '../core/history.dart'; import '../core/image_watermark.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; enum ToolMode { embed, verify } @@ -246,6 +248,16 @@ class _ImageToolScreenState extends State { icon: const Icon(Icons.ios_share, size: 18), label: const Text('Share watermarked PNG'), ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.image, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.asset, + reference: _embedOutcome!.payload.signature, + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, + ), ], ], ), diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index b0fe0d7..fda6c66 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -9,8 +9,10 @@ import '../core/history.dart'; import '../core/pdf_fingerprint.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; enum PdfMode { embed, verify } @@ -258,6 +260,18 @@ class _PdfToolScreenState extends State { icon: const Icon(Icons.ios_share, size: 18), label: const Text('Share fingerprinted PDF'), ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.pdf, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.document, + reference: _embedOutcome!.payload.identifier ?? + _embedOutcome!.payload.signature ?? + '', + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, + ), ], ], ), diff --git a/lib/screens/shell.dart b/lib/screens/shell.dart index 1016b83..f438417 100644 --- a/lib/screens/shell.dart +++ b/lib/screens/shell.dart @@ -9,6 +9,7 @@ import 'home_screen.dart'; import 'image_tool_screen.dart'; import 'pdf_tool_screen.dart'; import 'tools_hub_screen.dart'; +import 'trace_screen.dart'; import 'video_tool_screen.dart'; class AppShell extends StatefulWidget { @@ -51,6 +52,8 @@ class _AppShellState extends State { return VideoToolScreen(defaultOwner: _defaultOwner); case EmTool.pdf: return PdfToolScreen(defaultOwner: _defaultOwner); + case EmTool.trace: + return const TraceScreen(); } } @@ -59,6 +62,7 @@ class _AppShellState extends State { EmTool.audio => 'Audio', EmTool.video => 'Video', EmTool.pdf => 'PDF', + EmTool.trace => 'Trace', }; @override diff --git a/lib/screens/tools_hub_screen.dart b/lib/screens/tools_hub_screen.dart index bd648ad..c24804c 100644 --- a/lib/screens/tools_hub_screen.dart +++ b/lib/screens/tools_hub_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; -enum EmTool { image, audio, video, pdf } +enum EmTool { image, audio, video, pdf, trace } class ToolsHubScreen extends StatelessWidget { const ToolsHubScreen({super.key, required this.onOpenTool}); @@ -20,7 +20,7 @@ class ToolsHubScreen extends StatelessWidget { tag: 'Workspace', title: 'Protect & verify', desc: - 'Choose a medium. Everything is processed on your device — nothing leaves the phone.', + 'Protect files on-device, then trace public URLs across the internet for Signata fingerprints.', ), const SizedBox(height: 20), _ToolTile( @@ -55,9 +55,18 @@ class ToolsHubScreen extends StatelessWidget { tone: EmColors.accent, onTap: () => onOpenTool(EmTool.pdf), ), + _ToolTile( + tag: '05 / Internet', + title: 'Trace online', + desc: + 'Scan public media URLs, watch links for reappearance, and match fingerprints to your published claims.', + icon: Icons.travel_explore, + tone: EmColors.primary, + onTap: () => onOpenTool(EmTool.trace), + ), const SizedBox(height: 12), Text( - 'Tip: after protecting a file, switch to Verify mode to check any copy.', + 'Tip: protect a file, publish the claim, then Trace any URL where a copy might appear.', style: theme.textTheme.bodySmall ?.copyWith(color: EmColors.mutedForeground, height: 1.45), ), diff --git a/lib/screens/trace_screen.dart b/lib/screens/trace_screen.dart new file mode 100644 index 0000000..50b8de8 --- /dev/null +++ b/lib/screens/trace_screen.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/claim_crypto.dart'; +import '../core/claim_registry.dart'; +import '../core/claim_status_ui.dart'; +import '../core/trace_models.dart'; +import '../core/trace_store.dart'; +import '../core/url_tracer.dart'; +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +class TraceScreen extends StatefulWidget { + const TraceScreen({super.key}); + + @override + State createState() => _TraceScreenState(); +} + +class _TraceScreenState extends State { + final _urlController = TextEditingController(); + bool _busy = false; + bool _watchAfterScan = true; + String? _error; + TraceScanResult? _lastResult; + List _claims = const []; + List _watch = const []; + List _sightings = const []; + + @override + void initState() { + super.initState(); + _reload(); + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + Future _reload() async { + final claims = await ClaimRegistry.instance.listLocal(); + final watch = await TraceStore.instance.listWatchTargets(); + final sightings = await TraceStore.instance.listSightings(); + if (!mounted) return; + setState(() { + _claims = claims; + _watch = watch; + _sightings = sightings; + }); + } + + Future _scan({String? url}) async { + final target = (url ?? _urlController.text).trim(); + setState(() { + _busy = true; + _error = null; + _lastResult = null; + }); + try { + final key = await AccountClaimKeys.current(); + final result = await UrlTracer.instance.scanUrl( + target, + claimKey: key, + addToWatchlist: _watchAfterScan, + ); + if (!mounted) return; + setState(() => _lastResult = result); + await _reload(); + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _rescanAll() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final key = await AccountClaimKeys.current(); + await UrlTracer.instance.scanWatchlist(claimKey: key); + await _reload(); + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final remote = ClaimRegistry.hasRemote; + return ListView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 40), + children: [ + SectionHeading( + tag: 'Internet trace', + title: 'Find Signata media on the web', + desc: remote + ? 'Paste a public media URL to download and scan it for a Signata fingerprint. Claims can sync to your registry at ${ClaimRegistry.remoteBaseUrl}.' + : 'Paste a public media URL to download and scan it for a Signata fingerprint. Publish claims after protecting a file so sightings can match your catalog. Optional remote sync: set SIGNATA_REGISTRY_URL.', + ), + const SizedBox(height: 20), + EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Scan a public URL'), + const SizedBox(height: 10), + TextField( + controller: _urlController, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + onSubmitted: (_) => _busy ? null : _scan(), + decoration: const InputDecoration( + hintText: 'https://example.com/photo.png', + ), + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _watchAfterScan, + onChanged: _busy + ? null + : (v) => setState(() => _watchAfterScan = v), + title: const Text('Add to watchlist'), + subtitle: const Text( + 'Re-scan later to see if the fingerprint is still online.', + style: TextStyle(fontSize: 12, color: EmColors.mutedForeground), + ), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _busy ? null : () => _scan(), + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.travel_explore, size: 18), + label: Text(_busy ? 'Scanning…' : 'Scan URL'), + ), + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + StatusBanner(ok: false, title: 'Scan failed', subtitle: _error!), + ], + if (_lastResult != null) ...[ + const SizedBox(height: 16), + _ResultCard(result: _lastResult!), + ], + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Text('WATCHLIST', style: emMonoLabel(size: 10)), + ), + TextButton( + onPressed: _busy || _watch.isEmpty ? null : _rescanAll, + child: const Text('Rescan all'), + ), + ], + ), + const SizedBox(height: 8), + if (_watch.isEmpty) + const Text( + 'No watched URLs yet. Scan a link with “Add to watchlist” enabled.', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ) + else + ..._watch.map((w) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(w.label ?? w.url, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + if (w.label != null) ...[ + const SizedBox(height: 4), + Text(w.url, + style: const TextStyle( + fontSize: 12, + color: EmColors.mutedForeground)), + ], + const SizedBox(height: 8), + Text( + w.lastScannedAt == null + ? 'Not scanned yet' + : 'Last scan · ${_fmt(w.lastScannedAt!)}' + '${w.lastReference == null ? '' : ' · ${w.lastReference}'}', + style: emMonoLabel( + color: EmColors.mutedForeground, size: 9), + ), + const SizedBox(height: 10), + Row( + children: [ + OutlinedButton( + onPressed: _busy + ? null + : () { + _urlController.text = w.url; + _scan(url: w.url); + }, + child: const Text('Scan'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () async { + await TraceStore.instance.removeWatchTarget(w.id); + await _reload(); + }, + child: const Text('Remove'), + ), + ], + ), + ], + ), + ), + )), + const SizedBox(height: 24), + Text('MY PUBLISHED CLAIMS', style: emMonoLabel(size: 10)), + const SizedBox(height: 8), + if (_claims.isEmpty) + const Text( + 'After you protect a file, use “Publish for web tracing” so online scans can match it to you.', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ) + else + ..._claims.take(20).map((c) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${c.medium.wire.toUpperCase()} · ${c.subject}', + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text(c.owner, + style: const TextStyle( + fontSize: 13, color: EmColors.mutedForeground)), + const SizedBox(height: 8), + SelectableText( + c.reference, + style: emMonoLabel(size: 10), + ), + const SizedBox(height: 8), + Text( + c.remoteSynced + ? 'Synced to remote registry' + : 'Stored on this device' + '${ClaimRegistry.hasRemote ? ' · remote sync pending/failed' : ''}', + style: emMonoLabel( + color: EmColors.mutedForeground, size: 9), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: c.reference)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Fingerprint copied')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy fingerprint'), + ), + ), + ], + ), + ), + )), + const SizedBox(height: 24), + Text('RECENT SIGHTINGS', style: emMonoLabel(size: 10)), + const SizedBox(height: 8), + if (_sightings.isEmpty) + const Text( + 'Sightings appear here whenever a URL scan finds (or misses) a fingerprint.', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ) + else + ..._sightings.take(25).map((s) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.found + ? (s.matchedPublishedClaimId != null + ? 'Matched your published claim' + : 'Fingerprint found') + : 'No fingerprint', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: s.found ? EmColors.accent : EmColors.foreground, + ), + ), + const SizedBox(height: 4), + Text(s.url, + style: const TextStyle( + fontSize: 12, color: EmColors.mutedForeground)), + if (s.owner != null) ...[ + const SizedBox(height: 6), + Text('${s.owner} · ${s.subject ?? s.medium.wire}'), + ], + if (s.reference != null) ...[ + const SizedBox(height: 6), + Text(s.reference!, style: emMonoLabel(size: 9)), + ], + if (s.error != null) ...[ + const SizedBox(height: 6), + Text(s.error!, + style: const TextStyle( + fontSize: 12, color: EmColors.destructive)), + ], + const SizedBox(height: 6), + Text(_fmt(s.at), + style: emMonoLabel( + color: EmColors.mutedForeground, size: 9)), + ], + ), + ), + )), + ], + ); + } + + static String _fmt(DateTime at) { + final local = at.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ' + '${two(local.hour)}:${two(local.minute)}'; + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({required this.result}); + + final TraceScanResult result; + + @override + Widget build(BuildContext context) { + final s = result.sighting; + final banner = s.error != null + ? (ok: false, title: 'Could not scan URL', subtitle: s.error!) + : claimBanner(s.claimStatus); + return EmCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusBanner( + ok: banner.ok, + title: banner.title, + subtitle: result.matchedClaim != null + ? '${banner.subtitle} Matched published claim “${result.matchedClaim!.subject}”.' + : banner.subtitle, + ), + const SizedBox(height: 14), + DetailList(rows: [ + DetailRow('URL', s.url), + DetailRow('Medium', s.medium.wire), + DetailRow('Owner', s.owner ?? '—'), + DetailRow('Subject', s.subject ?? '—'), + DetailRow('Fingerprint', s.reference ?? '—', mono: true), + DetailRow( + 'Catalog match', + result.matchedClaim == null ? 'none' : 'your claim', + ), + ]), + ], + ), + ); + } +} diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index 9b9c74f..11afb79 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -8,9 +8,11 @@ import '../core/claim_status_ui.dart'; import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; +import '../core/trace_models.dart'; import '../core/video_fingerprint.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/publish_claim_button.dart'; enum VideoMode { embed, verify } @@ -257,6 +259,18 @@ class _VideoToolScreenState extends State { icon: const Icon(Icons.ios_share, size: 18), label: const Text('Share fingerprinted video'), ), + const SizedBox(height: 10), + PublishClaimButton( + medium: TraceMedium.video, + owner: _embedOutcome!.payload.owner, + subject: _embedOutcome!.payload.document, + reference: _embedOutcome!.payload.identifier ?? + _embedOutcome!.payload.signature ?? + '', + issued: _embedOutcome!.payload.issued, + alg: _embedOutcome!.payload.alg, + kid: _embedOutcome!.payload.kid, + ), ], ], ), diff --git a/lib/widgets/publish_claim_button.dart b/lib/widgets/publish_claim_button.dart new file mode 100644 index 0000000..a0ddea5 --- /dev/null +++ b/lib/widgets/publish_claim_button.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../core/claim_publisher.dart'; +import '../core/trace_models.dart'; +import '../theme.dart'; + +/// One-tap publish after a successful protect, for internet tracing. +class PublishClaimButton extends StatefulWidget { + const PublishClaimButton({ + super.key, + required this.medium, + required this.owner, + required this.subject, + required this.reference, + required this.issued, + this.alg, + this.kid, + }); + + final TraceMedium medium; + final String owner; + final String subject; + final String reference; + final String issued; + final String? alg; + final String? kid; + + @override + State createState() => _PublishClaimButtonState(); +} + +class _PublishClaimButtonState extends State { + bool _busy = false; + String? _status; + + Future _publish() async { + setState(() { + _busy = true; + _status = null; + }); + try { + final claim = await ClaimPublisher.publishProtected( + medium: widget.medium, + owner: widget.owner, + subject: widget.subject, + reference: widget.reference, + issued: widget.issued, + alg: widget.alg, + kid: widget.kid, + ); + if (!mounted) return; + setState(() { + _status = claim == null + ? 'Could not publish claim.' + : claim.remoteSynced + ? 'Published & synced for web tracing' + : 'Published on this device for web tracing'; + }); + } catch (error) { + if (!mounted) return; + setState(() => + _status = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : _publish, + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.public, size: 18), + label: Text(_busy ? 'Publishing…' : 'Publish for web tracing'), + ), + if (_status != null) ...[ + const SizedBox(height: 8), + Text( + _status!.toUpperCase(), + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + ], + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c957718..2ecbbb1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: shared_preferences: ^2.5.5 google_sign_in: ^7.2.0 flutter_secure_storage: ^11.0.0 + http: ^1.4.0 dev_dependencies: flutter_test: diff --git a/test/trace_test.dart b/test/trace_test.dart new file mode 100644 index 0000000..da53185 --- /dev/null +++ b/test/trace_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/claim_crypto.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/trace_models.dart'; +import 'package:signata/core/trace_store.dart'; +import 'package:signata/core/url_tracer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + + group('claim registry', () { + test('publishes and finds claims locally by reference', () async { + final published = await ClaimRegistry.instance.publish( + medium: TraceMedium.image, + owner: 'Studio Nova', + subject: 'art.png·120x90', + reference: 'ABC123HMAC', + issued: '2026-01-01T00:00:00Z', + alg: 'hmac-sha256', + kid: 'DEADBEEF', + ); + expect(published.remoteSynced, isFalse); + final found = + await ClaimRegistry.instance.findByReference('abc123hmac'); + expect(found, isNotNull); + expect(found!.owner, 'Studio Nova'); + expect(found.subject, 'art.png·120x90'); + }); + }); + + group('trace store', () { + test('watchlist and sightings persist', () async { + final watch = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/a.png', + label: 'Portfolio', + ); + expect(watch.url, contains('cdn.example.com')); + final listed = await TraceStore.instance.listWatchTargets(); + expect(listed.length, 1); + + await TraceStore.instance.addSighting(TraceSighting( + id: 's1', + url: watch.url, + medium: TraceMedium.image, + at: DateTime.utc(2026, 1, 2), + found: true, + claimStatus: ClaimStatus.authenticated, + owner: 'Studio Nova', + reference: 'ABC', + )); + final sightings = await TraceStore.instance.listSightings(); + expect(sightings.first.found, isTrue); + expect(sightings.first.claimStatus, ClaimStatus.authenticated); + }); + }); + + group('url tracer', () { + test('rejects non-http urls', () async { + expect( + () => UrlTracer.instance.scanUrl('ftp://x/y.png', persist: false), + throwsA(isA()), + ); + }); + + test('published claim JSON round-trips', () { + final claim = PublishedClaim( + id: 'c1', + medium: TraceMedium.pdf, + owner: 'A', + subject: 'd.pdf', + reference: 'ID', + issued: 't', + publisherId: 'u', + publishedAt: DateTime.utc(2026), + ); + final again = + PublishedClaim.fromJson(jsonDecode(jsonEncode(claim.toJson()))); + expect(again?.medium, TraceMedium.pdf); + expect(again?.reference, 'ID'); + }); + }); +} diff --git a/tool/registry_server.example.md b/tool/registry_server.example.md new file mode 100644 index 0000000..9d9235d --- /dev/null +++ b/tool/registry_server.example.md @@ -0,0 +1,19 @@ +# Optional Signata claim registry + +The app can sync published claims to a remote HTTP registry when launched with: + +```sh +flutter run --dart-define=SIGNATA_REGISTRY_URL=https://your-host.example +``` + +## Expected API + +### `POST /claims` +JSON body matches a published claim (`id`, `medium`, `owner`, `subject`, `reference`, `issued`, `publisherId`, `publishedAt`, optional `alg` / `kid` / `note`). + +Respond `2xx` on success. + +### `GET /claims/:reference` +Return the claim JSON (or `{ "claim": { ... } }`) for fingerprint lookup. + +Without `SIGNATA_REGISTRY_URL`, claims stay on-device and URL tracing still works by reading fingerprints embedded in the downloaded media. From a68ae0e16ef5dbe820b8e6d49aa38fac41178c00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 14:52:13 +0000 Subject: [PATCH 05/31] Add download for protected media and remove Studio Nova defaults. Fingerprinted files can be saved with a Download button, and ownership fields no longer prefill Studio Nova. Co-authored-by: Wejdan Al Amri --- lib/core/share_utils.dart | 52 ++++++++++++++++++++++++++++++ lib/screens/audio_tool_screen.dart | 46 +++++++++++++++++++++++--- lib/screens/image_tool_screen.dart | 46 +++++++++++++++++++++++--- lib/screens/login_screen.dart | 4 +-- lib/screens/pdf_tool_screen.dart | 46 +++++++++++++++++++++++--- lib/screens/shell.dart | 2 +- lib/screens/video_tool_screen.dart | 46 +++++++++++++++++++++++--- test/core_test.dart | 32 +++++++++--------- test/trace_test.dart | 6 ++-- 9 files changed, 238 insertions(+), 42 deletions(-) diff --git a/lib/core/share_utils.dart b/lib/core/share_utils.dart index 451068f..676f25b 100644 --- a/lib/core/share_utils.dart +++ b/lib/core/share_utils.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -22,3 +23,54 @@ Future shareBytes({ ), ); } + +/// Saves [bytes] via the native save/download dialog. +/// +/// Falls back to the Downloads (or app Documents) folder if the dialog is +/// unavailable. Returns the saved file path. +Future downloadBytes({ + required Uint8List bytes, + required String fileName, + String? dialogTitle, + List? allowedExtensions, +}) async { + final ext = _extensionOf(fileName); + + try { + final path = await FilePicker.saveFile( + dialogTitle: dialogTitle ?? 'Download fingerprinted file', + fileName: fileName, + type: ext == null ? FileType.any : FileType.custom, + allowedExtensions: ext == null ? null : (allowedExtensions ?? [ext]), + bytes: bytes, + ); + if (path != null && path.isNotEmpty) { + return path; + } + } catch (_) { + // Fall through to a direct Downloads/Documents write. + } + + final dir = await getDownloadsDirectory() ?? + await getApplicationDocumentsDirectory(); + await dir.create(recursive: true); + var target = File('${dir.path}${Platform.pathSeparator}$fileName'); + if (await target.exists()) { + final stamp = DateTime.now().millisecondsSinceEpoch; + final base = fileName.contains('.') + ? fileName.substring(0, fileName.lastIndexOf('.')) + : fileName; + final suffix = ext == null ? '' : '.$ext'; + target = File( + '${dir.path}${Platform.pathSeparator}$base-$stamp$suffix', + ); + } + await target.writeAsBytes(bytes, flush: true); + return target.path; +} + +String? _extensionOf(String fileName) { + final dot = fileName.lastIndexOf('.'); + if (dot <= 0 || dot == fileName.length - 1) return null; + return fileName.substring(dot + 1).toLowerCase(); +} diff --git a/lib/screens/audio_tool_screen.dart b/lib/screens/audio_tool_screen.dart index c9026b4..a126d70 100644 --- a/lib/screens/audio_tool_screen.dart +++ b/lib/screens/audio_tool_screen.dart @@ -17,7 +17,7 @@ import '../widgets/publish_claim_button.dart'; enum AudioMode { embed, verify } class AudioToolScreen extends StatefulWidget { - const AudioToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const AudioToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -139,6 +139,31 @@ class _AudioToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedWav, + fileName: 'signata-${_fileName ?? 'audio.wav'}', + dialogTitle: 'Download watermarked audio', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; @@ -247,10 +272,21 @@ class _AudioToolScreenState extends State { mono: true), ]), const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share watermarked WAV'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], ), const SizedBox(height: 10), PublishClaimButton( diff --git a/lib/screens/image_tool_screen.dart b/lib/screens/image_tool_screen.dart index 0f6ba45..06f160f 100644 --- a/lib/screens/image_tool_screen.dart +++ b/lib/screens/image_tool_screen.dart @@ -17,7 +17,7 @@ import '../widgets/publish_claim_button.dart'; enum ToolMode { embed, verify } class ImageToolScreen extends StatefulWidget { - const ImageToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const ImageToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -137,6 +137,31 @@ class _ImageToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedPng, + fileName: 'signata-${_fileName ?? 'image'}.png', + dialogTitle: 'Download watermarked image', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; @@ -243,10 +268,21 @@ class _ImageToolScreenState extends State { const SizedBox(height: 16), _ImagePair(outcome: _embedOutcome!), const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share watermarked PNG'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], ), const SizedBox(height: 10), PublishClaimButton( diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index c6c1afe..de211dd 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -158,7 +158,7 @@ class _LoginScreenState extends State { textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Display name', - hintText: 'Studio Nova', + hintText: 'Your name', ), ), const SizedBox(height: 12), @@ -170,7 +170,7 @@ class _LoginScreenState extends State { autocorrect: false, decoration: const InputDecoration( labelText: 'Email', - hintText: 'you@studio.com', + hintText: 'you@example.com', ), ), const SizedBox(height: 12), diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index fda6c66..8b5b409 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -17,7 +17,7 @@ import '../widgets/publish_claim_button.dart'; enum PdfMode { embed, verify } class PdfToolScreen extends StatefulWidget { - const PdfToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const PdfToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -136,6 +136,31 @@ class _PdfToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedBytes, + fileName: 'signata-${_fileName ?? 'document.pdf'}', + dialogTitle: 'Download fingerprinted PDF', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; @@ -255,10 +280,21 @@ class _PdfToolScreenState extends State { ], if (_embedOutcome != null) ...[ const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share fingerprinted PDF'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], ), const SizedBox(height: 10), PublishClaimButton( diff --git a/lib/screens/shell.dart b/lib/screens/shell.dart index f438417..bfba139 100644 --- a/lib/screens/shell.dart +++ b/lib/screens/shell.dart @@ -40,7 +40,7 @@ class _AppShellState extends State { } String get _defaultOwner => - AuthService.instance.user?.displayName ?? 'Studio Nova'; + AuthService.instance.user?.displayName?.trim() ?? ''; Widget _toolScreen(EmTool tool) { switch (tool) { diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index 11afb79..5a2fe3c 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -17,7 +17,7 @@ import '../widgets/publish_claim_button.dart'; enum VideoMode { embed, verify } class VideoToolScreen extends StatefulWidget { - const VideoToolScreen({super.key, this.defaultOwner = 'Studio Nova'}); + const VideoToolScreen({super.key, this.defaultOwner = ''}); final String defaultOwner; @@ -135,6 +135,31 @@ class _VideoToolScreenState extends State { ); } + Future _downloadMarked() async { + final outcome = _embedOutcome; + if (outcome == null) return; + try { + final path = await downloadBytes( + bytes: outcome.markedBytes, + fileName: 'signata-${_fileName ?? 'video.mp4'}', + dialogTitle: 'Download fingerprinted video', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved to $path')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + } + Future _exportReport() async { final outcome = _embedOutcome; if (outcome == null) return; @@ -254,10 +279,21 @@ class _VideoToolScreenState extends State { ], if (_embedOutcome != null) ...[ const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: _shareMarked, - icon: const Icon(Icons.ios_share, size: 18), - label: const Text('Share fingerprinted video'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _downloadMarked, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + OutlinedButton.icon( + onPressed: _shareMarked, + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Share'), + ), + ], ), const SizedBox(height: 10), PublishClaimButton( diff --git a/test/core_test.dart b/test/core_test.dart index 478a665..adc5a84 100644 --- a/test/core_test.dart +++ b/test/core_test.dart @@ -17,8 +17,8 @@ void main() { group('fingerprint', () { test('is deterministic and 16 hex chars', () { - final a = fingerprint('Studio Nova|photo.png·900x600|2026-01-01'); - final b = fingerprint('Studio Nova|photo.png·900x600|2026-01-01'); + final a = fingerprint('Creator|photo.png·900x600|2026-01-01'); + final b = fingerprint('Creator|photo.png·900x600|2026-01-01'); expect(a, b); expect(a, matches(RegExp(r'^[0-9A-F]{16}$'))); }); @@ -104,14 +104,14 @@ void main() { test('embed round trip verifies with HMAC', () async { final outcome = await embedWatermark( fileBytes: samplePng(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'sample.png', claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.resized, isFalse); expect(outcome.claimStatus, ClaimStatus.authenticated); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.recovered?.owner, 'Creator'); expect(outcome.recovered?.asset, 'sample.png\u00b7120x90'); expect(outcome.recovered?.isAuthentic(alice), isTrue); expect(outcome.recovered?.isAuthentic(bob), isFalse); @@ -170,14 +170,14 @@ void main() { test('embed round trip verifies with HMAC', () async { final outcome = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); expect(outcome.claimStatus, ClaimStatus.authenticated); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.recovered?.owner, 'Creator'); expect(outcome.recovered?.structure.version, '1.4'); expect(outcome.recovered?.structure.pages, 1); }); @@ -185,7 +185,7 @@ void main() { test('standalone verification of a delivered file passes', () async { final embedded = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', claimKey: alice, ); @@ -201,7 +201,7 @@ void main() { test('redundant comment survives stripped trailing marker', () async { final embedded = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', claimKey: alice, ); @@ -230,7 +230,7 @@ void main() { test('tampered identifier fails verification', () async { final embedded = await embedPdfFingerprint( source: samplePdf(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'doc.pdf', claimKey: alice, ); @@ -302,13 +302,13 @@ void main() { test('embed round trip verifies with HMAC', () async { final outcome = await embedAudioWatermark( fileBytes: sampleWav(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'clip.wav', claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.claimStatus, ClaimStatus.authenticated); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.recovered?.owner, 'Creator'); expect(outcome.recovered?.isAuthentic(alice), isTrue); }); @@ -349,21 +349,21 @@ void main() { test('embed round trip verifies with HMAC and uuid atom', () async { final outcome = await embedVideoFingerprint( source: sampleMp4(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'reel.mp4', claimKey: alice, ); expect(outcome.verified, isTrue); expect(outcome.structureMatch, isTrue); expect(outcome.claimStatus, ClaimStatus.authenticated); - expect(outcome.recovered?.owner, 'Studio Nova'); + expect(outcome.recovered?.owner, 'Creator'); expect(extractVideoUuidPayload(outcome.markedBytes), isNotNull); }); test('standalone verification of a delivered file passes', () async { final embedded = await embedVideoFingerprint( source: sampleMp4(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'reel.mp4', claimKey: alice, ); @@ -379,7 +379,7 @@ void main() { test('legacy trailer still extracts when uuid box is removed', () async { final embedded = await embedVideoFingerprint( source: sampleMp4(), - owner: 'Studio Nova', + owner: 'Creator', fileName: 'reel.mp4', claimKey: alice, ); @@ -405,7 +405,7 @@ void main() { ReportBody body() => const ReportBody( medium: 'image', subject: 'sample.png', - owner: 'Studio Nova', + owner: 'Creator', verified: true, issued: '2026-01-01T00:00:00Z', generated: '2026-01-02T00:00:00Z', diff --git a/test/trace_test.dart b/test/trace_test.dart index da53185..1583f7b 100644 --- a/test/trace_test.dart +++ b/test/trace_test.dart @@ -20,7 +20,7 @@ void main() { test('publishes and finds claims locally by reference', () async { final published = await ClaimRegistry.instance.publish( medium: TraceMedium.image, - owner: 'Studio Nova', + owner: 'Creator', subject: 'art.png·120x90', reference: 'ABC123HMAC', issued: '2026-01-01T00:00:00Z', @@ -31,7 +31,7 @@ void main() { final found = await ClaimRegistry.instance.findByReference('abc123hmac'); expect(found, isNotNull); - expect(found!.owner, 'Studio Nova'); + expect(found!.owner, 'Creator'); expect(found.subject, 'art.png·120x90'); }); }); @@ -53,7 +53,7 @@ void main() { at: DateTime.utc(2026, 1, 2), found: true, claimStatus: ClaimStatus.authenticated, - owner: 'Studio Nova', + owner: 'Creator', reference: 'ABC', )); final sightings = await TraceStore.instance.listSightings(); From 12046d95a4b3450e4a9529d3fadaad7e87ce66cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 15:39:14 +0000 Subject: [PATCH 06/31] Harden on-device login with PBKDF2, secure sessions, and lockout. Upgrade email password hashing to PBKDF2-HMAC-SHA256, store sessions in Flutter Secure Storage, enforce stronger passwords, rate-limit failed attempts, and avoid account-enumeration error messages. Co-authored-by: Wejdan Al Amri --- lib/core/auth.dart | 333 ++++++++++++++++++++++++++++---- lib/screens/account_screen.dart | 2 +- lib/screens/login_screen.dart | 310 +++++++++++++++++------------ pubspec.lock | 2 +- test/auth_test.dart | 97 ++++++++++ 5 files changed, 584 insertions(+), 160 deletions(-) create mode 100644 test/auth_test.dart diff --git a/lib/core/auth.dart b/lib/core/auth.dart index 98c1938..a5179dc 100644 --- a/lib/core/auth.dart +++ b/lib/core/auth.dart @@ -1,11 +1,18 @@ /// Auth session for Signata. /// -/// Email accounts are stored on-device (hashed) so the login flow works without -/// a backend. Google Sign-In uses the platform SDK when configured. +/// Email accounts are stored on-device (PBKDF2-hashed) so the login flow works +/// without a backend. Google Sign-In uses the platform SDK when configured. +/// +/// Hardening: +/// - PBKDF2-HMAC-SHA256 password hashes (with migration from legacy SHA-256) +/// - Sessions kept in Flutter Secure Storage (not SharedPreferences) +/// - Password policy + failed-attempt lockout +/// - Neutral credential errors (no account enumeration) library; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; @@ -60,14 +67,132 @@ class AuthException implements Exception { String toString() => message; } +/// Password strength rules for on-device email accounts. +class PasswordPolicy { + static const minLength = 10; + + /// Returns null when [password] is acceptable, otherwise a user-facing reason. + static String? validate(String password) { + if (password.length < minLength) { + return 'Password must be at least $minLength characters.'; + } + if (!RegExp(r'[A-Za-z]').hasMatch(password)) { + return 'Password must include at least one letter.'; + } + if (!RegExp(r'[0-9]').hasMatch(password)) { + return 'Password must include at least one number.'; + } + if (RegExp(r'^\s|\s$').hasMatch(password)) { + return 'Password cannot start or end with a space.'; + } + return null; + } +} + +/// Password hashing helpers (PBKDF2-HMAC-SHA256 + legacy SHA-256 verify). +class PasswordHasher { + static const algoPbkdf2 = 'pbkdf2-sha256'; + static const algoLegacySha256 = 'sha256'; + static const iterations = 120000; + static const keyBytes = 32; + + static String hash(String password, String saltBase64) { + final salt = base64Url.decode(saltBase64); + final derived = pbkdf2HmacSha256( + password: utf8.encode(password), + salt: salt, + iterations: iterations, + keyLength: keyBytes, + ); + return base64UrlEncode(derived); + } + + static bool verify({ + required String password, + required String saltBase64, + required String expectedHash, + required String algorithm, + }) { + final computed = algorithm == algoLegacySha256 + ? _legacySha256(password, saltBase64) + : hash(password, saltBase64); + return constantTimeEquals(computed, expectedHash); + } + + static bool needsUpgrade(String algorithm) => algorithm != algoPbkdf2; + + static String _legacySha256(String password, String salt) => + sha256.convert(utf8.encode('$salt|$password')).toString(); + + /// RFC 8018 PBKDF2 with HMAC-SHA256. + static Uint8List pbkdf2HmacSha256({ + required List password, + required List salt, + required int iterations, + required int keyLength, + }) { + if (iterations < 1 || keyLength < 1) { + throw ArgumentError('Invalid PBKDF2 parameters'); + } + final hmac = Hmac(sha256, password); + const hLen = 32; + final blockCount = (keyLength + hLen - 1) ~/ hLen; + final out = BytesBuilder(copy: false); + + for (var block = 1; block <= blockCount; block++) { + final blockSalt = BytesBuilder(copy: false) + ..add(salt) + ..add([ + (block >> 24) & 0xff, + (block >> 16) & 0xff, + (block >> 8) & 0xff, + block & 0xff, + ]); + var u = hmac.convert(blockSalt.toBytes()).bytes; + final t = List.from(u); + for (var i = 1; i < iterations; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < t.length; j++) { + t[j] ^= u[j]; + } + } + out.add(t); + } + + return Uint8List.fromList(out.toBytes().sublist(0, keyLength)); + } + + static bool constantTimeEquals(String a, String b) { + final aBytes = utf8.encode(a); + final bBytes = utf8.encode(b); + if (aBytes.length != bBytes.length) return false; + var diff = 0; + for (var i = 0; i < aBytes.length; i++) { + diff |= aBytes[i] ^ bBytes[i]; + } + return diff == 0; + } +} + class AuthService extends ChangeNotifier { AuthService._(); static final AuthService instance = AuthService._(); - static const _sessionKey = 'signata_session_v1'; + static const _sessionKey = 'signata_session_v2'; + static const _legacySessionKey = 'signata_session_v1'; static const _usersKey = 'signata_users_v1'; - - final _secure = const FlutterSecureStorage(); + static const _lockoutKey = 'signata_lockout_v1'; + static const _maxFailedAttempts = 5; + static const _lockoutBaseSeconds = 30; + static const _genericCredentialError = + 'Incorrect email or password.'; + + final _secure = const FlutterSecureStorage( + aOptions: AndroidOptions(), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); final _google = GoogleSignIn.instance; AuthUser? _user; @@ -102,15 +227,7 @@ class AuthService extends ChangeNotifier { _googleReady = false; } - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionKey); - if (raw != null) { - try { - _user = AuthUser.fromJson(jsonDecode(raw) as Map); - } catch (_) { - await prefs.remove(_sessionKey); - } - } + await _restoreSession(); _ready = true; notifyListeners(); } @@ -123,24 +240,51 @@ class AuthService extends ChangeNotifier { if (!_isValidEmail(normalized)) { throw const AuthException('Enter a valid email address.'); } - if (password.length < 6) { - throw const AuthException('Password must be at least 6 characters.'); + if (password.isEmpty) { + throw const AuthException('Enter your password.'); } + await _assertNotLockedOut(normalized); + final users = await _loadUsers(); final record = users[normalized]; if (record == null) { - throw const AuthException('No account found for that email. Create one?'); + await _registerFailedAttempt(normalized); + throw const AuthException(_genericCredentialError); + } + + final algo = (record['algo'] as String?) ?? PasswordHasher.algoLegacySha256; + final ok = PasswordHasher.verify( + password: password, + saltBase64: record['salt'] as String, + expectedHash: record['hash'] as String, + algorithm: algo, + ); + if (!ok) { + await _registerFailedAttempt(normalized); + throw const AuthException(_genericCredentialError); } - final hash = _hashPassword(password, record['salt'] as String); - if (hash != record['hash']) { - throw const AuthException('Incorrect password.'); + + await _clearFailedAttempts(normalized); + + // Upgrade legacy hashes on successful sign-in. + if (PasswordHasher.needsUpgrade(algo)) { + final salt = _randomSalt(); + users[normalized] = { + ...record, + 'salt': salt, + 'algo': PasswordHasher.algoPbkdf2, + 'hash': PasswordHasher.hash(password, salt), + 'iterations': PasswordHasher.iterations, + }; + await _saveUsers(users); } await _setSession(AuthUser( id: record['id'] as String, email: normalized, - displayName: record['displayName'] as String? ?? normalized.split('@').first, + displayName: + record['displayName'] as String? ?? normalized.split('@').first, provider: AuthProvider.email, )); } @@ -149,34 +293,48 @@ class AuthService extends ChangeNotifier { required String name, required String email, required String password, + String? confirmPassword, }) async { final normalized = email.trim().toLowerCase(); - final display = name.trim().isEmpty - ? normalized.split('@').first - : name.trim(); + final display = + name.trim().isEmpty ? normalized.split('@').first : name.trim(); if (!_isValidEmail(normalized)) { throw const AuthException('Enter a valid email address.'); } - if (password.length < 6) { - throw const AuthException('Password must be at least 6 characters.'); + final policyError = PasswordPolicy.validate(password); + if (policyError != null) { + throw AuthException(policyError); + } + if (confirmPassword != null && confirmPassword != password) { + throw const AuthException('Passwords do not match.'); } + await _assertNotLockedOut(normalized); + final users = await _loadUsers(); if (users.containsKey(normalized)) { - throw const AuthException('An account already exists for that email.'); + // Same generic wording as sign-in to avoid confirming email existence. + throw const AuthException( + 'Could not create that account. Try signing in, or use a different email.', + ); } final salt = _randomSalt(); + final id = 'email_${_randomId()}'; users[normalized] = { - 'id': 'email_${DateTime.now().millisecondsSinceEpoch}', + 'id': id, 'displayName': display, 'salt': salt, - 'hash': _hashPassword(password, salt), + 'algo': PasswordHasher.algoPbkdf2, + 'iterations': PasswordHasher.iterations, + 'hash': PasswordHasher.hash(password, salt), + 'createdAt': DateTime.now().toUtc().toIso8601String(), }; await _saveUsers(users); + await _clearFailedAttempts(normalized); await _setSession(AuthUser( - id: users[normalized]!['id'] as String, + id: id, email: normalized, displayName: display, provider: AuthProvider.email, @@ -233,15 +391,64 @@ class AuthService extends ChangeNotifier { if (_googleReady) await _google.signOut(); } catch (_) {} _user = null; + await _secure.delete(key: _sessionKey); final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_sessionKey); + await prefs.remove(_legacySessionKey); notifyListeners(); } + Future _restoreSession() async { + // Prefer encrypted session; migrate any leftover SharedPreferences session. + String? raw = await _secure.read(key: _sessionKey); + if (raw == null || raw.isEmpty) { + final prefs = await SharedPreferences.getInstance(); + raw = prefs.getString(_legacySessionKey); + if (raw != null) { + try { + final user = + AuthUser.fromJson(jsonDecode(raw) as Map); + await _setSession(user); + await prefs.remove(_legacySessionKey); + return; + } catch (_) { + await prefs.remove(_legacySessionKey); + raw = null; + } + } + } + if (raw == null || raw.isEmpty) return; + try { + final decoded = jsonDecode(raw) as Map; + // Reject tampered / incomplete session blobs. + if (decoded['token'] is! String || + (decoded['token'] as String).length < 16) { + await _secure.delete(key: _sessionKey); + return; + } + final userJson = decoded['user']; + if (userJson is! Map) { + await _secure.delete(key: _sessionKey); + return; + } + _user = AuthUser.fromJson(Map.from(userJson)); + } catch (_) { + await _secure.delete(key: _sessionKey); + } + } + Future _setSession(AuthUser user) async { _user = user; + final token = _randomSalt(); + final payload = jsonEncode({ + 'token': token, + 'issuedAt': DateTime.now().toUtc().toIso8601String(), + 'user': user.toJson(), + }); + await _secure.write(key: _sessionKey, value: payload); + + // Ensure legacy plaintext session is gone. final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_sessionKey, jsonEncode(user.toJson())); + await prefs.remove(_legacySessionKey); notifyListeners(); } @@ -266,6 +473,61 @@ class AuthService extends ChangeNotifier { await _secure.write(key: _usersKey, value: jsonEncode(users)); } + Future> _loadLockout() async { + final raw = await _secure.read(key: _lockoutKey); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return Map.from(decoded); + } catch (_) { + return {}; + } + } + + Future _saveLockout(Map data) async { + await _secure.write(key: _lockoutKey, value: jsonEncode(data)); + } + + Future _assertNotLockedOut(String email) async { + final data = await _loadLockout(); + final entry = data[email]; + if (entry is! Map) return; + final untilMs = entry['until'] as int?; + if (untilMs == null) return; + final remaining = + DateTime.fromMillisecondsSinceEpoch(untilMs).difference(DateTime.now()); + if (remaining.isNegative) return; + final seconds = remaining.inSeconds + 1; + throw AuthException( + 'Too many failed attempts. Try again in ${seconds}s.', + ); + } + + Future _registerFailedAttempt(String email) async { + final data = await _loadLockout(); + final entry = Map.from( + (data[email] as Map?) ?? const {}, + ); + final attempts = ((entry['attempts'] as int?) ?? 0) + 1; + entry['attempts'] = attempts; + if (attempts >= _maxFailedAttempts) { + final streak = (attempts - _maxFailedAttempts) + 1; + final seconds = _lockoutBaseSeconds * (1 << (streak - 1).clamp(0, 4)); + entry['until'] = + DateTime.now().add(Duration(seconds: seconds)).millisecondsSinceEpoch; + } + data[email] = entry; + await _saveLockout(data); + } + + Future _clearFailedAttempts(String email) async { + final data = await _loadLockout(); + if (!data.containsKey(email)) return; + data.remove(email); + await _saveLockout(data); + } + static bool _isValidEmail(String email) => RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); @@ -275,6 +537,9 @@ class AuthService extends ChangeNotifier { return base64UrlEncode(bytes); } - static String _hashPassword(String password, String salt) => - sha256.convert(utf8.encode('$salt|$password')).toString(); + static String _randomId() { + final rng = Random.secure(); + final bytes = List.generate(12, (_) => rng.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } } diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 7a15ad6..87430b2 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -80,7 +80,7 @@ class AccountScreen extends StatelessWidget { const MonoLabel('Session'), const SizedBox(height: 10), const Text( - 'Your session stays on this device. Sign out anytime — watermarked files are not affected.', + 'Your session is stored in on-device secure storage. Sign out anytime — watermarked files are not affected.', style: TextStyle( fontSize: 13, height: 1.45, diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index de211dd..f3e3294 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/auth.dart'; import '../theme.dart'; @@ -15,10 +16,12 @@ class _LoginScreenState extends State { final _nameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); bool _isSignUp = false; bool _busy = false; bool _obscure = true; + bool _obscureConfirm = true; String? _error; @override @@ -26,9 +29,15 @@ class _LoginScreenState extends State { _nameController.dispose(); _emailController.dispose(); _passwordController.dispose(); + _confirmController.dispose(); super.dispose(); } + void _clearSecrets() { + _passwordController.clear(); + _confirmController.clear(); + } + Future _submitEmail() async { setState(() { _busy = true; @@ -40,6 +49,7 @@ class _LoginScreenState extends State { name: _nameController.text, email: _emailController.text, password: _passwordController.text, + confirmPassword: _confirmController.text, ); } else { await AuthService.instance.signInWithEmail( @@ -47,6 +57,7 @@ class _LoginScreenState extends State { password: _passwordController.text, ); } + _clearSecrets(); } catch (error) { if (!mounted) return; setState(() => _error = error.toString().replaceFirst('Exception: ', '')); @@ -62,6 +73,7 @@ class _LoginScreenState extends State { }); try { await AuthService.instance.signInWithGoogle(); + _clearSecrets(); } catch (error) { if (!mounted) return; setState(() => _error = error.toString().replaceFirst('Exception: ', '')); @@ -103,147 +115,197 @@ class _LoginScreenState extends State { padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const EmLogo(size: 36), - const SizedBox(height: 28), - const MonoLabel('Welcome'), - const SizedBox(height: 12), - Text( - _isSignUp ? 'Create your account' : 'Sign in to Signata', - style: theme.textTheme.displayMedium, - ), - const SizedBox(height: 10), - Text( - 'Protect images, audio, video, and PDFs — then verify ownership from any copy.', - style: theme.textTheme.bodyLarge?.copyWith( - color: EmColors.mutedForeground, - height: 1.5, + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const EmLogo(size: 36), + const SizedBox(height: 28), + const MonoLabel('Welcome'), + const SizedBox(height: 12), + Text( + _isSignUp + ? 'Create your account' + : 'Sign in to Signata', + style: theme.textTheme.displayMedium, ), - ), - const SizedBox(height: 28), - EmCard( - padding: const EdgeInsets.all(22), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton.icon( - onPressed: _busy ? null : _submitGoogle, - icon: const Icon(Icons.g_mobiledata, size: 28), - label: const Text('Continue with Google'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(50), + const SizedBox(height: 10), + Text( + 'Protect images, audio, video, and PDFs — then verify ownership from any copy.', + style: theme.textTheme.bodyLarge?.copyWith( + color: EmColors.mutedForeground, + height: 1.5, + ), + ), + const SizedBox(height: 28), + EmCard( + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : _submitGoogle, + icon: const Icon(Icons.g_mobiledata, size: 28), + label: const Text('Continue with Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), ), - ), - const SizedBox(height: 18), - Row( - children: [ - const Expanded(child: Divider()), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12), - child: Text('OR EMAIL', - style: emMonoLabel( - color: EmColors.mutedForeground, - size: 9)), + const SizedBox(height: 18), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12), + child: Text('OR EMAIL', + style: emMonoLabel( + color: EmColors.mutedForeground, + size: 9)), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 18), + if (_isSignUp) ...[ + TextField( + controller: _nameController, + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.name], + decoration: const InputDecoration( + labelText: 'Display name', + hintText: 'Your name', + ), ), - const Expanded(child: Divider()), + const SizedBox(height: 12), ], - ), - const SizedBox(height: 18), - if (_isSignUp) ...[ TextField( - controller: _nameController, + controller: _emailController, + keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + autofillHints: [ + if (_isSignUp) AutofillHints.newUsername, + AutofillHints.email, + ], + inputFormatters: [ + FilteringTextInputFormatter.deny( + RegExp(r'\s')), + ], decoration: const InputDecoration( - labelText: 'Display name', - hintText: 'Your name', + labelText: 'Email', + hintText: 'you@example.com', ), ), const SizedBox(height: 12), - ], - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'you@example.com', - ), - ), - const SizedBox(height: 12), - TextField( - controller: _passwordController, - obscureText: _obscure, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submitEmail(), - decoration: InputDecoration( - labelText: 'Password', - hintText: 'At least 6 characters', - suffixIcon: IconButton( - onPressed: () => - setState(() => _obscure = !_obscure), - icon: Icon(_obscure - ? Icons.visibility_outlined - : Icons.visibility_off_outlined), + TextField( + controller: _passwordController, + obscureText: _obscure, + textInputAction: _isSignUp + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: + _isSignUp ? null : (_) => _submitEmail(), + autofillHints: [ + _isSignUp + ? AutofillHints.newPassword + : AutofillHints.password, + ], + decoration: InputDecoration( + labelText: 'Password', + hintText: _isSignUp + ? '${PasswordPolicy.minLength}+ chars, letter & number' + : 'Your password', + suffixIcon: IconButton( + onPressed: () => + setState(() => _obscure = !_obscure), + icon: Icon(_obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + ), ), ), - ), - if (_error != null) ...[ - const SizedBox(height: 14), - StatusBanner( - ok: false, - title: 'Could not continue', - subtitle: _error!, + if (_isSignUp) ...[ + const SizedBox(height: 12), + TextField( + controller: _confirmController, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submitEmail(), + autofillHints: const [ + AutofillHints.newPassword + ], + decoration: InputDecoration( + labelText: 'Confirm password', + hintText: 'Re-enter password', + suffixIcon: IconButton( + onPressed: () => setState( + () => _obscureConfirm = + !_obscureConfirm), + icon: Icon(_obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + ), + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 14), + StatusBanner( + ok: false, + title: 'Could not continue', + subtitle: _error!, + ), + ], + const SizedBox(height: 18), + FilledButton( + onPressed: _busy ? null : _submitEmail, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : Text(_isSignUp + ? 'Create account' + : 'Sign in with email'), ), ], - const SizedBox(height: 18), - FilledButton( - onPressed: _busy ? null : _submitEmail, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - child: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : Text(_isSignUp - ? 'Create account' - : 'Sign in with email'), - ), - ], + ), ), - ), - const SizedBox(height: 18), - TextButton( - onPressed: _busy - ? null - : () => setState(() { - _isSignUp = !_isSignUp; - _error = null; - }), - child: Text( - _isSignUp - ? 'Already have an account? Sign in' - : 'New here? Create an account', - style: const TextStyle(color: EmColors.primary), + const SizedBox(height: 18), + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _isSignUp = !_isSignUp; + _error = null; + _confirmController.clear(); + }), + child: Text( + _isSignUp + ? 'Already have an account? Sign in' + : 'New here? Create an account', + style: const TextStyle(color: EmColors.primary), + ), ), - ), - const SizedBox(height: 8), - Text( - 'Email accounts stay on this device. Google uses your Google account when OAuth is configured for the app.', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: EmColors.mutedForeground, - height: 1.45, + const SizedBox(height: 8), + Text( + 'Email passwords are hashed with PBKDF2 and kept in on-device secure storage. Google uses your Google account when OAuth is configured.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: EmColors.mutedForeground, + height: 1.45, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 6abb87a..bdef2ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -313,7 +313,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/test/auth_test.dart b/test/auth_test.dart new file mode 100644 index 0000000..90e5390 --- /dev/null +++ b/test/auth_test.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/auth.dart'; + +void main() { + group('PasswordPolicy', () { + test('rejects short passwords', () { + expect(PasswordPolicy.validate('Ab1'), isNotNull); + expect(PasswordPolicy.validate('Abcdefg1'), isNotNull); + }); + + test('requires letter and number', () { + expect(PasswordPolicy.validate('abcdefghij'), isNotNull); + expect(PasswordPolicy.validate('1234567890'), isNotNull); + }); + + test('accepts strong enough passwords', () { + expect(PasswordPolicy.validate('Correct1horse'), isNull); + expect(PasswordPolicy.validate('signata2026X'), isNull); + }); + }); + + group('PasswordHasher', () { + test('PBKDF2 is deterministic for same inputs', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 1)); + final a = PasswordHasher.hash('Correct1horse', salt); + final b = PasswordHasher.hash('Correct1horse', salt); + expect(a, b); + expect(a, isNot(equals(PasswordHasher.hash('Correct1horsf', salt)))); + }); + + test('verify accepts matching PBKDF2 hash', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 3)); + final hash = PasswordHasher.hash('SecurePass9', salt); + expect( + PasswordHasher.verify( + password: 'SecurePass9', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isTrue, + ); + expect( + PasswordHasher.verify( + password: 'wrong-password', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isFalse, + ); + }); + + test('verify still accepts legacy SHA-256 hashes', () { + const salt = 'legacy-salt'; + const password = 'OldPass12ab'; + final expected = + sha256.convert(utf8.encode('$salt|$password')).toString(); + expect( + PasswordHasher.verify( + password: password, + saltBase64: salt, + expectedHash: expected, + algorithm: PasswordHasher.algoLegacySha256, + ), + isTrue, + ); + expect( + PasswordHasher.needsUpgrade(PasswordHasher.algoLegacySha256), + isTrue, + ); + expect( + PasswordHasher.needsUpgrade(PasswordHasher.algoPbkdf2), + isFalse, + ); + }); + + test('constantTimeEquals is length-sensitive', () { + expect(PasswordHasher.constantTimeEquals('abc', 'abc'), isTrue); + expect(PasswordHasher.constantTimeEquals('abc', 'abd'), isFalse); + expect(PasswordHasher.constantTimeEquals('abc', 'ab'), isFalse); + }); + + test('pbkdf2 produces expected key length', () { + final key = PasswordHasher.pbkdf2HmacSha256( + password: utf8.encode('password'), + salt: utf8.encode('salt'), + iterations: 1000, + keyLength: 32, + ); + expect(key.length, 32); + }); + }); +} From f4c2a060ab2fce1bf99d9ab94ba2247b703a6fa1 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:15:37 +0300 Subject: [PATCH 07/31] Add social Trace for Instagram, TikTok, and X. Let users share media from those apps into Signata, batch-fingerprint it, and post protected copies back. Restyle Trace with a radar hero, platform cards, and a sighting timeline. --- android/app/src/main/AndroidManifest.xml | 62 + ios/Runner/Info.plist | 37 + lib/core/share_ingress.dart | 82 ++ lib/core/share_utils.dart | 38 + lib/core/social_platforms.dart | 291 +++++ lib/core/social_protect.dart | 203 ++++ lib/core/trace_store.dart | 4 +- lib/core/url_tracer.dart | 71 +- lib/screens/shell.dart | 47 +- lib/screens/tools_hub_screen.dart | 8 +- lib/screens/trace_screen.dart | 1340 ++++++++++++++++++---- pubspec.lock | 40 + pubspec.yaml | 2 + test/social_platforms_test.dart | 57 + 14 files changed, 2039 insertions(+), 243 deletions(-) create mode 100644 lib/core/share_ingress.dart create mode 100644 lib/core/social_platforms.dart create mode 100644 lib/core/social_protect.dart create mode 100644 test/social_platforms_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0cfb11a..018d96e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -42,5 +74,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7f395ae..e0bf69d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,42 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + LSApplicationQueriesSchemes + + instagram + tiktok + snssdk1128 + twitter + x + https + http + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + sharemedia-app.signata.signata + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Signata shared media + LSItemContentTypes + + public.data + public.image + public.movie + public.audio + public.text + public.url + + + diff --git a/lib/core/share_ingress.dart b/lib/core/share_ingress.dart new file mode 100644 index 0000000..5d711a6 --- /dev/null +++ b/lib/core/share_ingress.dart @@ -0,0 +1,82 @@ +/// Incoming shares from Instagram, TikTok, X, and the system share sheet. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +class SharedIngressFile { + const SharedIngressFile({ + required this.path, + required this.fileName, + this.mimeType, + this.text, + }); + + final String path; + final String fileName; + final String? mimeType; + final String? text; +} + +typedef SharedIngressHandler = void Function(List files); + +/// Listens for media/text shared into Signata from other apps. +class ShareIngress { + ShareIngress._(); + static final ShareIngress instance = ShareIngress._(); + + StreamSubscription>? _sub; + SharedIngressHandler? _handler; + + void listen(SharedIngressHandler handler) { + _handler = handler; + _sub?.cancel(); + _sub = ReceiveSharingIntent.instance.getMediaStream().listen( + _dispatch, + onError: (_) {}, + ); + ReceiveSharingIntent.instance.getInitialMedia().then((files) { + if (files.isNotEmpty) { + _dispatch(files); + ReceiveSharingIntent.instance.reset(); + } + }).catchError((_) {}); + } + + void dispose() { + _sub?.cancel(); + _sub = null; + _handler = null; + } + + void _dispatch(List files) { + final handler = _handler; + if (handler == null || files.isEmpty) return; + final mapped = []; + for (final file in files) { + final path = file.path; + if (path.isEmpty) continue; + // Text-only shares (a social URL) are represented as path-less / type.text. + if (file.type == SharedMediaType.text || + file.type == SharedMediaType.url) { + final text = file.path; // package stores text in path for text shares + if (text.trim().isEmpty) continue; + mapped.add(SharedIngressFile( + path: '', + fileName: 'shared-link.txt', + mimeType: 'text/plain', + text: text.trim(), + )); + continue; + } + mapped.add(SharedIngressFile( + path: path, + fileName: path.split(Platform.pathSeparator).last, + mimeType: file.mimeType, + )); + } + if (mapped.isNotEmpty) handler(mapped); + } +} diff --git a/lib/core/share_utils.dart b/lib/core/share_utils.dart index 676f25b..4f4f246 100644 --- a/lib/core/share_utils.dart +++ b/lib/core/share_utils.dart @@ -5,6 +5,9 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'social_platforms.dart'; +import 'social_protect.dart'; + /// Writes [bytes] to a temp file and opens the native share sheet, letting /// the user save to Files, AirDrop, message, etc. Future shareBytes({ @@ -24,6 +27,41 @@ Future shareBytes({ ); } +/// Shares one or more fingerprinted social assets, then optionally opens the +/// target app (Instagram / TikTok / X) so the user can finish posting. +Future shareProtectedToSocial({ + required SocialPlatformInfo platform, + required List items, + String? caption, + bool openAppAfterShare = true, +}) async { + if (items.isEmpty) return; + final dir = await getTemporaryDirectory(); + final files = []; + for (final item in items) { + final path = + '${dir.path}${Platform.pathSeparator}signata-${platform.shortLabel}-${item.fileName}'; + final file = File(path); + await file.writeAsBytes(item.bytes, flush: true); + files.add(XFile(file.path, mimeType: item.mimeType, name: item.fileName)); + } + + await SharePlus.instance.share( + ShareParams( + files: files, + text: caption ?? + 'Protected with Signata · ready for ${platform.label}', + subject: 'Signata · ${platform.label}', + ), + ); + + if (openAppAfterShare) { + try { + await platform.openApp(); + } catch (_) {} + } +} + /// Saves [bytes] via the native save/download dialog. /// /// Falls back to the Downloads (or app Documents) folder if the dialog is diff --git a/lib/core/social_platforms.dart b/lib/core/social_platforms.dart new file mode 100644 index 0000000..2d04ea9 --- /dev/null +++ b/lib/core/social_platforms.dart @@ -0,0 +1,291 @@ +/// Social surfaces Signata can protect media for and scan links from. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +enum SocialPlatform { instagram, tiktok, x, other } + +class SocialPlatformInfo { + const SocialPlatformInfo({ + required this.id, + required this.label, + required this.shortLabel, + required this.hint, + required this.androidPackages, + required this.urlSchemes, + required this.hostMatchers, + required this.accent, + required this.icon, + }); + + final SocialPlatform id; + final String label; + final String shortLabel; + final String hint; + final List androidPackages; + final List urlSchemes; + final List hostMatchers; + final int accent; // ARGB + final String icon; // material icon name key used by UI + + static const instagram = SocialPlatformInfo( + id: SocialPlatform.instagram, + label: 'Instagram', + shortLabel: 'IG', + hint: 'Reels, posts, and Stories you export or share into Signata.', + androidPackages: ['com.instagram.android'], + urlSchemes: ['instagram'], + hostMatchers: ['instagram.com', 'www.instagram.com', 'instagr.am'], + accent: 0xFFE1306C, + icon: 'photo_camera_outlined', + ); + + static const tiktok = SocialPlatformInfo( + id: SocialPlatform.tiktok, + label: 'TikTok', + shortLabel: 'TT', + hint: 'Videos you download or share from TikTok before posting again.', + androidPackages: [ + 'com.zhiliaoapp.musically', + 'com.ss.android.ugc.trill', + 'com.ss.android.ugc.tiktok', + ], + urlSchemes: ['tiktok', 'snssdk1128'], + hostMatchers: [ + 'tiktok.com', + 'www.tiktok.com', + 'vm.tiktok.com', + 'vt.tiktok.com', + 'm.tiktok.com', + ], + accent: 0xFF25F4EE, + icon: 'music_note_outlined', + ); + + static const x = SocialPlatformInfo( + id: SocialPlatform.x, + label: 'X', + shortLabel: 'X', + hint: 'Images and clips you share from X, or post after fingerprinting.', + androidPackages: ['com.twitter.android'], + urlSchemes: ['twitter', 'x'], + hostMatchers: [ + 'x.com', + 'www.x.com', + 'twitter.com', + 'www.twitter.com', + 'mobile.twitter.com', + 't.co', + ], + accent: 0xFFEFF3F4, + icon: 'tag_outlined', + ); + + static const all = [instagram, tiktok, x]; + + static SocialPlatformInfo? fromUrl(String rawUrl) { + final host = Uri.tryParse(rawUrl.trim())?.host.toLowerCase() ?? ''; + for (final platform in all) { + for (final matcher in platform.hostMatchers) { + if (host == matcher || host.endsWith('.$matcher')) { + return platform; + } + } + } + return null; + } + + Future openApp() async { + for (final scheme in urlSchemes) { + final uri = Uri.parse('$scheme://'); + if (await canLaunchUrl(uri)) { + return launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + // Fall back to the public web home. + final web = switch (id) { + SocialPlatform.instagram => Uri.parse('https://www.instagram.com/'), + SocialPlatform.tiktok => Uri.parse('https://www.tiktok.com/'), + SocialPlatform.x => Uri.parse('https://x.com/'), + SocialPlatform.other => null, + }; + if (web == null) return false; + return launchUrl(web, mode: LaunchMode.externalApplication); + } +} + +/// Tries to turn a social post page into a direct media URL via Open Graph tags. +class SocialMediaResolver { + SocialMediaResolver._(); + static final SocialMediaResolver instance = SocialMediaResolver._(); + + static const _ua = + 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)'; + + Future resolve(String rawUrl) async { + final url = rawUrl.trim(); + final platform = SocialPlatformInfo.fromUrl(url); + if (platform == null) return null; + + try { + final response = await http + .get( + Uri.parse(url), + headers: { + 'User-Agent': _ua, + 'Accept': 'text/html,application/xhtml+xml', + }, + ) + .timeout(const Duration(seconds: 25)); + if (response.statusCode < 200 || response.statusCode >= 400) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: + 'Could not open that ${platform.label} page (${response.statusCode}). ' + 'Share the media file into Signata instead.', + ); + } + + final html = utf8.decode(response.bodyBytes, allowMalformed: true); + final mediaUrl = _firstMatch(html, const [ + r'property="og:video:secure_url"\s+content="([^"]+)"', + r"property='og:video:secure_url'\s+content='([^']+)'", + r'property="og:video"\s+content="([^"]+)"', + r"property='og:video'\s+content='([^']+)'", + r'content="([^"]+)"\s+property="og:video"', + r'name="twitter:player:stream"\s+content="([^"]+)"', + r'property="og:image:secure_url"\s+content="([^"]+)"', + r'property="og:image"\s+content="([^"]+)"', + r"property='og:image'\s+content='([^']+)'", + r'content="([^"]+)"\s+property="og:image"', + r'name="twitter:image"\s+content="([^"]+)"', + ]) ?? + _jsonLdMedia(html); + + if (mediaUrl == null || mediaUrl.isEmpty) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: + '${platform.label} did not expose a public media file for this link. ' + 'In the app: Share → Signata, or save the file and Protect & post here.', + ); + } + + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: _absolutize(mediaUrl, url), + note: null, + ); + } catch (error) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + static String? _firstMatch(String html, List patterns) { + for (final pattern in patterns) { + final match = + RegExp(pattern, caseSensitive: false).firstMatch(html)?.group(1); + if (match != null && match.trim().isNotEmpty) { + return match.trim().replaceAll('&', '&'); + } + } + return null; + } + + static String? _jsonLdMedia(String html) { + final blocks = RegExp( + r']+type="application/ld\+json"[^>]*>([\s\S]*?)', + caseSensitive: false, + ).allMatches(html); + for (final block in blocks) { + final raw = block.group(1)?.trim(); + if (raw == null || raw.isEmpty) continue; + try { + final decoded = jsonDecode(raw); + final found = _walkForMedia(decoded); + if (found != null) return found; + } catch (_) {} + } + return null; + } + + static String? _walkForMedia(Object? node) { + if (node is Map) { + for (final key in ['contentUrl', 'url', 'image', 'thumbnailUrl']) { + final value = node[key]; + if (value is String && + (value.startsWith('http://') || value.startsWith('https://')) && + _looksLikeMedia(value)) { + return value; + } + if (value is List) { + for (final item in value) { + final nested = _walkForMedia(item); + if (nested != null) return nested; + } + } + if (value is Map) { + final nested = _walkForMedia(value); + if (nested != null) return nested; + } + } + for (final value in node.values) { + final nested = _walkForMedia(value); + if (nested != null) return nested; + } + } else if (node is List) { + for (final item in node) { + final nested = _walkForMedia(item); + if (nested != null) return nested; + } + } + return null; + } + + static bool _looksLikeMedia(String url) { + final lower = url.toLowerCase(); + return lower.contains('.mp4') || + lower.contains('.mov') || + lower.contains('.jpg') || + lower.contains('.jpeg') || + lower.contains('.png') || + lower.contains('.webp') || + lower.contains('video') || + lower.contains('image') || + lower.contains('media'); + } + + static String _absolutize(String mediaUrl, String pageUrl) { + final media = Uri.tryParse(mediaUrl); + if (media == null) return mediaUrl; + if (media.hasScheme) return mediaUrl; + return Uri.parse(pageUrl).resolveUri(media).toString(); + } +} + +class ResolvedSocialMedia { + const ResolvedSocialMedia({ + required this.platform, + required this.pageUrl, + required this.mediaUrl, + required this.note, + }); + + final SocialPlatformInfo platform; + final String pageUrl; + final String? mediaUrl; + final String? note; +} diff --git a/lib/core/social_protect.dart b/lib/core/social_protect.dart new file mode 100644 index 0000000..3df7d3c --- /dev/null +++ b/lib/core/social_protect.dart @@ -0,0 +1,203 @@ +/// Batch-fingerprint media destined for Instagram, TikTok, or X. +library; + +import 'dart:typed_data'; + +import 'audio_watermark.dart'; +import 'claim_crypto.dart'; +import 'claim_publisher.dart'; +import 'claim_status_ui.dart'; +import 'history.dart'; +import 'image_watermark.dart'; +import 'trace_models.dart'; +import 'video_fingerprint.dart'; + +class SocialProtectItem { + const SocialProtectItem({ + required this.fileName, + required this.bytes, + required this.mimeType, + required this.medium, + required this.reference, + required this.owner, + required this.subject, + required this.claimStatus, + }); + + final String fileName; + final Uint8List bytes; + final String mimeType; + final TraceMedium medium; + final String reference; + final String owner; + final String subject; + final ClaimStatus claimStatus; +} + +class SocialProtectService { + SocialProtectService._(); + static final SocialProtectService instance = SocialProtectService._(); + + Future> protectMany({ + required List<({String name, Uint8List bytes})> files, + required String owner, + bool publishClaims = true, + }) async { + final claimKey = await AccountClaimKeys.current(); + final out = []; + + for (final file in files) { + final medium = _detectMedium(file.name, file.bytes); + if (medium == TraceMedium.unknown || medium == TraceMedium.pdf) { + throw Exception( + 'Unsupported file for social posting: ${file.name}. ' + 'Use an image, video, or WAV audio clip.', + ); + } + + late final SocialProtectItem item; + switch (medium) { + case TraceMedium.image: + final outcome = await embedWatermark( + fileBytes: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: _withExt(file.name, 'png'), + bytes: outcome.markedPng, + mimeType: 'image/png', + medium: TraceMedium.image, + reference: outcome.payload.signature, + owner: outcome.payload.owner, + subject: outcome.payload.asset, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.video: + final outcome = await embedVideoFingerprint( + source: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: file.name, + bytes: outcome.markedBytes, + mimeType: 'video/mp4', + medium: TraceMedium.video, + reference: outcome.payload.structuralIdentifier(), + owner: outcome.payload.owner, + subject: outcome.payload.document, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.audio: + final outcome = await embedAudioWatermark( + fileBytes: file.bytes, + owner: owner, + fileName: file.name, + claimKey: claimKey, + ); + item = SocialProtectItem( + fileName: _withExt(file.name, 'wav'), + bytes: outcome.markedWav, + mimeType: 'audio/wav', + medium: TraceMedium.audio, + reference: outcome.payload.signature, + owner: outcome.payload.owner, + subject: outcome.payload.asset, + claimStatus: outcome.claimStatus, + ); + case TraceMedium.pdf: + case TraceMedium.unknown: + throw StateError('unreachable'); + } + + out.add(item); + await HistoryStore.add(HistoryEntry( + medium: item.medium.wire, + action: 'embed', + subject: item.fileName, + owner: item.owner, + verified: claimCountsAsVerified(item.claimStatus), + reference: item.reference, + at: DateTime.now(), + )); + + if (publishClaims) { + try { + await ClaimPublisher.publishProtected( + medium: item.medium, + owner: item.owner, + subject: item.subject, + reference: item.reference, + issued: DateTime.now().toUtc().toIso8601String(), + alg: claimKey == null ? null : 'hmac-sha256', + kid: claimKey?.kid, + note: 'Protected for social posting', + ); + } catch (_) { + // Local protect still succeeds even if publish fails. + } + } + } + + return out; + } + + static TraceMedium _detectMedium(String name, Uint8List bytes) { + final lower = name.toLowerCase(); + if (lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.webp') || + _isPng(bytes) || + _isJpeg(bytes)) { + return TraceMedium.image; + } + if (lower.endsWith('.mp4') || + lower.endsWith('.mov') || + lower.endsWith('.m4v') || + _isMp4(bytes)) { + return TraceMedium.video; + } + if (lower.endsWith('.wav') || _isWav(bytes)) { + return TraceMedium.audio; + } + return TraceMedium.unknown; + } + + static bool _isPng(Uint8List b) => + b.length >= 8 && + b[0] == 0x89 && + b[1] == 0x50 && + b[2] == 0x4e && + b[3] == 0x47; + + static bool _isJpeg(Uint8List b) => + b.length >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff; + + static bool _isWav(Uint8List b) => + b.length >= 12 && + b[0] == 0x52 && + b[1] == 0x49 && + b[2] == 0x46 && + b[3] == 0x46 && + b[8] == 0x57 && + b[9] == 0x41 && + b[10] == 0x56 && + b[11] == 0x45; + + static bool _isMp4(Uint8List b) => + b.length >= 12 && + b[4] == 0x66 && + b[5] == 0x74 && + b[6] == 0x79 && + b[7] == 0x70; + + static String _withExt(String name, String ext) { + final dot = name.lastIndexOf('.'); + final base = dot > 0 ? name.substring(0, dot) : name; + return '$base.$ext'; + } +} diff --git a/lib/core/trace_store.dart b/lib/core/trace_store.dart index b8b8b1d..bab1436 100644 --- a/lib/core/trace_store.dart +++ b/lib/core/trace_store.dart @@ -39,7 +39,7 @@ class TraceStore { Future addWatchTarget(String url, {String? label}) async { final normalized = url.trim(); - final existing = await listWatchTargets(); + final existing = List.from(await listWatchTargets()); final prior = existing.where((w) => w.url == normalized).toList(); if (prior.isNotEmpty) return prior.first; final target = WatchTarget( @@ -91,7 +91,7 @@ class TraceStore { } Future addSighting(TraceSighting sighting) async { - final items = await listSightings(); + final items = List.from(await listSightings()); items.insert(0, sighting); final prefs = await SharedPreferences.getInstance(); await prefs.setString( diff --git a/lib/core/url_tracer.dart b/lib/core/url_tracer.dart index a26f2d5..3495e56 100644 --- a/lib/core/url_tracer.dart +++ b/lib/core/url_tracer.dart @@ -10,6 +10,7 @@ import 'claim_crypto.dart'; import 'claim_registry.dart'; import 'image_watermark.dart'; import 'pdf_fingerprint.dart'; +import 'social_platforms.dart'; import 'trace_models.dart'; import 'trace_store.dart'; import 'video_fingerprint.dart'; @@ -43,12 +44,53 @@ class UrlTracer { throw ArgumentError('Enter a valid http(s) URL to a media file.'); } + var fetchUrl = url; + String? socialNote; + final social = SocialPlatformInfo.fromUrl(url); + if (social != null && !_looksLikeDirectMedia(url)) { + final resolved = await SocialMediaResolver.instance.resolve(url); + socialNote = resolved?.note; + if (resolved?.mediaUrl != null && resolved!.mediaUrl!.isNotEmpty) { + fetchUrl = resolved.mediaUrl!; + } else if (resolved != null) { + final sighting = TraceSighting( + id: _id(), + url: url, + medium: TraceMedium.unknown, + at: DateTime.now().toUtc(), + found: false, + claimStatus: ClaimStatus.missing, + error: resolved.note ?? + '${social.label} link could not be resolved to a media file.', + ); + if (persist) await TraceStore.instance.addSighting(sighting); + if (addToWatchlist) { + final watch = await TraceStore.instance.addWatchTarget( + url, + label: social.label, + ); + await TraceStore.instance.touchWatchTarget( + watch.id, + reference: null, + ); + } + return TraceScanResult(sighting: sighting); + } + } + Uint8List bytes; String? contentType; try { - final response = await http.get(Uri.parse(url)).timeout( - const Duration(seconds: 45), - ); + final response = await http.get( + Uri.parse(fetchUrl), + headers: { + 'User-Agent': + 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)', + 'Accept': '*/*', + }, + ).timeout( + const Duration(seconds: 45), + ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Download failed (${response.statusCode}).'); } @@ -58,6 +100,8 @@ class UrlTracer { bytes = response.bodyBytes; contentType = response.headers['content-type']; } catch (error) { + final message = socialNote ?? + error.toString().replaceFirst('Exception: ', ''); final sighting = TraceSighting( id: _id(), url: url, @@ -65,7 +109,7 @@ class UrlTracer { at: DateTime.now().toUtc(), found: false, claimStatus: ClaimStatus.missing, - error: error.toString().replaceFirst('Exception: ', ''), + error: message, ); if (persist) await TraceStore.instance.addSighting(sighting); return TraceScanResult(sighting: sighting); @@ -94,7 +138,10 @@ class UrlTracer { if (persist) await TraceStore.instance.addSighting(sighting); if (addToWatchlist) { - final watch = await TraceStore.instance.addWatchTarget(url); + final watch = await TraceStore.instance.addWatchTarget( + url, + label: social?.label, + ); await TraceStore.instance.touchWatchTarget( watch.id, reference: sighting.reference, @@ -133,6 +180,20 @@ class UrlTracer { uri.host.isNotEmpty; } + static bool _looksLikeDirectMedia(String url) { + final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); + return path.endsWith('.png') || + path.endsWith('.jpg') || + path.endsWith('.jpeg') || + path.endsWith('.webp') || + path.endsWith('.gif') || + path.endsWith('.mp4') || + path.endsWith('.mov') || + path.endsWith('.m4v') || + path.endsWith('.wav') || + path.endsWith('.pdf'); + } + static String _id() => 'sight_${DateTime.now().toUtc().microsecondsSinceEpoch}'; static TraceMedium _detectMedium({ diff --git a/lib/screens/shell.dart b/lib/screens/shell.dart index bfba139..31aa8b9 100644 --- a/lib/screens/shell.dart +++ b/lib/screens/shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../core/auth.dart'; +import '../core/share_ingress.dart'; import '../widgets/em_widgets.dart'; import 'account_screen.dart'; import 'audio_tool_screen.dart'; @@ -23,6 +24,39 @@ class _AppShellState extends State { int _index = 0; EmTool? _activeTool; final _historyKey = GlobalKey(); + List _pendingShared = const []; + int _traceNonce = 0; + + @override + void initState() { + super.initState(); + ShareIngress.instance.listen(_onShared); + } + + @override + void dispose() { + ShareIngress.instance.dispose(); + super.dispose(); + } + + void _onShared(List files) { + if (!mounted || files.isEmpty) return; + setState(() { + _pendingShared = files; + _traceNonce++; + _index = 1; + _activeTool = EmTool.trace; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + files.any((f) => f.text != null) + ? 'Shared link ready to scan in Trace' + : 'Shared media ready to fingerprint in Trace', + ), + ), + ); + } void _openTab(int index) { setState(() { @@ -40,7 +74,7 @@ class _AppShellState extends State { } String get _defaultOwner => - AuthService.instance.user?.displayName?.trim() ?? ''; + AuthService.instance.user?.displayName.trim() ?? ''; Widget _toolScreen(EmTool tool) { switch (tool) { @@ -53,7 +87,11 @@ class _AppShellState extends State { case EmTool.pdf: return PdfToolScreen(defaultOwner: _defaultOwner); case EmTool.trace: - return const TraceScreen(); + final shared = _pendingShared; + return TraceScreen( + key: ValueKey('trace-$_traceNonce'), + initialSharedFiles: shared, + ); } } @@ -74,7 +112,10 @@ class _AppShellState extends State { leading: inTool ? IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => setState(() => _activeTool = null), + onPressed: () => setState(() { + _activeTool = null; + _pendingShared = const []; + }), ) : null, title: inTool diff --git a/lib/screens/tools_hub_screen.dart b/lib/screens/tools_hub_screen.dart index c24804c..8572833 100644 --- a/lib/screens/tools_hub_screen.dart +++ b/lib/screens/tools_hub_screen.dart @@ -57,16 +57,16 @@ class ToolsHubScreen extends StatelessWidget { ), _ToolTile( tag: '05 / Internet', - title: 'Trace online', + title: 'Trace & social', desc: - 'Scan public media URLs, watch links for reappearance, and match fingerprints to your published claims.', - icon: Icons.travel_explore, + 'Fingerprint media for Instagram, TikTok, and X, scan post links, and watch the web for your claim.', + icon: Icons.radar, tone: EmColors.primary, onTap: () => onOpenTool(EmTool.trace), ), const SizedBox(height: 12), Text( - 'Tip: protect a file, publish the claim, then Trace any URL where a copy might appear.', + 'Tip: share a post or media from Instagram / TikTok / X into Signata, fingerprint it, then post the protected copy.', style: theme.textTheme.bodySmall ?.copyWith(color: EmColors.mutedForeground, height: 1.45), ), diff --git a/lib/screens/trace_screen.dart b/lib/screens/trace_screen.dart index 50b8de8..d684f89 100644 --- a/lib/screens/trace_screen.dart +++ b/lib/screens/trace_screen.dart @@ -1,9 +1,18 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../core/auth.dart'; import '../core/claim_crypto.dart'; import '../core/claim_registry.dart'; import '../core/claim_status_ui.dart'; +import '../core/share_ingress.dart'; +import '../core/share_utils.dart'; +import '../core/social_platforms.dart'; +import '../core/social_protect.dart'; import '../core/trace_models.dart'; import '../core/trace_store.dart'; import '../core/url_tracer.dart'; @@ -11,18 +20,29 @@ import '../theme.dart'; import '../widgets/em_widgets.dart'; class TraceScreen extends StatefulWidget { - const TraceScreen({super.key}); + const TraceScreen({ + super.key, + this.initialSharedFiles = const [], + }); + + final List initialSharedFiles; @override State createState() => _TraceScreenState(); } -class _TraceScreenState extends State { +class _TraceScreenState extends State + with SingleTickerProviderStateMixin { final _urlController = TextEditingController(); + late final AnimationController _radar; + bool _busy = false; bool _watchAfterScan = true; String? _error; + String? _status; TraceScanResult? _lastResult; + SocialPlatformInfo? _selectedPlatform = SocialPlatformInfo.instagram; + List _protected = const []; List _claims = const []; List _watch = const []; List _sightings = const []; @@ -30,11 +50,21 @@ class _TraceScreenState extends State { @override void initState() { super.initState(); + _radar = AnimationController( + vsync: this, + duration: const Duration(seconds: 8), + )..repeat(); _reload(); + if (widget.initialSharedFiles.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _ingestShared(widget.initialSharedFiles); + }); + } } @override void dispose() { + _radar.dispose(); _urlController.dispose(); super.dispose(); } @@ -56,6 +86,7 @@ class _TraceScreenState extends State { setState(() { _busy = true; _error = null; + _status = 'Sweeping the open web…'; _lastResult = null; }); try { @@ -66,14 +97,24 @@ class _TraceScreenState extends State { addToWatchlist: _watchAfterScan, ); if (!mounted) return; - setState(() => _lastResult = result); + setState(() { + _lastResult = result; + _status = null; + final platform = SocialPlatformInfo.fromUrl(target); + if (platform != null) _selectedPlatform = platform; + }); await _reload(); } catch (error) { if (!mounted) return; setState(() => _error = error.toString().replaceFirst('Exception: ', '')); } finally { - if (mounted) setState(() => _busy = false); + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } } } @@ -81,6 +122,7 @@ class _TraceScreenState extends State { setState(() { _busy = true; _error = null; + _status = 'Rescanning watchlist…'; }); try { final key = await AccountClaimKeys.current(); @@ -91,264 +133,1091 @@ class _TraceScreenState extends State { setState(() => _error = error.toString().replaceFirst('Exception: ', '')); } finally { - if (mounted) setState(() => _busy = false); + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _ingestShared(List files) async { + final urls = files + .where((f) => f.text != null && f.text!.startsWith('http')) + .map((f) => f.text!.trim()) + .toList(); + if (urls.isNotEmpty) { + _urlController.text = urls.first; + final platform = SocialPlatformInfo.fromUrl(urls.first); + if (platform != null) { + setState(() => _selectedPlatform = platform); + } + await _scan(url: urls.first); + return; + } + + final media = files.where((f) => f.path.isNotEmpty).toList(); + if (media.isEmpty) return; + await _protectPaths(media.map((f) => f.path).toList()); + } + + Future _protectFromPicker() async { + final picked = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const [ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'mp4', + 'mov', + 'm4v', + 'wav', + ], + ); + if (picked == null || picked.files.isEmpty) return; + + final files = <({String name, Uint8List bytes})>[]; + for (final file in picked.files) { + final bytes = await file.readAsBytes(); + files.add((name: file.name, bytes: bytes)); + } + await _protectFiles(files); + } + + Future _protectPaths(List paths) async { + final files = <({String name, Uint8List bytes})>[]; + for (final path in paths) { + final file = File(path); + if (!await file.exists()) continue; + files.add(( + name: path.split(Platform.pathSeparator).last, + bytes: await file.readAsBytes(), + )); + } + if (files.isEmpty) return; + await _protectFiles(files); + } + + Future _protectFiles( + List<({String name, Uint8List bytes})> files, + ) async { + final owner = AuthService.instance.user?.displayName.trim(); + if (owner == null || owner.isEmpty) { + setState(() => _error = 'Sign in with a display name before protecting.'); + return; + } + setState(() { + _busy = true; + _error = null; + _status = 'Fingerprinting ${files.length} file' + '${files.length == 1 ? '' : 's'} for ' + '${_selectedPlatform?.label ?? 'social'}…'; + _protected = const []; + }); + try { + final items = await SocialProtectService.instance.protectMany( + files: files, + owner: owner, + ); + if (!mounted) return; + setState(() { + _protected = items; + _status = null; + }); + await _reload(); + } catch (error) { + if (!mounted) return; + setState(() => + _error = error.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } + } + } + + Future _shareProtected() async { + final platform = _selectedPlatform; + if (platform == null || _protected.isEmpty) return; + await shareProtectedToSocial( + platform: platform, + items: _protected, + ); + } + + Future _openPlatform() async { + final platform = _selectedPlatform; + if (platform == null) return; + final ok = await platform.openApp(); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open ${platform.label}.')), + ); } } @override Widget build(BuildContext context) { - final remote = ClaimRegistry.hasRemote; - return ListView( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 40), - children: [ - SectionHeading( - tag: 'Internet trace', - title: 'Find Signata media on the web', - desc: remote - ? 'Paste a public media URL to download and scan it for a Signata fingerprint. Claims can sync to your registry at ${ClaimRegistry.remoteBaseUrl}.' - : 'Paste a public media URL to download and scan it for a Signata fingerprint. Publish claims after protecting a file so sightings can match your catalog. Optional remote sync: set SIGNATA_REGISTRY_URL.', + final theme = Theme.of(context); + final platform = _selectedPlatform; + + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: _TraceHero( + radar: _radar, + busy: _busy, + foundCount: _sightings.where((s) => s.found).length, + watchCount: _watch.length, + claimCount: _claims.length, + ), + ), ), - const SizedBox(height: 20), - EmCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const MonoLabel('Scan a public URL'), - const SizedBox(height: 10), - TextField( - controller: _urlController, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - onSubmitted: (_) => _busy ? null : _scan(), - decoration: const InputDecoration( - hintText: 'https://example.com/photo.png', + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace the signal', + style: theme.textTheme.displayMedium, ), - ), - const SizedBox(height: 12), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - value: _watchAfterScan, - onChanged: _busy - ? null - : (v) => setState(() => _watchAfterScan = v), - title: const Text('Add to watchlist'), - subtitle: const Text( - 'Re-scan later to see if the fingerprint is still online.', - style: TextStyle(fontSize: 12, color: EmColors.mutedForeground), + const SizedBox(height: 8), + Text( + 'Fingerprint media for Instagram, TikTok, and X — then scan public links for your claim.', + style: theme.textTheme.bodyLarge?.copyWith( + color: EmColors.mutedForeground, + height: 1.45, + ), ), - ), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: _busy ? null : () => _scan(), - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.travel_explore, size: 18), - label: Text(_busy ? 'Scanning…' : 'Scan URL'), - ), - ], + ], + ), ), ), - if (_error != null) ...[ - const SizedBox(height: 16), - StatusBanner(ok: false, title: 'Scan failed', subtitle: _error!), - ], - if (_lastResult != null) ...[ - const SizedBox(height: 16), - _ResultCard(result: _lastResult!), - ], - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: Text('WATCHLIST', style: emMonoLabel(size: 10)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: _PlatformSelector( + selected: _selectedPlatform, + onSelected: (value) => setState(() => _selectedPlatform = value), ), - TextButton( - onPressed: _busy || _watch.isEmpty ? null : _rescanAll, - child: const Text('Rescan all'), - ), - ], + ), ), - const SizedBox(height: 8), - if (_watch.isEmpty) - const Text( - 'No watched URLs yet. Scan a link with “Add to watchlist” enabled.', - style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), - ) - else - ..._watch.map((w) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: EmCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: EmCard( + glow: _busy, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Text(w.label ?? w.url, - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600)), - if (w.label != null) ...[ - const SizedBox(height: 4), - Text(w.url, - style: const TextStyle( - fontSize: 12, - color: EmColors.mutedForeground)), - ], - const SizedBox(height: 8), - Text( - w.lastScannedAt == null - ? 'Not scanned yet' - : 'Last scan · ${_fmt(w.lastScannedAt!)}' - '${w.lastReference == null ? '' : ' · ${w.lastReference}'}', - style: emMonoLabel( - color: EmColors.mutedForeground, size: 9), - ), - const SizedBox(height: 10), - Row( - children: [ - OutlinedButton( - onPressed: _busy - ? null - : () { - _urlController.text = w.url; - _scan(url: w.url); - }, - child: const Text('Scan'), - ), - const SizedBox(width: 8), - TextButton( - onPressed: () async { - await TraceStore.instance.removeWatchTarget(w.id); - await _reload(); - }, - child: const Text('Remove'), - ), - ], + MonoLabel( + platform == null + ? 'Scan any URL' + : 'Scan on ${platform.label}', ), + const Spacer(), + if (platform != null) + TextButton( + onPressed: _busy ? null : _openPlatform, + child: Text('Open ${platform.shortLabel}'), + ), ], ), - ), - )), - const SizedBox(height: 24), - Text('MY PUBLISHED CLAIMS', style: emMonoLabel(size: 10)), - const SizedBox(height: 8), - if (_claims.isEmpty) - const Text( - 'After you protect a file, use “Publish for web tracing” so online scans can match it to you.', - style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), - ) - else - ..._claims.take(20).map((c) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: EmCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 12), + TextField( + controller: _urlController, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + onSubmitted: (_) => _busy ? null : _scan(), + decoration: InputDecoration( + hintText: platform == null + ? 'https://cdn.example.com/photo.png' + : 'Paste a ${platform.label} post or media URL', + prefixIcon: const Icon(Icons.link_rounded), + ), + ), + const SizedBox(height: 10), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _watchAfterScan, + onChanged: _busy + ? null + : (v) => setState(() => _watchAfterScan = v), + title: const Text('Keep on radar'), + subtitle: const Text( + 'Re-check this link later for your fingerprint.', + style: TextStyle( + fontSize: 12, color: EmColors.mutedForeground), + ), + ), + const SizedBox(height: 6), + FilledButton.icon( + onPressed: _busy ? null : () => _scan(), + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.radar, size: 18), + label: Text(_busy ? 'Scanning…' : 'Scan link'), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: EmCard( + borderColor: Color(platform?.accent ?? 0xFF57D9EC) + .withValues(alpha: 0.45), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MonoLabel( + platform == null + ? 'Protect & post' + : 'Protect for ${platform.label}', + color: Color(platform?.accent ?? 0xFF57D9EC), + ), + const SizedBox(height: 10), + Text( + platform?.hint ?? + 'Import images or videos, fingerprint all of them, then share into the social app.', + style: const TextStyle( + fontSize: 13.5, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, children: [ - Text('${c.medium.wire.toUpperCase()} · ${c.subject}', - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text(c.owner, - style: const TextStyle( - fontSize: 13, color: EmColors.mutedForeground)), - const SizedBox(height: 8), - SelectableText( - c.reference, - style: emMonoLabel(size: 10), - ), - const SizedBox(height: 8), - Text( - c.remoteSynced - ? 'Synced to remote registry' - : 'Stored on this device' - '${ClaimRegistry.hasRemote ? ' · remote sync pending/failed' : ''}', - style: emMonoLabel( - color: EmColors.mutedForeground, size: 9), + FilledButton.tonalIcon( + onPressed: _busy ? null : _protectFromPicker, + icon: const Icon(Icons.auto_awesome, size: 18), + label: const Text('Fingerprint media'), ), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: c.reference)); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Fingerprint copied')), - ); - }, - icon: const Icon(Icons.copy, size: 16), - label: const Text('Copy fingerprint'), + OutlinedButton.icon( + onPressed: _busy || _protected.isEmpty + ? null + : _shareProtected, + icon: const Icon(Icons.ios_share, size: 18), + label: Text( + _protected.isEmpty + ? 'Share to app' + : 'Share ${_protected.length} to ${platform?.shortLabel ?? 'app'}', ), ), ], ), + if (_protected.isNotEmpty) ...[ + const SizedBox(height: 14), + ..._protected.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + item.medium == TraceMedium.video + ? Icons.movie_outlined + : item.medium == TraceMedium.audio + ? Icons.graphic_eq + : Icons.image_outlined, + size: 18, + color: EmColors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item.fileName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + item.reference.length > 10 + ? item.reference.substring(0, 10) + : item.reference, + style: emMonoLabel(size: 9), + ), + ], + ), + ), + ), + ], + const SizedBox(height: 8), + Text( + 'Tip: In Instagram, TikTok, or X use Share → Signata to send media or a post link straight here.', + style: theme.textTheme.bodySmall?.copyWith( + color: EmColors.mutedForeground, + height: 1.4, + ), + ), + ], + ), + ), + ), + ), + if (_status != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: _PulseStatus(text: _status!), + ), + ), + if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: StatusBanner( + ok: false, + title: 'Trace interrupted', + subtitle: _error!, + ), + ), + ), + if (_lastResult != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: _ResultCard(result: _lastResult!), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 8), + child: Row( + children: [ + Expanded( + child: Text('RADAR WATCHLIST', style: emMonoLabel(size: 10)), ), - )), - const SizedBox(height: 24), - Text('RECENT SIGHTINGS', style: emMonoLabel(size: 10)), - const SizedBox(height: 8), + TextButton( + onPressed: _busy || _watch.isEmpty ? null : _rescanAll, + child: const Text('Rescan all'), + ), + ], + ), + ), + ), + if (_watch.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'No links on radar yet. Scan a social or media URL with “Keep on radar”.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: _watch.length, + itemBuilder: (context, index) { + final w = _watch[index]; + final social = SocialPlatformInfo.fromUrl(w.url); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: _WatchTile( + target: w, + platform: social, + busy: _busy, + onScan: () { + _urlController.text = w.url; + _scan(url: w.url); + }, + onRemove: () async { + await TraceStore.instance.removeWatchTarget(w.id); + await _reload(); + }, + ), + ); + }, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text('PUBLISHED CLAIMS', style: emMonoLabel(size: 10)), + ), + ), + if (_claims.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Protect media above (or in any tool) to publish claims Trace can match online.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: math.min(_claims.length, 20), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: _ClaimTile(claim: _claims[index]), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text('SIGHTING TIMELINE', style: emMonoLabel(size: 10)), + ), + ), if (_sightings.isEmpty) - const Text( - 'Sightings appear here whenever a URL scan finds (or misses) a fingerprint.', - style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 40), + child: Text( + 'Sightings appear as a timeline whenever a scan finds — or misses — a fingerprint.', + style: theme.textTheme.bodyMedium?.copyWith( + color: EmColors.mutedForeground, + ), + ), + ), ) else - ..._sightings.take(25).map((s) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: EmCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - s.found - ? (s.matchedPublishedClaimId != null - ? 'Matched your published claim' - : 'Fingerprint found') - : 'No fingerprint', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: s.found ? EmColors.accent : EmColors.foreground, + SliverList.builder( + itemCount: math.min(_sightings.length, 25), + itemBuilder: (context, index) => Padding( + padding: EdgeInsets.fromLTRB( + 20, + 0, + 20, + index == math.min(_sightings.length, 25) - 1 ? 40 : 0, + ), + child: _SightingTile( + sighting: _sightings[index], + isFirst: index == 0, + isLast: index == math.min(_sightings.length, 25) - 1, + ), + ), + ), + ], + ); + } +} + +class _TraceHero extends StatelessWidget { + const _TraceHero({ + required this.radar, + required this.busy, + required this.foundCount, + required this.watchCount, + required this.claimCount, + }); + + final AnimationController radar; + final bool busy; + final int foundCount; + final int watchCount; + final int claimCount; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 210, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: RadialGradient( + center: const Alignment(0, -0.2), + radius: 1.05, + colors: [ + EmColors.primary.withValues(alpha: busy ? 0.28 : 0.16), + EmColors.accent.withValues(alpha: 0.08), + EmColors.background.withValues(alpha: 0.2), + ], + ), + border: Border.all(color: EmColors.border), + ), + ), + ), + AnimatedBuilder( + animation: radar, + builder: (context, _) => CustomPaint( + size: const Size(180, 180), + painter: _RadarPainter( + progress: radar.value, + accent: busy ? EmColors.accent : EmColors.primary, + ), + ), + ), + Positioned( + left: 18, + right: 18, + bottom: 16, + child: Row( + children: [ + _StatChip(label: 'Found', value: '$foundCount'), + const SizedBox(width: 8), + _StatChip(label: 'Watching', value: '$watchCount'), + const SizedBox(width: 8), + _StatChip(label: 'Claims', value: '$claimCount'), + ], + ), + ), + ], + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: EmColors.background.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: EmColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), + style: emMonoLabel(color: EmColors.mutedForeground, size: 8)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: EmColors.foreground)), + ], + ), + ), + ); + } +} + +class _RadarPainter extends CustomPainter { + const _RadarPainter({required this.progress, required this.accent}); + + final double progress; + final Color accent; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width * 0.42; + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = EmColors.foreground.withValues(alpha: 0.12); + + for (var i = 1; i <= 3; i++) { + canvas.drawCircle(center, radius * (i / 3), ring); + } + canvas.drawLine( + Offset(center.dx - radius, center.dy), + Offset(center.dx + radius, center.dy), + ring, + ); + canvas.drawLine( + Offset(center.dx, center.dy - radius), + Offset(center.dx, center.dy + radius), + ring, + ); + + final sweep = Paint() + ..shader = SweepGradient( + startAngle: 0, + endAngle: math.pi / 2, + colors: [ + accent.withValues(alpha: 0.0), + accent.withValues(alpha: 0.55), + ], + transform: GradientRotation(progress * math.pi * 2), + ).createShader(Rect.fromCircle(center: center, radius: radius)); + canvas.drawCircle(center, radius, sweep..style = PaintingStyle.fill); + + final beam = Paint() + ..color = accent + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + final angle = progress * math.pi * 2; + canvas.drawLine( + center, + Offset( + center.dx + math.cos(angle) * radius, + center.dy + math.sin(angle) * radius, + ), + beam, + ); + + canvas.drawCircle( + center, + 5, + Paint()..color = accent, + ); + } + + @override + bool shouldRepaint(covariant _RadarPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.accent != accent; +} + +class _PlatformSelector extends StatelessWidget { + const _PlatformSelector({ + required this.selected, + required this.onSelected, + }); + + final SocialPlatformInfo? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (final platform in SocialPlatformInfo.all) ...[ + if (platform != SocialPlatformInfo.all.first) const SizedBox(width: 10), + Expanded( + child: _PlatformCard( + platform: platform, + selected: selected?.id == platform.id, + onTap: () => onSelected(platform), + ), + ), + ], + ], + ); + } +} + +class _PlatformCard extends StatelessWidget { + const _PlatformCard({ + required this.platform, + required this.selected, + required this.onTap, + }); + + final SocialPlatformInfo platform; + final bool selected; + final VoidCallback onTap; + + IconData get _icon => switch (platform.id) { + SocialPlatform.instagram => Icons.photo_camera_outlined, + SocialPlatform.tiktok => Icons.music_note_outlined, + SocialPlatform.x => Icons.tag_outlined, + SocialPlatform.other => Icons.public, + }; + + @override + Widget build(BuildContext context) { + final accent = Color(platform.accent); + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + decoration: BoxDecoration( + color: selected + ? accent.withValues(alpha: 0.14) + : EmColors.card, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected + ? accent.withValues(alpha: 0.7) + : EmColors.border, + ), + ), + child: Column( + children: [ + Icon(_icon, + color: selected ? accent : EmColors.mutedForeground), + const SizedBox(height: 8), + Text( + platform.label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: selected ? EmColors.foreground : EmColors.mutedForeground, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PulseStatus extends StatefulWidget { + const _PulseStatus({required this.text}); + + final String text; + + @override + State<_PulseStatus> createState() => _PulseStatusState(); +} + +class _PulseStatusState extends State<_PulseStatus> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: EmColors.primary + .withValues(alpha: 0.25 + _controller.value * 0.35), + ), + color: EmColors.primary.withValues(alpha: 0.08), + ), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: EmColors.primary + .withValues(alpha: 0.55 + _controller.value * 0.45), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.text, + style: const TextStyle( + fontSize: 13, + color: EmColors.foreground, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _WatchTile extends StatelessWidget { + const _WatchTile({ + required this.target, + required this.platform, + required this.busy, + required this.onScan, + required this.onRemove, + }); + + final WatchTarget target; + final SocialPlatformInfo? platform; + final bool busy; + final VoidCallback onScan; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final accent = Color(platform?.accent ?? 0xFF57D9EC); + return EmCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.45), + blurRadius: 8, + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + target.label ?? platform?.label ?? 'Watch target', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + target.url, + style: const TextStyle( + fontSize: 12, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 8), + Text( + target.lastScannedAt == null + ? 'Awaiting first sweep' + : 'Last sweep · ${_fmt(target.lastScannedAt!)}' + '${target.lastReference == null ? '' : ' · ${target.lastReference}'}', + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + const SizedBox(height: 10), + Row( + children: [ + OutlinedButton( + onPressed: busy ? null : onScan, + child: const Text('Scan'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onRemove, + child: const Text('Remove'), + ), + ], + ), + ], + ), + ); + } +} + +class _ClaimTile extends StatelessWidget { + const _ClaimTile({required this.claim}); + + final PublishedClaim claim; + + @override + Widget build(BuildContext context) { + return EmCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${claim.medium.wire.toUpperCase()} · ${claim.subject}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text(claim.owner, + style: const TextStyle( + fontSize: 13, color: EmColors.mutedForeground)), + const SizedBox(height: 8), + SelectableText(claim.reference, style: emMonoLabel(size: 10)), + const SizedBox(height: 6), + Text( + claim.remoteSynced + ? 'Synced to remote registry' + : 'Stored on this device' + '${ClaimRegistry.hasRemote ? ' · remote sync pending/failed' : ''}', + style: emMonoLabel(color: EmColors.mutedForeground, size: 9), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: claim.reference)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fingerprint copied')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy fingerprint'), + ), + ), + ], + ), + ); + } +} + +class _SightingTile extends StatelessWidget { + const _SightingTile({ + required this.sighting, + required this.isFirst, + required this.isLast, + }); + + final TraceSighting sighting; + final bool isFirst; + final bool isLast; + + @override + Widget build(BuildContext context) { + final color = sighting.found ? EmColors.accent : EmColors.mutedForeground; + final social = SocialPlatformInfo.fromUrl(sighting.url); + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 22, + child: Column( + children: [ + Container( + width: 2, + height: isFirst ? 8 : 0, + color: Colors.transparent, + ), + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + ), + Expanded( + child: Container( + width: 2, + color: isLast + ? Colors.transparent + : EmColors.border, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 14), + child: EmCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + sighting.found + ? (sighting.matchedPublishedClaimId != null + ? 'Matched your claim' + : 'Fingerprint found') + : 'No fingerprint', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: sighting.found + ? EmColors.accent + : EmColors.foreground, + ), + ), ), - ), - const SizedBox(height: 4), - Text(s.url, - style: const TextStyle( - fontSize: 12, color: EmColors.mutedForeground)), - if (s.owner != null) ...[ - const SizedBox(height: 6), - Text('${s.owner} · ${s.subject ?? s.medium.wire}'), - ], - if (s.reference != null) ...[ - const SizedBox(height: 6), - Text(s.reference!, style: emMonoLabel(size: 9)), - ], - if (s.error != null) ...[ - const SizedBox(height: 6), - Text(s.error!, - style: const TextStyle( - fontSize: 12, color: EmColors.destructive)), + if (social != null) + Text( + social.shortLabel, + style: emMonoLabel( + color: Color(social.accent), + size: 9, + ), + ), ], + ), + const SizedBox(height: 4), + Text( + sighting.url, + style: const TextStyle( + fontSize: 12, + color: EmColors.mutedForeground, + ), + ), + if (sighting.owner != null) ...[ const SizedBox(height: 6), - Text(_fmt(s.at), - style: emMonoLabel( - color: EmColors.mutedForeground, size: 9)), + Text('${sighting.owner} · ${sighting.subject ?? sighting.medium.wire}'), ], - ), + if (sighting.reference != null) ...[ + const SizedBox(height: 6), + Text(sighting.reference!, style: emMonoLabel(size: 9)), + ], + if (sighting.error != null) ...[ + const SizedBox(height: 6), + Text( + sighting.error!, + style: const TextStyle( + fontSize: 12, + color: EmColors.destructive, + ), + ), + ], + const SizedBox(height: 6), + Text( + _fmt(sighting.at), + style: emMonoLabel( + color: EmColors.mutedForeground, + size: 9, + ), + ), + ], ), - )), - ], + ), + ), + ), + ], + ), ); } - - static String _fmt(DateTime at) { - final local = at.toLocal(); - String two(int n) => n.toString().padLeft(2, '0'); - return '${local.year}-${two(local.month)}-${two(local.day)} ' - '${two(local.hour)}:${two(local.minute)}'; - } } class _ResultCard extends StatelessWidget { @@ -362,10 +1231,16 @@ class _ResultCard extends StatelessWidget { final banner = s.error != null ? (ok: false, title: 'Could not scan URL', subtitle: s.error!) : claimBanner(s.claimStatus); + final social = SocialPlatformInfo.fromUrl(s.url); return EmCard( + glow: s.found, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (social != null) ...[ + MonoLabel('Hit on ${social.label}', color: Color(social.accent)), + const SizedBox(height: 10), + ], StatusBanner( ok: banner.ok, title: banner.title, @@ -390,3 +1265,10 @@ class _ResultCard extends StatelessWidget { ); } } + +String _fmt(DateTime at) { + final local = at.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ' + '${two(local.hour)}:${two(local.minute)}'; +} diff --git a/pubspec.lock b/pubspec.lock index bdef2ad..f2e1011 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -552,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: "72f229a38f2910029c11d840770c173b27072e348ef3376130b37514bc4556f6" + url: "https://pub.dev" + source: hosted + version: "1.9.0" record_use: dependency: transitive description: @@ -693,6 +701,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -701,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2ecbbb1..37c06be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: google_sign_in: ^7.2.0 flutter_secure_storage: ^11.0.0 http: ^1.4.0 + url_launcher: ^6.3.2 + receive_sharing_intent: ^1.8.1 dev_dependencies: flutter_test: diff --git a/test/social_platforms_test.dart b/test/social_platforms_test.dart new file mode 100644 index 0000000..289b279 --- /dev/null +++ b/test/social_platforms_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/social_platforms.dart'; + +void main() { + group('SocialPlatformInfo.fromUrl', () { + test('detects Instagram hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://www.instagram.com/p/abc/')?.id, + SocialPlatform.instagram, + ); + expect( + SocialPlatformInfo.fromUrl('https://instagr.am/p/abc/')?.id, + SocialPlatform.instagram, + ); + }); + + test('detects TikTok hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://www.tiktok.com/@user/video/1')?.id, + SocialPlatform.tiktok, + ); + expect( + SocialPlatformInfo.fromUrl('https://vm.tiktok.com/ZMabc/')?.id, + SocialPlatform.tiktok, + ); + }); + + test('detects X / Twitter hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://x.com/user/status/1')?.id, + SocialPlatform.x, + ); + expect( + SocialPlatformInfo.fromUrl('https://twitter.com/user/status/1')?.id, + SocialPlatform.x, + ); + }); + + test('returns null for unrelated hosts', () { + expect( + SocialPlatformInfo.fromUrl('https://cdn.example.com/a.png'), + isNull, + ); + }); + }); + + group('SocialMediaResolver helpers', () { + test('absolutize is covered via public resolve contract shape', () { + // Smoke: platforms list stays complete for Trace UI. + expect(SocialPlatformInfo.all.map((p) => p.id).toSet(), { + SocialPlatform.instagram, + SocialPlatform.tiktok, + SocialPlatform.x, + }); + }); + }); +} From 9edfbd890c85874c77a5ccb0fbfcd25b7b75a552 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:05:29 +0300 Subject: [PATCH 08/31] Bump version to 1.1.0 for social Trace release. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 37c06be..805256d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: signata description: "Signata — invisible ownership, verifiable authenticity. Digital watermarking for images, audio, video, and PDFs." publish_to: 'none' -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: ^3.12.2 From 4cb5fa4f2e5b372d78a2e9845058303f076ee687 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:26:05 +0300 Subject: [PATCH 09/31] Add in-app Google Sign-In setup and fix video UUID parsing. Let users paste a Web client ID without rebuilding, and correct the MP4 uuid walker so fingerprint round-trips pass. --- .gitignore | 3 + google_oauth.example.env | 13 ++- lib/core/auth.dart | 37 +++++-- lib/core/google_auth_config.dart | 71 ++++++++++++- lib/core/video_fingerprint.dart | 6 +- lib/screens/login_screen.dart | 163 ++++++++++++++++++++++++++++- lib/screens/pdf_tool_screen.dart | 2 +- lib/screens/video_tool_screen.dart | 2 +- 8 files changed, 272 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 3820a95..af4e971 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local Google OAuth secrets (use google_oauth.example.env as a template) +google_oauth.env diff --git a/google_oauth.example.env b/google_oauth.example.env index 754031e..bd2b008 100644 --- a/google_oauth.example.env +++ b/google_oauth.example.env @@ -1,10 +1,13 @@ -# Copy these into your run command, or paste the Web client ID into -# lib/core/google_auth_config.dart (defaultValue). +# Copy to google_oauth.env and fill in your Web client ID, then run: +# flutter run --dart-define-from-file=google_oauth.env # -# GOOGLE_SERVER_CLIENT_ID = Web application OAuth client -# (…apps.googleusercontent.com) ← required for Android +# Or paste the Web client ID in the app: login → Set up Google Sign-In # -# GOOGLE_CLIENT_ID = optional; usually leave empty on Android +# Google Cloud Console → APIs & Services → Credentials +# 1) OAuth client type "Web application" → GOOGLE_SERVER_CLIENT_ID +# 2) OAuth client type "Android" +# Package name: app.signata.signata +# SHA-1 (debug): 0A:3A:67:69:E8:44:A7:A5:0A:1B:FD:CD:61:A1:32:5C:2B:F6:9F:AA GOOGLE_SERVER_CLIENT_ID=YOUR_WEB_CLIENT_ID.apps.googleusercontent.com GOOGLE_CLIENT_ID= diff --git a/lib/core/auth.dart b/lib/core/auth.dart index a5179dc..e2db562 100644 --- a/lib/core/auth.dart +++ b/lib/core/auth.dart @@ -203,10 +203,19 @@ class AuthService extends ChangeNotifier { bool get isSignedIn => _user != null; bool get isReady => _ready; + bool get isGoogleConfigured => GoogleAuthConfig.hasServerClientId; + Future initialize() async { + await GoogleAuthConfig.load(); + await _initGoogleSignIn(); + await _restoreSession(); + _ready = true; + notifyListeners(); + } + + Future _initGoogleSignIn() async { try { // Android requires the *Web* OAuth client ID as serverClientId. - // Create it in Google Cloud Console → APIs & Services → Credentials. await _google.initialize( clientId: GoogleAuthConfig.clientId.isEmpty ? null @@ -218,18 +227,26 @@ class AuthService extends ChangeNotifier { _googleReady = GoogleAuthConfig.hasServerClientId; if (!_googleReady) { debugPrint( - 'Google Sign-In: set GOOGLE_SERVER_CLIENT_ID (Web client ID) ' - 'via --dart-define or lib/core/google_auth_config.dart', + 'Google Sign-In: missing Web client ID. ' + 'Paste it on the login screen, or set GOOGLE_SERVER_CLIENT_ID.', ); } } catch (error) { debugPrint('Google Sign-In init skipped: $error'); _googleReady = false; } + } - await _restoreSession(); - _ready = true; + /// Saves a Google Web client ID and re-initializes the SDK. + Future configureGoogleServerClientId(String clientId) async { + await GoogleAuthConfig.saveServerClientId(clientId); + await _initGoogleSignIn(); notifyListeners(); + if (!_googleReady) { + throw const AuthException( + 'Google Sign-In still failed to initialize. Check the Web client ID.', + ); + } } Future signInWithEmail({ @@ -344,12 +361,14 @@ class AuthService extends ChangeNotifier { Future signInWithGoogle() async { if (!GoogleAuthConfig.hasServerClientId) { throw const AuthException( - 'Google Client IDs are missing. Add your Web OAuth client ID as ' - 'GOOGLE_SERVER_CLIENT_ID (see lib/core/google_auth_config.dart), ' - 'and register an Android OAuth client with package ' - 'app.signata.signata + your SHA-1.', + 'Google setup needed. Tap “Set up Google Sign-In”, paste your Web ' + 'client ID, and make sure an Android OAuth client exists for ' + 'package app.signata.signata.', ); } + if (!_googleReady) { + await _initGoogleSignIn(); + } if (!_googleReady) { throw const AuthException( 'Google Sign-In is not available on this device yet. Use email instead, or configure a Google OAuth client for the app.', diff --git a/lib/core/google_auth_config.dart b/lib/core/google_auth_config.dart index 868bb38..b75c46f 100644 --- a/lib/core/google_auth_config.dart +++ b/lib/core/google_auth_config.dart @@ -5,13 +5,26 @@ /// 2. OAuth client type **Android** with package `app.signata.signata` + SHA-1 /// (needed for Google to trust this app; you usually do NOT paste that ID here) /// -/// Fill the values below, or pass them at run time: -/// `flutter run --dart-define=GOOGLE_SERVER_CLIENT_ID=....apps.googleusercontent.com` +/// Sources (first match wins): +/// 1. Runtime override saved from the login screen (secure storage) +/// 2. `--dart-define=GOOGLE_SERVER_CLIENT_ID=...` or `--dart-define-from-file=` +/// 3. Optional [defaultValue] below library; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + class GoogleAuthConfig { + GoogleAuthConfig._(); + + static const _storageKey = 'signata_google_server_client_id'; + + /// Debug Android package + SHA-1 for the OAuth Android client. + static const androidPackageName = 'app.signata.signata'; + static const androidDebugSha1 = + '0A:3A:67:69:E8:44:A7:A5:0A:1B:FD:CD:61:A1:32:5C:2B:F6:9F:AA'; + /// Web client ID — required on Android as `serverClientId`. - static const String serverClientId = String.fromEnvironment( + static const String _defineServerClientId = String.fromEnvironment( 'GOOGLE_SERVER_CLIENT_ID', defaultValue: '', ); @@ -23,5 +36,55 @@ class GoogleAuthConfig { defaultValue: '', ); - static bool get hasServerClientId => serverClientId.trim().isNotEmpty; + static String? _runtimeServerClientId; + static bool _loaded = false; + + static final _secure = const FlutterSecureStorage( + aOptions: AndroidOptions(), + ); + + static String get serverClientId { + final runtime = _runtimeServerClientId?.trim() ?? ''; + if (runtime.isNotEmpty) return runtime; + return _defineServerClientId.trim(); + } + + static bool get hasServerClientId => serverClientId.isNotEmpty; + + static bool get isLikelyClientId => + RegExp(r'^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$') + .hasMatch(serverClientId); + + /// Loads any previously saved Web client ID from secure storage. + static Future load() async { + if (_loaded) return; + try { + _runtimeServerClientId = await _secure.read(key: _storageKey); + } catch (_) { + _runtimeServerClientId = null; + } + _loaded = true; + } + + /// Saves a Web client ID so Google Sign-In works without rebuilding. + static Future saveServerClientId(String value) async { + final trimmed = value.trim(); + if (!_looksLikeClientId(trimmed)) { + throw ArgumentError( + 'Paste the Web client ID (…apps.googleusercontent.com).', + ); + } + _runtimeServerClientId = trimmed; + _loaded = true; + await _secure.write(key: _storageKey, value: trimmed); + } + + static Future clearServerClientId() async { + _runtimeServerClientId = null; + await _secure.delete(key: _storageKey); + } + + static bool _looksLikeClientId(String value) => + RegExp(r'^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$') + .hasMatch(value); } diff --git a/lib/core/video_fingerprint.dart b/lib/core/video_fingerprint.dart index 0df69ea..c11af8f 100644 --- a/lib/core/video_fingerprint.dart +++ b/lib/core/video_fingerprint.dart @@ -279,7 +279,8 @@ String? extractVideoUuidPayload(Uint8List bytes) { final size = ByteData.sublistView(bytes, offset, offset + 4) .getUint32(0, Endian.big); final type = String.fromCharCodes(bytes.sublist(offset + 4, offset + 8)); - if (size < 24 && size != 1) break; + // Top-level boxes only need an 8-byte header; uuid payload needs 24+. + if (size != 0 && size != 1 && size < 8) break; final end = size == 0 ? bytes.length @@ -288,7 +289,8 @@ String? extractVideoUuidPayload(Uint8List bytes) { ByteData.sublistView(bytes, offset + 8, offset + 16) .getUint64(0, Endian.big) : offset + size; - final windowEnd = end.clamp(0, bytes.length); + if (end <= offset || end > bytes.length) break; + final windowEnd = end; if (type == 'uuid' && offset + 24 <= windowEnd) { final userType = bytes.sublist(offset + 8, offset + 24); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index f3e3294..4d863b8 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../core/auth.dart'; +import '../core/google_auth_config.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; @@ -67,6 +69,11 @@ class _LoginScreenState extends State { } Future _submitGoogle() async { + if (!AuthService.instance.isGoogleConfigured) { + final configured = await _showGoogleSetupDialog(); + if (!configured) return; + } + setState(() { _busy = true; _error = null; @@ -76,15 +83,146 @@ class _LoginScreenState extends State { _clearSecrets(); } catch (error) { if (!mounted) return; - setState(() => _error = error.toString().replaceFirst('Exception: ', '')); + final message = error.toString().replaceFirst('Exception: ', ''); + setState(() => _error = message); + if (message.toLowerCase().contains('client') || + message.toLowerCase().contains('setup') || + message.toLowerCase().contains('oauth')) { + await _showGoogleSetupDialog(); + } } finally { if (mounted) setState(() => _busy = false); } } + Future _showGoogleSetupDialog() async { + final controller = TextEditingController( + text: GoogleAuthConfig.serverClientId, + ); + final saved = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Set up Google Sign-In'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Create two OAuth clients in Google Cloud, then paste the Web client ID here.', + style: TextStyle( + fontSize: 13.5, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 14), + const Text( + '1. Open Credentials → Create OAuth client', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + const Text( + '• Web application → copy its Client ID\n' + '• Android → package + SHA-1 below (no need to paste that ID)', + style: TextStyle( + fontSize: 13, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 12), + SelectableText( + 'Package: ${GoogleAuthConfig.androidPackageName}\n' + 'SHA-1: ${GoogleAuthConfig.androidDebugSha1}', + style: emMono(size: 11), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData( + text: GoogleAuthConfig.androidDebugSha1, + )); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('SHA-1 copied')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy SHA-1'), + ), + TextButton.icon( + onPressed: () async { + final uri = Uri.parse( + 'https://console.cloud.google.com/apis/credentials', + ); + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + }, + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Open Cloud Console'), + ), + ], + ), + const SizedBox(height: 14), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Web client ID', + hintText: '123-abc.apps.googleusercontent.com', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + try { + await AuthService.instance + .configureGoogleServerClientId(controller.text); + if (!context.mounted) return; + Navigator.pop(context, true); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error.toString().replaceFirst('Exception: ', ''), + ), + ), + ); + } + }, + child: const Text('Save & continue'), + ), + ], + ); + }, + ); + controller.dispose(); + return saved == true; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final googleReady = AuthService.instance.isGoogleConfigured; + return Scaffold( body: Stack( children: [ @@ -146,11 +284,28 @@ class _LoginScreenState extends State { OutlinedButton.icon( onPressed: _busy ? null : _submitGoogle, icon: const Icon(Icons.g_mobiledata, size: 28), - label: const Text('Continue with Google'), + label: Text( + googleReady + ? 'Continue with Google' + : 'Continue with Google (setup)', + ), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(50), ), ), + if (!googleReady) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: _busy + ? null + : () async { + final ok = + await _showGoogleSetupDialog(); + if (ok && mounted) setState(() {}); + }, + child: const Text('Set up Google Sign-In'), + ), + ], const SizedBox(height: 18), Row( children: [ @@ -297,7 +452,9 @@ class _LoginScreenState extends State { ), const SizedBox(height: 8), Text( - 'Email passwords are hashed with PBKDF2 and kept in on-device secure storage. Google uses your Google account when OAuth is configured.', + googleReady + ? 'Email passwords stay in on-device secure storage. Google uses your Google account.' + : 'Google Sign-In needs a one-time OAuth setup (Web + Android clients). Email login works without it.', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: EmColors.mutedForeground, diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index 8b5b409..34ef478 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -93,7 +93,7 @@ class _PdfToolScreenState extends State { subject: file.name, owner: outcome.payload.owner, verified: claimCountsAsVerified(outcome.claimStatus), - reference: outcome.payload.identifier, + reference: outcome.payload.structuralIdentifier(), at: DateTime.now(), )); } else { diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index 5a2fe3c..2f7fbde 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -92,7 +92,7 @@ class _VideoToolScreenState extends State { subject: file.name, owner: outcome.payload.owner, verified: claimCountsAsVerified(outcome.claimStatus), - reference: outcome.payload.identifier, + reference: outcome.payload.structuralIdentifier(), at: DateTime.now(), )); } else { From 6c6d5cf466ce7c777bf082b718b97ca3cde64f6c Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:44:05 +0300 Subject: [PATCH 10/31] Add deep integration tests and fix EmCard Material warnings. Cover media, auth, trace, and report paths end-to-end, and wrap EmCard in Material so ListTiles paint ink correctly. --- lib/widgets/em_widgets.dart | 37 ++-- test/deep_integration_test.dart | 307 ++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 test/deep_integration_test.dart diff --git a/lib/widgets/em_widgets.dart b/lib/widgets/em_widgets.dart index 608843d..15fad31 100644 --- a/lib/widgets/em_widgets.dart +++ b/lib/widgets/em_widgets.dart @@ -139,24 +139,27 @@ class EmCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: padding, - decoration: BoxDecoration( - color: EmColors.card, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: borderColor), - boxShadow: glow - ? [ - BoxShadow( - color: EmColors.primary.withValues(alpha: 0.25), - blurRadius: 40, - spreadRadius: -8, - ), - ] - : null, + return Material( + color: Colors.transparent, + child: Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + color: EmColors.card, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: borderColor), + boxShadow: glow + ? [ + BoxShadow( + color: EmColors.primary.withValues(alpha: 0.25), + blurRadius: 40, + spreadRadius: -8, + ), + ] + : null, + ), + child: child, ), - child: child, ); } } diff --git a/test/deep_integration_test.dart b/test/deep_integration_test.dart new file mode 100644 index 0000000..9a0d873 --- /dev/null +++ b/test/deep_integration_test.dart @@ -0,0 +1,307 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/audio_watermark.dart'; +import 'package:signata/core/auth.dart'; +import 'package:signata/core/claim_crypto.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/image_watermark.dart'; +import 'package:signata/core/pdf_fingerprint.dart'; +import 'package:signata/core/report.dart'; +import 'package:signata/core/social_platforms.dart'; +import 'package:signata/core/trace_models.dart'; +import 'package:signata/core/trace_store.dart'; +import 'package:signata/core/url_tracer.dart'; +import 'package:signata/core/video_fingerprint.dart'; + +Uint8List _png({int w = 48, int h = 36}) { + final image = img.Image(width: w, height: h); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + image.setPixelRgba(x, y, (x * 7) % 255, (y * 11) % 255, 90, 255); + } + } + return Uint8List.fromList(img.encodePng(image)); +} + +Uint8List _wav({int samples = 2400}) { + final dataSize = samples * 2; + final bytes = BytesBuilder(); + void writeStr(String s) => bytes.add(s.codeUnits); + void write32(int v) { + final b = ByteData(4)..setUint32(0, v, Endian.little); + bytes.add(b.buffer.asUint8List()); + } + + void write16(int v) { + final b = ByteData(2)..setUint16(0, v, Endian.little); + bytes.add(b.buffer.asUint8List()); + } + + writeStr('RIFF'); + write32(36 + dataSize); + writeStr('WAVE'); + writeStr('fmt '); + write32(16); + write16(1); + write16(1); + write32(16000); + write32(32000); + write16(2); + write16(16); + writeStr('data'); + write32(dataSize); + for (var i = 0; i < samples; i++) { + write16(((i % 200) - 100) * 40); + } + return bytes.toBytes(); +} + +Uint8List _pdf() { + const body = ''' +%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer<< /Size 4 /Root 1 0 R >> +startxref +190 +%%EOF +'''; + return Uint8List.fromList(utf8.encode(body)); +} + +Uint8List _mp4() { + final bytes = BytesBuilder(); + void atom(String type, List body) { + final size = 8 + body.length; + final header = ByteData(8)..setUint32(0, size, Endian.big); + final out = Uint8List(size); + out.setAll(0, header.buffer.asUint8List()); + out.setAll(4, type.codeUnits); + out.setAll(8, body); + bytes.add(out); + } + + atom('ftyp', [...'isom'.codeUnits, 0, 0, 0, 0, ...'isom'.codeUnits]); + atom('moov', [...'vide'.codeUnits, ...'soun'.codeUnits]); + atom('mdat', List.filled(128, 3)); + return bytes.toBytes(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final alice = ClaimKey.fromSeed('deep-alice'); + final bob = ClaimKey.fromSeed('deep-bob'); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + + group('deep media round-trips', () { + test('image embed survives extract and rejects foreign key', () async { + final embedded = await embedWatermark( + fileBytes: _png(), + owner: 'Wejdan', + fileName: 'shot.png', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + + final ok = await extractWatermark(embedded.markedPng, claimKey: alice); + expect(ok.claimStatus, ClaimStatus.authenticated); + expect(WatermarkPayload.tryParse(ok.raw)?.owner, 'Wejdan'); + + final foreign = + await extractWatermark(embedded.markedPng, claimKey: bob); + expect(foreign.claimStatus, ClaimStatus.foreignKey); + }); + + test('audio embed/extract authenticates', () async { + final embedded = await embedAudioWatermark( + fileBytes: _wav(), + owner: 'Wejdan', + fileName: 'clip.wav', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + final extracted = + await extractAudioWatermark(embedded.markedWav, claimKey: alice); + expect(extracted.claimStatus, ClaimStatus.authenticated); + expect(AudioPayload.tryParse(extracted.raw)?.owner, 'Wejdan'); + }); + + test('pdf structural fingerprint authenticates and survives strip of trailer', + () async { + final embedded = await embedPdfFingerprint( + source: _pdf(), + owner: 'Wejdan', + fileName: 'doc.pdf', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + expect(embedded.verified, isTrue); + + final verified = + await verifyPdfFingerprint(embedded.markedBytes, claimKey: alice); + expect(verified.claimStatus, ClaimStatus.authenticated); + expect( + verified.recovered?.structuralIdentifier(), + embedded.payload.structuralIdentifier(), + ); + }); + + test('video uuid atom is present and verifies', () async { + final embedded = await embedVideoFingerprint( + source: _mp4(), + owner: 'Wejdan', + fileName: 'reel.mp4', + claimKey: alice, + ); + expect(embedded.claimStatus, ClaimStatus.authenticated); + expect(extractVideoUuidPayload(embedded.markedBytes), isNotNull); + + final verified = + await verifyVideoFingerprint(embedded.markedBytes, claimKey: alice); + expect(verified.claimStatus, ClaimStatus.authenticated); + expect(verified.structureMatch, isTrue); + }); + }); + + group('deep auth policy', () { + test('password policy rejects weak and accepts strong', () { + expect(PasswordPolicy.validate('short1A'), isNotNull); + expect(PasswordPolicy.validate('alllettersxx'), isNotNull); + expect(PasswordPolicy.validate('1234567890'), isNotNull); + expect(PasswordPolicy.validate('Signata2026!'), isNull); + }); + + test('pbkdf2 verify fails closed on wrong password', () { + final salt = base64UrlEncode(List.generate(16, (i) => i + 9)); + final hash = PasswordHasher.hash('CorrectHorse9', salt); + expect( + PasswordHasher.verify( + password: 'CorrectHorse9', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isTrue, + ); + expect( + PasswordHasher.verify( + password: 'CorrectHorse8', + saltBase64: salt, + expectedHash: hash, + algorithm: PasswordHasher.algoPbkdf2, + ), + isFalse, + ); + }); + }); + + group('deep tracing', () { + test('social hosts map correctly', () { + expect( + SocialPlatformInfo.fromUrl('https://www.instagram.com/p/x/')?.id, + SocialPlatform.instagram, + ); + expect( + SocialPlatformInfo.fromUrl('https://vm.tiktok.com/abc')?.id, + SocialPlatform.tiktok, + ); + expect( + SocialPlatformInfo.fromUrl('https://x.com/a/status/1')?.id, + SocialPlatform.x, + ); + expect(SocialPlatformInfo.fromUrl('https://cdn.example.com/a.png'), isNull); + }); + + test('publish + find claim by reference is case-insensitive', () async { + final published = await ClaimRegistry.instance.publish( + medium: TraceMedium.image, + owner: 'Wejdan', + subject: 'a.png', + reference: 'AbCdEf123456', + issued: '2026-08-09T00:00:00Z', + alg: 'hmac-sha256', + kid: alice.kid, + ); + final found = + await ClaimRegistry.instance.findByReference('abcdef123456'); + expect(found?.id, published.id); + expect(found?.owner, 'Wejdan'); + }); + + test('watchlist dedupes URL and stores sightings', () async { + final a = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/pic.png', + label: 'Portfolio', + ); + final b = await TraceStore.instance.addWatchTarget( + 'https://cdn.example.com/pic.png', + label: 'Again', + ); + expect(a.id, b.id); + expect((await TraceStore.instance.listWatchTargets()).length, 1); + + await TraceStore.instance.addSighting(TraceSighting( + id: 's-deep', + url: a.url, + medium: TraceMedium.image, + at: DateTime.utc(2026, 8, 9), + found: true, + claimStatus: ClaimStatus.authenticated, + owner: 'Wejdan', + reference: 'REF', + )); + final sightings = await TraceStore.instance.listSightings(); + expect(sightings.first.found, isTrue); + }); + + test('url tracer rejects non-http schemes', () async { + expect( + () => UrlTracer.instance.scanUrl('file:///tmp/a.png', persist: false), + throwsA(isA()), + ); + }); + }); + + group('deep reports', () { + test('sealed report verifies and breaks on tamper', () { + final sealed = sealReport(const ReportBody( + medium: 'image', + subject: 'a.png', + owner: 'Wejdan', + verified: true, + issued: 't', + generated: 'g', + evidence: {'k': 'v'}, + )); + expect(verifySealedReport(sealed), isTrue); + final tampered = SealedReport( + body: const ReportBody( + medium: 'image', + subject: 'a.png', + owner: 'Other', + verified: true, + issued: 't', + generated: 'g', + evidence: {'k': 'v'}, + ), + seal: sealed.seal, + ); + expect(verifySealedReport(tampered), isFalse); + }); + }); +} From a45643d3f70e5b29a2819d240e976d4523f7638e Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:21:21 +0300 Subject: [PATCH 11/31] Bump version to 1.1.1 for Google setup and video fix release. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 805256d..2d51990 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: signata description: "Signata — invisible ownership, verifiable authenticity. Digital watermarking for images, audio, video, and PDFs." publish_to: 'none' -version: 1.1.0+2 +version: 1.1.1+3 environment: sdk: ^3.12.2 From afba040b5e67eac9d2bd716269bc681c91f4add4 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:13:17 +0300 Subject: [PATCH 12/31] Add in-app Privacy Policy and bump to 1.1.2. --- PRIVACY.md | 57 ++++++ lib/screens/account_screen.dart | 25 +++ lib/screens/login_screen.dart | 15 +- lib/screens/privacy_policy_screen.dart | 234 +++++++++++++++++++++++++ pubspec.yaml | 2 +- 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 PRIVACY.md create mode 100644 lib/screens/privacy_policy_screen.dart diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..ef0e937 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,57 @@ +# Privacy Policy for Signata + +**Last Updated:** August 10, 2026 + +At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform. + +## 1. Data We Collect and Process + +Given Signata's nature as a digital watermarking and ownership verification platform, we handle specific categories of data: + +- **Basic Account Data:** Upon registration, we collect the necessary information to create and manage your account (e.g., name, email address, and authentication data). +- **Media Files:** This includes images (PNG, JPG), audio (WAV), video clips (MP4/MOV), and documents (PDF) that you protect or verify using the app. +- **Metadata & Encrypted Fingerprints:** We process and generate structural identifiers (such as SHA-256) and hidden encrypted fingerprints to prove content ownership. +- **Trace Data & Public URLs:** The public links you input into the "Trace" tool to scan for your content on platforms like Instagram, TikTok, and X. + +## 2. On-Device Verification & Processing + +At Signata, the security and privacy of your content are our top priorities: + +- **Media Protection:** The processes of embedding hidden watermarks, generating audio fingerprints, and extracting structural identifiers (SHA-256) for videos and documents are performed locally on your device (on-device) whenever possible. +- We do not upload, store, or keep copies of your original files (images, audio, video, or PDFs) on our servers without your explicit consent, ensuring you retain exclusive ownership of your content. +- The data stored on our servers is strictly limited to encrypted fingerprints and ownership logs associated with your account, enabling you to verify content authenticity on demand. + +## 3. How We Use Your Data + +The data we collect is strictly used for the following purposes: + +- To enable you to embed hidden ownership fingerprints in media without altering its visual or auditory quality. +- To operate the Radar and Trace services, scanning public links and matching them against your registered fingerprints to detect unauthorized use. +- To improve our hidden fingerprint algorithms and their robustness against common modifications (such as compression, cropping, and color shifts). +- To provide technical support and manage your account. + +## 4. Third-Party Services and External Links + +- **Trace Tool:** This tool interacts with the public interfaces of platforms (like Instagram, TikTok, and X) to scan the links you provide. We do not control the privacy practices of these platforms, and your interaction with them is governed by their respective privacy policies. +- We will never share your Trace logs, search history, or encrypted fingerprints with any third parties for marketing or advertising purposes. + +## 5. Data Security + +We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof. + +## 6. User Rights (Access and Deletion) + +In accordance with applicable data protection laws, you have the right to: + +- **Access:** View your ownership records (History) and actively tracked links (Watching) directly through your account dashboard. +- **Deletion:** You have the right to request the complete deletion of your account. This action will permanently erase all your encrypted fingerprints and trace logs from our servers. (Note: Deleting your account will not remove the hidden watermarks already embedded in your exported files, but it will sever the link between those watermarks and your account records). + +## 7. Governing Law and Policy Updates + +This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email. + +## 8. Contact Us + +For any legal or technical inquiries regarding your privacy and data management, please contact us at: + +**Support Email:** FocusMindDev@gmail.com diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 87430b2..6f4324b 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../core/auth.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import 'privacy_policy_screen.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @@ -100,6 +101,30 @@ class AccountScreen extends StatelessWidget { ], ), ), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MonoLabel('Legal'), + const SizedBox(height: 10), + const Text( + 'How Signata collects, uses, and protects your data under Saudi PDPL.', + style: TextStyle( + fontSize: 13, + height: 1.45, + color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: () => PrivacyPolicyScreen.open(context), + icon: const Icon(Icons.privacy_tip_outlined, size: 18), + label: const Text('Privacy Policy'), + ), + ], + ), + ), ], ); } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 4d863b8..4d8896e 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -6,6 +6,7 @@ import '../core/auth.dart'; import '../core/google_auth_config.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import 'privacy_policy_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -450,7 +451,19 @@ class _LoginScreenState extends State { style: const TextStyle(color: EmColors.primary), ), ), - const SizedBox(height: 8), + TextButton( + onPressed: _busy + ? null + : () => PrivacyPolicyScreen.open(context), + child: const Text( + 'Privacy Policy', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13, + ), + ), + ), + const SizedBox(height: 4), Text( googleReady ? 'Email passwords stay in on-device secure storage. Google uses your Google account.' diff --git a/lib/screens/privacy_policy_screen.dart b/lib/screens/privacy_policy_screen.dart new file mode 100644 index 0000000..9b25070 --- /dev/null +++ b/lib/screens/privacy_policy_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +/// In-app Privacy Policy for Signata (PDPL / Kingdom of Saudi Arabia). +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key}); + + static const lastUpdated = 'August 10, 2026'; + static const supportEmail = 'FocusMindDev@gmail.com'; + + static Future open(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PrivacyPolicyScreen(), + ), + ); + } + + Future _mailSupport() async { + final uri = Uri( + scheme: 'mailto', + path: supportEmail, + queryParameters: {'subject': 'Signata privacy inquiry'}, + ); + await launchUrl(uri); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EmColors.background, + appBar: AppBar( + title: const Text('Privacy Policy'), + backgroundColor: EmColors.background, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + children: [ + const SectionHeading( + tag: 'Legal', + title: 'Privacy Policy for Signata', + desc: 'Last updated: August 10, 2026', + ), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Intro(), + const SizedBox(height: 22), + const _Section( + number: '1', + title: 'Data We Collect and Process', + body: + 'Given Signata\'s nature as a digital watermarking and ownership verification platform, we handle specific categories of data:', + bullets: [ + 'Basic Account Data: Upon registration, we collect the necessary information to create and manage your account (e.g., name, email address, and authentication data).', + 'Media Files: This includes images (PNG, JPG), audio (WAV), video clips (MP4/MOV), and documents (PDF) that you protect or verify using the app.', + 'Metadata & Encrypted Fingerprints: We process and generate structural identifiers (such as SHA-256) and hidden encrypted fingerprints to prove content ownership.', + 'Trace Data & Public URLs: The public links you input into the "Trace" tool to scan for your content on platforms like Instagram, TikTok, and X.', + ], + ), + const _Section( + number: '2', + title: 'On-Device Verification & Processing', + body: + 'At Signata, the security and privacy of your content are our top priorities:', + bullets: [ + 'Media Protection: The processes of embedding hidden watermarks, generating audio fingerprints, and extracting structural identifiers (SHA-256) for videos and documents are performed locally on your device (on-device) whenever possible.', + 'We do not upload, store, or keep copies of your original files (images, audio, video, or PDFs) on our servers without your explicit consent, ensuring you retain exclusive ownership of your content.', + 'The data stored on our servers is strictly limited to encrypted fingerprints and ownership logs associated with your account, enabling you to verify content authenticity on demand.', + ], + ), + const _Section( + number: '3', + title: 'How We Use Your Data', + body: + 'The data we collect is strictly used for the following purposes:', + bullets: [ + 'To enable you to embed hidden ownership fingerprints in media without altering its visual or auditory quality.', + 'To operate the Radar and Trace services, scanning public links and matching them against your registered fingerprints to detect unauthorized use.', + 'To improve our hidden fingerprint algorithms and their robustness against common modifications (such as compression, cropping, and color shifts).', + 'To provide technical support and manage your account.', + ], + ), + const _Section( + number: '4', + title: 'Third-Party Services and External Links', + body: null, + bullets: [ + 'Trace Tool: This tool interacts with the public interfaces of platforms (like Instagram, TikTok, and X) to scan the links you provide. We do not control the privacy practices of these platforms, and your interaction with them is governed by their respective privacy policies.', + 'We will never share your Trace logs, search history, or encrypted fingerprints with any third parties for marketing or advertising purposes.', + ], + ), + const _Section( + number: '5', + title: 'Data Security', + body: + 'We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof.', + ), + const _Section( + number: '6', + title: 'User Rights (Access and Deletion)', + body: + 'In accordance with applicable data protection laws, you have the right to:', + bullets: [ + 'Access: View your ownership records (History) and actively tracked links (Watching) directly through your account dashboard.', + 'Deletion: You have the right to request the complete deletion of your account. This action will permanently erase all your encrypted fingerprints and trace logs from our servers. Note: Deleting your account will not remove the hidden watermarks already embedded in your exported files, but it will sever the link between those watermarks and your account records.', + ], + ), + const _Section( + number: '7', + title: 'Governing Law and Policy Updates', + body: + 'This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email.', + ), + const _Section( + number: '8', + title: 'Contact Us', + body: + 'For any legal or technical inquiries regarding your privacy and data management, please contact us at:', + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _mailSupport, + icon: const Icon(Icons.mail_outline, size: 18), + label: const Text(supportEmail), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Intro extends StatelessWidget { + const _Intro(); + + @override + Widget build(BuildContext context) { + return const Text( + 'At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform.', + style: TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.number, + required this.title, + required this.body, + this.bullets, + }); + + final String number; + final String title; + final String? body; + final List? bullets; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$number. $title', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: EmColors.foreground, + height: 1.35, + ), + ), + if (body != null) ...[ + const SizedBox(height: 8), + Text( + body!, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ], + if (bullets != null) ...[ + const SizedBox(height: 10), + for (final item in bullets!) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 7, right: 10), + child: Icon(Icons.circle, + size: 6, color: EmColors.primary), + ), + Expanded( + child: Text( + item, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2d51990..053c7a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: signata description: "Signata — invisible ownership, verifiable authenticity. Digital watermarking for images, audio, video, and PDFs." publish_to: 'none' -version: 1.1.1+3 +version: 1.1.2+4 environment: sdk: ^3.12.2 From eb66bfb3d7a7fcc7f82c0e08e5b1712db38205fd Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:26:02 +0300 Subject: [PATCH 13/31] Add freemium demo gate: daily limits with mock ad and Premium unlock. --- lib/core/usage_entitlements.dart | 203 +++++++++++++++++++++++++++ lib/screens/account_screen.dart | 89 ++++++++++++ lib/screens/audio_tool_screen.dart | 7 + lib/screens/image_tool_screen.dart | 7 + lib/screens/pdf_tool_screen.dart | 7 + lib/screens/trace_screen.dart | 11 ++ lib/screens/video_tool_screen.dart | 7 + lib/widgets/usage_paywall.dart | 214 +++++++++++++++++++++++++++++ test/usage_entitlements_test.dart | 99 +++++++++++++ 9 files changed, 644 insertions(+) create mode 100644 lib/core/usage_entitlements.dart create mode 100644 lib/widgets/usage_paywall.dart create mode 100644 test/usage_entitlements_test.dart diff --git a/lib/core/usage_entitlements.dart b/lib/core/usage_entitlements.dart new file mode 100644 index 0000000..77a25e1 --- /dev/null +++ b/lib/core/usage_entitlements.dart @@ -0,0 +1,203 @@ +/// Freemium usage gate (demo build). +/// +/// Free plan: [freeProtectsPerDay] protect runs and [freeTracePerDay] Trace +/// scans per calendar day, per signed-in user. A mock rewarded ad grants +1 +/// of the blocked action for today; a mock purchase unlocks Premium, which +/// removes both limits. Counters and the premium flag are stored locally in +/// SharedPreferences, keyed by user id. +/// +/// The mock [BillingGateway] and [RewardedAdGateway] are the seams to swap in +/// real Play Billing / AdMob later without touching the gating logic. +library; + +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth.dart'; + +/// Which gated action is being consumed. +enum UsageKind { protect, trace } + +/// Result of trying to reserve an attempt. +enum GateResult { allowed, blocked } + +class UsageSnapshot { + const UsageSnapshot({ + required this.isPremium, + required this.protectUsed, + required this.protectLimit, + required this.traceUsed, + required this.traceLimit, + }); + + final bool isPremium; + final int protectUsed; + final int protectLimit; + final int traceUsed; + final int traceLimit; + + int get protectRemaining => + isPremium ? 1 << 30 : (protectLimit - protectUsed).clamp(0, 1 << 30); + int get traceRemaining => + isPremium ? 1 << 30 : (traceLimit - traceUsed).clamp(0, 1 << 30); + + int remainingFor(UsageKind kind) => + kind == UsageKind.protect ? protectRemaining : traceRemaining; +} + +/// Seam for a real billing SDK later. The demo build just succeeds. +class BillingGateway { + const BillingGateway(); + + Future purchasePremium() async { + await Future.delayed(const Duration(milliseconds: 600)); + return true; + } +} + +/// Seam for a real rewarded-ad SDK later. The demo build just succeeds. +class RewardedAdGateway { + const RewardedAdGateway(); + + Future show() async { + await Future.delayed(const Duration(milliseconds: 1500)); + return true; + } +} + +class UsageEntitlements { + UsageEntitlements._(); + + static final instance = UsageEntitlements._(); + + static const freeProtectsPerDay = 2; + static const freeTracePerDay = 1; + + final billing = const BillingGateway(); + final rewardedAd = const RewardedAdGateway(); + + /// Overridable in tests to simulate day rollover. + String Function() todayKey = _defaultTodayKey; + + /// Overridable in tests to avoid a signed-in user. + String Function() userId = _defaultUserId; + + static String _defaultTodayKey() { + final now = DateTime.now(); + return '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + } + + static String _defaultUserId() => + AuthService.instance.user?.id ?? 'anonymous'; + + String get _prefsKey => 'signata.usage.${userId()}'; + + Future> _load(SharedPreferences prefs) async { + final raw = prefs.getString(_prefsKey); + if (raw == null) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : {}; + } catch (_) { + return {}; + } + } + + Future _save(SharedPreferences prefs, Map data) => + prefs.setString(_prefsKey, jsonEncode(data)); + + /// Counters for today only; stale days reset to zero implicitly. + ({int protect, int trace, int protectBonus, int traceBonus}) _todayCounters( + Map data) { + if (data['day'] != todayKey()) { + return (protect: 0, trace: 0, protectBonus: 0, traceBonus: 0); + } + int asInt(Object? v) => v is int ? v : 0; + return ( + protect: asInt(data['protect']), + trace: asInt(data['trace']), + protectBonus: asInt(data['protectBonus']), + traceBonus: asInt(data['traceBonus']), + ); + } + + Future snapshot() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + final c = _todayCounters(data); + return UsageSnapshot( + isPremium: data['premium'] == true, + protectUsed: c.protect, + protectLimit: freeProtectsPerDay + c.protectBonus, + traceUsed: c.trace, + traceLimit: freeTracePerDay + c.traceBonus, + ); + } + + Future get isPremium async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + return data['premium'] == true; + } + + /// Consumes one attempt of [kind] if available. Premium always allows + /// without consuming. + Future reserve(UsageKind kind) async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + if (data['premium'] == true) return GateResult.allowed; + + final c = _todayCounters(data); + final used = kind == UsageKind.protect ? c.protect : c.trace; + final limit = kind == UsageKind.protect + ? freeProtectsPerDay + c.protectBonus + : freeTracePerDay + c.traceBonus; + if (used >= limit) return GateResult.blocked; + + await _save(prefs, { + 'premium': data['premium'] == true, + 'day': todayKey(), + 'protect': c.protect + (kind == UsageKind.protect ? 1 : 0), + 'trace': c.trace + (kind == UsageKind.trace ? 1 : 0), + 'protectBonus': c.protectBonus, + 'traceBonus': c.traceBonus, + }); + return GateResult.allowed; + } + + Future reserveProtect() => reserve(UsageKind.protect); + + Future reserveTrace() => reserve(UsageKind.trace); + + /// Rewarded-ad bonus: +1 attempt of [kind] valid for today. + Future grantAdBonus({required UsageKind kind}) async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + final c = _todayCounters(data); + await _save(prefs, { + 'premium': data['premium'] == true, + 'day': todayKey(), + 'protect': c.protect, + 'trace': c.trace, + 'protectBonus': c.protectBonus + (kind == UsageKind.protect ? 1 : 0), + 'traceBonus': c.traceBonus + (kind == UsageKind.trace ? 1 : 0), + }); + } + + Future unlockPremium() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + data['premium'] = true; + await _save(prefs, data); + } + + Future clearPremiumForDebug() async { + final prefs = await SharedPreferences.getInstance(); + final data = await _load(prefs); + data['premium'] = false; + await _save(prefs, data); + } +} diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 6f4324b..a2e4e6f 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import '../core/auth.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/usage_paywall.dart'; import 'privacy_policy_screen.dart'; class AccountScreen extends StatelessWidget { @@ -102,6 +104,8 @@ class AccountScreen extends StatelessWidget { ), ), const SizedBox(height: 16), + const _PlanCard(), + const SizedBox(height: 16), EmCard( padding: const EdgeInsets.all(18), child: Column( @@ -129,3 +133,88 @@ class AccountScreen extends StatelessWidget { ); } } + +/// Plan status + quota + mock premium purchase. +class _PlanCard extends StatefulWidget { + const _PlanCard(); + + @override + State<_PlanCard> createState() => _PlanCardState(); +} + +class _PlanCardState extends State<_PlanCard> { + UsageSnapshot? _snapshot; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final snapshot = await UsageEntitlements.instance.snapshot(); + if (!mounted) return; + setState(() => _snapshot = snapshot); + } + + Future _goPremium() async { + final purchased = await purchasePremiumFlow(context); + if (purchased) await _refresh(); + } + + @override + Widget build(BuildContext context) { + final snap = _snapshot; + return EmCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const MonoLabel('Plan'), + const Spacer(), + if (snap != null) + MonoLabel( + snap.isPremium ? 'Premium' : 'Free', + color: snap.isPremium ? EmColors.accent : EmColors.primary, + ), + ], + ), + const SizedBox(height: 10), + if (snap == null) + const Text( + 'Loading plan…', + style: TextStyle(fontSize: 13, color: EmColors.mutedForeground), + ) + else if (snap.isPremium) + const Text( + 'Unlimited protects and Trace scans. Thanks for supporting Signata.', + style: TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ) + else ...[ + Text( + '${snap.protectRemaining} of ${snap.protectLimit} free protects and ' + '${snap.traceRemaining} of ${snap.traceLimit} free Trace scans left today. ' + 'Watch an ad for extras, or go Premium for unlimited use.', + style: const TextStyle( + fontSize: 13, height: 1.45, color: EmColors.mutedForeground), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _goPremium, + icon: const Icon(Icons.workspace_premium_outlined, size: 18), + label: const Text('Go Premium'), + ), + const SizedBox(height: 8), + const Text( + 'Demo billing — real Play Billing / AdMob come later.', + style: TextStyle(fontSize: 11.5, color: EmColors.mutedForeground), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/audio_tool_screen.dart b/lib/screens/audio_tool_screen.dart index a126d70..af15bd6 100644 --- a/lib/screens/audio_tool_screen.dart +++ b/lib/screens/audio_tool_screen.dart @@ -10,9 +10,11 @@ import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum AudioMode { embed, verify } @@ -59,6 +61,10 @@ class _AudioToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == AudioMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['wav'], @@ -239,6 +245,7 @@ class _AudioToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == AudioMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( diff --git a/lib/screens/image_tool_screen.dart b/lib/screens/image_tool_screen.dart index 06f160f..945cdc3 100644 --- a/lib/screens/image_tool_screen.dart +++ b/lib/screens/image_tool_screen.dart @@ -10,9 +10,11 @@ import '../core/image_watermark.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum ToolMode { embed, verify } @@ -60,6 +62,10 @@ class _ImageToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == ToolMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles(type: FileType.image); final file = picked?.files.firstOrNull; if (file == null) return; @@ -239,6 +245,7 @@ class _ImageToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == ToolMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( diff --git a/lib/screens/pdf_tool_screen.dart b/lib/screens/pdf_tool_screen.dart index 34ef478..7841215 100644 --- a/lib/screens/pdf_tool_screen.dart +++ b/lib/screens/pdf_tool_screen.dart @@ -10,9 +10,11 @@ import '../core/pdf_fingerprint.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum PdfMode { embed, verify } @@ -58,6 +60,10 @@ class _PdfToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == PdfMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['pdf'], @@ -242,6 +248,7 @@ class _PdfToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == PdfMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( diff --git a/lib/screens/trace_screen.dart b/lib/screens/trace_screen.dart index d684f89..6b7a19d 100644 --- a/lib/screens/trace_screen.dart +++ b/lib/screens/trace_screen.dart @@ -16,8 +16,10 @@ import '../core/social_protect.dart'; import '../core/trace_models.dart'; import '../core/trace_store.dart'; import '../core/url_tracer.dart'; +import '../core/usage_entitlements.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; +import '../widgets/usage_paywall.dart'; class TraceScreen extends StatefulWidget { const TraceScreen({ @@ -83,6 +85,8 @@ class _TraceScreenState extends State Future _scan({String? url}) async { final target = (url ?? _urlController.text).trim(); + final allowed = await ensureUsage(context, UsageKind.trace); + if (!allowed || !mounted) return; setState(() { _busy = true; _error = null; @@ -119,6 +123,8 @@ class _TraceScreenState extends State } Future _rescanAll() async { + final allowed = await ensureUsage(context, UsageKind.trace); + if (!allowed || !mounted) return; setState(() { _busy = true; _error = null; @@ -163,6 +169,8 @@ class _TraceScreenState extends State } Future _protectFromPicker() async { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const [ @@ -187,6 +195,8 @@ class _TraceScreenState extends State } Future _protectPaths(List paths) async { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; final files = <({String name, Uint8List bytes})>[]; for (final path in paths) { final file = File(path); @@ -337,6 +347,7 @@ class _TraceScreenState extends State ], ), const SizedBox(height: 12), + const UsageStatusBanner(kind: UsageKind.trace), TextField( controller: _urlController, keyboardType: TextInputType.url, diff --git a/lib/screens/video_tool_screen.dart b/lib/screens/video_tool_screen.dart index 2f7fbde..f0fcb4e 100644 --- a/lib/screens/video_tool_screen.dart +++ b/lib/screens/video_tool_screen.dart @@ -9,10 +9,12 @@ import '../core/history.dart'; import '../core/report.dart'; import '../core/share_utils.dart'; import '../core/trace_models.dart'; +import '../core/usage_entitlements.dart'; import '../core/video_fingerprint.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import '../widgets/publish_claim_button.dart'; +import '../widgets/usage_paywall.dart'; enum VideoMode { embed, verify } @@ -57,6 +59,10 @@ class _VideoToolScreenState extends State { } Future _pickAndRun() async { + if (_mode == VideoMode.embed) { + final allowed = await ensureUsage(context, UsageKind.protect); + if (!allowed || !mounted) return; + } final picked = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['mp4', 'mov', 'm4v'], @@ -241,6 +247,7 @@ class _VideoToolScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_mode == VideoMode.embed) ...[ + const UsageStatusBanner(kind: UsageKind.protect), const MonoLabel('Ownership claim'), const SizedBox(height: 10), TextField( diff --git a/lib/widgets/usage_paywall.dart b/lib/widgets/usage_paywall.dart new file mode 100644 index 0000000..342cbec --- /dev/null +++ b/lib/widgets/usage_paywall.dart @@ -0,0 +1,214 @@ +/// Paywall dialog + quota banner for the demo freemium gate. +/// +/// `ensureUsage` reserves an attempt; when the daily quota is exhausted it +/// shows a dialog offering a mock rewarded ad (+1 today) or a mock Premium +/// purchase (removes limits). Returns true when the caller may proceed. +library; + +import 'package:flutter/material.dart'; + +import '../core/usage_entitlements.dart'; +import '../theme.dart'; + +/// Reserve one [kind] attempt, showing the paywall when blocked. +Future ensureUsage(BuildContext context, UsageKind kind) async { + final entitlements = UsageEntitlements.instance; + if (await entitlements.reserve(kind) == GateResult.allowed) return true; + if (!context.mounted) return false; + + final choice = await showDialog<_PaywallChoice>( + context: context, + builder: (context) => _PaywallDialog(kind: kind), + ); + if (choice == null || !context.mounted) return false; + + switch (choice) { + case _PaywallChoice.watchAd: + final watched = await _showMockAd(context); + if (!watched) return false; + await entitlements.grantAdBonus(kind: kind); + return await entitlements.reserve(kind) == GateResult.allowed; + case _PaywallChoice.goPremium: + final purchased = await purchasePremiumFlow(context); + return purchased; + } +} + +/// Mock purchase flow, also used from the Account screen. +Future purchasePremiumFlow(BuildContext context) async { + final entitlements = UsageEntitlements.instance; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Go Premium'), + content: const Text( + 'Unlimited protects and Trace scans, no daily limits.\n\n' + 'Demo build: this simulates a purchase — no real payment is made.', + style: TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Buy Premium'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + + final ok = await _showProgress( + context, + label: 'Processing purchase…', + task: entitlements.billing.purchasePremium(), + ); + if (!ok) return false; + await entitlements.unlockPremium(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Premium unlocked — limits removed.')), + ); + } + return true; +} + +Future _showMockAd(BuildContext context) => _showProgress( + context, + label: 'Playing ad… (demo)', + task: UsageEntitlements.instance.rewardedAd.show(), + ); + +Future _showProgress( + BuildContext context, { + required String label, + required Future task, +}) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: EmColors.card, + content: Row( + children: [ + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, color: EmColors.primary), + ), + const SizedBox(width: 16), + Expanded(child: Text(label)), + ], + ), + ), + ); + final result = await task; + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + return result; +} + +enum _PaywallChoice { watchAd, goPremium } + +class _PaywallDialog extends StatelessWidget { + const _PaywallDialog({required this.kind}); + + final UsageKind kind; + + @override + Widget build(BuildContext context) { + final what = kind == UsageKind.protect + ? 'free protects (${UsageEntitlements.freeProtectsPerDay}/day)' + : 'free Trace scan (${UsageEntitlements.freeTracePerDay}/day)'; + return AlertDialog( + backgroundColor: EmColors.card, + title: const Text('Daily limit reached'), + content: Text( + 'You have used your $what.\n\n' + 'Watch a short ad for one more, or go Premium for unlimited use.', + style: const TextStyle(height: 1.45), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Not now'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.pop(context, _PaywallChoice.watchAd), + icon: const Icon(Icons.play_circle_outline, size: 18), + label: const Text('Watch ad'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, _PaywallChoice.goPremium), + icon: const Icon(Icons.workspace_premium_outlined, size: 18), + label: const Text('Go Premium'), + ), + ], + ); + } +} + +/// Small quota banner for tool screens ("2 free protects left today"). +class UsageStatusBanner extends StatelessWidget { + const UsageStatusBanner({super.key, required this.kind}); + + final UsageKind kind; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: UsageEntitlements.instance.snapshot(), + builder: (context, snap) { + final data = snap.data; + if (data == null) return const SizedBox.shrink(); + + final String text; + final Color color; + if (data.isPremium) { + text = 'Premium — unlimited use'; + color = EmColors.accent; + } else { + final left = data.remainingFor(kind); + final noun = kind == UsageKind.protect + ? 'free protect${left == 1 ? '' : 's'}' + : 'free Trace scan${left == 1 ? '' : 's'}'; + text = left > 0 + ? '$left $noun left today' + : 'Daily limit reached — watch an ad or go Premium'; + color = left > 0 ? EmColors.primary : EmColors.destructive; + } + + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon( + data.isPremium + ? Icons.workspace_premium_outlined + : Icons.timelapse_outlined, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 12.5, color: color), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/test/usage_entitlements_test.dart b/test/usage_entitlements_test.dart new file mode 100644 index 0000000..4e0c4b8 --- /dev/null +++ b/test/usage_entitlements_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:signata/core/usage_entitlements.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final entitlements = UsageEntitlements.instance; + var day = '2026-08-10'; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + day = '2026-08-10'; + entitlements.todayKey = () => day; + entitlements.userId = () => 'test_user'; + }); + + test('fresh user gets 2 protects and 1 trace, then blocked', () async { + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + + expect(await entitlements.reserveTrace(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.blocked); + }); + + test('ad bonus grants one extra attempt of that kind only', () async { + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + + await entitlements.grantAdBonus(kind: UsageKind.protect); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + + // Protect bonus must not unlock trace. + expect(await entitlements.reserveTrace(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.blocked); + await entitlements.grantAdBonus(kind: UsageKind.trace); + expect(await entitlements.reserveTrace(), GateResult.allowed); + }); + + test('premium bypasses both limits without consuming', () async { + await entitlements.unlockPremium(); + for (var i = 0; i < 5; i++) { + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.allowed); + } + final snap = await entitlements.snapshot(); + expect(snap.isPremium, isTrue); + expect(snap.protectUsed, 0); + }); + + test('day rollover resets counters and ad bonuses', () async { + await entitlements.grantAdBonus(kind: UsageKind.protect); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + expect(await entitlements.reserveTrace(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.blocked); + + day = '2026-08-11'; + final snap = await entitlements.snapshot(); + expect(snap.protectUsed, 0); + expect(snap.protectLimit, UsageEntitlements.freeProtectsPerDay); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.allowed); + expect(await entitlements.reserveProtect(), GateResult.blocked); + expect(await entitlements.reserveTrace(), GateResult.allowed); + }); + + test('quotas are per user id', () async { + expect(await entitlements.reserveTrace(), GateResult.allowed); + expect(await entitlements.reserveTrace(), GateResult.blocked); + + entitlements.userId = () => 'other_user'; + expect(await entitlements.reserveTrace(), GateResult.allowed); + }); + + test('premium survives day rollover', () async { + await entitlements.unlockPremium(); + day = '2026-08-12'; + expect(await entitlements.isPremium, isTrue); + expect(await entitlements.reserveProtect(), GateResult.allowed); + }); + + test('snapshot reports remaining correctly', () async { + var snap = await entitlements.snapshot(); + expect(snap.protectRemaining, 2); + expect(snap.traceRemaining, 1); + + await entitlements.reserveProtect(); + snap = await entitlements.snapshot(); + expect(snap.protectRemaining, 1); + expect(snap.remainingFor(UsageKind.protect), 1); + expect(snap.remainingFor(UsageKind.trace), 1); + }); +} From 49a3976ff1b70269e6d756c0828d71a517573c0d Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:55:39 +0300 Subject: [PATCH 14/31] Bump version to 1.2.0 for freemium gate release. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 053c7a9..7a87a42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: signata description: "Signata — invisible ownership, verifiable authenticity. Digital watermarking for images, audio, video, and PDFs." publish_to: 'none' -version: 1.1.2+4 +version: 1.2.0+5 environment: sdk: ^3.12.2 From e6f20395340dd4eedc24e67cf71a922e3dd69e90 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:02:25 +0300 Subject: [PATCH 15/31] Publish privacy policy page for Play Store listing. --- docs/privacy.html | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/privacy.html diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..ed28415 --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,124 @@ + + + + + +Privacy Policy — Signata + + + +
+
Signata
+ +
Legal
+

Privacy Policy for Signata

+
Last Updated: August 10, 2026
+ +
+

At Signata, we are fully committed to protecting the privacy of our users and securing digital content with the highest security standards. This Privacy Policy explains how we collect, use, process, and protect your personal data, media files, and the links you trace through our platform.

+ +

1. Data We Collect and Process

+

Given Signata's nature as a digital watermarking and ownership verification platform, we handle specific categories of data:

+
    +
  • Basic Account Data: Upon registration, we collect the necessary information to create and manage your account (e.g., name, email address, and authentication data).
  • +
  • Media Files: This includes images (PNG, JPG), audio (WAV), video clips (MP4/MOV), and documents (PDF) that you protect or verify using the app.
  • +
  • Metadata & Encrypted Fingerprints: We process and generate structural identifiers (such as SHA-256) and hidden encrypted fingerprints to prove content ownership.
  • +
  • Trace Data & Public URLs: The public links you input into the "Trace" tool to scan for your content on platforms like Instagram, TikTok, and X.
  • +
+ +

2. On-Device Verification & Processing

+

At Signata, the security and privacy of your content are our top priorities:

+
    +
  • Media Protection: The processes of embedding hidden watermarks, generating audio fingerprints, and extracting structural identifiers (SHA-256) for videos and documents are performed locally on your device (on-device) whenever possible.
  • +
  • We do not upload, store, or keep copies of your original files (images, audio, video, or PDFs) on our servers without your explicit consent, ensuring you retain exclusive ownership of your content.
  • +
  • The data stored on our servers is strictly limited to encrypted fingerprints and ownership logs associated with your account, enabling you to verify content authenticity on demand.
  • +
+ +

3. How We Use Your Data

+

The data we collect is strictly used for the following purposes:

+
    +
  • To enable you to embed hidden ownership fingerprints in media without altering its visual or auditory quality.
  • +
  • To operate the Radar and Trace services, scanning public links and matching them against your registered fingerprints to detect unauthorized use.
  • +
  • To improve our hidden fingerprint algorithms and their robustness against common modifications (such as compression, cropping, and color shifts).
  • +
  • To provide technical support and manage your account.
  • +
+ +

4. Third-Party Services and External Links

+
    +
  • Trace Tool: This tool interacts with the public interfaces of platforms (like Instagram, TikTok, and X) to scan the links you provide. We do not control the privacy practices of these platforms, and your interaction with them is governed by their respective privacy policies.
  • +
  • We will never share your Trace logs, search history, or encrypted fingerprints with any third parties for marketing or advertising purposes.
  • +
+ +

5. Data Security

+

We implement industry-standard security protocols and advanced encryption (including SHA-256 hashing algorithms) to protect your data from unauthorized access, alteration, or disclosure. The binding of encrypted structural identifiers ensures that your proof of ownership remains secure and tamper-proof.

+ +

6. User Rights (Access and Deletion)

+

In accordance with applicable data protection laws, you have the right to:

+
    +
  • Access: View your ownership records (History) and actively tracked links (Watching) directly through your account dashboard.
  • +
  • Deletion: You have the right to request the complete deletion of your account. This action will permanently erase all your encrypted fingerprints and trace logs from our servers. (Note: Deleting your account will not remove the hidden watermarks already embedded in your exported files, but it will sever the link between those watermarks and your account records.)
  • +
+ +

7. Governing Law and Policy Updates

+

This Privacy Policy is governed by and construed in accordance with the applicable laws of the Kingdom of Saudi Arabia, primarily the Personal Data Protection Law (PDPL). We may update this policy periodically, and we will notify you of any material changes via the app or email.

+ +

8. Contact Us

+

For any legal or technical inquiries regarding your privacy and data management, please contact us at:

+

Support Email: FocusMindDev@gmail.com

+
+ + +
+ + From e1e1da0594f9812e71add3d15dc2a384dc4a2553 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:27:04 +0300 Subject: [PATCH 16/31] Add account deletion page. --- docs/delete-account.html | 106 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/delete-account.html diff --git a/docs/delete-account.html b/docs/delete-account.html new file mode 100644 index 0000000..b0fed02 --- /dev/null +++ b/docs/delete-account.html @@ -0,0 +1,106 @@ + + + + + +Delete Your Account — Signata + + + +
+
Signata
+ +
Account
+

Delete Your Signata Account

+
This page explains how to request deletion of your Signata account and the data associated with it.
+ +
+

How to request account deletion

+
    +
  1. Send an email to FocusMindDev@gmail.com with the subject line "Signata account deletion request".
  2. +
  3. Include the email address of your Signata account (the one you registered with, or the Google account you used to sign in).
  4. +
  5. We will verify the request and confirm the deletion by reply within 30 days.
  6. +
+

Alternatively, you can sign out and uninstall the app at any time — email accounts created in Signata are stored only on your device, so removing the app's data on your device deletes them locally.

+
+ +
+

What is deleted

+
    +
  • Account data — your name, email address, and authentication data.
  • +
  • Encrypted fingerprints and ownership logs associated with your account on our systems.
  • +
  • Trace logs and watchlist entries linked to your account.
  • +
+
+ +
+

What is kept

+
    +
  • Watermarks already embedded in your exported files cannot be removed remotely — they live inside files you created and shared. Deleting your account severs the link between those watermarks and your account records.
  • +
  • Legal compliance records, if any, may be retained only as long as required by applicable law (Kingdom of Saudi Arabia, PDPL), after which they are erased.
  • +
+

There is no additional retention period beyond the verification window: once confirmed, deletion is permanent and cannot be undone.

+
+ +
+

Questions

+

For any questions about deletion or our data practices, contact FocusMindDev@gmail.com or read the Signata Privacy Policy.

+
+ + +
+ + From b62f9dcd55ae24636e50fa85dc980e8073a1667b Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:32:57 +0300 Subject: [PATCH 17/31] Clarify data deletion without closing the account. --- docs/delete-account.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/delete-account.html b/docs/delete-account.html index b0fed02..9335eed 100644 --- a/docs/delete-account.html +++ b/docs/delete-account.html @@ -78,7 +78,16 @@

How to request account deletion

-

What is deleted

+

Delete some data without deleting your account

+

You can delete parts of your Signata data at any time while keeping your account:

+
    +
  • In the app: open History → Clear history to erase all locally stored protection and verification records, or remove individual watchlist links and published claims from the Trace screen.
  • +
  • By email: send a request to FocusMindDev@gmail.com with the subject "Signata data deletion request", stating your account email and which data you want removed (for example, trace logs or fingerprint records). We confirm within 30 days, and your account stays active.
  • +
+
+ +
+

What is deleted when you delete your account

- + diff --git a/docs/terms.html b/docs/terms.html new file mode 100644 index 0000000..c716ace --- /dev/null +++ b/docs/terms.html @@ -0,0 +1,88 @@ + + + + + +Terms of Use — Signata + + + +
+
Signata
+ +
Legal
+

Terms of Use

+
Last updated: August 14, 2026
+ +
+

By using Signata you agree to these terms. If you do not agree, please do not use the app.

+ +

The service

+

Signata is a digital watermarking and ownership verification tool. Protect, verify, and trace media on your device.

+ +

Your account

+

You are responsible for activity under your account, your claim key, and content you protect or trace. Do not use Signata for illegal, harmful, or infringing material.

+ +

On-device processing

+

By default, accounts, claim keys, history, and trace data stay on your device. Export a recovery kit before wiping the device.

+ +

Intellectual property

+

Signata, its design, code, and branding are owned by Wejdan Al Amri. You may not copy, scrape, or clone the app for commercial use. Source is published under our LICENSE.

+ +

Disclaimer

+

Signata is provided as-is. We do not guarantee uninterrupted service or that third-party platforms will preserve fingerprints.

+ +

Contact

+

FocusMindDev@gmail.com

+
+ + +
+ + diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 8cc370b..9ab7c3e 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -18,6 +18,7 @@ import '../theme.dart'; import '../widgets/em_widgets.dart'; import '../widgets/usage_paywall.dart'; import 'privacy_policy_screen.dart'; +import 'terms_of_use_screen.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @@ -142,14 +143,35 @@ class AccountScreen extends StatelessWidget { color: EmColors.mutedForeground), ), const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: () => PrivacyPolicyScreen.open(context), - icon: const Icon(Icons.privacy_tip_outlined, size: 18), - label: const Text('Privacy Policy'), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton.icon( + onPressed: () => PrivacyPolicyScreen.open(context), + icon: const Icon(Icons.privacy_tip_outlined, size: 18), + label: const Text('Privacy Policy'), + ), + OutlinedButton.icon( + onPressed: () => TermsOfUseScreen.open(context), + icon: const Icon(Icons.gavel_outlined, size: 18), + label: const Text('Terms of Use'), + ), + ], ), ], ), ), + const SizedBox(height: 24), + Text( + '© ${DateTime.now().year} Wejdan Al Amri · Signata · All rights reserved', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + height: 1.45, + color: EmColors.mutedForeground, + ), + ), ], ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e920420..4c702f1 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1057,18 +1057,10 @@ class _Footer extends StatelessWidget { const SizedBox(height: 20), const Divider(), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '© ${DateTime.now().year} Signata', - style: const TextStyle( - fontSize: 11, color: EmColors.mutedForeground), - ), - Text('RESEARCH & PROTOTYPE PHASE', - style: emMonoLabel( - color: EmColors.mutedForeground, size: 9)), - ], + Text( + '© ${DateTime.now().year} Wejdan Al Amri · Signata · All rights reserved', + style: const TextStyle( + fontSize: 11, height: 1.45, color: EmColors.mutedForeground), ), ], ), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 2760608..0f09cb0 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -5,6 +5,7 @@ import '../core/auth.dart'; import '../theme.dart'; import '../widgets/em_widgets.dart'; import 'privacy_policy_screen.dart'; +import 'terms_of_use_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -361,17 +362,38 @@ class _LoginScreenState extends State { style: const TextStyle(color: EmColors.primary), ), ), - TextButton( - onPressed: _busy - ? null - : () => PrivacyPolicyScreen.open(context), - child: const Text( - 'Privacy Policy', - style: TextStyle( - color: EmColors.mutedForeground, - fontSize: 13, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _busy + ? null + : () => PrivacyPolicyScreen.open(context), + child: const Text( + 'Privacy Policy', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13, + ), + ), ), - ), + const Text('·', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13)), + TextButton( + onPressed: _busy + ? null + : () => TermsOfUseScreen.open(context), + child: const Text( + 'Terms', + style: TextStyle( + color: EmColors.mutedForeground, + fontSize: 13, + ), + ), + ), + ], ), const SizedBox(height: 4), Text( diff --git a/lib/screens/terms_of_use_screen.dart b/lib/screens/terms_of_use_screen.dart new file mode 100644 index 0000000..ae56938 --- /dev/null +++ b/lib/screens/terms_of_use_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../theme.dart'; +import '../widgets/em_widgets.dart'; + +/// In-app Terms of Use for Signata. +class TermsOfUseScreen extends StatelessWidget { + const TermsOfUseScreen({super.key}); + + static const lastUpdated = 'August 14, 2026'; + static const supportEmail = 'FocusMindDev@gmail.com'; + static const licenseUrl = + 'https://github.com/WejdanBa-CS/Signata/blob/main/LICENSE'; + + static Future open(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const TermsOfUseScreen(), + ), + ); + } + + Future _mailSupport() async { + final uri = Uri( + scheme: 'mailto', + path: supportEmail, + queryParameters: {'subject': 'Signata terms inquiry'}, + ); + await launchUrl(uri); + } + + Future _openLicense() async { + final uri = Uri.parse(licenseUrl); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EmColors.background, + appBar: AppBar( + title: const Text('Terms of Use'), + backgroundColor: EmColors.background, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + children: [ + const SectionHeading( + tag: 'Legal', + title: 'Terms of Use', + desc: 'Last updated: August 14, 2026', + ), + const SizedBox(height: 16), + EmCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'By using Signata you agree to these terms. If you do not agree, ' + 'please do not use the app.', + style: TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + const SizedBox(height: 22), + const _Section( + number: '1', + title: 'The service', + body: + 'Signata is a digital watermarking and ownership verification tool. ' + 'Protect, verify, and trace media on your device. Purchases or ' + 'distribution of protected files happen outside Signata.', + ), + const _Section( + number: '2', + title: 'Your account', + body: + 'You are responsible for activity under your account, your claim key, ' + 'and content you protect or trace. Do not use Signata for illegal, ' + 'harmful, or infringing material.', + ), + const _Section( + number: '3', + title: 'On-device processing', + body: + 'By default, accounts, claim keys, history, and trace data stay on ' + 'this device. Export a recovery kit before wiping the device. ' + 'Optional registry integrations may publish fingerprint metadata you choose.', + ), + const _Section( + number: '4', + title: 'Intellectual property', + body: + 'Signata, its design, code, and branding are owned by Wejdan Al Amri. ' + 'You may not copy, scrape, or clone the app or service for commercial use. ' + 'Source is published under the terms in our LICENSE.', + ), + TextButton.icon( + onPressed: _openLicense, + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('View LICENSE on GitHub'), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(height: 14), + const _Section( + number: '5', + title: 'Disclaimer', + body: + 'Signata is provided as-is. We do not guarantee uninterrupted service, ' + 'that third-party platforms will preserve fingerprints, or that trace results ' + 'prove legal ownership in every jurisdiction. To the fullest extent allowed ' + 'by law, we are not liable for indirect or consequential damages arising from ' + 'use of the app.', + ), + const _Section( + number: '6', + title: 'Changes', + body: + 'We may update these terms. Continued use after changes means you accept ' + 'the updated terms.', + ), + const _Section( + number: '7', + title: 'Contact', + body: 'Questions about these terms:', + ), + TextButton.icon( + onPressed: _mailSupport, + icon: const Icon(Icons.mail_outline, size: 18), + label: const Text(supportEmail), + style: TextButton.styleFrom( + foregroundColor: EmColors.primary, + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.number, + required this.title, + required this.body, + }); + + final String number; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$number. $title', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: EmColors.foreground, + height: 1.35, + ), + ), + const SizedBox(height: 8), + Text( + body, + style: const TextStyle( + fontSize: 14, + height: 1.55, + color: EmColors.mutedForeground, + ), + ), + ], + ), + ); + } +} From 0681bb2cb896d06608f2ebef80008fa3445bfa68 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:16:41 +0300 Subject: [PATCH 27/31] Add secret scanning CI and expand SECURITY secrets checklist. Fail CI on leaked credential patterns in tracked files; document local-only OAuth and keystore handling. --- .github/workflows/ci.yml | 14 ++++++++++++++ SECURITY.md | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 667de09..b3ce8a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,17 @@ jobs: cache: true - run: flutter pub get - run: flutter test + + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scan tracked files for leaked secrets + run: | + set -e + patterns='eyJhbGci[A-Za-z0-9_-]{10,}|sk-proj-[A-Za-z0-9]{10,}|GOCSPX-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|[0-9]{12,}-[a-z0-9]+\.apps\.googleusercontent\.com' + if git grep -E "$patterns" -- ':!google_oauth.example.env' ':!*.md' ':!lib/screens/account_screen.dart' ':!.github/workflows/ci.yml'; then + echo 'Possible secret found in tracked files — see matches above.' + exit 1 + fi + echo 'No secret patterns in tracked source files.' diff --git a/SECURITY.md b/SECURITY.md index 5f1d02d..9ff4ace 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,3 +35,15 @@ Out of scope: ## Safe harbor Good-faith research that avoids privacy violations and service disruption is appreciated. + +## Secrets checklist + +**Never commit:** + +- `google_oauth.env` (use `google_oauth.example.env` only) +- `android/key.properties`, `*.jks`, `*.keystore` +- Real Google OAuth client IDs in source (build with `--dart-define-from-file` or paste at runtime in Account) + +Android SHA-1 fingerprints in docs are **not secrets** (they appear in signed APKs) but keep upload keystore passwords local only. + +CI runs a basic secret pattern scan on every push. From 9b7ffb7534503380248ad0bc82adfe95b9569a00 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:27:57 +0300 Subject: [PATCH 28/31] Add Signata app screenshots for README and Play listing. Include home, capabilities, tools, and Trace captures under docs/screenshots and link them from the README and release checklist. --- README.md | 19 ++++++++++++++++++ docs/screenshots/01-home.png | Bin 0 -> 107165 bytes docs/screenshots/02-capabilities-image.png | Bin 0 -> 74195 bytes .../03-capabilities-audio-video.png | Bin 0 -> 73390 bytes docs/screenshots/04-tools.png | Bin 0 -> 69099 bytes docs/screenshots/05-trace.png | Bin 0 -> 58172 bytes 6 files changed, 19 insertions(+) create mode 100644 docs/screenshots/01-home.png create mode 100644 docs/screenshots/02-capabilities-image.png create mode 100644 docs/screenshots/03-capabilities-audio-video.png create mode 100644 docs/screenshots/04-tools.png create mode 100644 docs/screenshots/05-trace.png diff --git a/README.md b/README.md index cad5299..e7cf160 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,24 @@ Native Flutter app for digital watermarking — images, audio, video, and PDFs **Latest release:** [v1.2.4](https://github.com/WejdanBa-CS/Signata/releases/tag/v1.2.4) +## Screenshots + +

+ Signata home + Image watermarking + Audio and video + Tools hub + Trace online +

+ +| Screen | File | +|--------|------| +| Home | [`docs/screenshots/01-home.png`](docs/screenshots/01-home.png) | +| Image watermarking | [`docs/screenshots/02-capabilities-image.png`](docs/screenshots/02-capabilities-image.png) | +| Audio & video | [`docs/screenshots/03-capabilities-audio-video.png`](docs/screenshots/03-capabilities-audio-video.png) | +| Tools | [`docs/screenshots/04-tools.png`](docs/screenshots/04-tools.png) | +| Trace | [`docs/screenshots/05-trace.png`](docs/screenshots/05-trace.png) | + ## Features - **Protect** — embed signed ownership fingerprints in images, audio, video (MP4/MOV), and PDFs @@ -94,6 +112,7 @@ lib/ main.dart assets/ docs/ # Play release, privacy HTML, delete-account page +docs/screenshots/ # App store / README phone screenshots website-source/ # original React site (reference) ``` diff --git a/docs/screenshots/01-home.png b/docs/screenshots/01-home.png new file mode 100644 index 0000000000000000000000000000000000000000..a7c23d5786991982cbc22f490e721a5a56bd87be GIT binary patch literal 107165 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

  • %n5 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 xz|{y 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%0DQf@#fy-g)q{$IVQ!* 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/02-capabilities-image.png b/docs/screenshots/02-capabilities-image.png new file mode 100644 index 0000000000000000000000000000000000000000..093d1642a1d9e951dd574abc76bfb275f3eb745b GIT binary patch literal 74195 zcmeFZWmsHIvnVgEL65paa1j5`qR8+(K{$cNrWK+!6@x?he8A z@b33J=iBG({oQl#exCc>^W)a4wN`btRIOg!v%0&g=W+UR8Gr|RtN0dxgaiN}Jsp6@ zRitS}85t9Gu-aQiWx2m2`T$RW@&*8aI=DK6<)x|h^bM%dmjBN2SDdN2%ZI=2{{o)M zy_o!~bO2zC{a>W{zhYxpSh|=$B{+UM>7Ad1KPgM}L=##4O*8*RoBd6T{zZGZesFzC zqy87|tOb^NqAi|iW~+ZkoBcc5{Dbpf=_8-gh(hh%{*v_<{H5^=O9ySur#t%7Ne*xU zfC2IV>A#-;Df|SEIRJp*IRNl1=AS&XQ~;np7yx)V`%fNy765=71OOn0|H=DjoqRBL zHvPBaP@b;H@81Ierv(52wmtwrI067*LjEo8>GCghqkf7af6~kG>97LW11tg507ZZU zz#PEwL~#S&0Js4Bk8=PSz%yjzzwilAo)8rs6%_>q6%!5ZIr^BS^zRW(ldOd$1VWHQ*}S7_E$^%@9-QA1r_NTIx@ynv?(6oNiY)flj1MXk)9OB z{fh>mKF23Oqd_O+BBGVjAm%p3AfW?0Ie&_ctNQVqS6WlcB|c}Ahm>C1EHS(0cXQvql`rm@PrT3LGUdY-hKfC~i03z%2d!@=EFq`%YSO8*mrc2!{qHz>)vU81!sc0Hp2< z44FDlQ@qR4l}TzShwbP26&>hn6=^UG@t$&>dng!jiN%Cx2;&!LzYCDF6=NOd;+ktww)_nO=gL_^vWi`1 z&!A%C6)SpuiUxsTe#H@DmSEyHYw)VQ-P}KXV=vs3MNcOw$`DEFcpnL3r;#9~5`;}w zsoDkP)pA;Mt)og#w3Qk2^G|sNj>*J^zw2bQl3t24cGO?hcTjvyO3F$7*$q8Ek%`mB zIIE7j!%PClUc?%QuTpw$s;kzV)f4PMc4`f7!l`)sbH&Z)*C6aLf#=+DxYYCTisLS3 zyP9{t9&_4R9Zi-ZY(v!39HdzO6bH&aCZn%-ibG$$_^?UN0;CUS+C4!_>M8jha~hs* zzg-V``Rh%@b5SSsNYy$ugx*&~qh0cWg0&cvQXAunId%dCc!UA8-8qVvfqf=RFYh{``Q=bc` zLVq)Wuo;l7Ebs`VXe1ybwxxv`I8I8TV9T={Ce10j;@>H5Zwc&!40M6{O64-j)tB6r z1Qh`TYqha^2AO0MW8R#PfU};?M?hfla2NCua811e94uEZ@-yi>_$6RkM|8B!%!yEM ztcccXs|8Bs4`*qBLqD(z@U}pPuoR`gsF~qJ8McwP@ccYM<92SlF4IfTCu;aWk?yit zCqxS#4|#1y;e#5(@zVuHKuRJ4B3@6tH(crqUVjC`PF{%tHG5BV@+FIp*x$@G+ z(3^-Zkwr5^V$YoTYpnwElv%Yqri=ddX;CfJ-d8f~I=Z2-U}I5vH`Te%GmKsJDk*anuQWj| zh>=;$aRmkXj9GxMim-@Iov2;5*VqDsM^Ry~1?zKLwQ825pXATJ1&UX2c&|%)IZfU= zx*brVQj&1#bKNOUdw(*mx6o}-%$rL|C|8ofTH*XU!(>2T8%SIRlfXHzt$hUi;Zu7A z%wj$QE{p95llxo!9SPlJ;7*m~j--w9-}VbQB3-<2=mCi*pw(=#-cQ(v(^m%bkB-m5 z8uD(tk_qxYqCfk@-2UNV z(|NQVEjnlrphT6%#26ja6}j9|GO^s=10Zg63GLh zBPIDwS*(6psanfwOiHwq*|x#fdUs4+c7>QC5>Pi~tZ?z8#@p{?6aF{4AKSLyLM*Cl zCI>BUO9hk3GB99Vm~_Jo9Q{rLK7r#2Ogk$;FJiMRV%{^z`>5vpUdOs%k}1`Q@nv39A`B?e&zRf^x>yIT0d9enr=4x2n0* z$%jg|(ra~a#g~4K(wnmtVqAU~XIRVQ3h@IlBRwB-FAQFnM z9IHLsVV-BoZm6#P3w`dp(Ji0a>Q!TWDm@U%@@p(T1@dvUY@MI7E~2+IyX4!N&LX7| zErlOa>wDd$Q0ghN^XFb)A+=9w+<+q#oH?>A^zBP{(^@C4k7lyD9O}y!97NrHcxPS3 z2#*&aOuYdnY89GC5|4n0`$Xx{MASj<6OYswqb(y2ZeorVq4WN@n}-@$nVgcwts?!y z*Pop=WK(LVKJId`dE?r}vR|P&LoipwxQM?rIz65bo`P7EO_7v`8&7wJ_PDaR4iCwM zS!bE9ye7xZBp=p{%B})+XkL2yCE0R1okhBD(Q9y`+zbf(=|AA-=VyTpSX18vZ3Q&Z z$dt;8Fv&n6VTE1WIMS|x{GZRYkNGSe8=t{eVs33scWoJ{|>`2;wteFB`B=--go+%sS=>Zr3q3!2&^@tVH> zIA-rH?|PAna44R7UIsn>^gf=SJcq}0!ivQ?x)Mah;R@b9P=w3VrQL=%j6X`Kuf0Z`kZv$ zw(d}5t5pa|Xy@B?8xyXXJ{bE7o8!-K$sZq3&`giZ0L_S<#qLx@$pS%zwW30vpV~dG zcOgO_@fh=jy4 zG7D1sfIj7S{XUVA)<-WDW_n}u2*`lxJ_58O9|3brkAUT;x)39;cqn=G2tYjj8vm#m zYSMA-E<3$$S6aWH1#X0*_$T!!&5B|r2NQihp2o(G9y3PYlh<<_5jhJe9nkm@}ld!Jwisobvnt1gZaTku{ixRT1=2T>_c) ztGXG>7{0t^>ZYMd)}2QEn24OW&+P5h&Tcc~2~YCvigmJ?S{AKKFEg#i^zzG%rOJt=B?IX;RESX? zU#`TX{ve*PV^mAqNL7J1H~qGa-#=O9uS5sA*4jtyHR@c&PvfynA0MzziP;?=jlJMr z`TdZ!#<6rL#{6@Gfg5UzO`{DxUDLTiw~}uur}D zttCGDWeA0A ziFR#X)Acbzylp9pr*0ZkwYRsiRX{VVI`QSC{<$ecNLe-S7za_~t8w^JKb~;cryxx9 z+Cb-gpO8K}Hqvk2e>-AlhNN5OgQBpa#+a`c<@ZcANfl!wtK(0uJN1f9R*447>!c8v zyrZ+_EJ4`-?QC8UCDRx8*dx}xz1`8?Z$QYMZo##PIG8bgoYwfYd*- zl#U^+*Q=!Kz!6Tj2h3lTPM=zT&2*}p?d>?On&Mj1y$ z;4P5cK*<6R-7&)FnGHMsG^^W&68pdo=QituNRe)dtSNyOGRzXnWTUup?+V0R&60@H zpN3hw)=D}vq~As0);F|^xhr1a!n62y&NazLK?0|1H)@^SGU9egw;42Pph^Sds%huD zRl;dE`1%JYW*diON@;%ApzNLzEer)F*sZqy#7?XOS3W2RTo`SkON_|70W!!m+~QX3 z6us9?UQcEX3Xi2-Z=}?q$;B7A?c$+mu#b_F)sb%+q0aP-ElUTe?iLRkJIq+stsS(5 zDZiUA;r4v;*p zE%~V{yr!Q>3{Ffxb3}pq0rr@b+mfoyyeLhOF{>g7Fm0|)WDg3kuq%H4{{mXhMsd{S5U zF`KHjY1hg3`X>>Zaw+ynlZgPg`S|Zl=90LQ{C0M*E7)$RmZ))6<~t|a*+0#s}W#v!9c~AP_x=Sb&nkM$@otCiqJN;}Y{OfYW4D zEH&Or&=btI>a{7XBZUtR-`0lP)*306s4gVzzKOz5lp>T$^c7t`ji4M&7%X^dvY$w> z=BG=Z08J}9>P^@wq#AyOZ2V9yyoQp}!#Q}tu4SS9DEscjBEm-gIKmTOJyWXPGBI|w z&+p3n9$0DZpg*MrD8>r``F#p&Iayg^NYV-uHZ4L-tUvA;5~AI!s@V2q=^) zJ#DtdI+l`EA_Ra{ecBuA3}jzHuhBBbP7PZ5_KbaZ9iy5b0XjD_A9n5^0eC$xaBY+( zy_{&gIP@W)k*uM2IB^;1>_<~y5-|8qha>xIZeF3~H&UscC}*b`A>Ndx{kBEU`fl2cOxWW-Ovj(w*f*depKzv;*OZR{_Hf2Kqb)i9 zgDZr7q(X@NEn~*O3$>!)W`K!6U%Z=ay8$ zVr<6vp?i3S_f@n-vJHFELX)yv&2D0!KH8Z>-Nf8}MS$7X{#T#Bi~d}k_#ZgUFJty~ zWA-@Mlhxf55jlzU_d&Yif2&YX-FhF9S#8Z@*za(dtza~1JCdD~sGyJH-!wVp_;;ku zKP2E3>;C_o>i=VFqjk)!n^|Z@Tg~Hu(*`MfzapzaUQF zKc8*n8Iby-VXbapShiUTU;gZELyw$O@`qsFj(Fq)Vr=$hq&pD?OuCSs_?I_gG_xC8GlHv<$lt)GNyM#wN_XwCI+!bThSRHV?Y24WJ z73__3yk?~JygoOPNM5tONEF|7N44pJrYu}irt=$pxf>jWE&NWX1HyT?P=kZw;^Hf% zKOHS?KuWiAPO4O6*1RC3Xck5cUZBPfbv5*UKMoEq)#ghXE1#>_Vqfy>8|O^u@g!lu zFb{rji_y|xN=3KkXjw+O>k?7xJ7Bv7$GYV;X|REwZTG8!4%ace&Dzq>yVW98!E%mz zb-7r((2gIz>9o*{1-`!F!CE-#6kePp7>HsZw2E5eIBwF-w6=Y^4)<-wvG~-d^f;R|e;MffL>`Q4`{}ucAsFIupLYa%$(-=1-;do`= zN#7ImXSYqCtVxt&GR+{}4HY+acV5UcDpzIhe`RK)S9hTJ?Nl>;>u5Xc{}l=xEGY;m^KuzH`gnHOPhq>04*SJ02lSZ}b~}8w0#{U?>47eDszLMxS@%P%NCf zA}&<=_1HU7OjYFSu&s(KAan!{G+d1PuNq%aa^crnzEp(`3l9)gF_HzbR1U;sV*-O@ z3GCJpQg#Vu_heJ3VVvQ%(I?(?`=Q1kvdSWYzeXt=Dil@Jq3|qx1aNavyqP%3Q|ej} z&#CsxEBEIsWi?H0P5dDfs@aS$JC6VZE}YESCP$kaM)s+`i%>^WCafLj*Kk1tPZ~f_ zX$ZmeArUTqamd-{LX8`J=i!a-&Ba7h`@=G|LzdM_uv;Y-MKTIR5c6$EvA3>TtyS+> z#f}=&@e;NbA&NV-uvNO0jCST5PFRc^QUs~e&m0N$`5zpL?Hn%M2$K(eFg+;1rc%0f zJ=0BI8}O5HEp=F@JPul37iAs~FRZ;SrF@ZVGzpQUcUEUv%~UnG(t4ff{6|%D5f9-yxoYveW)!E=>^?UeykwjQ4$llmQ1{Tor|gSwu{T~ zO9LaMfn`&n?n&8_%MqzP>mXX2WXunxTe4 zr6uuAj6437ItH4>#jPz8D3ipWs`KLR5pYs?meN~ztMW&CJixnshf}gSef6UJ+hQ5; z=b;ea7wyU#`)-by@q)6`aWqoVRY7|Ga#@n|J_@_W3JGw+sTWhf?tWX^=-Liq^1XZ7 zW_y?X`JvzXQ|B}u{jYXTZx8zd6JEe6c!x!!o>`2q2+!!n{W2rNBD7wLS z@vMZXDE#TsZ7*2jPFuzOueRzl56VQpRJ%_xvu4zdjBh1qz*x+b#hSL#q+K}cK{3z! zM~F8;M*q&L143mP+;$9&oa)j(T(jvgl~3niA_w_#GthiY8VHm^m33^SC~j|tP*pFy zhrsD+wd9j2#`E@3;c_fwSC`K}6Ffa&VuK@2d+R%+lV*y3cUeEXI5R=M?6R1lT5@F(M%B(po6`8c@e>;F`trD1sVMojX-*rD`;F{}EPN%8#- z?DC2V&&J~s#otva;pjAr**#; zx&P;*nbJYAV!4Y0%sG4-=S}gjeYm`2neEf!!pi)YYT?0^OtEcoC5uYSq@I^eiyFTO z@#%Bq(~gdx*)YkLAPQNUwCks+u*77K4+ws;^4Ibqi_OJEt`VpoZ^mH8l+&N6qqyH2 zCxZW{RZGJ~oHsrm8ZD}47eCx#^UZdbR6 z@DCRyz&}p1$Z;GoimRmKb(R4`?x4>)3wIn(i*nz6kfYA2lex%DJP zi2cSvDSClMd=I0ktq4cXtM<<&NwtEMf*$kP42oUb&SH@##Dn|fcfwgFmN#ma($}*> z<9VI1ed=EBglH!gYNUSPhk}ROA|b9eFnQ}#ids7;35?xG9vfKM!lFElAJQ# zM{rDrBr!9+<2y%rZIkeJ)W)sgH5|)7GmbCMy04WNp}}%_%{jgW1X-9QlJwqzVi?q) zxNhO;a;@tREY5bk;iuwZmHE7SZ|=r6>KYCj>Vj^ZSq)(KZrR5bl0$93c5&x}(uE%Z z41)=`K4CVgU?QDmY$Np`sC*+0TF8U8gJ*nbgs<=wT^S+fX^}eq>XS!;JaNkQVp00s=#$~%JyKm6*(>cTd5m%8+8+-BP zOg{LV*$?a6bS|0HK;aMF#|qAC3H;#T>bib*!oFBI5e@t2R@m&3_9H;rTlY6kpd0s6 z%1jHm<7r+$Ld-HN5cCG_1+B%w{U>l#AZ{YqrLPa2^K@Y+YfZ{O&*txmCK6bCnK74q=cK5x)BS0VeQny|qDzdAVkMB*I zzLGPgQ!ny2Jt~M4hQd-ltW`zPf89g9;xaOn?}r2TVxw6T+devm{#wm=T64$D7iD}WrZd!{ z@jlMmD5fr4=(%A9oReE$X+Jx-Ul^*wjJKkp+Tr6T?atTKb{ak)z8)fZTiCnK5*6dkRL6=m3$&~GIOTEFx z8I?i)P&hSle($!ro#{MPhD$pO7NHQ!*NR$mfyNq82s(JlX^K}`K7}9dL4T(RxTWej+hOw?ZVMBg4~4iv z4ZH+{W8eYX=iXoUZj(*8NdHDHTU$gU^g=(EuEc*?>G5UA%0(;MwKE5tZ4+Z zGe|wj_ZI2i6!_bcVkL4Ch1V*>Z;Z8>Xc|T0*pI9QWPtu7q^Ic!TVJjJPuT)|6F!ctxIRAGHL zB;1yG)Ph$W*VP!-fEa*UlNKlefaaDnD2lQvSP{l%lBd+i=FHiM+NjIO2v=idwN^{7 zExq=wze4q8f;gf!g-xp!I(EFjq3eHm2AA@fQF}PYQ*EdxC$n}|I zKr4eoOU)Z6i?{bC-fiMLj8?t@-3#}*Zb>2gKfB~w*<13$+CNJ7JIXIGfSAnN_C^C- zk9b=7R&wF^u}*%7BPHluC>o|;UOtN!^7*b1TRIi2O{>wE(&z+uDbDir(3-TQr9X-FEg}Rq2JQupL!A>7{CJbt!O-TthDUHyzA5VijTe$=5+9hCb9UTQTwm z^nDc5-80?eu5R^9%^mN&CC}91*EjRYG5!7Q%ZOxj#rXusItH4X*fqyaDsAt%n0wAJ zx0h=#^L&uAe^qn1=BFi8)+g*H%*g}>&Kcl&c?WG@RQ#;K_Q&NpBK$)y1d}&gFb!*G zQ8``L3hQ%`ytY}zOlrk*jh!>Muy?q*El+->A^O%_rHWw_q5~vPK&&V*%`y6->fbB$ zI~Wn(CV9TiD+PzO$O5ta^mIN4w=qMh< z`&1-?6NvsxWnmt1LiQ@g1I95%E2Ijp@j*>fs{>|f!bd=}&L;lKBLJTQ(B^5sOSr34cfDg#8l^(_($#O`OFpOl?OxMav&JZyNMZ*I$MNDC zy=kdT8jr6nM5@Di1eBMVG&M-vv&cXE2z>;siRe;&BGJigJfG6VRFIx>$I!Uio&NRY z)xsIUnjGgV=qWzfqB?z=mc->$C#x#45t`D#KC-r9V-QD%4uwd3rfa0=E8+y-0nX9# zj9!?4tHUP?Uu3fZ2dN=nQii|Q!ht4@-5LFzFgPX=O1Kpg8p-OcA!9@eJSM}YHQ zK6;&sH}0qg`x}e0-~^z35=kAMd0RMC)4UzE=V5yrC#Kn8IyAr3zIM#XQAv42d`ZC~9k!HI4~~mz z|MX;Af;CUnUourg(u~M8kBIhV}iok>~XY9D+6ybKIRc@ zd$L9Y(2AJGAd5ZqO&9ViZR=Cu>N^LqG3vHiC$x3Cuk6)$jRa&=xV%h?inqw&R1}mG zRu&E2h}+9|&{7qfI_?BMkwNwS`mc3~hyZ3VLB;)S%2n(3CFrJgVrO_uoHlp0<{eG` z>z;JCg#1O(+LGO(cc&&Cma>kEQ~(y`gyZONN3_(V;Oki3SRTp^)0B1&_S0Fd!aWz+ zYuk0?m{w$0`42eZzo#f)i&Aa*Gg}P^?r9uM3W82I77rN|VY6qrNg33M6|$7n?kM(A zQMVB#GmeVyJgNGaxGDseB{?h5eQkZ&7U{E^?u7ICD8v z(=QkN{0nj@pamyzz{ik@@;V)-&{IDo?s|TCJbepvkz<7{%{T3Gf*fBSD%mv8{b3$) zh^0uzd8W$8fE^mARhtdL!l39w^FDiCpXt`4oZ|O>Cm~Gj<)7DxvK}MKLcQ49k~50e zr&J*i`Rn%*dr1uLRr8yC+;X!#++;GvP%Ax)M?F!ZHW5Zvna%NM-*Pa1+3ln)9j7CgzXUi zFl_m~D*qh^y7CA2?Yo7^h2&L;r2m@Tdg9wnX?1wkBS1q=5|uu-TA(cI+PmHC4M(c! zy~we^#J&&VE|GtMo~B)sXsUftT|IFpUfc+}!N=c7{<%%{Cb09uG{q-3YOTNhR~+B? z4(yB#_56&VF~&cfOoa|bu7-?~1S2C+;ko6AQ-8gu?H9gWvFkx_T6q*Z)4yx{rXvH2 z%YX_F`xRlg;UOkx=wrxLdxqb6n56I)aakyx-BRow1-GNVtXuh&(DvZ=Rmq~3fin#z zvScF@hlv-&i;fa9R+C++fZ_$|a5Or&ka$IMn1BBIg1R^EyhFQZ6_K>86fN&XrvPQq zX=Dciu#qYxS$}H5*~_C}2_1wMJuuYxX1;7-+%anq?^qpFva=T?q{t)ee#h+7uYQOI zH%8#%8A*g*@>Ynw@u8LGDOd!j)%x&sgQyhwFYi<-P40LT{TxGDeRRW$KuSI)KQbeR zm`HY__HvCZLiYEcuNIKLR)?zQ3vgr38fY8^ouYP?k%6_;hVV_z^a=tULQLB3-@1v2 zL4XD;dX@qQ)|hmN*%w(rNdkbIl-R}FGx;q(ppuP;j`QAIk8#Ms#Sd6l*ZvqiaaTN%`moE9Yg1CBCS9f)#Z(M!X9M9{rZ&t{dS)=J4tgg-B>CuH5>!P1|>rM}Y3yu5C3O z$bKk6>`&L^Gam5#o~46p?QgkSil_OI z!-u02iiQ=^v+U*wHfr`sk_;(a8v2jfa3G87?dP@)YN|J*iQOdkFO$cjkuUAj_3M|; z`jWau4miwymW{vB-i2UaQ+`mCm;gNjXr9$+8jFa&n-i5$u3BxdK%J{3cWIH{^M}NJ zTgD^q^k2n`0e{+q6ZlC|DnC`jVI~PBt+&^>$@2w{j;i^n<|H+%H)2i5W^IAP$@tKU zB?DyD1j&6B4Py-9aM)QvS22A`PLmMj>6v*GlxwH4ifXf?chu{|_7VGZ*x zZvG1_ALe<@aFKK4}UtSl}8^Rxj&J=1(`N{QBB0U-o4KTds>i>JwQsI?oA8*j@7K+^s8&%e_(Li!zWe5#K&C zI9_Z%ukQBmcygRmF~~I^`r-EUN_#w8Qn__B3kc%R4W$RTad>lpiXvjTCm0s9gPr@tb|8`Qh{CZR-(Zzq&{BnBi^A>nqGia$<2J z^wzjTueHYO8``#_!;CXsb&+=A?Yw<+)ygf|pOD}^eoKPzg3DIwj+%5?<7YQUI-ktk2ItK?+n;VfiXdurv+qK5LOvX(No+}(_Mt-W!(>9@ma`QH$ zjaJy}x5L+S&`jz&bcV_JnQ_O!YTHqIzLDbLED@R;y$9W7p#+-H`R9CbhBxz>j17!a zW}j#_`jhe}m+eUm6-3qJau+1s(I$%wwBYq6Eg;WC1YOVc?y#zZit);KL#AAD1E)NR zA3=yc3wEG7ii_02wrcUiF2BYKQ;&U^kiTYo+7?)_a1yfWYIcncC^RQ}yiBCa^%X-4B(+XUD)l0EasB`Dg;QbOQWz?yaWGaP|nGwY{h9GI3W> z*8PgjmIM;m%odrEI$MGpP(XHdL)6FxAGmymTxMFu`pfVWLUW3$69H`u(Az!(awN%w zBBrEhr(>#=S~$tQ%#QpQ#}}R)gh^+{FZRlEQDTvtV4!^uo%4yQIf%2ND1=2i(6=&9 z9j&4XKz6sW8`VxydyaXJi_y^EY-T?IX0OSQ68$4F0lx3@cmym^#pR6W$uT6rS^i;^ z@R9GF`bb8q;Y~(b{IownBlX8V;WqkArPHQm*$J3v}(@A95^S)=L>3Q(4!l3_3=+ z+Hg?!w9(Z0*iIZo(3#KlKLVoqC5}T)Z|rzj@$S<1-oJXcbEM^Xb-@`Ukf`^rDM+q` z7p%#^P|F+x2KfL%YUamRh9BHl>bWg%4o4$m(_Z6JG9Vf$ zK_GBCcKlvhtpB>$^JC3XrL~5)kkm!S_5DH~4qFp1X`WC-g1+5=A{Cw&(UZNjEa7ey z?p@y#5NP4)`pIgrN@=#*A#;%^X*y*$t{}xpkyR2LgRB#r2W3^Q&p|(9oY=9NKl~va zyi$UhP%xf+AUU`<%lF5LjUz6G7XclnJIWO%bO z+9Cj-sas;MvmoU^xNPJo^@_c2nI$k_2*QqmXUgGpp3VEIAFHjbb(VV%-@)BTP6(+o zU)4d_m8pb%%8|4FRFI^IzNTDO4e8%nLKIm_hyqP=DO7L2rE_!}*192Oj9k6sez+)*87G>bj6fMGjQ~++V$Iu|J>Ie_+v!F-Sk3RwqiOEUu~eT^+U4q%KM;st z9*Z2KzAIFF3wr(J<-Qt!gHDYn1(<3iyi~n0jk^`w;@H+AgXs-CtrUr2M9?igo8V}e z;>C?Qsz^OA8d6E`v>8{>bmDA$Wr5hlaEcg$_uL4j${&Y=4jDG({T~6p1H-6L(tTNB z1)_+ybyfNVHaLe5tq#&n=UkF&fCAc?ZhVW5XZOIJ=E6$;8buljkAp!nazC-94OAAO zvw9}ly=~_Wz~07A@8=$VIl`^>{K7aa_hnqypqE8j2RskahFi4w^JIF7-Fv)<-QY^~ z$?wPmZt_3~c?8^L6Z`p1S{4~IX9p@`^wI)wzk5}DJv~z3$-gn5P?qQLrroMDZ+hEM z&O(A$Afw7)Nh3=^Smvnx?&|vqF=^MO&Z{As8OloDg}M~VzPpd7;nMTE98z>snfYzR z6)rP-N5rb7enU7hfLyqMH742lBOpyktx+)GZsI(_SK`?t;N!X1a7hN&vKvoFKItfl zQ}`Q(wVjZ}1yXianME0p{Z3`f+iS=1-NxfjYqV$~Y)bq!%02W;Q?XvY6lMncFPxy( zhnT7lZ4Z=*EH;h_bKjRj$Vqv35Kfbn`5YFdkXM=qjB^;>S&i$J$r%P$cyK^s9s_k$ z`e7Lrb}Qs~EY)p_p!QulZu1WS62%Im2Qt}=Y9(Sb(|f!w(PX>LX-Hy1FUc{Yt$9b<$eG<= z??Kb66k*K_RED$DyBpUk>b_I`NRQ7*A0EVdm=opM(CU7Ucrsi3Aoh0KdIWq8&3^=B zIzD+2q!*KQFGZy>eKF*ia&*i;Ir$dCy9t-@CWxh(g2<@m_HV3b`w9sX=j4bjsYNMy zlU~Xl2vW(h8wvcnvBDKX7=$JhJG3k%Av(UqkX!al#^uGiX8G~WnZF+&XKD&NhPA=x zZl-KnU5ooRrQ&Aeim-zCHp+$W$Uco~#o)<&5}E!@4ZEo@+@NH<6L5nFWIiiPr$~-? z5AD-pjrzs-4ZG>{_hbr7YbS%r`X5FgkBU#bd%M}bS#^#7Dn0wvONwb}SzGf?ns7h6 zf(Bb~bYxl@qyyHS#wI|&{K&T>6yU0sGvoFnGM@eT7I`IwpP){8h4{E0Ehi$vo_Wux z5fxvNM$Pl)INRcQIC+}EoD|68G#^!v0qdGTZGHt!a8?taAC(EGj%`=>*!3;Z4fwJ| z{4~QR)~|)Mj$)D6X*bqYLF+23w_<|vv~;S`_|mb{k*2LN^H_Qf^Zq$F_~&la|FJFg z|0BW=SjPYa$4TmD(@nzD8Pd%W=S-A|aWq&}bwHLla(P~q%imz1eN^k%)8}Q%O$Xx; zKE%!D+cBX<8nI+DTUp)TjO5x>SC0G|?eqo9=xPIHJUp6e64!Rvp@JoYOkq0BjvGza zMPpUv1vy;nX&n=mLdY{Jx&7oa9m3jME{hl7a#6R6=pJ~Iagbi-Kv_fL9*^@x;*gT1 z#;dwlFRI~L?CeELd?GqwXRlHA-X}ouXH((&I^uD2r=&cjxfVHM7343<`r}PW2E)Kt zr3t8od3k}zQo3~6b8evtu8T8-IIrIF#jxDl*s2G!bJL_$^tvgsD6aHNDN<7|-&{;E z&*Ht-PPf^4&wq2EvzO#B#1^Kl*4Hu`Cs!|g zK!;3al`Sde#{(_DL*?IW;>9BXefcdgPoubc-pzNf)&JQpHh$!rb0s}p(ysUeXgu!tn z>+9;ZX#FExKkD-&*6oNhQz(yg_}re$kSNjHVG63^=eMI#*(#%@-L5rM)hz7Ij6>qV zeHOKI`B*v|eGZ(w!8Du=M1m!<1O$mrO96?dkR}mVWBq`d1&b={sCQ7c`9lt9&x|!5 zavYjlqBA*IB>fKfcH|?t;1v$;J4Y)2lPF7ID;Iv301I1RM(@+#04N&Z>13_C^SZi= z*n2D}dnX4jC#hzvxd`@I8pZf3HYXu0cJdA(OGzmh|0TY=WXY|n88n2b0|tCnsG`D&X%Qs zvQUH_O#e1BGa{ZYJcK8R8tBnAtcEgZQ?->~=@DS3J-D?rhcp^qpI zQ!Se^&!a@K7MN@R!2#CeuZSDG_R-JbLeRZymAT{Fk2JIbNw$@TCbgRHJa=&oL$CoW{VZfyQ2|PcEYuu(};^ zRJNYrU=#{*wgMJ;oYsG1?=6GceBX6Z3T?3>#rnfFK(H2Cq_|6Hkzgf2DOL#X6qgov z2v&*{JXj&YX>peTL4vesaf)ku^55&6HEW$UXP-TL&;GV&-pNdoFYo)j&vV;#-PbKT z5YRv3T@%xI?2o^kHWhWH1-{t_54G_wq8ND3%d6GYV#+R`dd#o{w(H9v>H#Z0rGV17a$gWc9mt-f8H=_*-!Bpydf&5i}@pf>D(Zw4a zD?$$>W&lI@TFak&l21d*6!4u;557CrCZovRnJ$?q+187FqK&wi&lrsE^cMoy@tz!t zu$M`6*uI7fJfJcxq%A(iq-e0|@ob+GH4P35S1#0G4A;523Qv{jlx_7>irv)!_-S~a z`PyzpMd0+Ggm6=zu_&t#x%$g;{^NR70kHbA-d4->Pmxq4np-1NR<>r%-WLg|iml?D z=U>UomVfVc=-Qhh%HyM=qRgRZ>>15ZGq`^L?B!RV+?eymZBOr#Z4<{_vce@#fq9{t zx9^VbgMwJ=D>HgrH*2(WcU>YaFqNbAUJEx#jh09iw@2H^z(TzPUMecAg2o&kb6FCh z3nU%_0Oi;)3wbYbGI@7P9Y)M117BmUBQ(hcr>oeV1naeF0U+(3Fd{b0_CfNRZ-WalKe_gYsFn0olon-mD}RpL$1Kr%{_t-r)4Pb(sT6 z-GvZ!*%Fv3|F!xh*l>NHhj&lD zu6^ZxQ}SL=W!O^fWHC~R{yoF=;}K*upShPqt+YH3^3RJRJN?u0bKq3AWcC@2U_Fb^gW*SLwak*kuI{HxSBoCZ{s0Bgl&`4~j`{SA ze2j*WK*dV_nD^|^5Rlcse${_4V)aH&f6pkIW!yiLLk$|f5Awp4JdhN7*ovp^@do)S zVd+VsuV~u5h6>sM!xUi!%Rw{%zQJ7}`*puBrx_gktDm!G0J=^i7xk(oY7BsC)_Fs( zJ@t|0+f9L5$oT_9-^+8~CyZTl?EF^_ch$r!zOVhr74j)!haemvgm7Fzbuuv+f5#^=- z0`-0%gV{yd=0Jmw4cMUpkWx2RMf-rA3%%I0J5}G$2{nHku7B>4$dme7KqR8eki-O> zl!EZ)N|LQ5@Z&Bc0nfg5vi{(PLnXT@|Kc6y{lzP+P;siYcyp)|0X}!4-Bkvbcz9#h z+D~IJX(x|hup{Qa=9AgJiaj~L>`&I1oBgI2whA7jcG9`(%TWhn;=7RMrxp5gwAl7;;QmGL3D!r}Kb+PhOJq3^QHspegnjPqIJ4b4W|jv0XT6sd_3a zL-rMyfT{%&T#K~>qf_8iKe=`x4Ao=s#j30zo|vWl70bfF>T!m7j)@cJ5(T$<1uz9w zSPt`rj^w3*8OT`swOPxs`$)BLjjLbP7ej)5WI4uX!L$AGw;qmr`}paZ|pBbYu84B@Nf|nI5CuXl6bk*Z$=J;0{+v(jk?ui==w~@4OQh&NHSg_<-r2OE7&W-g?*UKGcz&8xmHf0B_A`q1HJYnEn7dV5?Q}@N z7R(Oifzc%}7%b1r?22YrwUR`?5q=i7Ui;42?VNsx$rG4SRGj5XFE#No7ZaIAkH1Mq#TlVSW-q2 zDEwle)X!08iAG9dE*`)8hx#4#7)z4`xQ+L4gHX=Reb|uujJ2h71AvQeNi#-Q%~i}( ztEZ}iCtf>zkp$%k1x4e0w7^0}$<^qWuqp%ZHX5WHVy`)?!7C99!eOgknA8JH-u?Ug~F z6A+)UEFep>H?$wc}7r5%;!zdR7w6h(3HBE>D%NljhCF((@SX=|#Zm z(vx%iXQTTsp1dhjW|u8;0Y;UPX#zE8b_~%M$NE)(U|-(?8wU0tSNW*)#(GElS#$Af zhB1*<22%tA4g32WjROIL`SVAy#SYF2EM@wRRu|Ht6GEqm=ONLLHQnx_ht}+OBVS5&I(} zpyFqe)R$*p{Pe0{oj-izY5HB4?^uz9^xfWWmM$wpInWR-T%G=)lM^XCDRq{*5YQSI zy9j;NYd8c$LtJSc$-Md0FM~j%y+Rn%(h>@b43k!g+c^}cXQ&vMuIwNU$!MZQ15jbr z=jB&h<*SElLLl@82fgv9n3Yf1>Zk!0_f2OMeeFc?vyU4Q=qVNG12q$X3Y(wb^!0qk z8;+ZdfCC|eP*Jj;I;++m6Vwa-A$BBM6QY=c@ZWYNBRPrZTd+q;TKTKgFr;J)`Cb(H zxhtHPiR-}SVPowx(%_CXo=YrI=f0{bA zlLPrK?eyu=p3XaD13~(B|K5vSRcTglu(L*YpWA@jx|C{IplHTHcY@$J6|9vi?%-c` zxoc#H2B>;_kinbA;9p0Yl2QtbE1@eDp2Vz=4VfC}f{(KsP`2T+ToK0!A~Qmo&d5f8 zkkiiIz_4y}AGiHxoTQzAOdTv;Ru#Fn zmnOLutq#VlUOpX`CsALa(vaLQv`0IUAnOt7%^8&+Dc`AjTlY^>PCa5nI2aAw+cl)n zyEgoy&G>a)SMVZRTI28~E!O6$n#!sriFV%f@hAyleN$qE0-`kBM{xhl{Ym3j;2_Je zK)#-%-KA4=)ibvAor&eYV(Fvm)kt!@j6|GjV7erGy)5teF^vscyw8w4x#OCe$WfyPH z*Y-?aFVeHTOe00BN(00mCOy1iu0*#AWxgklw9ItE{65_uvN?a8viHW_*hT2xbQrV<9WTrBA?xaHs35}V~eOkL> zyYoX`e>E*cvR`QO+C@gS9<3eDJx1Cln5N21zx>9|vJ-X>GZZDsN&tFYU2^kinuvGy82)Xfg!jiMVa_C2twHH+I# zh3IKG9!t92OEPSCu_saIG3%hUTbN$rJ8Jbzr0ci9Qp?J!o)RO^H<*x5p5(vSnH)}p zr4Ir%(Aq!oJ0vUQEpS<^(sRwf!jAP%kWrc$@jKjoQoc^&D5Co!u z3pS=9Of@hetdd*Q+nDEBUhi3C9NPzzEwbomv6&4Ib8QqQ7l~YHk{im|ctaEzC*h2W zb#REpthVrS;bz_5T+1N{)f2N<88=AA-oLCAIA{SQ?=?$-rD#oyIe0xSj9dDBbDAB* zpJ_!csQct4O5`Dm!Q}OY8{n;GkjWQs`GwF)MwW)+1sXWtWLGUeV1*(2g|YjWuFacR zDvq^1nD_&{U$}QR=D^uRARqAhm1{@N{I%~Yq~GGp;>|~)YU#`e=m^7kH>Ph%^NP)G z2Pw(@P0Rgoh?wtrn)Q5bE32;Lr*%^KJevkJo2v|Z>J{ZQB-RwRn*1HvUlO80*nC!1 zDaIat_Q=d>uH~sWc2T{0I6}ys!MA+C37`oIW7L4!uqBBVU`;Nk;q!)Igu`31aLBcV z<#7E=ql~wCe6L)J5JOM^dR{9Eg8qOnBy)Sf>u=79VJf0@26A**O9M9KS_oG>- zXE^&~T_eOHXmskDOnZs|UZj1OPVy}dcO#TPuWrqinc^4I=J_W>smVWUEr zG|%JD+#qAmLrT?Pc4Rut5xZaaCpPPG*(>SeCzs-|AM9O}FM01LY_H1r>laS=>Cd$B zk+g_yLd=p+V_fo7A2#z09=C;Tp7jSH1APzye7>=sA6W)izJ5=%89973rlb+fh3W-{ z)6ax<2$rW9E)b((hIxwe`%B6jl%)l07mVR0KRps3JAVt^xY665rEAXRUD=SSjBShw zjGepd;9I@1wtkg3ZvM%}f3@zMnW3=?!&4WO<&{2)Q@n@`7|>+`%aCNlic@4qW5x#e z4CC@dx1Dk?0GF<%tF@fO!qZ6*FAZ0*q5(?1@q~Woo(o#2m@cf6fNs0eT-|19h?aw_ zl|JFHF>{C*vVJUBKLkoqYwP?v$ul)=bjHxiZz+5eQ$^AqmB0wLzF|+phWI23v1TOt zp$_jjdh60HpNoEpcD4Sl472$y(e8YrzrvO$C`kQ{)$slsae9YXEC!&)9okyS>EnF?(cEdO$!H)Ob$GlKXP+z>8#b6yM&@mL13Dc zjC+JpoCf?J#Yq27$n#h-%$2+IgWUVkD;k5Y(tog;_I$3tFZy8B-O9gwXnE(=!3ZgG z&QE8g+l+>ixbt)KI4AY~qB-=u^a{!kCec~Y1OY)&2e|WdH3PHf9x{k@`9<#K70Te~ zAmyx@f_@8q@9BB07{d0exo$3@ul38(eqKYkprK(Gn3xB<<`^Lc$%gR)$>;k@;6c?u*ZHYTyOPgoWOBX*YTShbzN<8&|&ziMEi1 zi}OtW0|X0+t8CBn^B_NyfK5PZ7KIus9>y}PqwJo4@npjTni|=E#R&r%IrzY(#BMls z#;_=sO2)(OmT+`>V=woT#MryF*XB;*W%Yoy*?}(Uh8{5Z1_Wvr+YM-?O6};cyN?+i z>8I~`HoTERYcRGZdkGU3TJuO)^lW72BR$4Ck(##^KAhIh-;)(J0jH-C=jojA8apy+ zWICxk8OA<0+#23iCvoG`1xWHhFCsmP&W&CkKna2UwM~y1lCT&Pi*R;r06V|==f-^o z9xD(_%WWDdT%h={%y;n-N-jJgVkCX_Sr0cX5}ou@0Qsp>o;RKMgtlv>IijD`u*A-{e4pC9PsiWW+4I?6qG>eXTMq9N*g?0w%mA21Eie$a zxXocbPeq_^ExDS`AdlAl+D=hxq=L&<1?GJdfH_u~d!{))4rZ8sN|Q+;EORq&8a*F4 zDGlJvRsdaBJy)ptVk_4t>|Ea8VS=D$o8wgR6XAqg%#+iqnJDz8IlolL-Z#sF#yY&W0}nMj zHsz8Y78a%jlN@w-mCW-aB$gc>E&(Z>;(DB1m%+Y&$e0YN5IKov)P+H8267H9%1B%0uuV&j9&h|!{L0`?>aKwPP@G5!uVTh0GICx+*Y8~gJ(>FM)wH_XzF6bF z+Db*=q6SURFX(|Glnd;Is<@KKQ=|^fNh*QhS^crAtghzM)_?jI>=kWI0Kbg&3m z2I#}5oV`5H!*@ZShkI&1WYhh#H=69oCL?A zj>=p|3MeVK|)!54! zJ=|;M36vcbIJYx4D7j)WF>F267coTw_`M}3ez`pWLPEg)c47HJ3q#!^d-gNX;kwvT zv}WzHKDx-1vvxkED+?0qpr;a`)s@H4YcK<}5m=0s!1-HAS1h%-xS7hFsez=2 z6O#4zvBCwo(e+Om@j@%U#$g3)(XTUd>1(#P6~LD@k8yrlxG#*FEDZ?@q1?*q1-zuTq52Nos2w?@nw==jH&m^jMO02SWk;pIO@6bKNPF+#_z+iB^e=0M zNjJV}4qJ?PUC~ds@Y|PN%B$KGmP37T!N8-oM@wE)f-!*@=D2IzcXSM@fb=Lo)#}{T zVsc=q7kcjY)JS6T@Fjk>;q+c2fY&x-;D~A9swNm(h(p{x$vX(wj8UzNw+Xx48y>Lr z`D5DA*(E7(LgP&cYhf+)`Fmkq&DO^|L9`L;x+47GdMP_-P50{FZ&UHbpFLs6IVfr^Cd_EQ+A%&@kc2mxcQ3nlpYLO4ii(X^C3CuEmtt1kvxc z#Z)QzDe^MkC2M3111qj%``-UUWyH9!Cd7FwdZ}tutuALV0WV&2pzF@CV3rnVL`u5& zNS*x7iN9}Z)zGaY-9rPkQFl}6yb zsB~HPCDT>on#$-$;3D@j>iDS?1i78`6T)Z|v|v{>Wd`H# zEEIoZ=rMgcREymr!T@XFgR(9Fu?&cI(uXQ?!s%Vm-1Wk~VXBQ@;?I*~O`GEY#Rf#bc%Bf`^{SA?M0_d77p3IW;HP_t@9S~(M zihp!=ca&U6=$oLC+5hz~Jbg{#G_LyYUmJBqJ-t(qT=T;VG;WDt{uH3^8T2|EGR*dh z>FDWsJ$n)jw%vCLJ%NqTpK3H?*ENxEey)*yulIjvF|ilkbUV$uD~$-Da8bs4IQq;% z=p`e`&V%Da+t)K#>9X=qT}u!u(4rfYK3HMxOye?F^1h2_;kF(aAy7e^%Dc_v*g}>d zBP)h<|Hbo)xB9;C`n=79MNO+{MHs4U)%HjAFJ6Pw zG@=+Wcn=5U5h?I2&Yr0+55TF@q{mH$6PRV^qZdSYV&&A9SOPPD@&f`s14-9K=lr|P zcAs#5?(Mn|zN}sSmQYHhL>bl%BtW%}f{X%Pnq?bZI|Ccn{;(WDpF6tMd?oApkvTGE z5++H=eO0cZn4%w4Mv%ljj!-xL45tUR*m|yx=Ge50ihZ!`ej;E6D~oG_7lb2^v7ShM zjY%Rd{MP}i7pAPzV^*u`U8`7uizn?dWP_2*&*vfSm_;{`v102kbd@mpYkTqhD=Se`aqlG%7t_NHxErcJd+X^L}k zf(aaYP7BmRsQI&4nG)e$mxXSF#3FMmJpy`d-UwoRYdRlq9>mcmPlUW z2`0EDb2xA}&T@m@e%Ov?p4CUL<)<)caD58%Pa<^wi^o?l;^0(7iS!)X8U4)FJBAce z?gIe37Bi$TC`p>C9_JIzaH0!GDvc+&umGVK0|z}x@$NRKG=fr$jD$^l3U{K7J#NHT zh(2Y0Upwg@qmrj%Mvpg;DR76n!-9pXV45d$Q<6Eps77m4caRhU=<`r|Mdk=+qdGK! z6D!|2Ck#>D{Wx)I7uFm6H5@0eqHz>(hc%e4RGh*e4)YwEO*M6NtLJY&*4tNl@4ooB zNMsOX2nUd8;4GN;VgAEi+5M1(udP9(zO_oK(wizCVo;rwMv3mH;cSE^>!7BjT9Ah1 zECT4QPutpP`MgGA>G*=FanCTAb3t(i{)g6vXjOEjN$RvxW;mCEwXD-O&sR9Z`qfI| zjJtkhM>7A`Mn+fj5z9q;cSHwm`}T@vv1a(p7uu}fSYL5cPnwaWY9e-#^p_5+>dYxp zDe^20CAK`dKtq%5F0&o4^r6!y`~Nf$1EXb4bD?QGF%2kY=G*qjYHtHv`t<+ zW80={tBFpLy8OrB-covDbb{X+kMq-A{1@s9BxkH7Fw77nt-h~nXyBONtepH|hNPbE z$4@TNkT4wSJkZ2QGlIf;N9huDR5m`(F(y{(?2vSU=7Sh*lpodBh92U7IGXLk8NyV! z*<9bfgw?f*fkDB|OJQ4xvm7C8&@~#sQZyleM`0cP;A}p5Qbz|m(QK#KTW=s zUF^>jpU%Q9R&5`TyE`N>jB!#ebk}W=dd*O@l*da>X}a-yrX9U8BHt!vSaAV0WCJ0~ zoD{ijfRSRljyPvOr(%y=p3f!IKlW?1fHD_)4i8#-!%ypljNR(%lFh2WE=7{R*r-xT zz-nkfreQz;2=MOS07C@;X>Hb*ypof=z>}GccJ>5f>je!9SbsZ+ zSm80KDe*Gg*#&jhA;(ak=EMh(H8Oy08Pku`I%<8+U2HG`pNHwh-+%F>e*(bflGu%| zt*tJ&+qM>uuEy%Rp_W!VA3D-XoABGrJfO_LThJ!~D>fdTmU#i8I$uouKDyM_Whpn# zGsWZMMp8Umu$k|$EW~SgYGX>6RiBF0IJ@CDw;Jt;_;wuUGS_xkvJ|n^=-U>M$F6DE z^jo^w<0{@^P1g@R6rM7xFJM$@R{o`xT73Sz>{leNh!Iu@?9)AC{BWN?k5#^)XBUNi zv+tF=`xg%ww@J|xb)4-o9~4gvOvI5-90mxcc|tkYR@#lGaj7o`b>`2~$F{EXd*m5* zz8(8EC=j!2)xkWqC6&M_bHr2 zUPlKM%B1iA_}ZQVmyW!YsmH$PNg6V&204L#)HcvMhRCy}7A<$|%e-+OpRP32Wo3<~V{WYP@i!IqRT@5? z4jmlhDi1mz8sN&y=xT=xl&QD)X9WJ-#5hLI%e4O5CJvs*@eD_1yVDaoh2m|#j|aw5 z4($bXk8jUEUOBnl_hkW3x4W$!x}>A(5CQf8kf1Ugvp33vcQdYU&Hi+mu_^07>LCB& zZ3lqKKjCKi&!|zs$qUT^$xp&Qwb{EE9{nD<;(a6fN6IEl;5->J@>hU%|3x(?zns0< zluE~Cv%vS$PVvjtM9Acx?pLJ*Tmelk<`)Lh@7+*+ubtv;2M%i4HD7lz89UdGG+ax6 zs`^wFq;v016xC+Ss_A*d+$R$|bj_h$dRIBRlD$R4?7DWan^-+|aa4xj6V<3}*Up@N z#bh#fPTy2PN1wXsaRMuyJ_1NG$#3+VNX=p^lAmLw$SU>@)ZTX)qim{a5h5cSFR7#| z?*sdaeluyi%k#6#@l^mdUt7F8EvWxkKAphBj$oB%Ev}Xp=!Yc#te4_tu7h zC$*abCxYs#$aCNS#rt3soq89GZ7;l^DQlj+@%zMtccX*HZ~5%^RZJ{6;~&VP&N6zI;ldL1J;)$IM+n2-14W=FAKmv|ZrP0^8}fI1{LjdVAHMn0(4`-`GA7(~ z=XA5MRp`I_Q@4p(E1RV^OF3m>!&F4ANZtJh%a0OM=!T?04^OkM(YN@(MqJ$sLkH-u zT+vwGqkj}jqw$jKUYBR{eTm*tHYfn|A^vHiWo`9cLTL5&oo?N?ewXw`6I!!)>>BjF z2U>^^25X>s^1awqo8^ZOK+UXU0Rh!aedE)WdDdb@wI%^c>W=n8QSYR2?XhC`&>mG7 zV)2!Ztbe2^-pjsAvdJ2zOk?w*_-8Q)d9#~~TE>Cg7)!u&l$X#ee>xWuYY*ACx*Ndl z7@ue-Xz(cbUq*{LQ5SJ*PY=?m(AuNNNDI$-$7N8z{vi*Up|hsX?ZNTHb3sZmL^^I# zr(905Av^UeyEcOdMWaLU9ekg&80T0UUk<*Kq8=u@;**-c0O$G7T6vkG)6+;<%=Pw= z+02-g$TiYhDR0~Pl94o5eN;e-f}nT<7duyIy|?=1tU*z^m~AcATLxyO+0h6!sYGcc zO82@IH&tso`6)IRS9X4j&tW-xi%!=U^kw!rkJ}|3=#%cTUUN+*rsPxyI!(j1Xnq5@ z^6`E!U$OdF=S*wfACo=)>Rr!jdA=f`&N8}r<$nS(2{ws7-Rji3zSK8_)fY(iw8WDy z#Yx4jdn+Wpd6ejAgIv!X@B2_14Q3)XvLy6OQn=xleRw^Bi`UBaMu2XLb{8u zQg*Y+5cefPYMF&b9cCfKcsvHUim3ysI~Fqxs6Xw~>Z2J6_#SZ@?Y5a-@I=D(a)?!QtH@W7_-_&`q}=cbzdWf>d@;xF7BZ!m!cSAAcXdvPvz zvC}}y<$ujobCh^gw&XsHGW7$33>k5%BTiN3V57~_3ryF4xaGQkyX6v){<4p%dv;*j zZtSGhe$?RSTN+I$crGEI#LR@<6rDnox?M}eb?;3Rg{BBgamS88olePTcG6V#>4pj8 z`*5bj3Y-93l*K5 zkn0&bLxwv|1ps9{{UCx)50maG?2wJkBZ(=er;MP{{_K$ZG%XAha#V)$1Z(Pjwq>u{ znk+fVZ`6ipVH4%I~NyhJXzNM`s#NH4(*p<2xvMzH7nu0vL$yI;_ z-gAJqhJR~8rmjoo0ZqjRExHsx*ijGUIJBFItU4fNCs*6*7Gw{DwR_=k4Uy%%rK3Iz zjn5ndq2ihGlJ`s2YqwRy&~#y{Q+2hT{usM$mD?4v(jhCXZ0xnwTi3O{KStT@x0knk z+i5dA3jvaS{=FWD$A{Pc1=HmZNk5wTMJJ)&e=UHIm^gSG9Xy<%O0EYb;yN$S8z&sW z2Tv}~-%n7>TmhqC0>21pI`F8E4jd#$m8VL3yPx-a3jjn4 zI+_kX<%Bfw!Xa`jKQOr1B08>ct?<7BnE!Qy(o2#_j{&DeD#G9o7kbYCLH5Ccj{N49*D0N%avexoEXx=7J=~xO zo83pr4=TrOk}GEED<8Glyd6&=)TXz0r}0i^=E zC5ZAS>~2ZJ{?zZ~mwR&1CpwGMl3TINI*EVkPg1tGu%{_DN zKX`_CYGzHh*-5VswQw#g1#zlbp>e2Sv#oHt5#{-ZXXCwVanCb9U#SFV;p{gHPB{wy zzk59#8$C0-ExN=EHKyB8bR{NQnMiIkO9yFhrd>@7eNi8;_r}bOEjgYG*e^E(vI8F7 zD1|4NObsNg6Rb>lFQZIeNfqn*1y7IVX@v%d`kxF?()71eol3z7FI){@}gr-x>r8d|Gp9j?XPx9<#R0-|?qn+2}N*Q~J;K%<{jTYDb#F zMq286twDs>k3;~$+VG_jZcNV3ZD6+n4#k!k&*&_~$?xFEha~aS$%nsEI=ke$ioAw} zmY)5B1K)W5|2n(>8Mh4kH{-~hoTqQAIJL4VYl(U_YO@|jcTaAHN44PvLU}6pjj9Ig z=fr;P-O~T|S+>Eqw{_G}PLMKh=cL7ROz;%~bx#A1snx5!lQozGrHU;}s7m8z7Yfz< zFFjGKMUP(qlB3_Tm3;$_#A;W-v_e>6pQnUyT?o?K2WnG~!iVYfph3evP7N((DPAK0 zC8$jF87<3yfB-w@EZohV(o;wKX((Zd(LD0{D6U_BY@^;EJ-g|X5qkJq=2eMc<2UKq z*oq$$TG50lt>mYa*ZL>r%tYfY3UoAIhM#07)`{9XOQnQ)uBO~$v&)lLvG)-AynZJe z|2{d{xENyGrb^8Q{3CY~q1Djv3pbqtAw;4I1?|7*M^mmY*z{A*tv9lBeM-!_(((FF z*kth=9<5`o(zh9Qy$FisYIO^D0~Xolhf~#{12u~}-3_h+6)wAvg}f4QkgaDcKk^6V z^Jqh$J{I}*eu;a|M|I9vhR z4p+PHeQmev^|2|!Lz5tZmz}st4Gb&F$8(e7!eseh)GXZ#UsXE@`Ij^)l+KVkzD|6> z>O}@^0|1XlIkaf}&rapDzjzPsBzJN}gFl9Q*@!>dVwCsW%SwWcqJ~M>dmHL=L5iM% zzuSL#tLunvOL3c61s`O+6?(nwbYPMe1+NnnNiycUebPGaw=&;yLJ`th%qxNpc*P*pQ>h3yS$1AztjtSD|E19k;;}&H*VjYIv_Q-(+s&dsO|G;8 z;~CF8_1kBQKf8~zS4Ey5&si!*2@?oPtb$SGiz}2`8X;(yT&l9uO6K4P3WrDL)9X;skFxbAspW(`XyEr2HMte}#iuTpEi0WIz$ zH6+&!^Hcoe_Y9V1bMql}%N3qHh~M>$EGjYc#?F&&?$gHsW6p%&-KYd9T$r6Me|G;~ zMb&lrPf&NNSH#vd#_wxnQOSjWa$EB=?X8#hdaq(_kW>`nFR5T5hzmx%c>TB%`}MMf zv~#x~3688Z>3L0l z>Q}92EY!~BO|!f#`ix-<_nRV&dK@Nlj?Lex&)uIn$@Yrqoy==dp1!$F|7q+z*XFO3 zx+1CU@(gb0rhLtls-eMCOTu;j@ayFBst;zE{lI~ik5i8v9)%1a_6_{NTFIWQMIVPT zXw%a2zoR6-NqXt1j|E^@GpH><^^5DuF!btJ)bVF8csvkAg`mkxzK`pSJ?)pRZ=9j{ z=MVjJ8ssB?j1_Meh0DyG}@jXp89*w!>epe|>>o?QgV4Z85nMb5-&ELZr3^0?vF4$uB{6=ma zlk0W=TmkXx9iu1g->%axRGwcP+X`g-vxgm#4iry=yE1*A5-4C-zNxq$sm^?^?R;Hz zCil^-f3-X@yd%|1iD9^N-M`GoVN*M{%YBW5Y=;P1z9)W$=?cRMmF2&5X;zmcw46H% zjPlH>4mmT{{kj`FwpeG_2M&jP!5<^l@(nNQKU&mybK%p$xn;NldODx;c{|B!$)N3Y zm#N8oU!>p-!uv%nddY~@*TIWbw^kw~jkzo(xN>DwmQvVrq3}c0w&U(bI-R!b*O$;J zYb_9`y05HOs(HJ#ad=@pU-y{UK z^1~jtot1Zj+uSx@V_$>vtP(qEx=;&M>3v*s-Y54!lMQaX44fcz<6@HN^2lhhGB~a; zfbE_6G5aU4=b}5I-BlMgFGZ5pAEY$i}_6Zp>OAA@*Bgfc6ah5W( zmWaCyM%20x_Di4+X^bC-K0Y0qu+7Yz3(AEjrg18oF+2R_r7Yp~gU4BpQov~H*edpK zhRYa)ySfx>No#hC603*891Er&6?k=(JGxmqvi8k?(WgDI(K#q+yW1-5gY>qnXo6L{ zhDXH|q58po`abi?o{K>5xwM;9nGi*+t++r z=Y(#%C$#U@sh=^n1rsJe_f5*{gETfR^`hQBz_FlghcvHcccjOdn(Z9?_giqSG`wb| zUFOR5ZS7_+`deDvn!Najbl&Tr;D*r@@CWu#60Pxk*$WXV@A-Wl3i4d)%w3uBa_`|~ z6zTWocL?^nKI)D)I{jbW^ zU16$9*8YP=p5^wEcn<>jvbO(7Nd($^n)4_ak1$J63FlLstiMb^k3atiovjgW_qnj5G^ODjO42)tx> zj6Md%pYjWRBq%d8Zu%bS9)+zfQb1m%AT>z6(6Dk|JO6xdz}t&bhIrdqqRy+97B83c z^jGKg&3x{>uD)dqsf(rl1Wi?mEe>9hKD+zyU(f&fJmfZ4e!|(pq%LcPWxRh;`Q*8I zw5ZV~$CApCQIZvNP+yD3yOzGro*@QhtjrH`vTESE{#poiH8BYGA+@eh#!JuiV^^n= z@euv?Om@|CmK}(7A$5c)zF?xpI#mf(L-Ccv@;87zQ$y-9LZBZZaRKsc{prEetm$;~ zNbulJ?r-<+$vNRxY{sI)rIV*p@Vjr=1%QlD0DC2V~E1I(JBvSpIH@kly$lCNC zp~9w^ktsxwp(t&(8g1OL3wtNo<3^TosmqdIU*srqteP!cgblj| z6EU~C7=Vq+Ww*`$AYbAFC%2C{p2O$kC9ghb_HiMu|IUPF)~CVBX@4I7p=B)RT$uy} ze+mv)-Ay-Zv6uvbYk%RQN9>!9;+j$ILqxd4-v3kgYPFh5FvEr0uW*Wrv;&Y7A`&)M z9SVdS=z(8_+}k#B)j(AC(cLqNg)nr`<eTm} zdRq!pjGpL6V6`Fvc8)0;rxj%g6?TAfu^+xa$!eM<|EH{aT(*`AgA?*W(!JrdtXWdk zxO%&LW4X1vu3?8QZuZ~5fzvO+)A{#w+5GSW`D6a?iRmBf`LOny{Jx!{uqs+qySvs+ z{9WTtt5K9WdAh@=m%m#GgsE0rA{RUkqo?4-VdPP>WwaY4IFYSrc=oCi!uj&!&iJOR;gsYYg>C(F$Al8Y|3Nv|6Tp3_+QCIHi+~pK~yh+YVs7( z)%@J^fAP9><$Bc)X-hQ2kp>lO9QTp>Jp%{j8OG)qMYJb4XhOBp@(;aVj0^j&KY~%5 z)rExZpWAdwG4YjUHt!{4X4O8|E9O^&%&DKNx!_CIz`4J8%96DU!xam@5A1%Ouk3CaDQbuv(e03USi_(z1A>lZqByPn z9#@|=8fm$;dA~AY9wMs;4?FpL&D^F%q3I#$G+yN38$8a8cMT~B7vwKU9Nc(Pc*Kga z=g`L_&ZUDV-#i{EQUv0)1WPw26&B}}eK>v~Gu9vI+z_bP0D*)jnhVs60HPn#@adY`N#g4tiM&Ho|w?{a=skx&?VtX6`{TaJpLt7j(XU? z=5tyux`p4Tm-M>Bq)?*mnQ^fxXFV#TewEG&{=k2u{_ufAP|OW4mbP(nRAY{=DhFg`dy5hK1cSyWRTYzADQBV?eC@0v#=A^JiF)N$0cgi)8iVSZ!>rR zjDlqT0)Wa>^z$!QBhCuF^3h&57`}HD(;Xr*H>MW|3eQ{^1hg60SHzwIHnKEzJjI6} zR=@a_HxaEjdL?}J)%TG9hLL|}xL&n`MDX~%DHt6j=2a3jnX{+>YSx!(POx;V|JJuZ zjKHi%6$N4Ar@c{@{RTq_-D{U7&ejX=uG|g56N4_bGx=#vw+M=>7wnL+O$Ol?21QVo z!f8>*Z(0qnedk`0oTn%H-yrXk0)ammZBe7%rbDBu>JHSbMtMC~qC;=FV#%*a@y_M7 zb9iRvSo$MEr_~0`Axodd6AXb%*UV{(+s&kZ;tLgOyiL6K$mVWkmcEpY(Yr7y!Y z_=C=gX4So&x9`locF)n*dy2PrtovHW#f5(3^?c^7f3*EEWh)I;F@7^V)k5m}N7u<4 zkDtW_r0mhhEi%mKM4tvANu6Cz%N0EOV5`F6wbY3e@YV;pKADvyE| zl~ELQZKkn4xDeLF2PBf@-rqdiXZ z#I7o^-n+}lnvm}KUh31RR3Jp9{F15s#RG7q^x^2>eJxR5JzPkuLeEN|@62fn(^6}U zwr#acS@KxP%UM?NL9Y|x<5ut&1`X%B8N}2{d2)(ctap8Bg`XB96<1Hhkc`{TCA6QQv#^YVn+Go_gqI`6(l-3+R=NIW7{sw{PUNCzwE1Iq zun_O8cvif~=OTmZ^yxz zYvZj)3v9IVZhe*>{^B}i$$e_e%6_iRf=sDz2b85BWV+dLB>J?v;a5Mv{d8C^;<9;y z3bhC|i#4GRjYS^B4<1=)F%dx&xfsRW>}TUmZTf!o7N}h#l&?u2H7n+oEoUeVa*j-7 z5Cj=6)9)33jMKhU{jI2)mr7rI^C^N98-j{cEuoe&EfI-6sJXddQ1@#HA28{b8y>E6 zck1|EI^VQuNx5hWqNKuW-=KhcXL8L>A<*@lk#Dr(;Xz051kG$D0OnQwi|v#$D!>cGiUoDqBrHTnUnvG0^ZO@-g&%O5kcMgO3Ay3utw z-|S7?{?*&!?A!u=>73zWeEp6dB%)z1?y54P)kL-Or!lYq}JGag}dDEXZA#|0?8OANdjWke0SnjV^^d zWD5q03`mDI!i=PUU{(q8uka-}cU`I@9^Bkz8G<1mc*>f`-Q zs*m3bPl}*V7of#PjiljcJ&R7#iW6-7zzqlg>w^YkM&;mZpWbV2O;LnM2>IDd-#b>r zUi@O?^V;KN-5_CDP*+-XV7^3gCdRfhm>2 z%ftx|dRV(bIh7{U`=lCTOCH~W+nazM#JSO~Vy&^vlMB*^<-23)e(_D+GJYmie{9KEQhSZl3zL9ei~Sox%lf?BK3(B< zD7Ue?QGaoMB%e+BwYgxIGtFRxTnU}pYBkV09Z52c5Yk{K z_8DFY5a1%$IO{p}!6^onz#jT(69MGQ=;#eHuz2XZ7yNdP$l+#6RPi#l>ysjWfoN(9 zkD(||Er59_FJTDB+;l*;5AjCKSUl@WXgtilm%~she%`1cJ%2#q(tFv-aJ3Cea*-^Q z(nbqG9jT8jf~9~l_jLvYswVQYZYDZfX7F!qv?J2RZxTa%P=z?JYiB&#>@0veagwJ^ zZ{bDq?wj%ZHYteG1-BFg5x(!yTsy!E8?&KJgpJSXo*j@hDQGmev{5->Cy7x8R|n+n zk0=SPHl3W#46#rT-Kjnu7QGj~l33@`q_4Z+`cUj9R}o)wOb9VyMKJ&JenR-E0w0!k zhl5;Du)SV#cT_G1)ww*7B*;D%Z+(iyu||`zCW0>vu#XpxHZAvXB%C50#!hPq(21@% zpS9zrXsTWkyjk)>BP7%!+1zA!T*YuZwrX<9UhcjB`3uwHrJ3s zH7ie9QK`DOnMYXDv*=WAvGR1AZ0F_Q|F-Ghq!S5}YEq#QO=o9dFyu8nnd}NFj}wDh z1b;&{;@)e`6tC>U-=CGL$ANlNTEXjNUxThcH{XkkP+&h!!z10jf2!Zbg^O78*E9G6 z&>Km}JRRU(;2I!?MC?4#w04DXWKUhb$gz2&7(u|M%)QyVX_ND`*cJLU(cey-9wdQ?bS(cCf6IH26ID0U@OLM3em zo*Se$Y#PUh1?wa&UmyHgc4{?}ST4Dt%amMdPPc`jCIfJ6cvEJGo*bw~*c4STb z=TlA_Xq+_f^6(uSp5P{TuA6C&|G*!T9S1-1180)6%h-9sCh4!K*~%knUmUc2sG79h zwSA@}h7}@%MUcbhQ;ga%nROSMRjTHR#{Anjo1 zfKk!?^Q|^TlbLGXJqMT|xwyvQBb62~DH=KupU@kDQxJpxNS!$50lJF(v&!i#o{&9KZ%JFUk&SIZ<0j0)^kPTzgkpM$R2ui;vD}+w) zth`%{tl~)i?`?(u=SBW%D2hN`!YP(Vp#dfzawbZO*4Q8@=Mwc8w^%e+0^ndXd}s2Z zyJuGN)yQrNFR?FAkw+`18AOq@nn=YR5D*_1}h1`r({ea;XV%v31N zcjkS$o~t-nJ|5W*qZ@fAg3nfqtUneD^5S_MHhqAd>6Pm4%K^-}fi zX)v9{Yh@Y-P*c3J#Qe$L4y?6DB(P4?%ZJD=q$aYN7}m&+&xQ#~sZn40c&{^e+h3&V zF@=1u7LpoJP5mP1=|heMFph7VOo9eQt^TpBBaPtmK)$0Gn6EPS@9wu!I^?$z^(5)| zAv8dXrR3%xcYKlNFU+X+jJJ)j;zUZlv?BE^uj0cJo;8UjHU6cGOPiRb z*R!|)sjq#nOPc+io7pr0PTtVUH-Bl6mfDms{1^9TXxSSo6*c8c+@O*Hifa@Mg*X_n zS35sC6kDcWYxfX9oBFFQ%d{(pjEzOeUyUj~5pCrrbNJDFbpH6VTrX*W2pFVBeE^}3 z^loxmt$R1+q7dFI^@FrmT|ezi(+85KsjzT>Wfc^JRfxcs8Cv;Cg%Z9nd=|2iZmexU-PUc z%N0p(#)?#Fvb4j<4*{KfnA`+{z9sL26;|G-iq$Yf-qzH!A%hHC1jz=jgkve)}Oaz*gVO{c~Xg;}T#0(}*6i#OfbuuehClCt2j{?6w$`Lxvz= z9v+^B@aeBxT-KEHCuN9!61kmxXaOLA-P`@%PBW}$RByO8_7D|+m6Zj`CWS_{-em0_ zW}mP7f_ek;qyv$X#VmY4njS2U%%|dbQf_ZJPDw8NIq^Yy;nqz{k&17)=|nr5H#g$K z-k}GR7z`($H@J+irIgw;>v=!kV3zltq2qx;9U)~sBrEn(W0;n z|A}mmBX=??LrDF~BkJ!1_<&Dk=BqtJ!QV;`RcMCG!p#yt`NFtXET1&(x-~Ime3VGe zdLRGECh5uxt_DmMuFjGA9IEe-!p9ZVD%Qb>pe9b{aNssQ$S88ts}BJ$UH_1e{@yP8 z=7Mo>Qk2CU>bTF7h{=lM_p-Pq4B`9n!-+5+ymfju7~Tk5)1fr!D$ud?E$k@?u;s{7 zMnUzzw?|kPofg{57}N|e&KCAUk2dDq%T}?NH5QO6#HNU?onOa|s(S7e)`t>u`4+U@ zt-8iw-6{3U!A-d6x^zc%X8R=W)kRMSSQvYdBQmxL z)oX%#!UhpG@vWD(PI{w*^Rj-%Td!+Z)o(=r#m)4|!`rAa6r7^A&9Vw@KaR zKKFu`NF@>R*NHPQYEVhkzDcgL+UptCm_307y>`j(z!Ca$1^!PvV4xH}lQq|@p8sTq zJF$YP)Dq_%Inr+{r!eag`pc$g+z%~g>xQxmEcl4f%6-p%kO&rpINH4J`TMdnk8h(>kr3?uAU<}^CixF&sB zg$m8>pHGul&!#64-Bq~&?W5=BFC(64m^iQg${S_LPYku2*Qu7zehalh6L+2rd%(a9 z*A1E;P+86VHA!T7m+OXQzc;!^E-ekys$kOHc&F1NUq-L1n7MdC~6Wv^zokee7YdTL5qauf^of)|5D@`MGE+}dy=Vo zEY^@lhxj8H!EkO6J&(@t+2XedY3#}7pMSMylI^M=pyQDN5O__})AACKVkYZ7er$8j zCFMLc6KarZ<{v}^sekdM2{V%83Myb}F^cg~<)*|=JcV_C-=eg57?o^Hru0-BXwKF%lq z%jmL1%Ko*8UJ>~88&2*fKHaDUptp z!fNJrKD=j+_uo%;-@PqWJ@ry5&D}f}^BOHe9S4#MSTCH~d=a{>1PfVAZv(M^dk6At z2Gn3vRyhy^0nx#egQDt(k3Y=z9hSK~dzpvQ$bh@f2szzoT(1svp3w0{7+m zsBc+gIm^*H&^hE^6s7-8V2~+kSUoeFoNqeZ+rin+v!zb^wgX=K)d1GNO`jL_>UFn9 zRM=u>9M0##Ixi0Ww*NfL@27C6ywQ52<5 zliu0kO}#H^Ro9G>wIpMrBDVbUnq^vn{lSHeV}`>WIGhkAz|?FiSJpW)`-lgTFHNZv zr&+8Ug~){EqlKo{!+cEU?c?Y!T0?(GGDpLjL`+3n*s=a#;YG7D+^y-+jG+0e*YMXD z8B+o5jz_%Fz|sw69Mx#6(Pr<-cu}X2@X3sTq7c_`&|t# z3(lubIpvx@@=uTWv`|m@@92HE92)EH7!^}*WKCcmE8=@9fSd9QUo}J6Xq*EQ&lA6f zFXv`5zW6%$$g1|z$WR+PsZkS75kWd2b!=vf?{I&vQSr<{9A>V5q3F*L8M`~Grpk+{ zrvSbpMeKU%ym{p7@()ew#ZO&te9G$*sl^TXwgI@aNme;KTGZPn@!~z&&rMp=2iw7ahR>hNHug{&%rMuwK%s>=UbStZNVM2~d27`@ZL zdy5iUl(8=6SO0SFxJZ^0V`#(v`bod{^v$PAENUO$d?oU%?BG27M)1M-KL7q_U1NGV zP1UL;*RLgkn(2puQEpnIiArI_DX6rL|Q zgL&o7{2Nf((OY(yq+0LgCXoGoKR&q^ONo9TP&bJGLIk5?vAHR<#$?qdlq$t1^XNR+ z*6Y*1l}@wz{qIwgO>42GUp)ifV9HD{;uO&(*M1HkvutzLG=8;iiS+a^giZxZW5rKI zfUGRrRpAyql5a21EHdU~*m-o!W`WJ19?}9wg_pRXUq|Z%F{=nIL)^%SSD_sYuagZ= zy4HJ(&Helz|AK~oj~Qk%7w;_+xf!dlqt-T=t&N$AvK z?2%1!;`GqTDK6?iLq;C8|9xp4{Z|PMrSXT|_LspgE8k{!QYjw?R)RSW1YshiY(svJ z{k@#B>G&@lFi}H!ZQA~f5n3}zLexo%piNM-kCxlX;QPGy{VkuW%?a{&9D^9lbfhGY zH9EM|=DuE7*WPWWnNL+FB_tuxl*e1reneL(guCGV%W1lY8D`1dH$yt*Lzxk!7-5u0=Xx8g{{ZC7{_)Y)z~b9?BA1q$o#|EnX;ob?&6RbAVi>V9_-tmhn{34+7TMWPN;7Dq3Ygs45Is2<{ z{&-vG>iy4xaV!OiXB3LP4_xWg58kUgT7{kjVQWDq_*eJXln za~K6_`xx}m0SGEvbpm`~{GCxk99}6Q(#B|OYf=qevw+1)t9x~)J4s^1&MNgP1(Cnq z@2s!|+VbxL5pc;PbDtN=SuZ$D)4GH11;#mVBCvsyyK^FpyCw2pM`}l(zw*m~nwOv= zC}>Z2b}X(Q*p|)k5L!=6K3Gpo3_a!?KI3@kF1+mrp|>}^;skCNj|Oa!I}jSFi!;Tt zveTcx3CNl%XT|v(z|IXiD~$B-Fr_;*7~Lx;isVL-ZbxsIU+d12yR;A8<~v&HuPvZ| zAbnblj!Ik5LP+w6Ggk7XTO zCf2Y8C45{qT=W`$8<#j2jzVxe&Ai!l9uu4*A6?UBrz^~_zkvz2tF_m z_^0M%gMploHOB~soNYoFqArTY|n422eG3g%tD z+V&3Gu-lg~PNFBk-nJViI=4SV#L+V%z>CGz4k{dH;wvp2FUAXf9uJ*sFQ;@h*Kywl zrZL2EvXsAfe=-K@@`%nfmmr+ldf4<0B(UFo@%fR;ZDd@V3TMDma+%igv}JZGJf`yg zy%UaHDdHiuvDH{oMIee7%KoY-u8fEjm+&7=;gSvc=3aYr$FZskE&_qNDGICFki=~t z#Z@t}P3`_E>c~n_d^lQ_ygVKwf?0VS4RW872u;DMVg(C75h~qSSpXk~W%H{S!orsj zc=(?Cl<8+U+sKj+wyQ$iwyi$Q^PxrvU!^^w-yODlD@Di9r>72rL{?9^sMWgo*uaywE_ds#t+LZc8 z>jQrnN};F(B`>ai23IweKh2nDHVA({A`z^45jRhBz2g+ecNsng5_`hxs?;TQ)5kp5 zWp}O*E^6Qj0qCu98U*U*u~fghDBi=Je)y8m`sVTLD1P4%Bbp3vx7s6Ag-rbpoh25o z%4p$qIwj&5|4E@_E5x{s|1-uZ_bn-aFEKaL&fPUPtqFJ078NuD#rmy7@84+&*F*i; zmXLm%P)rb7o1<83KqyTXzHiF2L4uqshnq_??XKySaEi)P{==bf`%@v>!S0 zj?U6o#M@$_jm}(n^km|^B2B-4{~1k2r5vdJU{Vd(=%2%t^EW7Dttj$U0HTYMov{1VLYxYv z{IZxd+NR)-_(zc@-65}D_oJgs+<8jo*aqs0NzDswXk3{WkR7Z+r0r06b+58v?aHC1 z2q8JYR$mHq;z`p@pp3`h0v6Hbe%|!gn-A<1?-v;orYzlaeHs@Ksj3gtzI79B@;z~A z0x>{dsPdSeL|du&sdhCnlYZJFG8|sf4~pg_ey@4s^T8^nB#wU#pu3rWIkCed&+)K%emO zn^%uPuNO9!8om(}H!sxK7p1u!>^+l`W_W^&tgO3mhUZqMX}#gy|DwpOjO=))L@Uq9 zJ5J09c^W5H(YF)XYXnf6=owl+ZI{?9H`RIXP|t`af{j@1i6UYznYq=8u0VR%rV5jG@6dcTyrds|$07k?BnHKT zXe#u8G)VoE2>$WwN$77$J{_Ise0r;jz@|kJr8C2M!I{W~pi24fW=uvSqm` zW`eBCj>|W9K5BIdn87#*`*!QxyOo6I4S#P$GjU!QY*E(dus&;e-~J(>JHq@Lc<6Lj zzqMQ1XG-?_+Z9Wi)QZWI!Z+j*k9llbuV zY_C9^JSUg!Mnp0V0|aR(x>BT}HEvP7Z5=<&d`Bq~->taW*mnm~z=@9M)k#J;Q=czF zLKMLVJLbuAYtym6zc0^L6k^Q3fYbRUAAj-b^}L^`ZBVdpwsr@P<|3atp#SgmYe9|~ zNlFsyxqG_CGwxf9GL+$!>-)w}3W@dP@S+3_}7fvzZM`7Aj4E9SXv8k@ZS0MpemRfO(>b-%Q*5Tbs_sehXczYuC<*PS_k6E7KR3Pp z1!w;bll2V-31d?F2@ItNiv6w>>L2CWpW>)oK2Y3*=Dr(qYY>OQ>A*Tz=SOAEZiVXl ze^jDhdT@$cx^9j4oO-(~Hizt3fsHIJ9GZ=c?vLP+9IPyXMVjKO6z^fbUX7;gevMyw z>|mvX_F=Hv5NW~8BxfNRF!e#SeG`rCXLF4Do;D60@1v+jNP8m&wGN?A6UU6+-FnqG zk$23>q4w%jk?#7?B^#`GglE&Ep!u4TW-lR%*}aTpe&iy)Op)%LvjsFV$JZ!U~I)+ zb^W}))IqX||0^%WhIMm5j392&-+e4sezc-m>f{++4l~VSSrr_w3F!I9>pV@tr;fBP z^;_@W!^ak4S`V`bQu|#9SGa(j7_0z`sLpBIF{%IYG+4|5-T(=Zo3WKTwAV&OL#V@} ziW&{Dp`{16l-X}F2q9iJs5&Hpbl3(yfgst3wo(2Rd9RKN5=KgnS^~o#pL!E}5xFVZ zmgO>?Vbb?RKi=$)&P#V;PW-jq)OVMjd}Vg-w&G4zEy$WUa=BNg|6BErVKvGf-BtTOo9MF`1}xQPB| zq+0Ham_HbXG*?%$ew3jkn=p)=O-w3b%^zg}Q^`gh6ZwcR z5eYCr2cr7BG_De2)v`0nh^^=79Tg`%4Pk(+cfFEjdB7?70y3hZ38b_n=Rn~2l^$O$ zb?7u+8L@uZrw$u&Sd9Jq{{7(II4u$FJKf?=J3K+GqS*}XlgQABF9s|g7bue6Q59Un z?uruIwaO8s(n&SzG?UR8#Ib!mzR24gi=7k#86-z`rJGa|dwQ;XQd_avVLz8t zn-!;6erN`8fErjwk5Hy4^UO^S{RAPUpJdK+S2%T9v7x7NpSu^L2P0o1#>Z;#l}&3L zlCLtHJ8P|LJdH^SSg$4h9F_~`vC6^;mc4lJZ~bKi&#w1FM6WLw*1h(zeX)w%9Nils zPxF8~`3M@s=4PcgRYy;fywpuBZUamTlna=-CM-A0_>#Km7BU{li*@mCqz!{OGcNlOso;5WgCf{GR9 zRWgt#8H^C?eGb()N-b&$XU!db0_J<>L+M(<4ib_(QzKJ|v;D$T_l-%60Z{NaJ%mtC zKRmGsyA4?bi&=oV1vg*)tQI3%VQBu(>wi@G8EjCF_xm~l+C^=>%$xMGPaNOjXL=@>{!>d`YbAZt*-@=dANkIvlTWv;BN zct@q+hbu+8S?}*=Va)NBo=d&;YG}XUx_-G9dU^xhJjqTOR77-)#{;Y(C6~wVy6&1+KYiQQUK`U(P>W(S@jPS(0 zo?C?b`d9Zii&X4mzI>zQh+z%VvNC04+PT(-h zN?;gx=MCw+b8;FkDkpg z$J=*W(|2dnBL79v(Da@R>6n@j8r7Q_avu?iIYWH*e50R$YNxDL!G#MBl`G|?L+gT9lehm_eVrFT zIksd*WNfFssVSu~o zB`~9*$5}m(ln1ezv705E$f(sFm)3$=|A)-U z1PYf8QliHhrV6)S>ryr6rDkTB`84^x;FrmOyriF!i*EdMfMW&I-MC7<@g!pju)xi3 z@drrmL};AM*cxtCa(dtV;Gd`#p1Z{jQmq6+_8mRA36^1*W7@LdaOTya|21l0NtQ6Rc5n zNgMged|~kihN@+c@O)ftxBE!<$ET_Z52YI}QawYv@v92R9``QgxaM!t^4miNQHG%F zQ7;dyuwu{^Cj@sCIb`jgjQKi1c@#5+x}BVZP%Z2=A->#J>I0quuT6#9L<1B__rE-H zIgjr3ZJG_ycCQQa>u=WN5kb_J{AF`(1_>g;!&D>XGv;M(H4-z9M=D|JKbW1`LP^hy zN^z$(lAicNIm9fKf~P@S-K&abS_^~~W6DtWZ3qJ(#pf|+-PVEPj5QgB zs8{jtXEx}+MSs6q`!Clt85Z)tp$TMIh_Nc!Y|cmC_VRfDM{kIW`cK)^)+gM5u)bs3 z`$rh@LWFnhX8#i~&;63$G?BJGx^WOURyqlCk3C}IP3SzL^jk2RdLUK4_i5uE>)i3} zK(gGwL-6+wb9Mir&#ynDm7`2)3z9R{sduQDYu|%n+?tS-2B(^T%XfpWP3^%ZWib8b zcV|1JlE+5=W@h*`^`tY=rHjO3i~I1`S!=Ux6t9HDb62ZgK# z#-6{;3glTV{%Ln2yy(^0x|E?B>hUdGrL?roy>!^ie`2H-8nnG&`iIVYaF{>ej|5ji z`d=xAr8TS03`ni~lOW>G`g?*0S^mYWV}^Dj-;Fc!d?7j(Rz&Q)ge-9jM&)#$voGX{ zL~{E*amcEPF{n^zeomM3E$uMGxCsiF*$|>iaUS`o;I+|>K+2ayBy{wa@7eIJf6v9} z#m@4*e3VOT-OuLGG?mNLgFYy@Ff|Y})czzI)xKDhR_WH&H}#!wY-0wm`*zkCUDEsd z%ApvqJr4oeM&A4Ryb2#<#vPQXx;qciyYE&ohHolTpL9u znDH>0?nKTN{opzuGe#}lnfTgwL4{lqKVX|SR`}*y0g(bQtQPlzSLNkv(#l~;RXLbx z%bBdzXZz~2HfO zWp>Keaxp>gx$AWbgw1{Py>#~PBPmh!4>J+Q$cozBXXS5hl*J?`1cff#Bs~&Or?1Nvo$aIN4Dfl+6LBr=#JZYet(qHd19zio*pas;7@e@^e& zkI05u)EU}SzH|GM4Vp^Sv^b_DK{$%^2{=jbk3R@@?NS4CbwT7O1cG2J``SP&kEU_p zqO}Vfnb4L|psDc^m=0UN56xxKuR6~3YBYJfcIoM3oW|#jgt=9w7|e+dF~v|^*NN&j zva+fiP!vdCwI6@Ga);N*4P>{}0gj#F623Krj-g~DP;&ysun#CSai*^KxI3J)m8?}i ziyv^Bh%;1iC9&ceKEe1%U@hJdEsOcl0@H1>u-GyhIVF|-y9nHhX-}r6zc~QB&ggCF zF~!IfQ;&>VW>?fUfgBm-D?PaVM*Vy5oq62%+7oK~DnEV1=XDC=dj@x@3NnDu@y`f3 zJP&S(uf)#kf1?tA^H!Wk+J(OH&M`N@hs)|Jwy+7SI!qra#_?C2`LDtoe(4ACE6UUN zWXnhPhi=|?ZlB@LX-uA|>#mQ_!VFOcm#~H~|K%{Uu&NNXaGoxB9D2w96c_TMq~gUg zua@Zuz3IDaFmisLQh~(t;c}a4s9a+;W7ynvanCzMX`vgEfSovNsQiq{c3 zaOtD2i0HOE~Wn)XRPvdVhL)=nIg#T4cmcU4k&={p`J*3tk>~)nbA-pM)kx920I8T2!o1IMrqkbmBH1@tOR1_eX1beQsHmhCzsGTObqb;a;{Gn;D`PQ zgiw;|*F0V5g(7B0hMQn$@?Cd4O&o>AEdljl2vwy=oDUPfN%`4=%L5T7B}Rp`!&}nz zVy^VoALhg@SrK=gw>jvyS0ds8KdXV#^T@7)CX9J^&WVu_}k2;seA31-TPsFV! z#N8U#O5t6Kv639G2<*i>MH!@yEw7bZ-R|V-s6qUSRJEj1nPf-NH68G3fjmnT^8yRr z=55WOBZ{U`B4o)gF*h*uwX|b*pP1+?W+`+WHPtLIYnLT{hk26nbDZjhFtgf5zJdUVrUXRi zg-oJ_>eR_NL^svQ-c$s#~U;cYab%tlYBI z_9xM#ix0e43~|%?=ZkHKR1K<*bK^#EY{31q7>q%=2*bqHScFiXfZSHhK@AK++`)G2 zjtMAEbIKxjq~(@R@WPr|m@5hRa1pEt3;I7IWB#9;7}0~?Qp)JE9bk+^vdtSq(G6Sg zjJ}k8#pwJfIZ@AA6)6b`-siXGEl#Sg*BmYMuGnXlT-^v`h9K0V7718-i-kc$Gp|W# z;)0ufI|)Rb5uAN!RJZ4I!PaTc{%lLwOL&J*MEJ>KhP_Gfoy+cRzTsx3QezJ(lC}|8 zs+o_Dj*KQ`t%cH_lN)E@=8*j;<2DB@Y!(90beZ~v=H~sU-#xaq7BXNkA)2@!I_1?W zm1q5NrUPRn-D_#<89l9#T@2`%f7p(%5U2J(|KXRPxKE=C(ndvWSoofA>~(+P26_kP z`xu%RV{`Uo9-*m$q5sA>dZ#?M@Kd}RpIJs5Abr1s`iI?mjp|m}i#~>t)1NJXY(@8J zi{%SVCd{%O7|}f&^|!8|a{-6S_;0V~sp#0`cNvU>KYhAkV@2`ajhur9 zKvv7>HxxYtS)j#Z_#lg6bR@>iZChld=ZPZ8-0f4&A(HH>wo>EydVm$7j)?WsEOTrP zw`sjI;A5A??(9O8t8my+A<=` zZxn+xXurqlZE3xC>G;u8UFCqQ=j3vw&QeCQln+z$#n1s+BUrutGJGDuxCvXpbz zzp|)sli1jXc3!Y@b&!|96Syi5SzLV^OMQN4;O5QJXX_y5rM*;i^@cJ)7h7gys3Uv~ zWk~urWCT%LFR&9mgswW|$v_}NT)56vc>0LI@#oW$&E&B%1; zd4g5Jyz6ar?@foSFOuzCP;IwP%z%^gPv+5nhF!z^31g8g;d^Zx*}30FZmby^E09ii zO|&$fn_6sTK4Vqs4;G?JF5jj@JmV_kS5HtW{btAh)*@N8)s%e7^hHv=LM!0y$1s zzFK}j{$f04c*(HJ;z9@XYMJ?3bHm-#4Pjy#8rRXxfMjPL!yTO)4qtck7lx@$R2o6( z74^m>^7VYVPVZ69Q8e)fC=i8&3nwWAr6Tpm^uISw`D`+LYF)FHn9%i0Z@U?=I`ai5 zn$8ITLFfc1DfPTS+L;yYkNZl6o>DGwhSEAd4$J^3j;tlHLW`CR_oJ4hsiIC>dWgX` zr@ll@V$&>RDv?pI>E}Kh>lFe;kgTFqlkip(9;0AB5$A(&(#vjZT!c=aC%7k+nIx}@ z+7L&(@Z3(pR%oq0=)FK}?ai&Y_iTX8i)E-R#H z+I(ty-=Op_CkK}HG>ouxab8g~DCa)^k5yQ@I?@Wy2{3ply=h9=q^)FYi*dEvO%bws z|0vorkSkv}!=D}o4LuqepCRDMIv299>VI3R$-b%kPXEEbK@C3s7bm&KmM-MzQN0m|M1rnKI>?NG@E8(X}liK{P4molP?Fpzo4 zw=KUPD<6sxZ&LYu3#Z2ZSY%oJPzG z*Kmfa^n!;MuRc@-q#A5-j`-So)pxyNb`7<$`OTOjVcXEBt(`P1$f^YfQ&Y0k_?~9{ z(0`Z9H2pNdTXL!vb2Bikk(d4j-Tk8rLCz8+ltIO3lLdx^g{M$nH$QB?d1T;=xpK3o z5AdtM@F|?+u>)X6UUaC5?U%%uet%IOB8>b*SX3D&8mEgRzN_o%ddH;aScov%O>uJ@ ziZ*lJ$~HMJW8XvVCce7Qx6QYv*JSA;1x9!?&_g-D0t-qi)TT}ZCroxq9~eyU-@{#S zmj14EHfuC^QKnK)&aZleqh~Y|x&{TGayInVSNW&#(=Z=2zlPVam&^)hH0U8NGFESh2tyT1e zPJ+7`Ax~B@5jv9|U;AHtB{cUhE^F~D$FM3s+k^33hmU*t?@Vt8Ax$Ub+`9j zQVi%rppQUw=UU+B`Xwa<3hW<$#ZF8c0nHl6FtVZIIq zFUc&-+;AwQ1RFFo_g_b8k7!Y3A@9?E@WT7vJAB3`=FOFkOq#qUgEK}QTR7e*tO0q5 z8vtXf)p(|;GpAUUtiLVG8;08ZXy%^0zB324&tBtdTcw(IUm% z_}&82S}0$5eXyIpVWR*0$#b{os+-vB(Z$jCKGr|TQ{Xww`;+l|rznf>GWFN@zkPp$ z#{7Ch&kT;MlgBk|BIrECU}IrYW@e4pH-C-0S@;zG@k+Q-w!TR zd7Bd2)u17A%`F}kr4s)3GSf9IixZ$Vq@l^JAm()=p{N-^~MnMo}vAf4?O&4)uug^_9L_4rjC%usNdWw+Bdsz6J?8MnZ7 zIrV+`=CKvNauVCYcbu)-n<8{D+PYlj)!xINDLr)1fKgvP-so3d>Wx@H*!n(6g~X)< z;|ASx`TYbM-TM;DW>YJML#~!eSHzeA?SgSENbU-}0)z`4sPnTs$fV=D%xzzp)Q1Ob z!{Q6+j-?e9zQj6WN~~aEzN_ASm(2EbNN(`IC<<+FRCb%{C9Q<)M5U$#e^?L?Oy|&x z^Yc{P90=PWpSJ>lC`AbR@J{uRRa-CQgxm_F%Q*n6HY4jId+$X!c;A#H_!FdztZqME zVWlwNX2o+VtsmNPn=?{ej`0ZmJQQDYu6L+uj=vXQeYue56&I1U?1Y*W%4*R;RLRdI z3W7S3L1ty)xmwT)Pn(H*OS#g0z8CHF+Iwf9XW_7bos+6`5HS~C$p6TWnu8*2H@vKc zOscEg7S&x}Y{J!m;-c{GIo+MwOF~=j!QFrgF+&S-4L4SbVL6Pctcg0gkp|L?1=krf zggCnn_?kaVZwqJel@qFZuh7F-?g`PPGDz~L-c9R$P6e;ZEwZPT7&W$3%qss*3Q*#j z_1LZYprgx4ed}gTkW9=#L|*LMMtaC!v@Z+Oxm~lVqgRVt2&2MBPXAKr?239WVa3bd z$E@rzD`_+|C0LNEROJ$G*XJ(}Z~C~g_~(dzpJKlLwu-wl!*0suqiuMANBjs3cb;bwr!>fEd})B5a2V}ej&FN&Is{t z_*bBuK;smrO7y4-O)dUVT^Ru3S94+C+I01~136FBA`&+2l^RZ75*YUS zm<}o8N2!iSB~qP8L-{6gYZLM5UUj+0_RKnO?FJsfrIllba_fEBQKLR3t!ovXOXtr- zTLzLYi;3UkjH>ePI(H-m-=FlkRJ+@5&?i~D=uq|T4ZnYoM*j`560w*dItriCQt~1` zgk+xHqIzCBeGhXx`=47pu^~h5DS`9e{`taQ&PEf-!<*G=6uW+PxF2RMWRqa6LjWi= z#YrONQy5!bn^PVP2H&Q}VuoTXd}R{F0U%Yr??k1_bBOIpW$^nOru5!PrS!sV=-}fmgEbnpPy`tpY17nv#;8AuTPz^tGqbx;6}Hybg?xCh=We?TNX~BkC=DVsqg>A-dhH>87}IgP@ohj z6|6080YVxm6xU(}5;QmjXmPjV)Kk-HXRT9})PCY_jYYPdh-j2qP0i)9K9hDFA?rF({8xAnVaZ zy2t&74_ra!evoY=KL=Sm{dVUH857@&v!ToMnncf6v8Xe?XtLk>y;C^@0)dD>vsy_R zuDlLRL0#zG6w;(uUEfnqK8)=|)@4(vlI5YcXr-w#6p4Y_)t(u5Y01-KjPs@E?k``z zRx&~-36pFR^7KYY6n(2%yKK;vfvBdmFC4XfJv}I{rsRB-5a${^-|&YE1e?hidkoHl zM4{2109k^9Xu`0`uAd^}KTB*1|M#ev%j))@?=y9r14So%VbvX-2jx898r;Ixpw&v6 z9<=fV8wB?RTQ(UZJMKOH`AP2GKw6D3GZbN~2hyKJ7RjEoW`^-Uf{cI0X5Aj&|N0*; zSJitJW!(7QVl2XgqL~F;GqDfrK_2?V;vqBIAwlrOsSwYwpZhxX{TO?-Qbl*A+QH~i zq2hXMLX5RRDR4ikQL|b^Ydk!ts^kLEwic^wG1__b`iS1!G(%tSab6K*f3w@CXhAX4 z^vK(K%If8Ot|;$u>f20*cOVN5B5z1|Cdf0EtZOyIW+7>7t&wbd&tXViZ;LLR z4mD@eab+~~K_kbnesV^}Av=-m^Dfc3K;Y*^Zyi!{4X!M=4|shd*Av^`E}C6kAM6|! zZ9m6S0K2CB3WvutMMF6sfXtOk^5{Hv|kwp+ovWV4O$5!EV+C zw`e!Ag}Q*k{Ya$m1;TQ&a^8NL@Dhk0uSL*r?Kb?yd3X45x0_RI!S9FwMfbz32ifku z9L&Z`NM(S_F{Zp9dZMtu==LxwVU~P}+CrfCGxJr+2>BRJ@j<@O7d80Q^3m?4O$LG= zs!vbjf>8lZ=qj(m$e&R+zl}3(z@~!CSt|n(LS+>MipF#9ML)DKgqD{wGjxxPQz@yQJS!NCYH-ERLV8fX`im%Fh3$9 zb~7mID0?QCL~*5eoMs?3x*XSHy_fmJ!{Dy?RP}|@{^KE@XiV9jNi40)(QWF&oWPR` zac&U$Jqn}nw%(bE2sFhkkZYuk;9p032hSv~i1Q!5j#-7GOQw7+x#gA;_fQ+_jw8GM z$9N8sFDASCAfuWykN{Ptg_@aQ1DB2lpSZ4saXSIK;`Rrh>1V!7XAvmBtD{(oJ$6pj z0Dp>)PESa%wZ`t` zjN7^BM3LM#n%H`sJoct5lmy1qnP7Pn! zL(z4{+)wu4XbF-jq?ZG>0TeehPAHi&_J}E=Q41>#gGy=dYn+X}0?3jL0wg7Xkg%KJ z>XsJ`$0XVF-~j-ET#|c?d#zJ-QzfP{=OB76aJIW?HW0p39Su{EH1{fvpX5xkZd7il zi7@rNQ=;$S-ss^$f0l;J)1I5ZCBEuckUxb>O|y@@A$V*^Vw!s^9U+-r`n!UAdR;KK zt%Ylw&dacYFIhv^IX>I!kp#NPx+C!y4G}Rhi_fl+GeR%?OM~~+<1Iu(`_w{wtLSwdYyRM z^!SOkL)NOeMFjPdZO58=`%%cjJ+jyjtolF%bFLTWc~k(Cn%cNp%Qz^q4ufyWbn zyg#^X&RUvJ!t34b0-kN2!L`T8!1aNFpJJ0{%*(g?-l&NXuqe{7kM*DAmp4KuJSSfV z%-qNuh{Un~K=Hrt#xn7vN7Dsi&^sG_ci}hS=u)(PLI*1uaU))ligNfV3*}buy)$Jz z`ZQ5qZDou4In6w&)R;fj`EWPJOCOnRYglmDj>5dx4{X!OstR%Tm24aH6noirUq1ff zug2;w@DJDu-%RkfOrQRzWByLD`1`2pPb#$<579*z)eHHa!I{*zPkWN)tn1vnqg&k# zT91{p8D8&kpDAO9n+dS%(hJJG3yXedONR#z=gz&ms$GEry?988z@1ZIoY)ynE8T?XBvR8_*`+YoP%JhCJcY9C4*@SVi(aA8_7pl{D#dr^q*Z zRXMg(A~ZX;m4H_+zge8^4>}_M_<`*$cUVpvT7=bdZV+F^y|=ao#2V=QBrmsti~Yr6AjuC-=o`+T z%;T_rv+;GgxE>NKyP0n+fpv7NFqjKhP-PJ>{?KP?EcuE(vQ_ylRzvuo%sG$*UErou zjcR=-3LR%(o7mC&tsGJHyiUQfUzb{*`2h`!8J#xG%X+{n-zdd)DF@)eo7s3Q4ojc^ zc{w4;y;`*#o@Y?%gnc|sH1KCd#g8F5yb%g&1nfJ^75|BXmYkP7Q#jK-53#SrRQI?k zy%x9Qc(omdQ$+Lu4*A3w{iq8&TyI92bcFMH@QAKBF2ritS-VdK7U19l0=gY=9!Tun z%Hi82pd*S9MI?&SqY5xhKB-IU<$uC8gN?eS+Jw3`e;kwS-Oio@;-T-ApS{qXrBzU) zfrL0^Pv3y<|KH;}|HkIM68Vniv*^j1ZcQJRfi6rX{?RX_@Y*wS5^mZ~z?zYd`)TEB zS6!j3K>&#}-6fcH!&(IYd?E4{cgkNHYZ*ulruscTWne6BO?Q$E3I5yb=E8V5=9ym$|VF07(L)$Q1g=_YiKfA~!ZRn>6p zOzz_kI5Z3O+g*ysEYWY<#D?)q<{hcBX39MdWqB7B%GRJmab5Se;wjMlPlpGL`$cx2 z#0}SQsp5yU2O(hl*Dq*x#_T_Lf&(zYHDzi(6TRb+u`>;6DV+^p>K%G2Thbr)COh4u zd5=!>MQSC{xfZ#CI&_XgLm`zcOvjMGTCD#MsrkMZ26B| zZwyL4x9>biC&Kcn49;KF=XS9cBT5c5UbZK1?{?^uk)vZYI2L*rGZsR;qs52m(`9g5Kvfr4xS|*>zGYT*W=@ ztcG$9zW)%A?fh`+|7CkB34nJrT-2pFnUT15j*hf<@2CU{NBjcCaQ3I=7*sa$ zzdQ1=#yyx(2OS00w#=Qv+;t07Bu54_SLUC;{g9#BLka$#Vu2f*!|SX9u97y6&*F zYo=kw1hTv6klppsOuz$GW?y)$@-7S|mV>F7wPGaQe}__CWzI9?*<1Ui7JfWIxfHsTdEil@>C4HOzl#0>{4JsgRgmCER`7&w5EDfd1h?z!Vq5;nR1#lh{rGK^W9Cfi4))U~b9 zq^I5jQsg#nrZ6jAQ3K}k12`?lu*%|#J%xVjw!M0ixyG6Si3o)=JW+kOVR3iOQxIjU=v{C{!!$guf82_-5>zh|#E z@*V631@07%`_x9&G@1`*fQv}|_%Q2(j0}?)5nI8+l)1EOw9DRB6=s*pBh5modvw}u zit0F2SNcgpuD28?2(K1tGT3(I@o|9XK2J&N8gaLen(W5Z^REdaqvb6ChJZI@!lV=w ztmMs4Kr99G8l54kT+i?OSk}bdPT_IGu{>{hlXX1UnIEtZy>H2ER)~Av&|F)34u@0+&#N> z86Lsn5%RJf;)$0KlZ{?Ca3KI_UBmmEn1l#mYCxZBsWs`@{3>XeK0(s1+)TZ6Y**uZ z5CHwnB*VH+Bq&9SCop|4!Ff5)S@nrWj;i{0nToTWXrX|T$w;DVQX)p{DLO+=ya4{* z$b}X{7LOxz{bRKf61I|c|D3q-l$)%tfgy7FJX~w3sqC4JtgY+?O#ex`^`_h@5or;D z4}k-YTK28sc=)+qUZ01Ye!q82q(NzlGG;?znwBT9oVsARS^vK26 zpKbw4RnL?BL~Q;@7dEStGDi>Iuwri*;+pW#zj6|hpHPZ=D!Z7chvh)_w}?RpfItHq zmfsM~^r&?6Q=cMrk#B=fLSq<0?VitxvMe|g4mf8R=)2u&pi$RT#)b`H-$L(~Dm=`gh`SXN$89vue(&Krp@c)Hd9%RLoq0rCH){2L~#ete@DZs3A# zjt!jwGmJLP_gr)W^I+7 z>^+gHO*OuT|Ioi%{@EaWKU+74`K_8Je6;;d`Ff~!?HFrhemAAB_+CBJ0I~=3^LJz3 zEKn^q)Ecr&PRO3_pW8dax1dd~Vah8EbkB(|48Z&*wJrmZWHkHY5FSm`|8D(@qwkRV z7iZV(g51OJhaT_!r9UVtN>4&eRALufPE|)@868%Qd^ORf$1&KhTu3iy?(07KK+eWG+ z^dU}Ik#W|%z0gi|5;N0ghRS%gqpfw$R;ZN7RpSn4=%5G!8)dGmVY2pR*2L)jfGTZl zFXtplkX`#w|)3b>+bO1)*UPhh#xn9PT zp|)D(BOiCBcbV~q4oRMSMh@;>YhKFPT~YKT1O)QsifUpoDsp7%^qWR$TjmXUjorG5 zShQ#Fp6cwL_T+EIHa8{3_Z8qot!fkkuxl?Kh+*}Z7XYpm52U(_dZgLZce+q^k)tEn zUbjQ7J26nR3_0c7)90F)2K0&`A8O4V19JB@Qogd$GkIK>b_-wwht=b&3B9)pPTn>Q}LSX8nqu5MKgT6aAxUsHtL(D=!@1CSYqX1 z-Aki5CV+T{kIl>rG#cz4ukrw9P(4YSoWdfcJ?325?C*Sja^M}z8>OqEnLIs=0HLPS z{`{_APQQ{)t~s^nW0G$tX2670Vx1AF>9t=>m9at@tCOF*`dCpXb z)YPo&ITJV;VXp_$?+y8nht-3E@E|8DqH&lj+a(XWo~-3FP^5++M_s$z=q_%(dGbco zQ#st4K&&u|4{NHWgQO&f-4&g}y2sM?Rn2t0?L&DN)Jo>^a&UgUmxA@iZl$5U zH(uyo5-!X9yg+q9#*vuAX6QoREcYuT)ZEGw`fw&O?HM334TBZqUFwuhnXlqpsiDT< z*M&zsKFUj|{ikZ<97sXj%U^bUnwZhp3}3faPb~0zf^0|m`V=LXY1@A~B`!81}7@9=lw-`(1?dxL@v=4*vN$<`M$)GNb1c~T+yVmT{$KqDEyiQGz9 z^kbv|aD{k!fu}KFVxS%q|14Y3`CCd1#gS*o4C|+5e`^3I%{6F3SeAxe?TTm|9)=En z>eKZ82t4*4(y_yhxruM)cvZOX6*DV?j7Ao0gY3^m_)88g$F~!%A8-0)&Fm-(x_8(g zYp+6~Unp$UR79azLjy@-2Fi_@`Wh*v%n>J z>8_Az!n0>CvJMi%k6RSnI8^h4i`#S_Y=%idiC7T@nx9mQaAFkF6u)Bqxsgo&J!|FP zfHvY0)`^pVQ+r0HRj<^$a`Ejg01uL%XYd-D7tW~=5|GrR?0V7c6vK862nAHcX zK}YABg601AbG@R}_AbXC+R%+C6@B-fPE%q4w&1@?qAI7n=#Klkf64h8l0Z;W;48E4 z;6~+O1?W_s^yS0kmz!ie13F2wE7ARB*7_9j?C(C$Iab^&KhCILIQGH!!3nbJu9!5t zwyM^Bm;chOXQg}42!$w%dZ(G2J&mhI4a=%mXb8(N^v+B(JSv_|F`V5vjA2p|{6(OT zSLu&eUMyH*2(i7BYcL%H8Sd(YY9|*r&Lv{zW@TrHVX1i|c+^SY z)y5!TDKlSpw{_!X&}6BgN9iO0~K#>`l+r_=hx%YH?y)&j|ivFuwy zI&yAXeHzyR)U}q@w#h#u4q)7|3VI^{!C2VX$Z>{CP@tV^#-t0JM463_6FRMVa@(vR z7W3z6C5&<(pOlsU#*ecW!AX*UFh6_|r+fb+I7FCviC z=6DZjCCd@CKnNwsFaV)(Jg?s?uz7xRYpc>Uc@8wNF8pm2qRsxwh<4_{=|Rx=PPOJI_oz^#1gC2n&;fH;)EFxnYJ@ zJAv@KBJ~s*4xW}4EPlM1pQ7onR5$oZze znGG9#8ytWnT`AGo2_dLio)WOH%AHMvKH=i}<%n6XOh^b;XT&L`QQz^{&YsOWK}fkY zu6X_&Tw2Uoec4qmn1E8>eL-yx4m*ZL6Fy772*SDdRT}bXgq6Gj2Hj3zn)*H^NTH4N z?VTxYfH5<1VN=DW|NG_czp=yx8df|%O99t36VQ>LX(y)?)<6Gx5_cgm-dgRg!`<#L z=s^RMA6mNF(!^Ms<37Lz(tpw(d@Bw}Bi9keMdd0uq0kO=;NKFJv6)|`c2x5RgHPNd zG9|fnp7$F!G-0h5SRPbPPG7a;9%)WxHH(H!WK@_W`?Wz2mWEF=j~J8ccFOJI#n7eVG(?O+{7@u>T*3{u`jFQa>Tpc8?trTvmF zwT*O-=o}Scqqrg$-lL;2U$y-vf4Nq$k(FxCxStF}rZ_9*Qw&G6ym66Y+?08@ zagI!*6G8*bZMvwtxF4B?T}`_F^q3u+1~tOH9(B;K4DS_2jus^E%N*PO#epH~I4g>) z9|oHuv(;HK3(yk28XRMNp(D~fdO7c5JfG4J8Nut*^v*2L(!MQdi3zjM5ROFU4Vq`j zIabD|Bg^A~<}&YC$y9#8DLE)Pm3=q;O~2_fFLFOeZcSMvlrb;y;vrwHcXd-1G15h@ zv^pDm)CiS}H?Uvy$;2Ucyd;7Y`fHhdpcoT{B**@g%-=#Wqr$S6KpC6do0&^LG@~ z-kNdTn;C{ciz$oziOgX5R1{fc;QW1vzE6Mk(7`Z&2pdHPWpIk7Ht#ssK5>=L-!x|&FQWWh zkRtI;-$*jTi2G$LlX^0ogG+*HS)(V5f;BKJmO}og$4Lov5*y97zuxIyIR{S4%FDta zIt^gLpsgtI8&GR|tv9J0r{2d+`VU|GQ5{W7Q`?%wX<-&5ufb(}^(tgIGVc`$Y*+(2 z>~=3%6$Qw6qBP;e7rEby5=DRR;*Cf1f~ zz;NvghpI6*3z%+`*3gTZchjn$5Gmltoe|Hk(+Qxxy$d({c+y%#%py>?QWy}kdzoifrkKnt@}K8D00GAfj}rf9pTCrjuno|z2B4IYoB$s z+ctTgkvNpoCxit|9U-HFL91K~?E2~^^`+YcBrb4@cBfW4j;k$a>ecAXFh<)IXh1$f zso=y^vStV+o$|#@-NI0Chi9RtB?+6i7Cfp^^F%b=TRREZ?l>(zj^PD?+d7?zNX`tt znV0_|-|#HN%RNR!h;!<=Z*n=l@22kI()BxAG<70iUSpd(cJ7hXybKa^^(XYZqa2HL z?S9oyPOAM<`_xmmQzUu&GwxLEp<`4s87%hkBdfJC6~zS<&*i0tl zsw02iY%Lxb|HVNHDt|nyl)TZ>0vjH1ks&pxlZc_iYn7$St#oZcSvPGVP9Fgdbp;b$ z{Dqg|(9rdV=FQKyQk{JD2SP0q&u(T0^)OxzLlq_Q5E=7xIJk(I1J?Io_VKiBaZ&Q5 zN3!dAPMPZMu5x~cNFhuq8~>cxQrsUeWA#x8WsR-%2z_u<=lyMg$>Y+t6lRvv zY-J%79gLzHw9Ojr^ZAg0y1&x=e9k&)rrM}fAV1ID>j^@r4}SAIR`AMJq}I`MIhO0l z_wDKXCU-N(%Qx~+4Jy0B!E7d-ksS`7uV-Ym$IZhYr&}4D`GRThZ@IQX>B<3KH71oM z;gMr{1Bv8AUe@~o0YHk;8a7o+fQu+Rn0pRN#_$lP{*yIjzLI9a9(_G8dP2niK84izoQN!Ebm$+o0 zT=*o9b>CVMKakL#Tp0>aCr3N#SqM&EBo&vSOT3alpR;3|0YE(U!yQ{k)7pM%+^wb+W^Ze-Eve^7a)tj9Y*r?{W*Uz6aQgjhgIKd zzbcYEB2So~2}n7)S|mHA=<9k7AbP1B-(pQ1d^9T)U|$fUHZrTy*JxAhNF*TaF<33F zM3RSwZp~(JBXUbw8p12rq5(5$S+xdpfNN9qq=&ONr09NK+jKZ9`eJ5M++u>om`|A??Umo< z7$CE&a;Dr3FSuKNd)e0=+ZQFGO`*$(4QzMVqW(UANRy<*+hkk(!EE)&RR1tpQ5*ce zsy6|bx)zZMBv-Fn7*t)N$Wo~X!J|I>zPvvJAHQge?li#h)! zIxLY^28|+X7W?qyX+sw6;JZaV=b{jbFs676$u5HFC1OEJnr9hTK2eoS&cr#{fsMK} zXohv(n<;tWdsu>bw3+crS3>yG9wLR934yF9OD7dtvb2GbeG*CtRCVH9O~QS31NAfo z)uK^KRESq6&~Hlu2K4YSMJTrzSqAjY`2!#U^|DWXlOT;iv;Mxuo_lzFBh4{ff9^^^ zdYT&cweC94C)Y3NeJ*oDEhk?*e4R}emebxFU5lkfF0Ma z>d-I*bWhi!U8pTB{gr}va}vN6_}l?3tz>z5F^50~&T{+OZ!xEyTCFpBA>RG6)`u%> z2N%-j_Yp`iZvn;IC`us5emTT#QEOE)&pgdDU{}@WfydyPpQ6 zoN?6C5DSTwr-D1X$O($7s;ZxkiZlC|6&=_@&ekM5Sp0ckncbU#D=%ugBbG{@x#kgO zvEJ}w7$wn~KnPw$U}bi0bu^@rc+|7L%B+e7dx*;NYR_>wmWM`0vBArMlWg_A0?c=) zXx)w;Gp|1ZzjV)aGT)H^vo#D&v-(`Be*E^tzs z8jRLnj#H8T%hL^8Q@=h(JsG61SL0x#(5NS>epLJL&E>ruH|=`p(soP!ajLRrA#@&Y|c{!l!{g zxF?LM2DrYg0Mr;6ULtMKXu(cIVKgF(`2&_(GaLR{mX~BVbSFPvH1$8M_iR9|XQ;u3 zN7KLn$U+~?(CL%UBQ2+jXgIjpG9pes9<^VJ_4|$evw!d-?!-nlRkhOqXMcxs$%Bh` z;Y+_DK&CiRh)}5^EVk<)VbYBGQuHHaCOU~^i;GdhEaalthqhgTcw`S3Yxlw4wx3v3HxzW8 z=*UEdjNzq;*U1|a8x4NS6eN&DbZQSW>Jblg7YFr`!p$p)F6?`~>S8Gp#OWSA!32pe zYi3Ggb+MaOa8alD8v$lvz#ryv&HQAIFWHs<^+kt^iyYsdls(%D`rP3l<-KJmy7Jsa zLs-vjyApmSqC_C@8@TG%jWxTXfe0=}J;l_x#hXomrxmUjpLb+J#3zYW zzx_=iUr5_Km4D)Yujoa6#MPRW#RS*6bAqj<5u6=Shf*(7W=tpbhx~L|0dKr)gBK)` zCKPq$7PT2jCzL`Uez+nuEK|~{iA^^eeeS!a8=aFT6U=L`AB6AneiwCK4q23jOl;+l z;mqu3aegTY^8kLpM9vkdD#}{i3+#4R`B2#AxgIzGAE8zzY(iUO)tg&O6V7Pj)+MX#lQ2&#GgBqAFI5e^QyHHlU!Hk*PR z6CQiKT0bmKOM%(|4YPUT%^1HNvE~}!u(u4^dz8Id2fshQp`S%84ZwFNFPo*U6i{s%z!M3fpI4QvQ3=!wDR(Y5H_hgX^u1)Hs3KHM2;B*e0Y!gk=Qt$WjpOmWCCe zS6M|TzWZ4&P^hG64biz-yX0cL1djR$yisRgjnfXw%n|yda07LPX|>Zf%A?F-DQZd!Ij6dl(@bf27pI^-4P=g$uy=l7N+>EOc6g#ne*E>ZiBfxB=S- z^R9QsyIxe>R&gE?UYX*xsrC6H*m$;S(kM`@re+4G$YhvKXEnv~0BAg2V1tXrXVYEW z=Xr+eI3?V`S*JJyBuvonC)LR+@IH9FrjU`4$S5$ddF-%^2mZoD>=OLe3&z4U0Vqg} zBLNpq=VbsKo*wj=th?=Um?KsLw)X3}UG#6AD9D$LskqMBHPTY3*Al?|Twr$vK z%iKGW`+lSU8!w;yQ)g{uawd!ABtk0mOOa`LI!7NTkk1`bN3%%HU?lVM?K{qavGEDl zyZ2iz#?MGelBwL_xNE!SncV$Xe{sGXU2Z3J<>(q4?3S6<-WU`nT6ox7I{g=9RW!YQix$! z0@hsi5h<)gg6lrTBN(mH<4*k#UpzXV@c*&4St&6lj7T*^79tL`C{+PjR0!JzJhb>H z`kJ?+slQxOWw-Fu8~9z@9`YAmCT-LPyiQ(@uc15pDzxnb`0dfUHId-7U8eflpc>xF z_tSFzIG@^_S3Gq+7?~umFRd10#;Xte#YD6o4}y+}*HdR%LjtF?DW_=9b6uio{gR_VCJ!%(!*z`2%j$&(lEFk?z~(>+PMZsmJIUkw$$4sI z1~}W{WeI}iH?EG&C@ewI90>W}*k@d!H{+St+wb*##6ETW#9C(8O4-X^kzO%O5;$na z22m7Rea-=uV)H>T*fSOsRww_j;LiW&dn_s>6Yr0kd)@hV+sDnbf)I^QAmJw=CUxh1 z`?gvO%{A|gWs2+CVKw>4RgzfN?1wE`A;GZ;#UnDil6Ys6kqIW&Y+!bMikZ;>!rfBP z+s-~EGbfn>^o^WP@9o=0|Fnc-$D6#rIGme}8L^nr8V2RQ%i25G#{`N0dd88c1a*Rx zA9HE3|BDl!xu%-G=8M6SDkS^Te*K?k{!bnJKedC?_Ptsn+C>reEtEuMYxK3Pnj>sz z!b!m2K8y9P97F_xjcev;=58s@@UwHrc1a4_)kCr1jZuKaCvV(?dkxl*b*$NMPNiSK zQ0hlR@qb<|M{86>p?K4@mj$O||GZB60pE`PJR_8_zIFZQ)h!dEUS0klFaOUs#oNDp z{O-eg=9KC`uX-m4n*Z}5|9`F%MF^}IV1coZgc~yYU3|aCw{lq8_8-eFIg5g`6e|*s{#b-t{t6f+|tEX!?nm*_11>`KDfbmTA18`zn)! z2|XUF+{LZI+aphRF6%9M$m44y2B*#xZevs}ZLeAVd&=}ey-WcDd9xyJ1e|ZlXW&hov)x6a<&W-ecn5TNCFAUsrlCQI~cs&S{)cZ@#sPPQTnVhfiebHR#`EY$mEj( z3~YjP@SGzFx1zv6ftAr;)$sgBUz68TzQ(tfsn0`)H_oe3ACY(Ek%lEqUw|9e9CYKK z4;aj?IlbR@O7E$6=ne73MNm$4l{+tmpWntjsNF55sm;raPBGY z;9N`ujTx*9#CKPzZeDvCF2l2e84PE7z8nohz44;TIx-_19KE!GHfV5k+RwE1J*vib zPzqR3$ASV-EBPQ&gz?JJ)IYu1W>g=cPchr49h>l|<7p0P9T|yoBO&c~d={iz;rg2A z5;5|T+=vb0?(Q3uhmVz)@@t8+0wJ3J8kTUc1!L_93b%qy94X9{%z5|SDf-pU=4{Wh z^~k#SHQ$TR!j-i2Zd$V#dRJdUpYA%(16Bz`8kjAzc0FBc9;0WJ|!-zkBN}g4H`7JRZRb{I9C}$BkCNa5zCRV9O9ix|F{r4>L4y zrZc8c@ZtcVdM_z)F)N#gIiM@8)A3@>v`i@5f=iRieFpz9X9w}x>6{0_1~!EKQjY!y zZ)k{b8N`QcEI7g{;}LTI++eE#r8aNFGgFZt1MCWlqww!#07U{UMk` zAC~kfE$q0vOrCqZZz$jkBc19u;vYq?!;XAJxw(>70o8Xbrm-+pOmkF{cTUEj3-HA~ z$4H|;r3abCLOnXEF-OD;Kd;3>jespK)#U8_eVv&pwpp=g%s$zd%w*O4yG8wT-;WIF zlc>|X#f&?KEs+Ju(vKL2kEN2IeI~a3P#D+X{)g_z;X-7s5*SyfIt+Arr!C5jWvc#~ z6W73Y&%nEnCOEu#RI5i;%KkI3ZONY900(;lhO3`?_=hJ^y*K%awaNX_RUZgeP=ft4^lB^uo0@my4vw zP`2qK`s;pTWqOhS@V;qxr7Ph_{bK_Ak%W1g&be+4c5M#;(buT8g2rqy>3Z9w0((be zbDJ4N&E(@t>+2!Ok3Ls~vrk($j1NT*|Kfb!_r2@OErP_B!{Q4O|H7&!nv(a9T~u<} zI0p*!oa@Rd9_zd16?L7sQ;kMV=>ElNT{))ly=Lw$y;N>3tT_B-R?DfDVDA=Mbo4dN zBN{eN*pic7skMEutK|kg7(}k!O1!tz<&I^Pht(%vK_gr)GDz_8t~h6P8oD=cO9kGi z=^rQANqQ&xfLayj{}9BGPSL38;{nyCVex!w#4Q8fTH*;t{uf8#otUV{j>#vbl3cc& zQ@5ve{a0m)rv%mfA6pdaUIl%C5{BCo_+BvL@9$2?7O_e}U^(&s0##9ZtTc01t_USW zF*fNPJuF>wwZyPURRNS=^$F%(M&W4nZ2RvQ+@30EqGl$(4-VjlzH;%&Q?O%Y%BPOa z%O;}#M0!T~9)ML+DBYgv_K0|;>eWS(sziw>9Suv8g~V+hPiV}`w`FWj+Pik=Nu+?j zRDU|UA2^%`uoAhT=cHl4Ync9vgSNmbOGmG}|Kik?U5*=_`eeqyGwSBq#3eq=9mVKZuQB;^3i&-oCXd{~iF+>L*v$@KmYGPqteXyMtckEXC`yAKlC} z13j_(YxUXwB-8ZjWz5akkG(5x%kvOPL*??pjG6@3ZrO>d2Rk7F)UIf{pDJNX%X(L@-9g^~AJ}7`D*6`A>XYTFTT~ zT5!r(cDvbPztYS$hVYz+n%DTLQm>a`AVuCU+!0ROkup(BZ3Yd{Yc{4b)md6Yn-@g9qXTWk(L^W}-xpSqBAc+J>H=5Bx0fi_@jiV44~5E|76J89 zaY*_r{exS&wA23uz&SLLjIh(<1CB=<%+j7OWQ|p z;#jTEv)Zi6(OrVyZ%}&n-SR)4AW8^|cN8Tbr@PIHSjQP#z;k4(8|nq7$i}`m(Szbxr1y#>dv1&nsPjN}2+ z?!nMksaW^;IPmGAt=V53O)NG!%?Wb+<>+A-YyOp%Qrs!D_Q@4-AX$&p=y(msmu$wX zgeRP%22zli`75tzJ%?fND7eo>^VWxUU4&HN+~qKSBkyScrg@`*Kdc3GUfc~v-~o&Nh%n}qu!i#`Kh$iF!4 zAbGkH0)#&Oks=OV#)MUk2c*AA-)y>6^=C2-nXZ`j3$Mp4-izj0@tMW}{2}U)UvaDg zS8>g@#tl_J06yQ(wX4*)9EExexHu+y+H03GMR+Tk~ zMPzbOe{r6Fyh%;cY;&YMS}BEL=gLUv>w??2*XT`md&kcnN!G1%W6gpJ z$@1q4lXS31(b1)+)Zfv0INs?a=UPz#pPaYcOjNat{*&U8;1<9u=9U%TkQl#YG?aDK zJ~ibgIpAGjo(X!{15S~ZKo!fPvm=Xfnr zqhY%1FV8q-ZL5QzE8Y+S9Tu1Gj_lNW3MZ!~;Y$ z^GFS|bW8D~G$A8zjm(QOkHo-ZY4^w4?$3R1{>_i?&3p6Sd^2z6o88iP&wDDlbP*;J z-!d#I1&vP&^IS!cE95O>5p2bQ^@@(wI|9Yhkb+pZGU`S_e!3ddzg=W^rRnC@0PY&( zYt^cK4L$zTPMV?;(plo~D|~XH+&sTWx}PrvOuyubOK~kv*lK(ERz|l#Gk`Rslu6GR zQFnAfKx(|D?QZ3xV?JHBOY8J$j4UbI|J7$gCsTO52?(sHL#I@C!0kPdD&V4&YHWXjthI|^9l;NT|Uxr!y&T|^UWb_$pcJ5dTv>iNABU6($8VRMRuW_EoibY zC56FZOvLOG6+Iw&)@94fL+szmP32kPTVBLnoaN#Ccq+PyVp7i|?JidLhNk{C{7fas znfEf=x8ajT-|E=g8?3K3r;ACRUc85s0t0L4zMz}NO7`i=uriwS@h}s@;CNrY)UBLJ zkN=py>A}!k-Q}D{=8Ad7TtrE7~4&3^NRY(M4$LTr0#JxgV)oxwqSpI>DzJ zvs^Y=Y&xj!pp()(P-60Y1YokJ?dpw_=_kK%0!;6pYtwDTVSQ-2!`l#k4IgdD3dNO9 zPGfCVL%B&adZ$)oyPF^vA)%L9LR#8)bf@Wb@Nw$-+2dMX*V#&;O2J1%bx85z_`q>u z8j7EuT7CigNf7?B40+Mc-aDhyi)um}$Wqg)EKzl=7(0&ZtK62E?e5+>nZXBfqM;&( zE<6IM?~E6y2dgx8Sc0qtgQKqB!1zN3?YmxZYU=IsISp3jF$k+f>e^QzpT^OL<|lJ4 zfjTK8`qlFLwu%#1`WroVO}X-bG%;Febn(fN`b9ccR-o*A9x6LR+z^ow*7cUpFMj~m zJGD>&uS7XCXlOg$c&?iC)foGZkU3WEo$2$IU%e0BIQ8xT&tpiyyFJ+$)C(CTw}Tj* zt{ZQ%OKb?j(t%}^Mb29Om+f^V>02aYS8mBq>Zs6ega!1kDVxmy}Lv8e3&| zsN|zvFE^S&VrwDRmabwzVw`rgSSnO)-N0NoD7BG{4p=Y7BrE~W$i)KEU<<#li)uf( z1Q5&UQ^l{l&_>dy^C$)!QM25^>StY7mhOQs&@U{yeXq6;`UcbE?_^+!nXBLx7AsdGLXxo1VF1LbXJ*r}2IjY& z&~gQ1i?qEXiWDBNA^J{pzsPK!6d2&`kHYHpK)&m}QEvBUHUA>0glVyAGz%LXy%hu0 e!Km!ZZx&-IDUaIRFuwWA;xXWM(5G1(E;Da+raCZiW5Q4jFfFMI~XK)DakO09YL4yZ( zI6UwBd*1!NJ-hpzv->;$o$9-LuCBVftfx!vtLky?@h1QuEUO?3Ktcilke&{};~LVO zf|Qi88bnoAK}qJH0fT@ihx!@-aBy^Yg~&1+WKL0B8UT07rls zfb$7)16~8T0DO;&04V?pGV)*Y1YN$S6op?(fn9(4L{7qGKQm& zjf9MX`lLbtM5lehg+WLsp-#jN(J)C!B&L7k;_CLHrt3S8WD+KcX-Xc0)=N?WO;}JJ zFTb{q`^V&h+WN_Qy34yQ`V4?pAvkA zhKlkJ4I~0&AT1Y)gbC^kbvl<133)Yy++E-Qpb(~S$VWiC%wYJwmQ63=eaHHp z?Ywc6q=5oBkuYfs$3;{iCsQubtU)m4m2<5N+Tgm`%4+A8rd*2m7L$4L=Z=Fo&d} z8^G*7!Cj{E);zML(YG*lO~yo|xv2rQ&}oU!H`lq?=gjz4l&QP%2KozkOi@u?X=@E} zbCoy58hoQCd*7!BlE0O&l0L>0c4KAMyMacySDY9U%Wms(>Ev z2${WJBjla07P&nr4eV~Q5Mm#tnd2nE4xrpq@>v}wA=fj#PAQFX zn7KP4dAvzAy_c9?*02xECauYQs-XJn1T|;n@GOckU~iycF`Imh%fM$bUT!ocL7#Tn zWb`AY65@9v{8?gPT`dMCO?+B&vlL#?{GPQqb3L=l+Ck@b?F3cmW)~4Q zIa6l4Z8f~5gG`5#+0-LzHxROoez=$=)xM^JZg$*O$@=gX{fc+DE1P=|V(fRlGM!Cc z$c%k2d{o@_%eRNhLP2WjE9L!1LsbPzxcLp|*#Q0c)=#fEUF^4opy384K2=Kt`7~0H zF*1F}P`*;d@rnXtXI`Dv#4z|)(kH%+tSGI!?G}NXL!eV<5L7Lg78*;1udzL!g zb_1nn)onc(ZBp%9Y&HfCb-&)%Oiu9vhH@gmXydhS>4v2KYNf&p|3q`aHmQU!*j zrOXlGvT(al{?W!1pH!9ZueZoD_A;FutW!H!s+02HiC%yQY~r-1qE++PLg2k<3?U4c zs5%EyW+$59Ic6J__*+q49;0$A>&7&elKf=2CJ3zdx9k|jQDU|E$iY+=Kh87tHY%qt zR*`Ce+xWgMU`@-*Gvq7)e8&WZ-1M_lNPLVq%cI4e=ONp{7W;Oo=bP<&{*4um?VG>V zf7e6~#qBVg?4pG(Cm0t5}{!rK;a-OaKFU1(6NBDMDSmJjUY0*w6k_zNxzDlK-{G9Q$G zeOrLd#8YMPY2;9NIXD?C>9}prp$4fodH?lN?r4=Cl3`-baZ9b;|Cpxh1>T7EUk>4i{r@R;&PG?As?tYO82 zB@;a`Q3*>}Q7Q2ys9;A<7Xk0<776x@l{#0L)uo53If0axKkOqIszdlDrx#RI?ucx0Y9TBuG^{}0hD6L#y7%`fIqTU zcPl!NfY^3JaF5MRPRb20s`zx}f>z9FxQQUM916FybE5~wcwmHfwhf3~m22N)yvsoFmC z_PMYy;JRBES3j+^_Xd2Plj+D=KQZ{omR3-GHl8N)=y}nSRkX%6jyg%yJGEE!B6-U; zRF41zd}kXv>Ok1dtk=Mo(&`bmo2W`~-EgTof1#b?&BSW67tvIo9myZX&>~kmcW|>C zP|sd|r3R_;8B(vfIr~L~$LHp%?Zs2L$TX0Fb7?vfyJl!{p{<6PNvbblrR+jQ#waJO zToUwzh%gD}{a8dHR_c7yymG+alol*E0}N?;7BmAy=SAAks?(yF5FPXh6C_-U-J?5V zrC@S6UXGXG7_hcM^tXR?Ix4*f$&|k(QR8MuXk{v|?A~4Oe@kzK&Kx#U35jGn2vR9r zI<^d*9qsL$?)q;(0!p9iUk44&ik-T-cEm%|I^JSn&T820wwJ_sez#4jADLQkk*M&% z+ZtxcUzxjcu^&a*)Na!b&Bl|-pxALJ_PCeEm#4X)iT5Q7g`+#}TETO)rji`m@M8Z^-j$~bE6mQmB>$OBZ=UTZ5Ca0|@54dj|2L5+@R2Y;+gp<0g_eDc zfzM2b;J~G%wM^a7X$2+7C9X>^$$EyUD(>8V&%oJs5YxNeYKd@a8aCn~Y%HuIkkLlr zkD&U4cz=alJDEODb?V_pwjaTo!Wm;jU`VU$(*Wq7q9c3uelO#@F5ipe|va@_tdMW?<0B>mb6+b8K1xY_o z>9IS)y<Q zmi3@pH&BDWK`f!Xe-nfOQkWAx@P46LzRH=MyQQ;~^P7x>ZFKvP+Yi;*sSno2;iqv( zuKukU3~5(9W1C(HU&(E@%CE@u5RQ_z|3Z>{oE*+=q`@ic6-Ze02@sfK?ZC*~glgAu zR|ifa5Ghw0c>R$1e!U>&5zuEQcAE6m{7yZ6uoP7fY^0BXPq~i(rp^raO835*Yk%X( zYq5j;99?I*9eeZkg0Ib9>hhuof#2pHcz?#$!@}tHCe#n-i_~r%mmRUGv)&Qb_Sld~ zDHkS60-uEriJB)W@XEn;`{@fm75Rdmx;cGM>y;WDeR7CMo>pcvLAAsQuX^I_N80{} z$nbLEgx%Ev(I}-i&f&uW7}UeHvun@d6aEn0LVJ*t;s0mpBn+-#WJMCc4X;{Ivt^wk zkh4g*!=#?6t0OI5SN=?Yt_{{K4rxyO-+Q}%b$kCFup)21EVV1d6M4^?TVa*4^^130 z-Fc6+#6FG|3~6yqs$KV_2vNl#h#ImG&UOKV@#n5ez%+R8GcGl&ckY6au1$MId)(}9 ze`GSTZ~o#FBHKtOemT?S(>x-+ipsVC34a7|H*{fAzc5s#@L*P4UvN@PiTp{xNYm~Z z=3D32=$6|3LEisjSQ9@4S$>uJN@Cwu_h;$OL6-$f6yFb#b1IN2M<2YY-~!k15ujGO zKj^f$DMY5oW-0|zG^@l~GWiPxgGN%uit-gUH^?BV{kA;YR4J=XsO+ z`(lBS#@jeBkGFzSmVTgNzZeOS!lqW#HJnwRa(vQVrOzc-J`0x?rTh|VtipcT>LG?u z2d;XVOe^AoFgFC=UXKw`7P2*?YDUzIfVB6jjcoK#YORX_;x$bedI;Jx|J0^yePnBZYezwQf@4$7K z=4X3khZvj<;Wj7Hw;79x;ZOkVYj?Z~*&uBjpfj5hiaAlV*+Mvc*S<;2jle{g3?fbu zyueJ98|)W76myq3+mABMYhuV|DBkH1j(ew?7SGh0rgx9ena#JuuXIvoKoPX(GI(&P z)GDdfEDhC+|JZx1GARQ0yZ2krJq=-fPqg!(`l;e@J9-jZF-P8MM#|fqq1!&R=aoxY+(#d! zU>}5pbL{lafiYhQ&9SY-qS=t~F68|HjP=|)nhqSOK3Y+D01_4(51}>CL_e?04&TyI zlt!o1TOF*v^^H27HP3x_t&ifE%gZ&!Q1BjCtoOD#k^I!4aq0N^f>zMvd2CPGHhxY| z#Vh)c5mJioO(z6(g^aj-L7%}A!k!DwRysv8P5l|H1mJv;1Ox4bq>aK9-1U9Y_x%+a zM#hN-l|UxxsKgC#>CkBD)g$~-k6*PCXassxBjQ2+`LYOiB%w;J)VAf?3A=$se;`%3 zL4f|E4g=$6!amB;8W4$oMHx-I{V3=B3&WXjjmbUl-jTA4XgQP~NQA0Nz5=QoT12{pkguATD9~Q%;CeNd9Q6h+EvsagF$+Z#L zkAPZvq+~ulw_Y_gESw;3f_x-Yd>l6jVqY|bDy3a_cL2^OWIDomWRV*6anjg_u<0OK zT>qtujej7_ zXu3$9=G`o?`i7$cmBpr7O`N{zr{~<|DSts{q2cWJna!lph6R&loq+nc)-WF8`q20W zWbGX0iTTTmvRrUGmqwU-4zmi6X!e{>QWVsj)373|GftfFPC>xFJFsT zywcn34f)w@;lL*Okt_yYqDaxwGFgqfekz{qoji>A?n!A=E#n);d9YKd6M1+#E2J?~rWui@*lLGq z96=w{ADSA3^?tahyS=;g{ei#-EGa{h!ORx^geAwOwIpU5C5@L$ldHi{FNE)xwD5w0 zCD+-O<}Lc2Hu|$psve$|jCFrSD^dyDKJF9ElwR3D3|&ANJ~D)Nf1i=&NPHSyUxeEg zxBYBY{o@_K`j4#X)tfLeq%S3%=ePkV z(k5`rbN+|CiLW-hPWMu|f!|EZqvbu=24QK$BgI1CMRjmuKsVdo|MrkX)0Uzq3`_E` zPCEpLuvBu8{-}6i?4Qq1{ZsRE5BUH0_^UquzpsxEok(K=T~!lWzPko@Op$%yk=-hP z#oXYjfBtzs541RTf+RRh0HlhiwABe0_HhRgB5Kp?#7+yb@j^CxAG0(I5P0Wl2J_*+ zFZT`&g4M|}#n)IVmXiw_*WKDxb=74Aj@-js2QR>x*PLSC#k{`^xjaPNju{&GEveO) zcsg)^M7vmYR#-W<*%m_RFU`wc}wU`>zfc;ugo3e=APy!cxTnjda3Gv6}> z;})^WGFYl%eb1M}ot8Ve^EdcY(f%$At7zXTW5DqHYCK)qHJf zl#T=Dwm6915-y9{V}1!xQ@Q9DgA0UO{to2;)pA4WMmA4Nzd5-9yC|o6vH>GvNy8bO zZGouwW_W{f5iv8P`VKEG+sF0KIH@eptE4BJk=nq-S}xPe2_lgJjXlqSmJo2$c@R$Q z{B+r!fqmnN5JZ5R)VZQQ9{qI)^_vh^z#X6zW|AtnDR56M*S8V(Dvp{##z6XX^;MCc zt5k$TA!vYTjuyrI5rB35)HMOQ?D%xDT2_A8hrWYO`@Uaf2wIT8Ra=ehtdm+}%h38{ z_cFIETDX&ED2IT;Og-S-?OG#KX2BPe$iQ0D4@C7!x}+=&tI7ztSi0*zc7#>-Na|HK)#3HyWF6Tmh-?NiVpD zUmtP!#xiY*6+}Z?!c^&@_U&B5C(Xpx{AI~+tAEKlZiNW+*tqqw=>TM>y^!iQ)w7+d z7i$hbi;k7jy^~A;viJ5mNaw)?yD1+BrxPFb)~;_G zG9Cfv)TJR{NR99Czq6A5N4G#ek1lAG=sN%_q`-cacXdXrLP0xWYGC8R#v+PnzBVJa z+q>fJtmw)CukCm=g>I5;Aau(0q=`sO&Z z_{ALa$as6gpKBHVy+^=oSMIqOg*WUGFtB$ey}ArJt&lUq7vO96h0g3M_Y%((Qol*W zEfqX|Ko+yR(TVl(UlPkCcmrqSZ#cR9o!IIA9L%F;pi556;o^1Oaz;5_Kvh2$`P zyjHF7*|R-GxW9Ux`^68xD4~}gN36dC2des-)19~{t{1@{?P%CmWFsis%0595*YgJr zr&pry|4`$dZ1+{oXP)P(G`EJw$;Se{85RxuPw8Yb=QeYe+P&14lg&#yzEUUN4k-*tyo93OYv0~U4tm1- z{gsG!yZPvri<4rO8)CJK>u0Bkt?fefjiEgv5o1!)%Nli`+B3zCOhbiNq>w;2PjzWNSn5N z54Rk*j~k}Yb_oqECp>Ca*uy*L92IT0^CMWJcMYBVh|_?$9j+fxDUX)bGY+ljMTa(4 z800Le!&4V*dB|C83TbFDP%j0+a&FsNyE;5M8nGQQE?4h~MfFal{Y!4|+Xc_!2H_yR zcE zeUGtY@(>_R8HC%`-*8m&Pz56bI_fdTt^g5_0FZGjuX_x$iG=c%9|Ul6d4hGzYmk%j zJC0L5wkksmeJz)Nyz+e&#e+J*8RmQi_P`a!ebo;1oX8`dxUfE{Lu6vFO8D%?NT4l` z8VsrPHGUNk!FDNkqvcp0_cy8?L8L#?iIrr8a2CdLV{V=OFW1u{a&sN#Rr9K+C0L78 z>6`O^nSuUgbo_4s?J0id46-@hM?hqQjTRG~uhU)i_K%`m_P6IY6-Y!@1LyOM~3qPHGTCHjO({Ag%U&^x=yUhyGdKMUN zJ;LNJv=WLo^ggTGA6Db5sLrvA8Y$$;-V(pSWyp$NgbhnG>6yR<_DUpEdV>pr3i9X; zZluygD>$y1PdyXe&}C69Q#6@H`uXfD z7UzEQ-KuT#0%vND6I{h~j!N-U$NI|^A=n_5y@SI-o)Dt~wY8)IQGrj5K}~B^HRsH= zcJr8rEBg%LNb$Xm8mf-`cesz(w=%fEd)*^L@f!1sxa2Q#{e=b&uP|S6>&DMxqAOR> z8m}?zK&d9uvW@+*ZiuD~rivP)zm&W@Zbw|GI`u#G-51vcQjVfU->Uwk<`}NpU#_OL zG=Ivcd>YS?PPSKkJCYe?-PsBIp~Um5zEYb!Y9N~{Jg5HDj|`80_tpg)ThM0gQpewO z*_*W|*qroySi`H52i8tlT=k>ksg*5?SAFM&Mu~L9YsQkCo~~1(A%{7b^xiOfj??ra z<^+-M=rioj=dns9CAD8UzeDmnkEcLj3kTM~1Jin#swX}XKTy@_HsP15rQ z<(hk4d4m1C%27&3XONPGn+Bc8Tbr8MZ+bA5NEBd|tDjWs4w#z`g*-ohfcrp84JmtF zIwd=jtq^@6RDza3y>}TJXLFkPOc4_X5=rXOsJr>(@2=Q|6 z^|ysh^pf6enct84l0NHnEXR8jw0r)L&)T#ZFENy>NUunA(y48_f75?dQ{yGW+4#&{ zCpx?$bkYDaSy6$pIT%#e!QfbO+5$^i*vB!pG$Tn*l?&up%57{3A6ZTP1X}aFAVVZq zLEv1Y`se8e369kZ%qxf3zymD&;E)?mG>zAeHmw}3bO=8MY? zE?GwhO;{3=zEs;%HJ_{g>o)89Un73=f1$s|{!euAe?9P@Lo5~=9&)|wv4AiR!=7_b zKAUbR4u;eCDbQ%Q>$bo$2X{I=O@>XSVnhvd%-quBUbEDH!LO9x07`tLp&D2=eEVKe z@DVV{=2IJ+0L|EYMi7jf^)>uu-O3?H9Zg5>^E51Q7bZmC9w#p1avD#~d5%x@BEx+I z)X+8>r44;)08Q-fUV!!X!SV}PJZKdZZRbw6JN>;-%I+a;u(IOWPvbYK>TWFpDNRex zQp&-3ERqmRW(kn|q3uqBkH%&OT2c}Iz0q^FFja7Xe5)+DHw39i!Oa$=b~h50m;AqibngnleBV#XIbCc%|4E@pUfQ#Q@FHD7(B>!ZHS<#2rXR`ivr ztyUgh!;&;J3--G%{NHo}=q_9At_g z)@#PMT|TCdfSs;Kz{;$qz$4%QqitP}!mfjK%AO6^H!(#3dT^t8Qpn=sVzX9B3&sXe ze@foZW!+5ee6j=2hMWtL%ODmu3?7Ut4X7Xn$1jb;4W{(91LwsAsHSHee=mqufzNYG zGN=pq^B)0{75>~gF2AoCzidL`ljQQR>g%^Y11{FlySVu^1>!~mt{fPylME){R2iP-D9BO4=DIl|(@GHXNLNCN zQ2y}Z!bEw=))@Pwd|ZUM;DJ_7`;E%Ry`SxO$86l?I5R$HnT{?Cv#!W4k;sNExLxB{ z&u`>HvOMOSgugrYXIto~;Uf7c*^4cUR#K3F_&TTG2gg1-TAWNAs1`LSv%6_?8x;q zbpyJ{iS|sF)<7)~m~4%cT!^gFXsq|DNaXU(&U1<A{hMSs zh@&bsv4Trq8!P4uyIQks(`j1N{-=*6s5WB%gD%GH=`MkVTLM?VQ)HnX#LcyHah(%v+0^2#}#T=eT z*|-2+(eg@HkbVvO07k!0nr+?Z{JFN+SCMDZxADZD^??A&#?Q~r(8P_}U)A1TLXRuq z3Lk8g%3VN~vsNZS(=7XepUE-7LE#res~p$9fkgC}hIv2QX+&u2q8le;wNW^CL;vh~ z*!I}#>qbxF6Hb3d36T&Uj$l=1G#rlc6fN=*xALIAh|qP;f}Ibh7fk*fNU(FLf0m0| zo7nIQFVAX+?O{<2wOP{YWG#4f8e;v#sa@_>yd^9d+Qu0J&?XtUZ^j+P&(Tiugf- z9>t)Fe9q*e`>`#T0vQsUywLmwLMSFq*t8=l;~C=)0e?9`g~x2wWZ`gf6DNE^3(Fr{ zG7!l`AIZvSwn^(_QBHcA9~G6Mb?cf3#R&0GW$X&$+~F8VsgcpjLODCzn|YKvleb_c zb0V+ar=4hrmY7N7H#OM-n<%ALhRu*#xVPXMM;Igpm(4s)1a6lw&)iveX16z)HTiMZeS(8_~(wunaK^XW#8FvU0v%we}!gOL}Vj zRyj>Ck5GqA9DR?VT%c&Om;hFD2n)4c(`<@cYz^$@d?t$+&$eapr=%4Sfo&kugN@}~ zoy?r{DcQejERF+ryMdnc{K+=-UZMqUibAT?C}J5sB(-TN56k=-u}5F|Yzmeb#+Y2K z>A$cLei?ju&~mbSQ}*dxY^n_9t~vZXR;!971eZ@#LeXO;IM}8>jt-Wu^WZozBJRpI zTj;3jwS!AEwb4k7)(Olkxws8hu}-CAb%C;NR`yL*0DQ5kEjrS?nOw4dHm9uhn7(K4 zGk<;KI3@>GSrTSMW8{|92Ikct&12GA>7#2#TQV$AjqGd=welCG6&`f`j-Ie&5?$Ym zpruuAORgV|B*ZgKv6J@0aa)yGm4ALzX7^_W9GDG8`m2va!P-bPCzJAmf;2cE^QyFr zY0t%gX90VVn>4FF=TH9oE^`;YvI6>!V&xp1XxR*j+WAmbw?i^2*tmhRU~KMiBqXI3 z_#cuG|L{crAIm+Snjm0kXPc{ORZX(KgYaG>-yFm;9N(F}7*2taV&3N}ozqWPu z4{_oeGq*BBbA=@vfy2Tw!9*Zi%KDEDR6T}+#X=9*zWwm7IKG!`Is61J*-{6CM+0+E z3kf;@dfZDxJeIZrmOXJ(H)-WIZhf2?t{vq^ejyBrW&f8T+HHv!7L?| zx4z<@2it11N*=!~0$%W#U2%@ZP9t87v2IO$wAL|&Rr|$;U+%Wn|ERI*KMl2l9z(e| z7b&D^?b6MMl7+Q@G`#9F8hGdbnXq8Vo2+cr<;-$)P3LMBYVWJWrX=7tbBFl0Z&5A4 zP8_k7FTNltvC@Z;f=jJ(f_aV8KRAu|2+)Nl*Ny_sOGKX0c;- zcuWQFf)S(ri!NjJ6wI;r^(8`rf?^i3%M~99e`lpCG51$jb1osdWw$p-a z`iR~LoUGLQMVtTd2M}pW>M=JdUG*YQciL1f24Wju$lD@w59TW6_ z@}&#vgpme(68L&zpwoqXJ$TQgaz<>}`5Gz|^)56TfZ*8B-PS}C9Y6J^!=;x_KMH}Q zNn}#4q7nO?y98}RimA1IK zF9Y|UtAFX991##k{9^EEM0#DRSLFZjN0Z?p|e#nw;dpYED&^nK+( z{P>!y4Sq`{wL&p}GBf6MmSgYbeERwJ;I+{J!<#u56X6*{>)GTh_9yF=Lg)XY&5PfV`h-}*= z#;nUie$CRe^W+{^17cDo>9;>`dYhGH9aQZ6fL{pX;npAXHapIz2>hd~z!9EAs^&df zeaCw;zkVfqf#ok6E9@le%mmsSX?Ad$`K{JVK&@a`dc_uA2Q^jV?yt}xkUJ3FJO|wmv747#8q3FBwTX5KwzI3e=;(Jl#;9WO?|c2 zH+Eo+1)98kW0RoAerU)jDObu}n0GRonr#51`ubN3SgYz}jeFm=t4hDaJ3!r2!T&>u zpcic6Bs!N%DN{}HGY24`*&g$vmy+^h1=+|{>qqwWc1cI+WWEwUe_Bp<DI`Aan+`(>DxWGNGOfpc;MV5`4v`;Y(;CsJgbS^2t`2{;Pu0sHT41bQ`4-^*1x2Y|JK0U|gj^lqbilhb zg~d3t(%iVC&3_Qyklc_0{o>Q`HMy>6!6k5OrgF%>u%L-5;O6JW{E>bomu0`6qqG&| z)J`>1GZ90~EANF3i-5)Mrghsgy*M=9eiPZPEm0Tdn*chGMs*r9EJq+5iEA5*zklIQ4cZbYCJF6qf~)!QMh2> zT$zBKQ-DAKruxeHLe;Cql&Q*0bC0sNIY)ydygn|Pp5&#(IU6t}N8LU?rQ?TTxXw7cS!SjTv78g8An@pf#@Qh zT^19FMQvC7XLl)+K8C7JCoBJ3@7cbBFK;Q&J>T>+G{K=1QW{caQnG?6BL>$AdHZZu zgLchCGkVSZ>1JL%sac}bZ;OeyK9BOxQ(+%#uXHC%c#}Iox3DBmMQ?&zHwW9zM0Dd@ zgZUX$GaSIr#f#J#Su_GmF!UqC_Y*x-b0)HZo8#PmoJZlTuZCUX7a{RFR8n}fTZVw& z$NHEG-%rWs%4f)(D%MHas}cg#&SlfpbN7s>i%;B{)51E(T^X2-4i4d!Q~JIIO&d;> z*zsd4Hh;W>>S+-Dv29(ul4X;oMz!iW>&f%)E4i)1Uenp<{WmU58l#;nZ6XxWdpa^YzbiUyFNy!MOlD-V>r z%8F6`V>gYqF`_S2mp}PVe+mGTS*&G5xJmTk=j@=y-k(cuj+ZZsb{K#9L?>(29lRYv zr!kpnD@al+a8;@%!7QiG9AMTUxAAg$gs3TjBWx~aXxfcTYmRC(BjbOIMbgOLpkp^%*ZoRa*9equ04h%9{9KKz)Ysp12VZu3N4 zw@Zm@!P_-H!p7t^eei$oYCxb?L8q4mD>ULIupuz zj_2W)Ubn7mvdt6Ha`bfO!LP&om=$!f^)OeGaT`}VyBn%BAgglhe7_jcW{!9aJOn9F z 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/04-tools.png b/docs/screenshots/04-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..a90655b4da1b66149bf31452b82a37f3e1fbbd95 GIT binary patch literal 69099 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 z

    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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/05-trace.png b/docs/screenshots/05-trace.png new file mode 100644 index 0000000000000000000000000000000000000000..277412394282e1fcc41896a552e490a04c63dca3 GIT binary patch literal 58172 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

    _&8}jSD%v2-8b$$gN15-6q zNX}L(E#!Kg<=4EJw>E_ld9t$RHqmi5xMubVL6cYtj26jY<5(>}S}&7%KHfX?Ig00b zEzo;LWE)fZf&vbMrSjJ|IaaB`o!49Vj5%0Af@s~kn#dnUE1qf9j=~vgTF|C56qs82 zD84b0aB^x5f+oo8s`hqYqo{VVAu2}xKlkf!8q71h&8z}5vp6RspkrPzU_NnKjNxs&kd=1>B5=YgBwpF?E%AOB9%YP)pQg zOk|V1ABdn(oRK8&0T<1wP6Eu(-57sKF?j=fVd(AsE$3=@cuMNZ-y7x-`$$i@&|U_S zCW2G}R+dv%T9z&O!}Z_MqD%WxCV&0@{+Yhrm3MX)Y z&fw%Jb$KX+kIl1`dSiy~nnFIRJwfkZw0=8I>K*Iqtum2xS2e~7x>c^b^#T$T1=*Pn z3^?+j^;TGUA@?L9MTU0_4$WE$=qAc!cq?Xtn}Z;<^J-%2jA6<@5cqRkM}lrf?#M1sr2I{q#vvnzGbNBS~lafz#MNTrpRro zFz#Q-kqQQ%S);e&=$!KAOp_EXP{U)O*pT;jI~LziMp)`jih&EqK^jNue2N#=V%iFP zcv5Q>;isy-k%J6MMu@YJMoybqVL#P__|gLZsy;T(+(*qd01*L?72gJ-M$Oo-lsq zyo~0$nb$8-04b%ZD4I==Wpc=s^_&tHIwbF4)ni=sUZxR=ph9yZC47hF9Ja7k^{18r zPL3)X)yQ%;|H^2s6cAVi#4+w5uVLqI!zXvPL9@;i+)M}X?FoZ302=!$kJw4-it^UA z(evzt`z!=w%J~M~(_`)iqn+U5;tOtIXH9F4DyXx)7`U;D?oWu{#J3Ob!Jv^Q3DCw< zc^ajK7*(~5Q=(P;x_m{WO1?vo<_m2=+`6gTixo;Lt&$z5|75ja)Mc!BH?pI0BY!Kl z1aa}C?gZd7fUqF9!U*+%MuhPRiK`rcPI+x`T1!ooxU~R#xkJ6JgZhsFIV+z2C(WZT zmpo(k>l7=B9^cV=!nlUx^DzF-NeN`BeiZXVml zBT`P4KqF3IEYsY!TI4iaRW%rc^Rh5cvC=HT*M~la8Zp(}G8ox6l|h46K|eu+&WsKo z)m0F~luo^T*PZ7*PD@;iqP`}E4x2I*c=+&sOMdIdytcsFyE3~Q#DVrW5G8NxyHW~w z2S?f11} zYP8Sf6!GZ^7R99CbDy9v{hhr>jA+~dGn0Id2}pm#!ASeD2dPLvVQRAGHMEG79OmdE z{GMicCnaP(X)O<%jsqYi9KR^pm_?U0?bz1efn)h-jT{T*xujd$6ct=MpLoPCWdziQ z`UYbLFkWb;M@AhmuzBupIs3h=y392;Z#L$W?VjdKT2kqrap67J5mJ#T6Zl zki~EmTy|N*^4_x7zXE=oCZ!h8#8dGgNk0MTT{p?`D^!-0o5yytPtqVzNQ6y>;Egc- z?Y&kfZo`sVmljD)fGpah7FVdk(<8=~T6A$`=kq-KHcFwE>|)k2Vaxv6rvOT3}uj)R{Sd?Ict?fu@S3 zD&j0IO=`va7XcRqmDnrxhjR-eO&Nyl4W^P6wjSRbFGNI?E)0Y=+urKUk5{5gkDL9n z=(Cc`=P*(*P2-1cI<&)eu-qf^td!bwoE+Pz0{d@|fNh$~N7F6SO;2s@J~~XvO9+9` zLN7^9JPmXO=8}GvpOu`yF$=lnSY4_5%ubn_1*kj`kGw8N8%Z5D%uF_ufL;ZxVO=Wf>-H(zKn?ROLHJjZ4~#-+Hz2rAsfxx zr9$z~8S5c;5Th}nChGkCN)tQ_N3#L%4Kj49z}E1&=yvCy4D4w>c@i(v#b?%;6ZVxf z0&nI_8*UbeNHYTp9sVND|Icv#Nu&pb;|?g{YR!xQA8q%7 z>XMjMM<{p8QF86YyO+WRp^4NPgj0DUwRq%%_>bJ=gZ8rPIe;_TzG3dvr&S=Ety584 zg%?GhSb;boalU3Jq|DbC&@wJW=P+z`HeGE-NAqMPlMA^iZXDAI*q&Zkn|CcFD&n+{v7w9Y=54mWOX&B9qb--a}6yF<~+{B^tV3&td# zVy+8SEH0X30V!v+K{GWiURNV~+x=H;&nFl@en?tJ=_!60jYh*49sZ(-RCvS6T$WevmY2)cc3v7iQ4Q?_fC8 z53_rQm$-S*$%QwKAog@JaC=)um%eIw6OR^^tB3&F5AEXX) zVKRRY5HMIW^zAmrmYS`{tpJsOId$P_&4=;yCNM$AikEJupO0aZXsH{Tw+Ms1y^7PZ z%-sd8MHbLb9^=0sAZ3eslQj*itJe~b(vrjE7EOpmG)pCJHDQ*&;N>^(2q2<0RQLuB`N^H@`)v1SI=S8-A=L-L+Uu|=px zWR*5Hq~!2gtobVy2g2(}w7sIMD=Ij+j9*(SU(9d`4ltQH+b_b_4Bop%ic+18Y$ZLK zA8WB+mMsLGG1x4N*oe&U`L+=b17Lc3JNRGm1wYuT*=JQ$+S#nyLuXNA7mEq%{ zr%%u_hn$_s(1>v+d3aZIgV(g*SLp;Ep)=~$0~CWXwYLZYBX zOpv8Y)`I2z{Zfmm6WsWnL|M6!mrZoSipBvh_W0aJf zYhJ!@5f%Y4dM@1XXXzMI=R4WQ(=CTcB#sbNOMP#(q3%_z?qwbLVjiz+*vYI_phdV=|u6$h@{ZU0Y928pM}Vt2@nu z{OCKv*mH#Z(XI`}OROHN8NTU@BQl2gC8G%cbd>?? z&?Iq-y`cq9+z7;L)cDZPbe#?I&WpT?4iTxj1L{#_BHB>P;keuj3by(P7kH=+lH zpM6YE$5iughH&D5zuY%S5wcO|uIIyn2Y$reh-+Ag|8nJr{jx}3$}J# z;@Z3RA2KM?4Yvy&PKaGchV!3@G6fl?g9zT0}6Ouk@Fn59q%xGU(`(nm$&r~!? z>P3+I^HG}hw5;_`9CI%k%g|&ZIS-Fws5bX#`Ee@JE@s!IQx3O9M;s)DNtrWgWHR^? z5~3=&6}Q_^bx@_k&R4HB)s&o-VwmY$(P~h|ke)0%M1cWsOWbL*EcyH+ih(MFtEOd} z?}xIS!$~|!zF&`(Qe!cG<2AQD8QD(eymSJiV4|uKc95WFo$pyaAASL}->Kc??V-&k zT~t|WqC6Sq>J!kn-6wA@{KA;iMs9hIzeK!)u6 ztj?9u1hD0}UURcg&c^+u_<}t|(rO$~;iOZBJme%vdpS$gp~6@K3EtRUH*Xdi{Fo$X z>;5vvzB{g=$E?7tE@Vz!U+=0Np@tHns2FqfD3AgU+xlp`TtNcX&Qhs5FfO~$t~UXz7;eE#xV{QL)D+6};- zON+#$`|oIHlfR=q_vLr`@RML?Isft$akQfl5~1Aa%NCOBjMht7ym3N-wR z{NXrpdlSg?LsW$n`bjFj@*xz3ZUsf&r2Io)^5V1S1EqepTHpIiJpY;ZI(G5j4McJ7 zn(9csmQl~l1zn^FMwNfU{KrHjJ^&YfM>8ep>$Z+5I=qY+@+b>->$opFCMM#wm?neC zi&b+^Alos+iZ%`u6Xa-&e2VL-QZr&uyj`v7{~oyepJ_wPU5)i!+Jxy>(;`WJR@;$i}eGrI^MDLx58iwfRhukgf8ILay zrXnUa4pjV9t#}%glq?y8X7gSl%(ED1u&un$)=!NA_;7_&=|k&*wTox0ILg7VFGSh)q3vaih9S8EahO-5v@|uAP1QhD64pKt?ihh z`Mjo6wDK1%5|*FP0?~en9NvFh7$Bxh;O^Q_*PFTKEJ+e1<^2^S3v@ZcK1Nxc7(6-g}-2I5hNB^&V@t1yQj%m#9tb z?+1|oEXn@&tq-p97Z7{I5G-XD6%!FmMEHF6-w4sq#Qy^P1F7RI+e{0W`}v>LQWBO{ z+oM~x>i{$v+EXIkfwx>_>u33If1Q~YEf470)cHMW>8C{Qzp1igDYgH^w|KWko8WOI zp?D?2-?1Br-TR5Oont~9S{ewr6dNWYou-YW`P4GzWLQ>NtCP(;scCYeDgMUpAiIl% zimPE*Oe*{5VjLAEQhoxr9{r?$;F;q?_fhpe`&f9(c-L^nhh(U*7a^rKQ@tDYBStEG zk#a$kIp{r;w!3ADn!1{jSMvBlR8=ix_q2uAgNUr0=%>jeeDqdSV1*36INl6D=a*yZ zmIB@tcqP+_OQ^AySP! zm(X$@(ZJF#O9vn-qNsYgJ6jq%JbspcPIc&5XJh#sshRr|T|o6=A8-9^mjS1?-HW|d zHQ^=K;Qqnex9Tk;GZMskGhW6|jGwAQ?gFceB2Jtk_CO|M^b(Kbm7#r8h1XBBeeaMFuYk$dU-I;$g@)q1g5YDh#?qRHDKZn<=Hc%Ii;lUT zgChG_d1=X?)#2&N$R~>(H$2hUPkEAhhq#cHg)O zpShH78U}+TCx`~q{o9SEzHL$f>Q2CS`3y6|U!-0ur5d_->6MbZ0S%4d^{!=L|Mq%8k^jK_Ojemj|Sw)k90GL zn8aAGQD2OIT%)HB%jpTL4}8G{H#*DbTlQ3D$9$&Hu*s)N6wOD&zg7en162|0^)Lw z{$;>$^96rQUd2aFTxGkg&fn2E&hQz;f0JXn;9H-_wwM|Baoh4p+Dhq5He*%xVP>4} zeDOo@CXW=0nfxPc?oCIzuYEpaYghYyd8(~(`GH7x$o%CVrNBo)vf*Va$%)4VpDINPp#ddT99nZK(j&gr0dm@v<-`baS%r?Sp4tx_Xl~A< za<(HsAI1Q07Eavc_g2$QA2~0J6RZOy=r3xc3<@$FMI0*f(h?Hq;C%~>R<2(MGk9Oj z%ai|n+)JZ>9#%0MVFNA+}U?Gztc{EMShGy%BWVhz0K z1c?GJbrKjIejBD#QuY-YA1LfXauMn;k6sjopYePR?l^ulhG8Dz%G2ZLlVo3%}En=-6w65bRL=K^_%SwpK5m)qN_Emf6TJ z{IB61kN(7o{fn2J?)+m4WSFp*j_y9izOS&~I8zDZ4Si|Q_UD&B{QPUsTl~K?!L=tO zQBDTliSo+uqhywkF7}ygu2fI9B*{nid(+m3%3w6sHqOpR_!R!UzoWJLh$noz50&|m z@7C%8tT$DI&$q}Z>vXBSK&KQP$BpQq;->O`N?;EVHy;v@Gc}K8z%;xXA4fL2|jw(xR2^WjJ=^0MT82V^Qc@~heKHPG|9x8C* zHb+tc^0W^<)Ed?qUY@|;D3qA6B1!@;jSUwK4MTg9c=O`RH{CipW1W3|yQ!2vP?2$- zX+<9P&lb*jJsVbTR(!C?Z+H}hZT3ix6STo2`jcv6RfM&9b0LS(+hgo_HP-J}2t9}S z{6F2CQ3d|#A=*0zdUcE)@ThmCgqRvyp+3uXJR$2(y1AAYcqx44C>XapBxiG8Yu*@_ z!5tMMN!7)Ax`~JWs38AofhSg*IgQNrUj5Ti$~Ix4 zAE!6onR{cHOeZ=464+iV4Gt! zz5@B&m;ZmBG36-owI(`B*nb$k4L|#tul@Kk=+M7wy~&>DFrd`_5o5qDFgfdJ`JMje9@kl9vIR}k*9Pmt&RHV{$U8BhLN; ziPs5q-)TP|s8zE#+LH{S#MqppgfQL{=XM`3#5ZVJ42bH2m_CyZ5PxJ*jj|grOuDGW zkBZLp-VvA3+YwJwjbFvMSIV-FeZtO2gnT&>z$*8^{mJgcx4Q}aba~MK#pUbG?x(k@ zqyhE;+{MU_mE6nPi;)CWfm{n4W-sbfz;rg~hWS9v_^*7g*;d3`vFfh>iIJi|zC>0jb6@ZzdQu}-?;CS#O! zCkZarKY@y!*P@3~X(r5}ZyAhvTq9%-NPSzQ<+NH7zM@0WXes@eJ`1{_X9m=^l2-j? z#4hNbPc9aJr*&+=?1 zL{UGVxRxy`(ql|4++2&twp^LrwrnXeys3ago2jKK$jAJbu{{!IhDt>=jF!G1$m&p6O#otph{$OBzjsdgyQst858{8& z5&o9@Zk6uP9k&WC%Z#3dAg#P#LYC_ye4?(h+&5&Cd}B^CIWUqp0ZIC{F}cih>>$p1 zrtXv1O@S&FfN5xY6rrWS{MCiEs6*@0SHNt2;`Fy2U4FSqDBpB7)xUAh4=Sp%^;H+J z#Z_s3YnN$ptn#=L(Y1jZHC36+Y2`4Rj?nc(Q>R;iP7vfS@bt}v|D`rR7?Azi=?X>>pkf^&>}}>h z7B;IP#i>B$N}9M6nz9fYmt+0R+F+h#&xVs*%fJ@+ks_EHrpNdS5&ui@2SWhG6a^nh<-=Bx~|U#YoGSt-JV2TPv$5Fi74`;()^DFWrU3#CK{ zFY&m?(J=1DgzbMoz#_V7$^oXt8r@h;wWVLsexZK>iAvZZn-7q_6co>VM{R5Qe=e&l zUyYmhFg~XeP(2;OjvyE)sAi=#G#1m!+<%<}@1SZ@RZPw85E888jMRJAOP^|x{|fV3tO{+wGnw^HoRpa*x<-- zD~+8uV57D16hD!^q_sK;wdIa`zuLA7V{H92_=Vr%V*B@TtiVzRkIx2mXHbP&50zRV zuk%xVgDDz)GIQhhDeE7S1K04PT!zxKPTun~H&-RCE>-P=+tpu8Mv(K0){TnpVQMBU zaBT-b3eW$jDkfd<0H>9LYOt*XkAb-deP8{O^ck5`L|sI9S;DKal4_K-{<{{GTb|wV){q4iPHYy4i>p*VjTz9s$W^`G-Ml%>n=Cp|uxg)^f85soxTuN*I zgMDak4)=S;b0goB&n6w$?3A}Ok=5`)mH4Bhqn`LDNS7fCbI&rha+BsUqCTqGTIuzZgQHq33jOjP?wT%bn7+Hmt-)3 z>58>7V587r_u0Vw@)BdjhRz?d#aqWSgTIn3TkIAv*Ho_~xOJBgC4}@y;Am zf;~NN?|OZc+OD#nx6ox>zPI~wUKQ**-LabLJ&vHXS+94J>Xa)cXK*Z$PmG~CyNO>v zE6(Q{mQ-4oG{TLmEb5Y3jMX;a^Syeq_@{$B{n93UH3AfVN@dp+!@R22xwLgai?-5Mwi%d;^8@+fc!U|w91nPz1rL7cGYSkRrXkWS!khha zR@12j^4b~YHcxUUr10&T!rzKMO$^RgEk&z*}$-U3HN1SUK zkbqq;+z$3YoIof(svSNVyeE2`L0qR%sEekUKqA6 zxI{M5=5x-claFyBN+B3F*Gzf!RQ|xB(bj}TY%RTP>M%P%oT6*-y968WsQC;FSr(9L zHq0y1W6z?0#2??iBj4^t6mEHp02$r5b2y4u5g4kZXRP2}b1?OKgJCKErCal_$i}J# zI(J1qZw=L!?Sm-Jd4R}ti}&X>iy{%hvw8QC@!UaqT^$0UN7>$>G=@BO43cXCoOs_d z3sBN>$e_vZtz1G{u98h3<0jJXsu3!sQFk$DTfHqwY$L>mhwGYyA9EOGeSq&SET01H zXtTfwmVsNc(Oxg_t!Q??zyQ{oe)6su$)gvDripjTM%$FH@y`3Z3%niOMnWk)PTmg8 z{dXf=+3NFCU{7fpJ87mfwQm43ykxIyZ?2KVtE3$I6qc3P$`U>A?D&RP-k*5o2*1ZG z(K8$p)i1t8KPNufpKp-wsG##RJVV8Ji*DgSzdzJkwzz4wY;*t7f#CLPzMCh_2&-!v zKZm;?9o+^(b^|6c6Vw7mGpRX z>ix|zTlI%i(p@wyZ}N9*XHtg+EZS+!0_|dZ!O+B%+)QYGW+Fs=K>KSU zw9E+!J<{R6|vS`@OzX;5T7{ z0q?g{=p|lLn`U&fliK1{TgAAhaW6r$xSUGBhpnL=Z5SDnJkpJA23fZ^CE|`6-d%^G z_;rX!8ou}D&^>UBc&4yD$?lQtX30@FN#(nl!{pojU~$Q^Cm{1MWiY(c*j7~q8!I8^ zu$~yf4!sH)$JsgVWSv7(FamK%aD(s3ubZNup`6)OM)gR1Er@Y=(B3>bxwzIviDp>3^dR3dKZ`qSZV@ z2n?7NqHhS2&o(#;;hld=7X&GkH3TuKQg3>uN=Esd6gb4a&a$6NE`I3;O;G8Z;au+@ zH`?0dF%dO^z&GaBS1puZrU!CSYJ2FyKJ&qPy%%=0176nWXzq%c83y^v-Vz&@ zXP+22tqVMz(P1H%LmkrN=?Aqo&U!>=$8Xz=(bEV`DOj|$_J;z;V{tkGV_iMA`luZQ z9p>JP>2>^^>l)fz22Hv0LU@4P4S#%i4Dr<9vfv5sXX$kmFT1l162uSJ}fnSksPso%_jPr&zx)-wc1dX;*uvAe3 z;rl?BbaKdK6-4`TCQ@q2wf3AHVaG|JA0|g>82Sgs%@sUv=pUl%6UQYRGykJZ+f7MR zyVP#@Va)zUo2wM+{JXrvjV1dT1}R0uN0bV{>hmA_lw3Bc@D8+Ha0Y2eIFSvqs)n8T zyWjk)W36<(W~KNlkzrui)JAdQ}m@?mT^0!CR+E?KpakOLC literal 0 HcmV?d00001 From 77697e6bdb8f3513599d9669a74f70105cf2ff73 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:28:10 +0300 Subject: [PATCH 29/31] Document Signata screenshots in the Play release checklist. --- docs/RELEASE_PLAY.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/RELEASE_PLAY.md b/docs/RELEASE_PLAY.md index 1d6c70d..b885799 100644 --- a/docs/RELEASE_PLAY.md +++ b/docs/RELEASE_PLAY.md @@ -41,6 +41,18 @@ Uses `google_oauth.env` when present; otherwise email-only Google button stays h - Passwords: `android/key.properties` (local only, gitignored) - Keep a secure backup of both — losing them blocks future updates unless you use Play App Signing recovery. +## Store listing screenshots + +Phone captures for Play Console / README live in [`docs/screenshots/`](./screenshots/): + +- `01-home.png` — hero / brand +- `02-capabilities-image.png` — image watermarking +- `03-capabilities-audio-video.png` — audio + video +- `04-tools.png` — protect & verify hub +- `05-trace.png` — Trace / Instagram scan + +Upload these (or cropped phone-frame variants) under Play Console → Main store listing → Phone screenshots. + ## Smoke test before upload - [ ] Email signup → activate → Home From 321bc6dab4ea97cb8ed0cb821a48fe58311f6cdb Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:43:18 +0300 Subject: [PATCH 30/31] Harden Trace and registry against SSRF and scan abuse. Add safe URL validation, trace rate limiting, and OWASP control docs aligned with Givy. --- SECURITY.md | 22 ++++++ lib/core/claim_registry.dart | 39 ++++++++-- lib/core/safe_url.dart | 134 +++++++++++++++++++++++++++++++++ lib/core/social_platforms.dart | 30 +++++++- lib/core/trace_store.dart | 7 +- lib/core/url_tracer.dart | 34 ++++++--- test/safe_url_test.dart | 66 ++++++++++++++++ test/trace_test.dart | 20 +++++ 8 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 lib/core/safe_url.dart create mode 100644 test/safe_url_test.dart diff --git a/SECURITY.md b/SECURITY.md index 9ff4ace..c68b0b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,3 +47,25 @@ Good-faith research that avoids privacy violations and service disruption is app Android SHA-1 fingerprints in docs are **not secrets** (they appear in signed APKs) but keep upload keystore passwords local only. CI runs a basic secret pattern scan on every push. + +## OWASP-aligned controls (Cheat Sheet Series) + +| Control | Status | Notes | +|--------|--------|-------| +| **Authentication** | Strong | PBKDF2-SHA256, TOTP 2FA, lockout, secure session storage | +| **Cryptography** | Strong | Claim keys and watermarks stay on-device; recovery kit integrity checks | +| **Input validation** | Hardened | Trace URLs validated in `lib/core/safe_url.dart` — http(s) only, no private/metadata hosts, no embedded credentials | +| **SSRF (Trace / registry)** | Hardened | Block localhost, RFC1918, link-local, and CGNAT ranges before any outbound fetch; registry base must be **https** | +| **Rate limiting** | Hardened | Trace scans capped at 40/hour per device session (`TraceRateLimiter`) | +| **Logging / errors** | Good | Neutral auth errors; remote registry failures logged without leaking secrets | +| **Secrets** | Good | OAuth and keystore material via env / dart-define only; CI secret scan | +| **Transport** | Good | Registry requires HTTPS; Trace allows public http(s) media URLs only after host validation | + +### Trace URL policy + +- Allowed: public `http://` and `https://` media or social page URLs +- Blocked: `file://`, `ftp://`, `localhost`, `.local`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`, URLs with userinfo + +### Optional registry + +Set `SIGNATA_REGISTRY_URL` to a **public https** endpoint. Invalid or private URLs are ignored at configure time. Claim references in remote lookups are length-capped and URL-encoded. diff --git a/lib/core/claim_registry.dart b/lib/core/claim_registry.dart index 698c32c..b7ee933 100644 --- a/lib/core/claim_registry.dart +++ b/lib/core/claim_registry.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'auth.dart'; import 'local_data.dart'; +import 'safe_url.dart'; import 'trace_models.dart'; class ClaimRegistry { @@ -28,7 +29,25 @@ class ClaimRegistry { ); static void configure({String? remoteBaseUrl}) { - if (remoteBaseUrl != null) ClaimRegistry.remoteBaseUrl = remoteBaseUrl; + if (remoteBaseUrl == null) return; + final trimmed = remoteBaseUrl.trim(); + if (trimmed.isEmpty) { + ClaimRegistry.remoteBaseUrl = ''; + return; + } + final parsed = SafeUrl.parseRegistryBase(trimmed); + if (!parsed.isOk) { + debugPrint('Invalid registry URL ignored: ${parsed.error}'); + ClaimRegistry.remoteBaseUrl = ''; + return; + } + ClaimRegistry.remoteBaseUrl = parsed.uri!.toString().replaceAll(RegExp(r'/+$'), ''); + } + + static Uri? get _remoteBaseUri { + if (!hasRemote) return null; + final parsed = SafeUrl.parseRegistryBase(remoteBaseUrl); + return parsed.isOk ? parsed.uri : null; } static bool get hasRemote => remoteBaseUrl.trim().isNotEmpty; @@ -78,14 +97,16 @@ class ClaimRegistry { Future findByReference(String reference) async { final needle = reference.trim().toUpperCase(); - if (needle.isEmpty) return null; + if (needle.isEmpty || needle.length > 128) return null; for (final claim in await listLocal()) { if (claim.reference.toUpperCase() == needle) return claim; } - if (hasRemote) { + final base = _remoteBaseUri; + if (base != null) { try { - final uri = Uri.parse('${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}' - '/claims/${Uri.encodeComponent(reference)}'); + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims/${Uri.encodeComponent(needle)}', + ); final response = await http.get(uri).timeout(const Duration(seconds: 12)); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); @@ -128,10 +149,12 @@ class ClaimRegistry { note: note, ); - if (hasRemote) { + final base = _remoteBaseUri; + if (base != null) { try { - final uri = Uri.parse( - '${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}/claims'); + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims', + ); final response = await http .post( uri, diff --git a/lib/core/safe_url.dart b/lib/core/safe_url.dart new file mode 100644 index 0000000..2c55674 --- /dev/null +++ b/lib/core/safe_url.dart @@ -0,0 +1,134 @@ +/// OWASP-oriented URL guards for Trace and optional claim registry. +library; + +/// Result of validating a user-supplied http(s) URL. +class SafeUrlResult { + const SafeUrlResult.ok(this.uri) : error = null; + const SafeUrlResult.err(this.error) : uri = null; + + final Uri? uri; + final String? error; + + bool get isOk => uri != null; +} + +/// Blocks private networks, metadata endpoints, and non-http(s) schemes (SSRF). +class SafeUrl { + SafeUrl._(); + + static const blockedHostnames = { + 'localhost', + 'localhost.localdomain', + 'metadata.google.internal', + }; + + /// Public http(s) URL suitable for Trace downloads. + static SafeUrlResult parseTraceUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Enter a URL.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return const SafeUrlResult.err('Only http and https URLs are allowed.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('URLs with embedded credentials are not allowed.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('That URL points to a private or local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Private or internal network URLs are not allowed.'); + } + return SafeUrlResult.ok(uri); + } + + /// Remote registry base must be https and not point at private networks. + static SafeUrlResult parseRegistryBase(String raw) { + final trimmed = raw.trim().replaceAll(RegExp(r'/+$'), ''); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Registry URL is empty.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (uri.scheme != 'https') { + return const SafeUrlResult.err('Registry URL must use https.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('Registry URL cannot include credentials.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('Registry URL cannot point to a local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Registry URL cannot point to a private network.'); + } + return SafeUrlResult.ok(uri); + } + + static bool _isPrivateOrReservedHost(String host) { + if (host == '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) { + return true; + } + final ip = _parseIpv4(host); + if (ip == null) return false; + final a = ip[0]; + final b = ip[1]; + if (a == 127) return true; + if (a == 10) return true; + if (a == 0) return true; + if (a == 169 && b == 254) return true; + if (a == 192 && b == 168) return true; + if (a == 172 && b >= 16 && b <= 31) return true; + if (a == 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + + static List? _parseIpv4(String host) { + final parts = host.split('.'); + if (parts.length != 4) return null; + final out = []; + for (final part in parts) { + final n = int.tryParse(part); + if (n == null || n < 0 || n > 255) return null; + out.add(n); + } + return out; + } +} + +/// Simple in-process rate limiter for Trace scans (per device session). +class TraceRateLimiter { + TraceRateLimiter._(); + + static final TraceRateLimiter instance = TraceRateLimiter._(); + + static const maxScansPerHour = 40; + static const window = Duration(hours: 1); + + final List _events = []; + + bool allow() { + final now = DateTime.now().toUtc(); + _events.removeWhere((t) => now.difference(t) > window); + if (_events.length >= maxScansPerHour) return false; + _events.add(now); + return true; + } + + void resetForTests() => _events.clear(); +} diff --git a/lib/core/social_platforms.dart b/lib/core/social_platforms.dart index b7c5c74..ab06a62 100644 --- a/lib/core/social_platforms.dart +++ b/lib/core/social_platforms.dart @@ -6,6 +6,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; +import 'safe_url.dart'; + enum SocialPlatform { instagram, tiktok, x, other } class SocialPlatformInfo { @@ -126,14 +128,25 @@ class SocialMediaResolver { 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)'; Future resolve(String rawUrl) async { - final url = rawUrl.trim(); + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + final platform = SocialPlatformInfo.fromUrl(rawUrl); + if (platform == null) return null; + return ResolvedSocialMedia( + platform: platform, + pageUrl: rawUrl.trim(), + mediaUrl: null, + note: parsed.error, + ); + } + final url = parsed.uri!.toString(); final platform = SocialPlatformInfo.fromUrl(url); if (platform == null) return null; try { final response = await http .get( - Uri.parse(url), + parsed.uri!, headers: { 'User-Agent': _ua, 'Accept': 'text/html,application/xhtml+xml', @@ -178,10 +191,21 @@ class SocialMediaResolver { ); } + final absolute = _absolutize(mediaUrl, url); + final mediaParsed = SafeUrl.parseTraceUrl(absolute); + if (!mediaParsed.isOk) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: mediaParsed.error ?? 'Resolved media URL was blocked for security.', + ); + } + return ResolvedSocialMedia( platform: platform, pageUrl: url, - mediaUrl: _absolutize(mediaUrl, url), + mediaUrl: mediaParsed.uri!.toString(), note: null, ); } catch (error) { diff --git a/lib/core/trace_store.dart b/lib/core/trace_store.dart index e7e162f..fbc9620 100644 --- a/lib/core/trace_store.dart +++ b/lib/core/trace_store.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'local_data.dart'; +import 'safe_url.dart'; import 'trace_models.dart'; class TraceStore { @@ -52,7 +53,11 @@ class TraceStore { Future addWatchTarget(String url, {String? label}) async { await _ensureMigrated(); - final normalized = url.trim(); + final parsed = SafeUrl.parseTraceUrl(url); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); + } + final normalized = parsed.uri!.toString(); final existing = List.from(await listWatchTargets()); final prior = existing.where((w) => w.url == normalized).toList(); if (prior.isNotEmpty) return prior.first; diff --git a/lib/core/url_tracer.dart b/lib/core/url_tracer.dart index 9b9dfdf..2cb5f80 100644 --- a/lib/core/url_tracer.dart +++ b/lib/core/url_tracer.dart @@ -10,6 +10,7 @@ import 'claim_crypto.dart'; import 'claim_registry.dart'; import 'image_watermark.dart'; import 'pdf_fingerprint.dart'; +import 'safe_url.dart'; import 'social_platforms.dart'; import 'trace_models.dart'; import 'trace_store.dart'; @@ -39,10 +40,16 @@ class UrlTracer { bool persist = true, bool addToWatchlist = false, }) async { - final url = rawUrl.trim(); - if (!_looksLikeHttpUrl(url)) { - throw ArgumentError('Enter a valid http(s) URL to a media file.'); + if (!TraceRateLimiter.instance.allow()) { + throw ArgumentError( + 'Too many trace scans this hour. Try again later.', + ); + } + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); } + final url = parsed.uri!.toString(); var fetchUrl = url; String? socialNote; @@ -51,7 +58,13 @@ class UrlTracer { final resolved = await SocialMediaResolver.instance.resolve(url); socialNote = resolved?.note; if (resolved?.mediaUrl != null && resolved!.mediaUrl!.isNotEmpty) { - fetchUrl = resolved.mediaUrl!; + final mediaParsed = SafeUrl.parseTraceUrl(resolved.mediaUrl!); + if (mediaParsed.isOk) { + fetchUrl = mediaParsed.uri!.toString(); + } else { + socialNote = mediaParsed.error ?? + 'Resolved media URL was blocked for security.'; + } } else if (resolved != null) { final sighting = TraceSighting( id: _id(), @@ -85,8 +98,12 @@ class UrlTracer { Uint8List bytes; String? contentType; try { + final fetchUri = SafeUrl.parseTraceUrl(fetchUrl); + if (!fetchUri.isOk) { + throw Exception(fetchUri.error ?? 'Blocked URL.'); + } final response = await http.get( - Uri.parse(fetchUrl), + fetchUri.uri!, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)', @@ -254,13 +271,6 @@ class UrlTracer { return out; } - static bool _looksLikeHttpUrl(String url) { - final uri = Uri.tryParse(url); - return uri != null && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.host.isNotEmpty; - } - static bool _looksLikeDirectMedia(String url) { final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); return path.endsWith('.png') || diff --git a/test/safe_url_test.dart b/test/safe_url_test.dart new file mode 100644 index 0000000..0e2d845 --- /dev/null +++ b/test/safe_url_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; + +void main() { + group('SafeUrl.parseTraceUrl', () { + test('accepts public https URLs', () { + final result = SafeUrl.parseTraceUrl('https://cdn.example.com/a.png'); + expect(result.isOk, isTrue); + expect(result.uri!.host, 'cdn.example.com'); + }); + + test('rejects ftp and file schemes', () { + expect(SafeUrl.parseTraceUrl('ftp://x/y.png').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('file:///etc/passwd').isOk, isFalse); + }); + + test('rejects localhost and private IPs', () { + expect(SafeUrl.parseTraceUrl('http://127.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://localhost/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://192.168.1.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://10.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://169.254.169.254/').isOk, isFalse); + }); + + test('rejects URLs with embedded credentials', () { + expect( + SafeUrl.parseTraceUrl('https://user:pass@example.com/x').isOk, + isFalse, + ); + }); + }); + + group('SafeUrl.parseRegistryBase', () { + test('accepts public https registry', () { + final result = SafeUrl.parseRegistryBase('https://registry.example.com'); + expect(result.isOk, isTrue); + }); + + test('rejects http and private hosts', () { + expect(SafeUrl.parseRegistryBase('http://registry.example.com').isOk, isFalse); + expect(SafeUrl.parseRegistryBase('https://127.0.0.1').isOk, isFalse); + }); + }); + + group('ClaimRegistry.configure', () { + test('ignores invalid registry URLs', () { + ClaimRegistry.configure(remoteBaseUrl: 'https://127.0.0.1'); + expect(ClaimRegistry.hasRemote, isFalse); + ClaimRegistry.configure(remoteBaseUrl: 'https://registry.example.com'); + expect(ClaimRegistry.hasRemote, isTrue); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + }); + + group('TraceRateLimiter', () { + test('allows scans under the hourly cap', () { + TraceRateLimiter.instance.resetForTests(); + for (var i = 0; i < TraceRateLimiter.maxScansPerHour; i++) { + expect(TraceRateLimiter.instance.allow(), isTrue); + } + expect(TraceRateLimiter.instance.allow(), isFalse); + TraceRateLimiter.instance.resetForTests(); + }); + }); +} diff --git a/test/trace_test.dart b/test/trace_test.dart index 1583f7b..1b87df1 100644 --- a/test/trace_test.dart +++ b/test/trace_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:signata/core/claim_crypto.dart'; import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; import 'package:signata/core/trace_models.dart'; import 'package:signata/core/trace_store.dart'; import 'package:signata/core/url_tracer.dart'; @@ -14,6 +15,7 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); ClaimRegistry.configure(remoteBaseUrl: ''); + TraceRateLimiter.instance.resetForTests(); }); group('claim registry', () { @@ -70,6 +72,24 @@ void main() { ); }); + test('rejects private network urls', () async { + expect( + () => UrlTracer.instance.scanUrl('http://192.168.0.5/a.png', persist: false), + throwsA(isA()), + ); + expect( + () => UrlTracer.instance.scanUrl('http://127.0.0.1/a.png', persist: false), + throwsA(isA()), + ); + }); + + test('watchlist rejects blocked urls', () async { + expect( + () => TraceStore.instance.addWatchTarget('http://localhost/x'), + throwsA(isA()), + ); + }); + test('published claim JSON round-trips', () { final claim = PublishedClaim( id: 'c1', From f574fec80134bfb07419a09f49a3bcf63cbc9600 Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:10:05 +0300 Subject: [PATCH 31/31] Potential fix for code scanning alert no. 2: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ce8a1..e7d17b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest