import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; class ProtocolHelper { static const String prefix = "@S:"; static const String gPrefix = "@G:"; static const String typeNack = "NACK"; static const String typeSym = "SYM"; static const String typeSfra = "SFRA"; static const String typeNorm = "NORM"; static const String typeKeyInit = "KI"; static const String typeKeyReply = "KR"; static const String typeAsym = "AE"; static const String typeAsymFrag = "AF"; static const int maxSmsChars = 136; static const int fragmentHeaderBytes = 6; static const int fragmentChunkBytes = 90; static String encodeTransportPayload(List bytes) { return base64UrlEncode(bytes).replaceAll('=', ''); } static List decodeTransportPayload(String value) { // Parity with Python _repair_base64 and SecureCryptoHelper.b64uDecode // 1. Map common mangles including space-to-plus String clean = value.replaceAll(' ', '+'); // 2. Map mangled separators to standard Base64 characters clean = clean.replaceAll('|', '/').replaceAll(';', '/').replaceAll('!', '/'); // 3. Keep ONLY valid Base64/Base64URL characters clean = clean.replaceAll(RegExp(r'[^A-Za-z0-9+/=\-_]'), ''); // 4. Standardize alphabet (Base64URL -> Standard Base64 for the decoder) clean = clean.replaceAll('-', '+').replaceAll('_', '/'); // 5. Recalculate padding clean = clean.split('=')[0]; final missingPadding = (4 - (clean.length % 4)) % 4; return base64.decode(clean + ('=' * missingPadding)); } static String _b64Encode(List bytes) => encodeTransportPayload(bytes); static List _b64Decode(String value) => decodeTransportPayload(value); static List _transportCandidates(String value) { final normalized = value .replaceAll('¡', '@') .replaceAll('¡', '@') .replaceAll('ö', '|') .replaceAll('ö', '|') .replaceAll('§', '|') .replaceAll('§', '|') .replaceAll(RegExp(r'\s+'), ''); final candidates = {normalized}; final separatorPattern = RegExp(r'[|;!]'); final matches = separatorPattern.allMatches(normalized).toList(); if (matches.isEmpty) return candidates.toList(); final replacementOptions = ['', '-', '_']; final maxRepairs = matches.length > 4 ? 4 : matches.length; void build(int index, String current, int offset) { if (index >= maxRepairs) { candidates.add(current); return; } final match = matches[index]; final start = match.start + offset; final end = match.end + offset; for (final replacement in replacementOptions) { final next = current.substring(0, start) + replacement + current.substring(end); final nextOffset = offset + replacement.length - (match.end - match.start); build(index + 1, next, nextOffset); } } build(0, normalized, 0); return candidates.toList(); } static Map? _parseAsymmetricFragmentPayload( String encoded, { required bool isGroup, }) { for (final candidate in _transportCandidates(encoded)) { try { final raw = _b64Decode(candidate); if (raw.length < 6) continue; final packetId = raw .sublist(0, 4) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); return { "type": "afrag", "isGroup": isGroup, "packetId": packetId, "totalParts": raw[4], "partNo": raw[5], "chunk": _b64Encode(raw.sublist(6)) }; } catch (_) {} } return null; } static String buildSymmetricMsg(String encryptedPayload) { return "$prefix$typeSym|$encryptedPayload"; } static String buildAsymmetricMsg(String b64Payload) { return "$prefix$typeAsym|$b64Payload"; } static String buildSymmetricFrag( String packetId, int partNo, int totalParts, String chunk) { return "$prefix$typeSfra|$packetId|$partNo|$totalParts|$chunk"; } static String buildNormalMode() { return "$prefix$typeNorm|"; } static String buildKeyInit(String publicKeyPayload) { return "$prefix$typeKeyInit|$publicKeyPayload"; } static String buildKeyReply(String publicKeyPayload) { return "$prefix$typeKeyReply|$publicKeyPayload"; } static List buildAsymmetricFrames(List payloadBytes, {String? packetId}) { final single = "$prefix$typeAsym|${_b64Encode(payloadBytes)}"; if (single.length <= maxSmsChars) { return [single]; } return buildAsymmetricFragments(payloadBytes, packetId: packetId); } static List buildAsymmetricFragments(List payloadBytes, {String? packetId}) { final random = Random(); final packetRaw = packetId != null ? Uint8List.fromList(List.generate( packetId.length ~/ 2, (index) => int.parse(packetId.substring(index * 2, index * 2 + 2), radix: 16), )) : Uint8List.fromList(List.generate(4, (_) => random.nextInt(256))); if (packetRaw.length != 4) { throw StateError('Asymmetric packet ids must contain exactly 4 bytes.'); } final total = (payloadBytes.length / fragmentChunkBytes).ceil(); if (total > 255) { throw StateError('Payload is too large for SMS fragmentation.'); } final frames = []; for (var i = 0; i < total; i++) { final start = i * fragmentChunkBytes; final end = min(start + fragmentChunkBytes, payloadBytes.length); final chunk = payloadBytes.sublist(start, end); final raw = Uint8List.fromList([ ...packetRaw, total, i + 1, ...chunk, ]); frames.add("$prefix$typeAsymFrag|${_b64Encode(raw)}"); } return frames; } static Map parseMessage(String rawText) { if (rawText.isEmpty) return {"type": "plain", "body": ""}; // Step 1: Preliminary cleaning (Mojibake Fix & Control Code Removal) // Remove all non-printable control characters immediately String cleaned = rawText.replaceAll(RegExp(r'[\x00-\x1F\x7F-\x9F]'), ''); // Parity with Python: DO NOT trim(). Only remove newlines and nulls at the end. // This preserves trailing spaces that might be mangled '+' characters. String body = cleaned.replaceAll(RegExp(r'[\x00\r\n]+$'), ''); // Mojibake Fix body = body .replaceAll('¡', '@') .replaceAll('¡', '@') .replaceAll('ö', '|') .replaceAll('ö', '|') .replaceAll('§', '|') .replaceAll('§', '|') .replaceAll('¿', '?') .replaceAll('¿', '?'); // Fast-path for legacy symmetric fragments. This avoids falling through to // generic SYM/b1 parsing when the SMS transport slightly damages the // prefix but the SFRA structure is still intact. final directSfra = RegExp( r'SFRA\s*[|;!ö§]\s*([^|;!ö§\s]+)\s*[|;!ö§]\s*(\d+)\s*[|;!ö§]\s*(\d+)\s*[|;!ö§](.*)$', caseSensitive: false, ).firstMatch(body); if (directSfra != null) { final lowerBodyForGroup = body.toUpperCase(); final rawChunk = directSfra.group(4) ?? ''; return { "type": "sfra", "isGroup": lowerBodyForGroup.contains('@G:') || lowerBodyForGroup.contains('G|'), "packetId": (directSfra.group(1) ?? '').trim(), "partNo": int.tryParse((directSfra.group(2) ?? '').trim()) ?? 1, "totalParts": int.tryParse((directSfra.group(3) ?? '').trim()) ?? 1, "chunk": rawChunk.replaceAll(RegExp(r'\s+'), ''), }; } final directAf = RegExp( r'AF\s*[|;!ö§]\s*(.+)$', caseSensitive: false, ).firstMatch(body); if (directAf != null) { final lowerBodyForGroup = body.toUpperCase(); final parsedAf = _parseAsymmetricFragmentPayload( directAf.group(1) ?? '', isGroup: lowerBodyForGroup.contains('@G:') || lowerBodyForGroup.contains('G|'), ); if (parsedAf != null) { return parsedAf; } } // Step 2: Protocol Search final protocolTypes = [ typeSfra, typeSym, typeAsym, typeAsymFrag, typeNorm, typeKeyInit, typeKeyReply, typeNack ]; // Structural delimiters (Mainly | ; ! but also ö and § as fallbacks) final sepRegex = RegExp(r'[|;!ö§]'); int startIdx = -1; String? detectedType; bool isGroup = false; // A. Standard Header Search (@S: or @G:) - Case Insensitive final lowerBody = body.toLowerCase(); final sIdx = lowerBody.indexOf(prefix.toLowerCase()); final gIdx = lowerBody.indexOf(gPrefix.toLowerCase()); if (sIdx != -1 || gIdx != -1) { isGroup = gIdx != -1 && (sIdx == -1 || gIdx < sIdx); startIdx = isGroup ? gIdx : sIdx; final actualPrefixLength = isGroup ? gPrefix.length : prefix.length; final contentAfterPrefix = body.substring(startIdx + actualPrefixLength).trim(); final lowerContent = contentAfterPrefix.toLowerCase(); for (final t in protocolTypes) { if (lowerContent.startsWith(t.toLowerCase())) { detectedType = t; break; } } } debugPrint( '[PROTOCOL] Search Header: detected=$detectedType, startIdx=$startIdx, isGroup=$isGroup'); // B. Fuzzy Fallback (Marker + Separator) if (detectedType == null) { final fuzzyProtocolTypes = [typeSfra, typeSym, typeAsymFrag]; for (final t in fuzzyProtocolTypes) { // Broaden the separator character set for fuzzy matching final fuzzyRegex = RegExp('${t.toLowerCase()}\\s*[|;!ö§:]', caseSensitive: false); final match = fuzzyRegex.firstMatch(body); if (match != null) { detectedType = t; startIdx = max(0, match.start - 3); final lookback = body.substring(0, match.start).toUpperCase(); isGroup = lookback.contains('@G:') || lookback.contains('G|'); break; } } debugPrint( '[PROTOCOL] Search Fuzzy: detected=$detectedType, startIdx=$startIdx, isGroup=$isGroup'); } // C. Raw Variant Fallback (b1: or h1:) if (detectedType == null) { final normalizedBody = body.trimLeft(); final lower = normalizedBody.toLowerCase(); if (lower.startsWith('b1:') || lower.startsWith('h1:')) { return { "type": "sym", "isGroup": false, "payload": normalizedBody.trim() }; } return {"type": "plain", "body": rawText}; } // Step 3: Extract Payload from First Structural Separator // Find the first occurrence of detectedType after startIdx final typeStartPos = body.toUpperCase().indexOf(detectedType.toUpperCase(), startIdx); if (typeStartPos == -1) return {"type": "plain", "body": rawText}; final sepMatch = sepRegex.firstMatch(body.substring(typeStartPos)); if (sepMatch == null) { if (detectedType == typeKeyInit || detectedType == typeKeyReply || detectedType == typeSym) { return { "type": detectedType.toLowerCase(), "isGroup": isGroup, "payload": body.substring(typeStartPos + detectedType.length).trim() }; } return {"type": "plain", "body": rawText}; } final firstSepIdxInSubstring = sepMatch.start; final firstSepIdxInBody = typeStartPos + firstSepIdxInSubstring; // Crucial difference: Keep the absolute raw content without trimming to prevent truncation of trailing Base64 spaces (mangled '+') final absoluteRawContent = body.substring(firstSepIdxInBody + 1); final rawContent = absoluteRawContent.trim(); final sepChar = sepMatch.group(0)!; final parts = [detectedType, ...rawContent.split(sepChar)]; debugPrint( '[PROTOCOL] Parsed: type=$detectedType, isGroup=$isGroup, sepChar=$sepChar, partsCount=${parts.length}, rawLen=${body.length}'); if (detectedType == typeSfra) { debugPrint('[PROTOCOL] Fragment Debug: parts=$parts'); } // Type Handlers if (detectedType == typeNorm) return {"type": "norm", "isGroup": isGroup}; if (detectedType == typeSym) { // Support both: SYM|payload AND SYM|packetId|payload if (parts.length >= 3) { return { "type": "sym", "isGroup": isGroup, "packetId": parts[1].trim(), "payload": parts.sublist(2).join(sepChar) }; } return {"type": "sym", "isGroup": isGroup, "payload": absoluteRawContent}; } if (detectedType == typeSfra && parts.length >= 4) { final packetId = parts[1].trim(); final partNo = int.tryParse(parts[2].trim()) ?? 1; final totalParts = int.tryParse(parts[3].trim()) ?? 1; // Accurate Chunk Extraction: skip exactly 3 delimiters in absoluteRawContent int dCount = 0; int chunkStartPos = 0; for (int i = 0; i < absoluteRawContent.length; i++) { if (absoluteRawContent[i] == sepChar) { dCount++; if (dCount == 3) { chunkStartPos = i + 1; break; } } } // Preserve suspicious separator noise inside chunks so the crypto layer // can try repair candidates instead of silently dropping real data. final String rawChunk = (chunkStartPos > 0) ? absoluteRawContent.substring(chunkStartPos) : (parts.length > 4 ? parts.sublist(4).join(sepChar) : ""); final String chunk = rawChunk.replaceAll(RegExp(r'\s+'), ''); return { "type": "sfra", "isGroup": isGroup, "packetId": packetId, "partNo": partNo, "totalParts": totalParts, "chunk": chunk }; } if (detectedType == typeNack && parts.length >= 3) { return { "type": "nack", "isGroup": isGroup, "packetId": parts[1], "missingPart": int.tryParse(parts[2]) ?? 1 }; } if (detectedType == typeKeyInit) { return { "type": "key_init", "isGroup": isGroup, "payload": absoluteRawContent }; } if (detectedType == typeKeyReply) { return { "type": "key_reply", "isGroup": isGroup, "payload": absoluteRawContent }; } if (detectedType == typeAsym) { if (parts.length >= 3) { return { "type": "asym", "isGroup": isGroup, "packetId": parts[1].trim(), "payload": parts.sublist(2).join(sepChar) }; } return { "type": "asym", "isGroup": isGroup, "payload": absoluteRawContent }; } if (detectedType == typeSym) { final payload = parts.length > 1 ? parts.sublist(1).join(sepChar) : ""; return { "type": "sym", "isGroup": isGroup, "payload": payload.replaceAll(RegExp(r'\s+'), '') }; } if (detectedType == typeAsymFrag) { final parsedAf = _parseAsymmetricFragmentPayload( absoluteRawContent, isGroup: isGroup, ); if (parsedAf != null) { return parsedAf; } } return {"type": "plain", "body": rawText}; } }