From 44b7eaa75f55e246491a97807fc85fecc53a0f59 Mon Sep 17 00:00:00 2001 From: Domingo Gallardo Date: Tue, 24 Mar 2026 14:46:55 +0100 Subject: [PATCH] Professionalize API docs and test coverage --- .github/workflows/ci.yml | 21 + .github/workflows/full-regression.yml | 22 + .gitignore | 4 +- Docs/JSONContract.md | 142 + Docs/WhatsAppDatabase/README.md | 2 +- README.md | 171 +- Sources/SwiftWABackupAPI/BackupManager.swift | 130 +- Sources/SwiftWABackupAPI/ChatSession.swift | 10 +- .../SwiftWABackupAPI/DatabaseProtocols.swift | 16 +- Sources/SwiftWABackupAPI/Errors.swift | 49 +- Sources/SwiftWABackupAPI/FileUtils.swift | 10 +- Sources/SwiftWABackupAPI/GroupMember.swift | 10 +- Sources/SwiftWABackupAPI/Int+SQLHelpers.swift | 4 +- Sources/SwiftWABackupAPI/MediaCopier.swift | 8 +- Sources/SwiftWABackupAPI/Message.swift | 12 +- .../SwiftWABackupAPI/MessageInfoTable.swift | 15 +- .../SwiftWABackupAPI/ProfilePushName.swift | 9 +- Sources/SwiftWABackupAPI/ReactionParser.swift | 4 +- Sources/SwiftWABackupAPI/Row+Helpers.swift | 8 +- .../SwiftWABackupAPI/String+JidHelpers.swift | 10 +- .../SwiftWABackupAPI/SwiftWABackupAPI.swift | 711 +--- Sources/SwiftWABackupAPI/WABackup+Chats.swift | 96 + .../WABackup+Connection.swift | 49 + .../SwiftWABackupAPI/WABackup+Contacts.swift | 76 + .../SwiftWABackupAPI/WABackup+Messages.swift | 373 ++ .../Data/JSONContract/chat_dump_payload.json | 38 + Tests/Data/JSONContract/chat_info.json | 10 + Tests/Data/JSONContract/contact_info.json | 5 + Tests/Data/JSONContract/message_info.json | 22 + Tests/Data/JSONContract/reaction.json | 4 + .../BasicIntegrationTests.swift | 107 + .../ErrorHandlingTests.swift | 85 + .../InternalHelperTests.swift | 57 + .../JSONContractTests.swift | 94 + .../SwiftWABackupAPITests.swift | 3763 +++++++++++++++++ Tests/SwiftWABackupAPITests/TestSupport.swift | 450 ++ 36 files changed, 5880 insertions(+), 717 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/full-regression.yml create mode 100644 Docs/JSONContract.md create mode 100644 Sources/SwiftWABackupAPI/WABackup+Chats.swift create mode 100644 Sources/SwiftWABackupAPI/WABackup+Connection.swift create mode 100644 Sources/SwiftWABackupAPI/WABackup+Contacts.swift create mode 100644 Sources/SwiftWABackupAPI/WABackup+Messages.swift create mode 100644 Tests/Data/JSONContract/chat_dump_payload.json create mode 100644 Tests/Data/JSONContract/chat_info.json create mode 100644 Tests/Data/JSONContract/contact_info.json create mode 100644 Tests/Data/JSONContract/message_info.json create mode 100644 Tests/Data/JSONContract/reaction.json create mode 100644 Tests/SwiftWABackupAPITests/BasicIntegrationTests.swift create mode 100644 Tests/SwiftWABackupAPITests/ErrorHandlingTests.swift create mode 100644 Tests/SwiftWABackupAPITests/InternalHelperTests.swift create mode 100644 Tests/SwiftWABackupAPITests/JSONContractTests.swift create mode 100644 Tests/SwiftWABackupAPITests/SwiftWABackupAPITests.swift create mode 100644 Tests/SwiftWABackupAPITests/TestSupport.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59a2219 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + quick-checks: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Run quick test suite with coverage + run: swift test --enable-code-coverage diff --git a/.github/workflows/full-regression.yml b/.github/workflows/full-regression.yml new file mode 100644 index 0000000..2b99d97 --- /dev/null +++ b/.github/workflows/full-regression.yml @@ -0,0 +1,22 @@ +name: Full Regression + +on: + workflow_dispatch: + schedule: + - cron: "0 4 * * 1" + +jobs: + full-fixture-regression: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Run full regression suite + env: + SWIFT_WA_RUN_FULL_FIXTURE_TESTS: "1" + run: swift test diff --git a/.gitignore b/.gitignore index 477da2b..846f045 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,7 @@ DerivedData/ .netrc Package.resolved .vscode -Tests/** +Tests/Data/** +!Tests/Data/JSONContract/ +!Tests/Data/JSONContract/** complete_project.txt diff --git a/Docs/JSONContract.md b/Docs/JSONContract.md new file mode 100644 index 0000000..3f88a22 --- /dev/null +++ b/Docs/JSONContract.md @@ -0,0 +1,142 @@ +# JSON Contract + +This document defines the canonical JSON shape exposed by `SwiftWABackupAPI` when encoding the public `Encodable` models with: + +- `JSONEncoder.dateEncodingStrategy = .iso8601` +- `JSONEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]` + +The contract is verified by the snapshot tests under `Tests/SwiftWABackupAPITests/JSONContractTests.swift` and the fixtures stored in `Tests/Data/JSONContract/`. + +## Encoding Rules + +- Dates are encoded as ISO 8601 strings with timezone information, for example `2024-04-03T11:24:16Z`. +- Object keys are sorted when using the recommended encoder configuration above. +- Optional properties are omitted when their value is `nil`. +- Arrays preserve the order returned by the API. + +## `Reaction` + +```json +{ + "emoji": "👍", + "senderPhone": "34636104084" +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `emoji` | `String` | Yes | Emoji used in the reaction. | +| `senderPhone` | `String` | Yes | Phone number derived from the reacting JID, or `"Me"` for the owner. | + +## `ChatInfo` + +```json +{ + "chatType": "individual", + "contactJid": "34636104084@s.whatsapp.net", + "id": 44, + "isArchived": false, + "lastMessageDate": "2024-04-03T11:24:16Z", + "name": "Aitor Medrano", + "numberMessages": 153, + "photoFilename": "chat_44.jpg" +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `Int` | Yes | Chat identifier from `ZWACHATSESSION.Z_PK`. | +| `contactJid` | `String` | Yes | Raw WhatsApp JID for the chat. | +| `name` | `String` | Yes | Resolved chat display name. | +| `numberMessages` | `Int` | Yes | Number of supported messages returned by the API. | +| `lastMessageDate` | `String` | Yes | ISO 8601 timestamp for the latest supported message. | +| `chatType` | `"group" | "individual"` | Yes | Chat type exposed by the API. | +| `isArchived` | `Bool` | Yes | Whether the chat is archived. | +| `photoFilename` | `String` | No | Copied avatar filename when photo export is requested and available. | + +## `MessageInfo` + +```json +{ + "caption": "Example caption", + "chatId": 44, + "date": "2024-04-03T11:24:16Z", + "id": 125482, + "isFromMe": false, + "latitude": 38.3456, + "longitude": -0.4815, + "mediaFilename": "example.jpg", + "message": "Claro, cada vez que vaya a la UA te aviso.", + "messageType": "Text", + "reactions": [ + { + "emoji": "👍", + "senderPhone": "Me" + } + ], + "replyTo": 125479, + "seconds": 12, + "senderName": "Aitor Medrano", + "senderPhone": "34636104084" +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `Int` | Yes | Message identifier from `ZWAMESSAGE.Z_PK`. | +| `chatId` | `Int` | Yes | Parent chat identifier. | +| `message` | `String` | No | Message text or normalized event text. | +| `date` | `String` | Yes | ISO 8601 timestamp for the message. | +| `isFromMe` | `Bool` | Yes | Whether the message was sent by the owner. | +| `messageType` | `String` | Yes | Human-readable message type name. | +| `senderName` | `String` | No | Resolved display name for incoming messages. | +| `senderPhone` | `String` | No | Resolved phone number for incoming messages. | +| `caption` | `String` | No | Media caption or title. | +| `replyTo` | `Int` | No | Identifier of the replied-to message when it can be resolved. | +| `mediaFilename` | `String` | No | Exported media filename when media is copied or resolved. | +| `reactions` | `[Reaction]` | No | Reactions attached to the message. | +| `error` | `String` | No | Optional warning associated with media handling. | +| `seconds` | `Int` | No | Duration for audio and video messages. | +| `latitude` | `Double` | No | Latitude for location messages. | +| `longitude` | `Double` | No | Longitude for location messages. | + +## `ContactInfo` + +```json +{ + "name": "Aitor Medrano", + "phone": "34636104084", + "photoFilename": "34636104084.jpg" +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `String` | Yes | Resolved display name. | +| `phone` | `String` | Yes | Phone number derived from the contact JID. | +| `photoFilename` | `String` | No | Copied avatar filename when available. | + +## `ChatDumpPayload` + +```json +{ + "chatInfo": { "...": "..." }, + "contacts": [ + { "...": "..." } + ], + "messages": [ + { "...": "..." } + ] +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `chatInfo` | `ChatInfo` | Yes | Chat metadata for the export. | +| `messages` | `[MessageInfo]` | Yes | Messages returned for the chat. | +| `contacts` | `[ContactInfo]` | Yes | Contacts resolved for the chat. | + +## Notes + +- `ChatDump` remains available as the legacy tuple returned by `getChat(...)`. +- `ChatDumpPayload` is the recommended type for JSON export because it is stable, explicit, and directly `Encodable`. diff --git a/Docs/WhatsAppDatabase/README.md b/Docs/WhatsAppDatabase/README.md index 9c407a6..f992566 100644 --- a/Docs/WhatsAppDatabase/README.md +++ b/Docs/WhatsAppDatabase/README.md @@ -43,7 +43,7 @@ All schema checks live in `DatabaseHelpers.swift` and `DatabaseProtocols.swift`; | 11 | GIF | Treated like video, stored as MP4 in the backup. | | 15 | Sticker | Returns `.webp` filename. | -`SwiftWABackupAPITests.testChatMessages` verifies that the counts for each supported type are stable against the fixture (e.g. 5532 images, 489 videos, 310 statuses). +`SwiftWABackupAPITests.testChatMessages` verifies that the counts for each supported type are stable against the fixture (currently 5281 images, 489 videos, and 264 status messages). ### Status (`ZMESSAGETYPE = 10`) Subcodes (Fixture Snapshot) diff --git a/README.md b/README.md index 2bc0a27..b54e9af 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,170 @@ # SwiftWABackupAPI -Swift API to explore your WhatsApp chats in an unencrypted iPhone backup. It is used in the companion MacOS command line application [WABackupExtractor](https://github.com/domingogallardo/WABackupExtractor). +`SwiftWABackupAPI` is a Swift package for exploring WhatsApp data stored inside an unencrypted iPhone backup. It powers the companion macOS CLI application [WABackupExtractor](https://github.com/domingogallardo/WABackupExtractor), but it can also be consumed directly from your own Swift tools and apps. -## ⛔️Privacy Warning⛔️ +## Privacy Warning -This tool is designed to access WhatsApp backup data for legitimate purposes such as data backup and -recovery or data analysis. It is crucial to remember that accessing, extracting, or analyzing chat data -without the explicit consent of all involved parties can violate privacy laws and regulations, as well -as WhatsApp's terms of service. +This package is intended for legitimate backup, recovery, export, and personal analysis workflows. -Any use of this tool should respect privacy laws and regulations, as well as WhatsApp's terms of service. +Accessing or processing WhatsApp conversations without the explicit consent of the people involved can violate privacy laws, workplace policies, and WhatsApp terms of service. Make sure you have the legal and ethical right to inspect the data before using this package. -Always respect the privacy and rights of others.☮️ +## What The Package Exposes +The public API is centered on `WABackup`: + +- Discover available iPhone backups with `getBackups()` +- Connect to a WhatsApp `ChatStorage.sqlite` database with `connectChatStorageDb(from:)` +- List chats with `getChats(directoryToSavePhotos:)` +- Export a chat with `getChat(chatId:directoryToSaveMedia:)` +- Export the same data as an `Encodable` wrapper with `getChatPayload(chatId:directoryToSaveMedia:)` + +Returned models are `Encodable` and designed to be easy to serialize: + +- `ChatInfo` +- `MessageInfo` +- `ContactInfo` +- `Reaction` +- `ChatDumpPayload` + +## Requirements + +- A macOS environment with access to an iPhone backup directory +- A non-encrypted backup that contains WhatsApp data +- Permission to read the backup folder + +By default, `WABackup` looks under: + +```text +~/Library/Application Support/MobileSync/Backup/ +``` + +On many systems you will need to grant Full Disk Access to the host app or terminal. + +## Installation + +Add the package dependency in `Package.swift` using the release rule that matches how you publish or consume the package: + +```swift +.package(url: "https://github.com/domingogallardo/SwiftWABackupAPI.git", from: "1.0.10") +``` + +Then add the product to your target dependencies: + +```swift +.product(name: "SwiftWABackupAPI", package: "SwiftWABackupAPI") +``` + +## Basic Usage + +```swift +import Foundation +import SwiftWABackupAPI + +let backupAPI = WABackup() +let backups = try backupAPI.getBackups() +guard let backup = backups.validBackups.first else { + throw NSError(domain: "Example", code: 1) +} + +try backupAPI.connectChatStorageDb(from: backup) + +let chats = try backupAPI.getChats() +let payload = try backupAPI.getChatPayload(chatId: chats[0].id, directoryToSaveMedia: nil) +print(payload.chatInfo.name) +print(payload.messages.count) +``` + +If you want exported media and copied profile images: + +```swift +let outputDirectory = URL(fileURLWithPath: "/tmp/wa-export", isDirectory: true) +let chats = try backupAPI.getChats(directoryToSavePhotos: outputDirectory) +let payload = try backupAPI.getChatPayload(chatId: chats[0].id, directoryToSaveMedia: outputDirectory) +``` + +## JSON Export + +`ChatDumpPayload` exists specifically so consumers can serialize a full export without depending on the legacy tuple-based `ChatDump`. + +Recommended JSON settings: + +- `JSONEncoder.dateEncodingStrategy = .iso8601` +- `JSONEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]` + +Example: + +```swift +let payload = try backupAPI.getChatPayload(chatId: 44, directoryToSaveMedia: nil) + +let encoder = JSONEncoder() +encoder.dateEncodingStrategy = .iso8601 +encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + +let jsonData = try encoder.encode(payload) +let jsonString = String(decoding: jsonData, as: UTF8.self) +print(jsonString) +``` + +The formal JSON contract is documented in [Docs/JSONContract.md](./Docs/JSONContract.md). + +## Media And Profile Images + +When an output directory is provided: + +- Message media is copied using the original WhatsApp filename when possible +- Chat avatars are copied as `chat_.jpg` or `chat_.thumb` +- Contact avatars are copied as `.jpg` or `.thumb` + +You can observe writes through `WABackupDelegate`: + +```swift +final class ExportDelegate: WABackupDelegate { + func didWriteMediaFile(fileName: String) { + print("Processed media file: \(fileName)") + } +} + +let delegate = ExportDelegate() +backupAPI.delegate = delegate +``` + +## Error Handling + +The package exposes three error families: + +- `BackupError` for backup discovery and file-copy issues +- `DatabaseErrorWA` for SQLite connectivity, missing rows, and unsupported schemas +- `DomainError` for higher-level WhatsApp interpretation problems + +Example: + +```swift +do { + let chats = try backupAPI.getChats() + print(chats.count) +} catch let error as BackupError { + print("Backup error: \(error.localizedDescription)") +} catch let error as DatabaseErrorWA { + print("Database error: \(error.localizedDescription)") +} catch { + print("Unexpected error: \(error)") +} +``` + +## Tests + +The repository now ships two testing modes: + +- Quick verification: `swift test` +- Full regression against the large bundled fixture: + +```bash +SWIFT_WA_RUN_FULL_FIXTURE_TESTS=1 swift test +``` + +The quick suite covers smoke scenarios, JSON contract snapshots, helper behaviour, and failure handling. The full suite keeps the large regression checks for counts, fixtures, contacts, and message content. + +## Additional Documentation + +- [Database reference](./Docs/WhatsAppDatabase/README.md) +- [JSON contract](./Docs/JSONContract.md) diff --git a/Sources/SwiftWABackupAPI/BackupManager.swift b/Sources/SwiftWABackupAPI/BackupManager.swift index d43a856..ee15b9d 100644 --- a/Sources/SwiftWABackupAPI/BackupManager.swift +++ b/Sources/SwiftWABackupAPI/BackupManager.swift @@ -8,51 +8,56 @@ import Foundation import GRDB -// A backup is valid if it contains the WhatsApp sqlite database +/// Result returned when scanning a backup directory. +/// +/// Valid backups contain the WhatsApp `ChatStorage.sqlite` database. +/// Invalid backups point to directories that look like iPhone backups but +/// cannot be used by this package. public typealias BackupFetchResult = (validBackups: [IPhoneBackup], invalidBackups: [URL]) +/// Scans the standard macOS backup folder and identifies usable iPhone backups. public struct BackupManager { - // Default directory where iPhone stores backups on macOS. private let backupPath: String + /// Creates a manager rooted at the provided iPhone backup directory. public init(backupPath: String = "~/Library/Application Support/MobileSync/Backup/") { self.backupPath = backupPath } - // Fetches the list of all valid and invalid backups at the default backup path. - // Each valid backup is represented as a IPhoneBackup struct. Invalid backups are - // represented as a URL pointing to the invalid backup. - // - // The function needs permission to access - // ~/Library/Application Support/MobileSync/Backup/ - // Go to System Preferences -> Security & Privacy -> Full Disk Access - + /// Returns valid and invalid iPhone backup directories found under `backupPath`. + /// + /// Accessing the default macOS backup location may require Full Disk Access. public func getBackups() throws -> BackupFetchResult { let expandedBackupPath = NSString(string: backupPath).expandingTildeInPath - let backupUrl = URL(fileURLWithPath: expandedBackupPath) + let backupURL = URL(fileURLWithPath: expandedBackupPath) var validBackups: [IPhoneBackup] = [] var invalidBackups: [URL] = [] + do { - let directoryContents = try FileManager.default.contentsOfDirectory(at: backupUrl, includingPropertiesForKeys: [.isDirectoryKey]) + let directoryContents = try FileManager.default.contentsOfDirectory( + at: backupURL, + includingPropertiesForKeys: [.isDirectoryKey] + ) + for url in directoryContents { do { - // Verificar si es un directorio let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) if resourceValues.isDirectory == true { - // Solo intentar obtener el backup si es un directorio - let backup = try getBackup(at: url) - validBackups.append(backup) + validBackups.append(try getBackup(at: url)) } } catch { invalidBackups.append(url) } } + return (validBackups: validBackups, invalidBackups: invalidBackups) } catch { throw BackupError.directoryAccess(error) } } +} +extension BackupManager { private func getBackup(at url: URL) throws -> IPhoneBackup { let fileManager = FileManager.default @@ -70,16 +75,19 @@ public struct BackupManager { do { let statusPlistData = try Data(contentsOf: url.appendingPathComponent("Status.plist")) - let plistObj = try PropertyListSerialization.propertyList(from: statusPlistData, options: [], format: nil) - - guard let plistDict = plistObj as? [String: Any], + let plistObject = try PropertyListSerialization.propertyList( + from: statusPlistData, + options: [], + format: nil + ) + + guard let plistDict = plistObject as? [String: Any], let date = plistDict["Date"] as? Date else { throw BackupError.invalidBackup(url: url, reason: "Status.plist is malformed.") } let iPhoneBackup = IPhoneBackup(url: url, creationDate: date) - - // Check if the backup contains the WhatsApp database + do { _ = try iPhoneBackup.fetchWAFileHash(endsWith: "ChatStorage.sqlite") } catch { @@ -87,59 +95,74 @@ public struct BackupManager { } return iPhoneBackup - } catch { throw BackupError.directoryAccess(error) } } private func isDirectory(at url: URL) -> Bool { - var isDir: ObjCBool = false - return FileManager.default.fileExists(atPath: url.path, - isDirectory: &isDir) && isDir.boolValue + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + && isDirectory.boolValue } } +/// Relative WhatsApp filename and hashed on-disk filename stored in the iPhone backup. public typealias FilenameAndHash = (filename: String, fileHash: String) - + +/// Represents an iPhone backup that contains a WhatsApp database. public struct IPhoneBackup { let url: URL + + /// Absolute path of the backup directory. public var path: String { - return url.path + url.path } + + /// Creation date reported by `Status.plist`. public let creationDate: Date + + /// Directory name used by iTunes/Finder to identify the backup. public var identifier: String { - return url.lastPathComponent + url.lastPathComponent } +} +extension IPhoneBackup { private var manifestDBPath: String { - return url.appendingPathComponent("Manifest.db").path + url.appendingPathComponent("Manifest.db").path } private func connectToManifestDB() -> DatabaseQueue? { - return try? DatabaseQueue(path: manifestDBPath) + try? DatabaseQueue(path: manifestDBPath) } - // Returns the full URL of a hash file + /// Returns the on-disk URL for a hashed file stored inside the backup. public func getUrl(fileHash: String) -> URL { - return url + url .appendingPathComponent(String(fileHash.prefix(2))) .appendingPathComponent(fileHash) } - // Returns the file hash of the file with a relative path in the - // WhatsApp backup inside the iPhone backup. + /// Resolves a WhatsApp relative path suffix to the hashed backup file identifier. public func fetchWAFileHash(endsWith relativePath: String) throws -> String { guard let manifestDb = connectToManifestDB() else { - throw DatabaseErrorWA.connection(DatabaseError(message: "Unable to connect to Manifest.db")) + throw DatabaseErrorWA.connection( + DatabaseError(message: "Unable to connect to Manifest.db") + ) } do { return try manifestDb.read { db in - let row = try Row.fetchOne(db, sql: """ - SELECT fileID FROM Files WHERE relativePath LIKE ? - AND domain = 'AppDomainGroup-group.net.whatsapp.WhatsApp.shared' - """, arguments: ["%"+relativePath]) + let row = try Row.fetchOne( + db, + sql: """ + SELECT fileID FROM Files WHERE relativePath LIKE ? + AND domain = 'AppDomainGroup-group.net.whatsapp.WhatsApp.shared' + """, + arguments: ["%" + relativePath] + ) + if let fileID: String = row?["fileID"] { return fileID } else { @@ -151,35 +174,36 @@ public struct IPhoneBackup { } } - // Returns an array of tuples containing the filename and its corresponding - // file hash for files that contains the relative path string in the - // WhatsApp backup inside the iPhone backup. - public func fetchWAFileDetails( - contains relativePath: String) -> [FilenameAndHash] { - + /// Returns hashed file entries whose WhatsApp relative path contains the provided fragment. + public func fetchWAFileDetails(contains relativePath: String) -> [FilenameAndHash] { guard let manifestDb = connectToManifestDB() else { return [] } var fileDetails: [FilenameAndHash] = [] + do { try manifestDb.read { db in - let sql = """ - SELECT fileID, relativePath FROM Files WHERE relativePath LIKE ? - AND domain = 'AppDomainGroup-group.net.whatsapp.WhatsApp.shared' - """ - let rows = try Row.fetchAll(db, sql: sql, arguments: ["%" + relativePath + "%"]) + let rows = try Row.fetchAll( + db, + sql: """ + SELECT fileID, relativePath FROM Files WHERE relativePath LIKE ? + AND domain = 'AppDomainGroup-group.net.whatsapp.WhatsApp.shared' + """, + arguments: ["%" + relativePath + "%"] + ) + for row in rows { - if let fileHash = row["fileID"] as? String, + if let fileHash = row["fileID"] as? String, let filename = row["relativePath"] as? String { - let filenameAndHash = (filename: filename, fileHash: fileHash) - fileDetails.append(filenameAndHash) + fileDetails.append((filename: filename, fileHash: fileHash)) } } } } catch { print("Cannot execute query: \(error)") } + return fileDetails } } diff --git a/Sources/SwiftWABackupAPI/ChatSession.swift b/Sources/SwiftWABackupAPI/ChatSession.swift index 4e5e831..45f5b32 100644 --- a/Sources/SwiftWABackupAPI/ChatSession.swift +++ b/Sources/SwiftWABackupAPI/ChatSession.swift @@ -43,10 +43,10 @@ struct ChatSession: FetchableByID { } } -// MARK:‑ Convenience API (firmas preservadas) +// MARK: - Convenience API extension ChatSession { - /// Chats con al menos un mensaje distinto de STATUS. + /// Returns chats with at least one supported non-status message. static func fetchAllChats(from db: Database) throws -> [ChatSession] { let statusType = SupportedMessageType.status.rawValue let supported = SupportedMessageType.allValues @@ -75,14 +75,14 @@ extension ChatSession { } } - /// Chat por id o error `chatNotFound`. + /// Returns a chat by id or throws if it does not exist. static func fetchChat(byId id: Int, from db: Database) throws -> ChatSession { if let chat = try fetch(by: Int64(id), from: db) { return chat } throw DatabaseErrorWA.recordNotFound(table: Self.tableName, id: id) } - /// Nombre de la sesión para un `contactJid`. + /// Returns the chat display name stored for a contact JID. static func fetchChatSessionName(for contactJid: String, from db: Database) throws -> String? { try Row.fetchOne( @@ -92,7 +92,7 @@ extension ChatSession { )?["ZPARTNERNAME"] } - // MARK:‑ SenderInfo helper usado por WABackup + // MARK: - Sender Info typealias SenderInfo = (senderName: String?, senderPhone: String?) static func fetchSenderInfo(chatId: Int, diff --git a/Sources/SwiftWABackupAPI/DatabaseProtocols.swift b/Sources/SwiftWABackupAPI/DatabaseProtocols.swift index d97a47d..7b631a1 100644 --- a/Sources/SwiftWABackupAPI/DatabaseProtocols.swift +++ b/Sources/SwiftWABackupAPI/DatabaseProtocols.swift @@ -12,7 +12,7 @@ import GRDB // MARK: - GRDBSchemaCheckable -/// Con‑forma cualquier modelo que necesite validar su esquema. +/// Conform models that need to validate their backing SQLite schema. public protocol GRDBSchemaCheckable { /// Exact table name in the SQLite schema. static var tableName: String { get } @@ -21,7 +21,7 @@ public protocol GRDBSchemaCheckable { } public extension GRDBSchemaCheckable { - /// Default implementation that re‑uses the existing helper. + /// Default implementation backed by `checkTableSchema`. static func checkSchema(in db: Database) throws { try checkTableSchema( tableName: tableName, @@ -32,19 +32,19 @@ public extension GRDBSchemaCheckable { } // MARK: - FetchableByID -/// Generic fetch for rows addressed by a primary key. +/// Generic fetch support for rows addressed by a primary key. public protocol FetchableByID: GRDBSchemaCheckable { - /// Primary‑key type (usually `Int64` or `Int`). + /// Primary key type, usually `Int64`, `Int`, or `String`. associatedtype Key: DatabaseValueConvertible - /// Primary‑key column name (e.g. `"Z_PK"`). + /// Primary key column name, for example `"Z_PK"`. static var primaryKey: String { get } - /// Row‑based initializer required by GRDB. + /// Row-based initializer required by GRDB. init(row: Row) } public extension FetchableByID { - /// Returns the model instance with the given id or `nil`. + /// Returns the model instance with the given id, or `nil` if it does not exist. static func fetch(by id: Key, from db: Database) throws -> Self? { let sql = "SELECT * FROM \(tableName) WHERE \(primaryKey) = ?" if let row = try Row.fetchOne(db, sql: sql, arguments: [id]) { @@ -53,7 +53,7 @@ public extension FetchableByID { return nil } - /// Convenience: throws if not found. + /// Convenience wrapper that throws when the record does not exist. static func require(by id: Key, from db: Database) throws -> Self { guard let value = try fetch(by: id, from: db) else { throw DatabaseErrorWA.recordNotFound(table: Self.tableName, id: Int64("\(id)") ?? -1) diff --git a/Sources/SwiftWABackupAPI/Errors.swift b/Sources/SwiftWABackupAPI/Errors.swift index 64316c4..5eed730 100644 --- a/Sources/SwiftWABackupAPI/Errors.swift +++ b/Sources/SwiftWABackupAPI/Errors.swift @@ -4,41 +4,49 @@ // // Created by Domingo Gallardo on 17/4/25. // -// -// Granular error namespaces that will eventually replace WABackupError. -// While the migration is in progress we keep a compatibility layer. -// import Foundation -// MARK: - Backup‑layer errors +/// Errors raised while discovering or copying files from an iPhone backup. public enum BackupError: Error, LocalizedError { + /// The backup directory could not be accessed. case directoryAccess(Error) + + /// A candidate backup directory is incomplete or malformed. case invalidBackup(url: URL, reason: String) + + /// A hashed backup file could not be copied to its destination. case fileCopy(source: URL, destination: URL, underlying: Error) + /// Localized description of the error. public var errorDescription: String? { switch self { - case .directoryAccess(let err): - return "Failed to access backup directory: \(err.localizedDescription)" + case .directoryAccess(let error): + return "Failed to access backup directory: \(error.localizedDescription)" case .invalidBackup(let url, let reason): return "Invalid backup at \(url.path): \(reason)" - case .fileCopy(let s, let d, let err): - return "Failed to copy \(s.lastPathComponent) to \(d.path): \(err.localizedDescription)" + case .fileCopy(let source, let destination, let error): + return "Failed to copy \(source.lastPathComponent) to \(destination.path): \(error.localizedDescription)" } } } -// MARK: - Database‑layer errors +/// Errors raised while interacting with SQLite databases used by the package. public enum DatabaseErrorWA: Error, LocalizedError { + /// A database could not be opened or queried. case connection(Error) + + /// The WhatsApp schema no longer matches what the package expects. case unsupportedSchema(reason: String) + + /// A requested record was not found. case recordNotFound(table: String, id: CustomStringConvertible) + /// Localized description of the error. public var errorDescription: String? { switch self { - case .connection(let err): - return "Database connection failed: \(err.localizedDescription)" + case .connection(let error): + return "Database connection failed: \(error.localizedDescription)" case .unsupportedSchema(let reason): return "Unsupported database schema: \(reason)" case .recordNotFound(let table, let id): @@ -47,17 +55,26 @@ public enum DatabaseErrorWA: Error, LocalizedError { } } -// MARK: - Domain / higher‑level errors +/// Higher-level domain errors raised while interpreting WhatsApp data. public enum DomainError: Error, LocalizedError { + /// A referenced WhatsApp media file could not be located in the backup manifest. case mediaNotFound(path: String) + + /// The owner profile could not be determined from the database. case ownerProfileNotFound + + /// A catch-all for unexpected conditions that do not fit a narrower case. case unexpected(reason: String) + /// Localized description of the error. public var errorDescription: String? { switch self { - case .mediaNotFound(let p): return "Media not found at \(p)" - case .ownerProfileNotFound: return "Owner profile not found in database" - case .unexpected(let r): return "Unexpected error: \(r)" + case .mediaNotFound(let path): + return "Media not found at \(path)" + case .ownerProfileNotFound: + return "Owner profile not found in database" + case .unexpected(let reason): + return "Unexpected error: \(reason)" } } } diff --git a/Sources/SwiftWABackupAPI/FileUtils.swift b/Sources/SwiftWABackupAPI/FileUtils.swift index 8e63fe0..0ab329b 100644 --- a/Sources/SwiftWABackupAPI/FileUtils.swift +++ b/Sources/SwiftWABackupAPI/FileUtils.swift @@ -9,12 +9,12 @@ import Foundation -/// Agrupa helpers relacionados con ficheros de WhatsApp dentro del backup. +/// Groups helpers related to WhatsApp files stored inside an iPhone backup. enum FileUtils { - public typealias NameHash = FilenameAndHash // (filename, fileHash) + typealias NameHash = FilenameAndHash - /// Devuelve el fichero más reciente cuyo nombre empieza por `prefixFilename` - /// y tiene la extensión indicada (`jpg`, `thumb`, …). + /// Returns the newest file whose name starts with `prefixFilename` + /// and ends with the provided extension (`jpg`, `thumb`, and so on). static func latestFile(for prefixFilename: String, fileExtension: String, in files: [NameHash]) -> NameHash? { @@ -34,7 +34,7 @@ enum FileUtils { return latest } - /// Extrae el sufijo entero que WhatsApp añade a las fotos de perfil + /// Extracts the timestamp suffix used by WhatsApp profile photos /// (`Media/Profile/-.jpg`). static func extractTimeSuffix(from prefixFilename: String, fileExtension: String, diff --git a/Sources/SwiftWABackupAPI/GroupMember.swift b/Sources/SwiftWABackupAPI/GroupMember.swift index 56e9915..84a8fb2 100644 --- a/Sources/SwiftWABackupAPI/GroupMember.swift +++ b/Sources/SwiftWABackupAPI/GroupMember.swift @@ -3,7 +3,7 @@ // SwiftWABackupAPI // // Created by Domingo Gallardo on 3/10/24. -// Refactor: adopta GRDBSchemaCheckable + FetchableByID +// Refactor: adopts GRDBSchemaCheckable + FetchableByID // import GRDB @@ -28,16 +28,16 @@ struct GroupMember: FetchableByID { } } -// MARK: - Convenience API (mantiene firmas usadas en WABackup) +// MARK: - Convenience API extension GroupMember { - /// Devuelve el miembro por id o `nil`. + /// Returns the group member by id, or `nil` when it does not exist. static func fetchGroupMember(byId id: Int64, from db: Database) throws -> GroupMember? { try fetch(by: id, from: db) } - /// Ids únicos de miembros de un chat que envían mensajes soportados. + /// Returns distinct member ids that appear in supported messages for a chat. static func fetchGroupMemberIds(forChatId chatId: Int, from db: Database) throws -> [Int64] { @@ -56,7 +56,7 @@ extension GroupMember { .compactMap { $0["ZGROUPMEMBER"] as? Int64 } } - // MARK: Sender‑info “raw” acceso conservado + // MARK: - Raw Sender Info struct GroupMemberSenderInfo { let memberJid: String let contactName: String? diff --git a/Sources/SwiftWABackupAPI/Int+SQLHelpers.swift b/Sources/SwiftWABackupAPI/Int+SQLHelpers.swift index 5ab7693..15820c6 100644 --- a/Sources/SwiftWABackupAPI/Int+SQLHelpers.swift +++ b/Sources/SwiftWABackupAPI/Int+SQLHelpers.swift @@ -8,8 +8,8 @@ import Foundation public extension Int { - /// Devuelve "?, ?, ?" con tantas interrogaciones como indique el valor. - /// Si `self` es 0 devuelve la cadena vacía (no suele usarse). + /// Returns `"?, ?, ?"` with as many placeholders as the integer value. + /// Returns an empty string when the value is `0`. var questionMarks: String { guard self > 0 else { return "" } return Array(repeating: "?", count: self).joined(separator: ", ") diff --git a/Sources/SwiftWABackupAPI/MediaCopier.swift b/Sources/SwiftWABackupAPI/MediaCopier.swift index 1b4bfaf..e9773a2 100644 --- a/Sources/SwiftWABackupAPI/MediaCopier.swift +++ b/Sources/SwiftWABackupAPI/MediaCopier.swift @@ -4,7 +4,7 @@ // // Created by Domingo Gallardo on 17/4/25. -// Encapsulates all file‑copy logic so it can be reused from WABackup. +// Encapsulates file-copy logic so it can be reused from WABackup. // import Foundation @@ -13,9 +13,9 @@ struct MediaCopier { let backup: IPhoneBackup weak var delegate: WABackupDelegate? - /// Copia el hash del backup al directorio destino (si se indica) y devuelve el nombre del fichero. - /// ‑ Si el fichero ya existe, no hace nada. - /// ‑ Notifica al delegate siempre que el fichero haya sido «procesado». + /// Copies a hashed backup file into the destination directory when one is provided. + /// If the target already exists, the copy is skipped. + /// The delegate is notified whenever the file is processed. @discardableResult func copy(hash: String, named fileName: String, diff --git a/Sources/SwiftWABackupAPI/Message.swift b/Sources/SwiftWABackupAPI/Message.swift index de801b6..13387ba 100644 --- a/Sources/SwiftWABackupAPI/Message.swift +++ b/Sources/SwiftWABackupAPI/Message.swift @@ -3,7 +3,7 @@ // SwiftWABackupAPI // // Created by Domingo Gallardo on 3/10/24. -// Refactor: adopta GRDBSchemaCheckable + FetchableByID +// Refactor: adopts GRDBSchemaCheckable + FetchableByID // import Foundation @@ -52,15 +52,15 @@ struct Message: FetchableByID { } } -// MARK: - Convenience API (preserva firmas previas usadas por WABackup) +// MARK: - Convenience API extension Message { - /// Mensajes de un chat filtrados por tipos soportados. + /// Returns chat messages filtered to the set of supported WhatsApp types. static func fetchMessages(forChatId chatId: Int, from db: Database) throws -> [Message] { let supported = SupportedMessageType.allValues - let placeholders = supported.count.questionMarks // Int extension + let placeholders = supported.count.questionMarks let sql = """ SELECT * FROM \(tableName) WHERE ZCHATSESSION = ? AND ZMESSAGETYPE IN (\(placeholders)) @@ -71,7 +71,7 @@ extension Message { .map(Self.init(row:)) } - /// Devuelve el primer `ZTOJID` que identifica al owner (perfil propio). + /// Returns the first `ZTOJID` that identifies the owner profile. static func fetchOwnerJid(from db: Database) throws -> String? { try Row.fetchOne( db, @@ -83,7 +83,7 @@ extension Message { )?["ZTOJID"] } - /// Obtiene el `Z_PK` de un mensaje a partir de su `stanzaId`. + /// Resolves a message primary key from its `stanzaId`. static func fetchMessageId(byStanzaId stanzaId: String, from db: Database) throws -> Int64? { try Row.fetchOne( diff --git a/Sources/SwiftWABackupAPI/MessageInfoTable.swift b/Sources/SwiftWABackupAPI/MessageInfoTable.swift index fe1de6a..33b627a 100644 --- a/Sources/SwiftWABackupAPI/MessageInfoTable.swift +++ b/Sources/SwiftWABackupAPI/MessageInfoTable.swift @@ -4,27 +4,24 @@ // // Created by Domingo Gallardo on 3/10/24. // -// -// Re‑implemented with GRDBSchemaCheckable + FetchableByID -// +// Re-implemented with GRDBSchemaCheckable + FetchableByID import Foundation import GRDB struct MessageInfoTable: FetchableByID { - // MARK:‑ Static metadata required by the protocols + // MARK: - Static Metadata static let tableName = "ZWAMESSAGEINFO" static let expectedColumns: Set = ["Z_PK", "ZRECEIPTINFO", "ZMESSAGE"] - static let primaryKey = "ZMESSAGE" // ← clave que enlaza con ZWAMESSAGE - typealias Key = Int // o Int64 si prefieres + static let primaryKey = "ZMESSAGE" + typealias Key = Int - // MARK:‑ Stored properties + // MARK: - Stored Properties let messageId: Int64 let receiptInfo: Data? - // MARK:‑ Row → Struct + // MARK: - Row to Struct init(row: Row) { - // usa el helper Row.value(for:default:) propuesto messageId = row.value(for: "ZMESSAGE", default: 0) receiptInfo = row["ZRECEIPTINFO"] as? Data } diff --git a/Sources/SwiftWABackupAPI/ProfilePushName.swift b/Sources/SwiftWABackupAPI/ProfilePushName.swift index 0750956..355be38 100644 --- a/Sources/SwiftWABackupAPI/ProfilePushName.swift +++ b/Sources/SwiftWABackupAPI/ProfilePushName.swift @@ -2,7 +2,7 @@ // ProfilePushName.swift // SwiftWABackupAPI // -// Refactor: adopta GRDBSchemaCheckable + FetchableByID +// Refactor: adopts GRDBSchemaCheckable + FetchableByID // import GRDB @@ -12,7 +12,7 @@ struct ProfilePushName: FetchableByID { static let tableName = "ZWAPROFILEPUSHNAME" static let expectedColumns: Set = ["ZPUSHNAME", "ZJID"] static let primaryKey = "ZJID" - typealias Key = String // el PK es el JID (texto) + typealias Key = String // MARK: - Stored properties let jid: String @@ -25,10 +25,9 @@ struct ProfilePushName: FetchableByID { } } -// MARK: - Convenience API (opcional) -// Para mantener la firma “parecida” al método anterior. +// MARK: - Convenience API extension ProfilePushName { - /// Devuelve el push‑name o `nil` si no existe registro. + /// Returns the stored push name for a contact JID, if present. static func pushName(for contactJid: String, from db: Database) throws -> String? { try Self.fetch(by: contactJid, from: db)?.pushName diff --git a/Sources/SwiftWABackupAPI/ReactionParser.swift b/Sources/SwiftWABackupAPI/ReactionParser.swift index 6f7954a..ebf837e 100644 --- a/Sources/SwiftWABackupAPI/ReactionParser.swift +++ b/Sources/SwiftWABackupAPI/ReactionParser.swift @@ -11,8 +11,8 @@ import Foundation struct ReactionParser { - /// Convierte el blob `receiptInfo` en un array de `Reaction`. - /// Devuelve `nil` si no hay reacciones válidas. + /// Parses a WhatsApp `receiptInfo` blob into reactions. + /// Returns `nil` when no valid reactions can be extracted. static func parse(_ data: Data) -> [Reaction]? { var reactions: [Reaction] = [] let dataArray = [UInt8](data) diff --git a/Sources/SwiftWABackupAPI/Row+Helpers.swift b/Sources/SwiftWABackupAPI/Row+Helpers.swift index 95e6e51..43fd5ea 100644 --- a/Sources/SwiftWABackupAPI/Row+Helpers.swift +++ b/Sources/SwiftWABackupAPI/Row+Helpers.swift @@ -11,14 +11,14 @@ import GRDB public extension Row { - /// Devuelve el valor tipado de la columna o `defaultValue` si es `NULL` - /// o la columna no existe. + /// Returns the typed column value, or `defaultValue` when the column is `NULL` + /// or not present in the row. func value(for column: String, default defaultValue: T) -> T { return self[column] as? T ?? defaultValue } - /// Convierte automáticamente un timestamp (`Int`, `Int64` o `Double`) - /// al `Date` basado en `timeIntervalSinceReferenceDate`. + /// Converts a numeric timestamp (`Int`, `Int64`, or `Double`) into a `Date` + /// using `timeIntervalSinceReferenceDate`. func date(for column: String, default defaultDate: Date = Date(timeIntervalSinceReferenceDate: 0) ) -> Date { diff --git a/Sources/SwiftWABackupAPI/String+JidHelpers.swift b/Sources/SwiftWABackupAPI/String+JidHelpers.swift index 2e2ae16..cba8732 100644 --- a/Sources/SwiftWABackupAPI/String+JidHelpers.swift +++ b/Sources/SwiftWABackupAPI/String+JidHelpers.swift @@ -11,22 +11,22 @@ import Foundation public extension String { - /// Parte *user* antes de la “@”. + /// User portion before the `@`. var jidUser: String { components(separatedBy: "@").first ?? self } - /// Parte *domain* después de la “@”, en minúsculas. + /// Domain portion after the `@`, lowercased. var jidDomain: String { guard let idx = firstIndex(of: "@") else { return "" } let dom = self[index(after: idx)...] return dom.lowercased() } - /// `true` si es un chat de grupo (“…@g.us”). + /// Returns `true` for group chats (`...@g.us`). var isGroupJid: Bool { jidDomain == "g.us" } - /// `true` si es un chat individual (“…@s.whatsapp.net”). + /// Returns `true` for individual chats (`...@s.whatsapp.net`). var isIndividualJid: Bool { jidDomain == "s.whatsapp.net" } - /// Alias del helper existente para coherencia. + /// Convenience alias for the extracted JID user portion. var extractedPhone: String { jidUser } } diff --git a/Sources/SwiftWABackupAPI/SwiftWABackupAPI.swift b/Sources/SwiftWABackupAPI/SwiftWABackupAPI.swift index 7300ed3..9a7d4fc 100644 --- a/Sources/SwiftWABackupAPI/SwiftWABackupAPI.swift +++ b/Sources/SwiftWABackupAPI/SwiftWABackupAPI.swift @@ -6,30 +6,55 @@ // This module provides an API for accessing and processing WhatsApp databases // extracted from iOS backups. It includes functionality for reading chats, // messages, contacts, and associated media files. +// import Foundation import GRDB -/// Represents information about a WhatsApp chat. +/// Represents a WhatsApp chat returned by the public API. public struct ChatInfo: CustomStringConvertible, Encodable { - /// The type of chat (group or individual) + /// The supported chat categories exposed by the API. public enum ChatType: String, Codable { + /// A multi-participant WhatsApp group. case group + + /// A one-to-one WhatsApp conversation. case individual } + /// Stable identifier of the chat session in `ZWACHATSESSION`. public let id: Int + + /// Raw WhatsApp JID associated with the chat. public let contactJid: String + + /// Display name resolved for the chat. public let name: String + + /// Number of supported messages available through the API. public let numberMessages: Int + + /// Date of the latest supported message in the chat. public let lastMessageDate: Date + + /// The chat category derived from the contact JID. public let chatType: ChatType + + /// Indicates whether the chat is archived in WhatsApp. public let isArchived: Bool + + /// Copied profile image filename when `directoryToSavePhotos` is provided. public var photoFilename: String? - - /// Initializes a new `ChatInfo` instance. - init(id: Int, contactJid: String, name: String, - numberMessages: Int, lastMessageDate: Date, isArchived: Bool, photoFilename: String? = nil) { + + init( + id: Int, + contactJid: String, + name: String, + numberMessages: Int, + lastMessageDate: Date, + isArchived: Bool, + photoFilename: String? = nil + ) { self.id = id self.contactJid = contactJid self.name = name @@ -38,10 +63,10 @@ public struct ChatInfo: CustomStringConvertible, Encodable { self.isArchived = isArchived self.chatType = contactJid.isGroupJid ? .group : .individual self.photoFilename = photoFilename - } + } + /// A human-readable description intended for debugging. public var description: String { - // Provides a human-readable description of the chat. let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .medium @@ -53,16 +78,18 @@ public struct ChatInfo: CustomStringConvertible, Encodable { + "Chat Type - \(chatType.rawValue), " + "Is Archived - \(isArchived), " + "Photo Filename - \(photoFilename ?? "None")" - } + } } -/// Represents a reaction to a message. +/// Represents a reaction attached to a message. public struct Reaction: Encodable { + /// Emoji chosen by the reactor. public let emoji: String + + /// Phone number extracted from the reacting JID, or `"Me"` for the owner. public let senderPhone: String } -/// Supported message types in WhatsApp. enum SupportedMessageType: Int64, CaseIterable { case text = 0 case image = 1 @@ -77,7 +104,6 @@ enum SupportedMessageType: Int64, CaseIterable { case sticker = 15 var description: String { - // Provides a string representation of the message type. switch self { case .text: return "Text" case .image: return "Image" @@ -95,31 +121,62 @@ enum SupportedMessageType: Int64, CaseIterable { /// Returns all supported message types as an array of raw values. static var allValues: [Int64] { - return Self.allCases.map { $0.rawValue } + Self.allCases.map(\.rawValue) } } -/// Represents information about a WhatsApp message. +/// Represents a WhatsApp message returned by the public API. public struct MessageInfo: CustomStringConvertible, Encodable { + /// Stable identifier of the message in `ZWAMESSAGE`. public let id: Int + + /// Identifier of the parent chat session. public let chatId: Int + + /// Message text or event text resolved by the API. public let message: String? + + /// Message timestamp. public let date: Date + + /// Indicates whether the message was sent by the owner. public let isFromMe: Bool + + /// Human-readable message type name. public let messageType: String + + /// Resolved sender display name for incoming messages. public var senderName: String? + + /// Sender phone number resolved from the sender JID. public var senderPhone: String? + + /// Caption or title associated with linked media. public var caption: String? + + /// Identifier of the replied-to message when it can be resolved. public var replyTo: Int? + + /// Filename of copied media when export is requested. public var mediaFilename: String? + + /// Parsed reactions for this message. public var reactions: [Reaction]? + + /// Optional textual warning associated with media processing. public var error: String? + + /// Duration in seconds for audio and video messages. public var seconds: Int? + + /// Latitude for location messages. public var latitude: Double? + + /// Longitude for location messages. public var longitude: Double? + /// A human-readable description intended for debugging. public var description: String { - // Provides a human-readable description of the message. let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .medium @@ -132,605 +189,103 @@ public struct MessageInfo: CustomStringConvertible, Encodable { } } -/// Represents a contact's information. +/// Represents a contact returned alongside a chat export. public struct ContactInfo: CustomStringConvertible, Encodable, Hashable { + /// Resolved display name for the contact. public let name: String + + /// Phone number derived from the contact JID. public let phone: String + + /// Copied profile image filename when available. public var photoFilename: String? + /// A human-readable description intended for debugging. public var description: String { - return "Contact: Phone - \(phone), Name - \(name)" + "Contact: Phone - \(phone), Name - \(name)" } - // Hashable conformance to use in sets or as dictionary keys. + /// Hashes the contact by phone number so contact sets remain stable. public func hash(into hasher: inout Hasher) { hasher.combine(phone) } + /// Contacts are considered equal when they refer to the same phone number. public static func == (lhs: ContactInfo, rhs: ContactInfo) -> Bool { - return lhs.phone == rhs.phone + lhs.phone == rhs.phone } } +/// Legacy tuple returned by `getChat(chatId:directoryToSaveMedia:)`. public typealias ChatDump = (chatInfo: ChatInfo, messages: [MessageInfo], contacts: [ContactInfo]) -/// Protocol to notify delegate about media file operations. +/// Encodable wrapper around a full chat export. +public struct ChatDumpPayload: CustomStringConvertible, Encodable { + /// Chat metadata for the exported conversation. + public let chatInfo: ChatInfo + + /// Messages returned for the chat. + public let messages: [MessageInfo] + + /// Contacts resolved for the chat. + public let contacts: [ContactInfo] + + /// Creates a payload from its individual components. + public init(chatInfo: ChatInfo, messages: [MessageInfo], contacts: [ContactInfo]) { + self.chatInfo = chatInfo + self.messages = messages + self.contacts = contacts + } + + /// Creates a payload from the legacy `ChatDump` tuple. + public init(_ chatDump: ChatDump) { + self.init( + chatInfo: chatDump.chatInfo, + messages: chatDump.messages, + contacts: chatDump.contacts + ) + } + + /// A human-readable description intended for debugging. + public var description: String { + "ChatDumpPayload(chatId: \(chatInfo.id), messages: \(messages.count), contacts: \(contacts.count))" + } +} + +/// Receives callbacks when media files are copied to disk. public protocol WABackupDelegate: AnyObject { - /// Called when a media file has been written. + /// Called after a media file is copied or already exists in the output directory. func didWriteMediaFile(fileName: String) } -/// Extension to safely perform read operations on the database queue. extension DatabaseQueue { func performRead(_ block: (Database) throws -> T) throws -> T { do { - return try self.read(block) + return try read(block) } catch { throw DatabaseErrorWA.connection(error) } } } -/// Main class to interact with WhatsApp backups. +/// Main entry point for exploring a WhatsApp iPhone backup. public class WABackup { - var phoneBackup = BackupManager() - public weak var delegate: WABackupDelegate? - - private var chatDatabase: DatabaseQueue? - private var iPhoneBackup: IPhoneBackup? - private var ownerJid: String? - - private var mediaCopier: MediaCopier? - - /// Initializes the backup manager with an optional custom backup path. - public init(backupPath: String = "~/Library/Application Support/MobileSync/Backup/") { - self.phoneBackup = BackupManager(backupPath: backupPath) - } - - /// Retrieves available iPhone backups. - /// - Throws: `directoryAccessError` if the backup directory cannot be accessed. - public func getBackups() throws -> BackupFetchResult { - do { - return try phoneBackup.getBackups() - } catch { - throw BackupError.directoryAccess(error) - } - } - - /// Connects to the ChatStorage.sqlite database from an iPhone backup. - /// - Throws: An error if the ChatStorage.sqlite file is not found or the database cannot be connected. - public func connectChatStorageDb(from backup: IPhoneBackup) throws { - let chatStorageHash = try backup.fetchWAFileHash(endsWith: "ChatStorage.sqlite") - let chatStorageUrl = backup.getUrl(fileHash: chatStorageHash) - let dbQueue = try DatabaseQueue(path: chatStorageUrl.path) - - try checkSchema(of: dbQueue) - - self.chatDatabase = dbQueue - self.iPhoneBackup = backup - self.ownerJid = try dbQueue.performRead { try Message.fetchOwnerJid(from: $0) } - self.mediaCopier = MediaCopier(backup: backup, delegate: delegate) // ← NUEVO - } - - /// Validates the schema of the WhatsApp database. - /// - Throws: An error if the schema is unsupported. - private func checkSchema(of dbQueue: DatabaseQueue) throws { - do { - try dbQueue.performRead { db in - // Check the schema for each relevant table. - try Message.checkSchema(in: db) - try ChatSession.checkSchema(in: db) - try GroupMember.checkSchema(in: db) - try ProfilePushName.checkSchema(in: db) - try MediaItem.checkSchema(in: db) - try MessageInfoTable.checkSchema(in: db) - } - } catch { - throw DatabaseErrorWA.unsupportedSchema(reason: "Incorrect WA Database Schema") - } - } - -// MARK: - Chat-Related Methods - - /// Retrieves all chats from the connected WhatsApp database. - /// - Returns: An array of `ChatInfo` objects. - /// - Throws: An error if the database is not connected. - public func getChats(directoryToSavePhotos directory: URL? = nil) throws -> [ChatInfo] { - guard let dbQueue = chatDatabase, - let iPhoneBackup = iPhoneBackup else { - throw DatabaseErrorWA.connection(DatabaseError(message: "Database not connected")) - } + var phoneBackup: BackupManager - let chatInfos = try dbQueue.performRead { db -> [ChatInfo] in - let chatSessions = try ChatSession.fetchAllChats(from: db) - - return chatSessions.compactMap { chatSession -> ChatInfo? in - guard chatSession.sessionType != 5 else { - return nil - } - - var chatName = chatSession.partnerName - if let userJid = ownerJid, chatSession.contactJid == userJid { - chatName = "Me" - } - - var photoFilename: String? = nil - if let directory = directory { - photoFilename = try? fetchChatPhotoFilename( - for: chatSession.contactJid, - chatId: Int(chatSession.id), - to: directory, - from: iPhoneBackup - ) } - - return ChatInfo( - id: Int(chatSession.id), - contactJid: chatSession.contactJid, - name: chatName, - numberMessages: Int(chatSession.messageCounter), - lastMessageDate: chatSession.lastMessageDate, - isArchived: chatSession.isArchived, - photoFilename: photoFilename - ) - } + /// Delegate used to observe media export events. + public weak var delegate: WABackupDelegate? { + didSet { + mediaCopier?.delegate = delegate } - - return sortChatsByDate(chatInfos) } - - /// Sorts chats by their last message date in descending order. - private func sortChatsByDate(_ chats: [ChatInfo]) -> [ChatInfo] { - return chats.sorted { $0.lastMessageDate > $1.lastMessageDate } - } - -// MARK: - Message-Related Methods - - /// Retrieves messages for a specific chat. - /// - Parameters: - /// - chatId: The chat identifier. - /// - directory: Optional directory to save media files. - /// - Returns: An array of `MessageInfo` objects. - /// - Throws: An error if messages cannot be fetched or processed. - public func getChat(chatId: Int, directoryToSaveMedia directory: URL?) throws -> ChatDump { - guard let dbQueue = chatDatabase, - let iPhoneBackup = iPhoneBackup else { - throw DatabaseErrorWA.connection(DatabaseError(message: "Database or backup not found")) - } - let chatInfo = try fetchChatInfo(id: chatId, from: dbQueue) - let messages = try fetchMessagesFromDatabase(chatId: chatId, from: dbQueue) - - let processedMessages = try processMessages( - messages, - chatType: chatInfo.chatType, - directoryToSaveMedia: directory, - iPhoneBackup: iPhoneBackup, - from: dbQueue - ) + var chatDatabase: DatabaseQueue? + var iPhoneBackup: IPhoneBackup? + var ownerJid: String? + var mediaCopier: MediaCopier? - let contacts = try buildContactList( - for: chatInfo, - from: dbQueue, - iPhoneBackup: iPhoneBackup, - directory: directory - ) - - return (chatInfo, processedMessages, contacts) - } - - /// Fetches chat information by ID. - private func fetchChatInfo(id: Int, from dbQueue: DatabaseQueue) throws -> ChatInfo { - return try dbQueue.performRead { db in - let chatSession = try ChatSession.fetchChat(byId: id, from: db) - - return ChatInfo( - id: Int(chatSession.id), - contactJid: chatSession.contactJid, - name: chatSession.partnerName, - numberMessages: Int(chatSession.messageCounter), - lastMessageDate: chatSession.lastMessageDate, - isArchived: chatSession.isArchived) - } - } - - /// Fetches messages for a specific chat from the database. - private func fetchMessagesFromDatabase(chatId: Int, from dbQueue: DatabaseQueue) throws -> [Message] { - return try dbQueue.performRead { db in - return try Message.fetchMessages(forChatId: chatId, from: db) - } - } - - /// Processes messages to create `MessageInfo` objects. - private func processMessages( - _ messages: [Message], - chatType: ChatInfo.ChatType, - directoryToSaveMedia: URL?, - iPhoneBackup: IPhoneBackup, - from dbQueue: DatabaseQueue - ) throws -> [MessageInfo] { - var messagesInfo: [MessageInfo] = [] - try dbQueue.read { db in - for message in messages { - let messageInfo = try processSingleMessage( - message, - chatType: chatType, - directoryToSaveMedia: directoryToSaveMedia, - iPhoneBackup: iPhoneBackup, - from: db - ) - messagesInfo.append(messageInfo) - } - } - return messagesInfo - } - - /// Processes a single message to create a `MessageInfo` object. - private func processSingleMessage(_ message: Message, chatType: ChatInfo.ChatType, directoryToSaveMedia: URL?, iPhoneBackup: IPhoneBackup, from db: Database) throws -> MessageInfo { - guard let messageType = SupportedMessageType(rawValue: message.messageType) else { - throw DomainError.unexpected(reason: "Unsupported message type") - } - - let senderInfo = try fetchSenderInfo(for: message, chatType: chatType, from: db) - - let messageText = resolveMessageText(for: message, - messageType: messageType, - senderInfo: senderInfo) - - var messageInfo = MessageInfo( - id: Int(message.id), - chatId: Int(message.chatSessionId), - message: messageText, - date: message.date, - isFromMe: message.isFromMe, - messageType: messageType.description - ) - - // Fetch sender info if the message is not from the user. - if let senderInfo = senderInfo { - messageInfo.senderName = senderInfo.senderName - messageInfo.senderPhone = senderInfo.senderPhone - } - - // Handle replies. - if let replyMessageId = try fetchReplyMessageId(for: message, from: db) { - messageInfo.replyTo = Int(replyMessageId) - } - - // Handle media content. - if let mediaInfo = try handleMedia(for: message, directoryToSaveMedia: directoryToSaveMedia, iPhoneBackup: iPhoneBackup, from: db) { - messageInfo.mediaFilename = mediaInfo.mediaFilename - messageInfo.caption = mediaInfo.caption - messageInfo.seconds = mediaInfo.seconds - messageInfo.latitude = mediaInfo.latitude - messageInfo.longitude = mediaInfo.longitude - messageInfo.error = mediaInfo.error - } - - // Fetch reactions to the message. - messageInfo.reactions = try fetchReactions(forMessageId: Int(message.id), from: db) - - return messageInfo - } - - /// Sender name and sender phone - typealias SenderInfo = (senderName: String?, senderPhone: String?) - - /// Fetches sender information for a message. - private func fetchSenderInfo(for message: Message, chatType: ChatInfo.ChatType, from db: Database) throws -> SenderInfo? { - if message.isFromMe { - return nil - } - - switch chatType { - case .group: - if let memberId = message.groupMemberId { - return try fetchGroupMemberInfo(memberId: memberId, from: db) - } - case .individual: - return try fetchIndividualChatSenderInfo(chatSessionId: message.chatSessionId, from: db) - } - return nil - } - - /// Fetches the message ID that the current message is replying to. - private func fetchReplyMessageId(for message: Message, from db: Database) throws -> Int64? { - if let mediaItemId = message.mediaItemId, - let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), - let stanzaId = mediaItem.extractReplyStanzaId() { - return try Message.fetchMessageId(byStanzaId: stanzaId, from: db) - } - return nil - } - - /// Handles media content associated with a message. - private func handleMedia(for message: Message, directoryToSaveMedia: URL?, iPhoneBackup: IPhoneBackup, from db: Database) throws -> (mediaFilename: String?, caption: String?, seconds: Int?, latitude: Double?, longitude: Double?, error: String?)? { - guard let mediaItemId = message.mediaItemId else { return nil } - - var mediaFilename: String? - var caption: String? - var seconds: Int? - var latitude: Double? - var longitude: Double? - var error: String? - - // Fetch and copy media file if needed. - if let mediaResult = try fetchMediaFilename(forMediaItem: mediaItemId, from: iPhoneBackup, toDirectory: directoryToSaveMedia, from: db) { - switch mediaResult { - case .fileName(let fileName): - mediaFilename = fileName - case .error(let errMsg): - error = errMsg - } - } - - // Fetch caption if available. - caption = try fetchCaption(mediaItemId: mediaItemId, from: db) - - // Fetch duration for audio/video messages. - if let messageType = SupportedMessageType(rawValue: message.messageType), messageType == .video || messageType == .audio { - seconds = try fetchDuration(mediaItemId: mediaItemId, from: db) - } - - // Fetch location data for location messages. - if let messageType = SupportedMessageType(rawValue: message.messageType), messageType == .location { - (latitude, longitude) = try fetchLocation(mediaItemId: mediaItemId, from: db) - } - - return (mediaFilename, caption, seconds, latitude, longitude, error) - } - - private func fetchMediaFilename(forMediaItem mediaItemId: Int64, - from iPhoneBackup: IPhoneBackup, - toDirectory directoryURL: URL?, - from db: Database) throws -> MediaFilename? { - if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), - let mediaLocalPath = mediaItem.localPath, - let hashFile = try? iPhoneBackup.fetchWAFileHash(endsWith: mediaLocalPath) { - - let fileName = URL(fileURLWithPath: mediaLocalPath).lastPathComponent - try mediaCopier?.copy(hash: hashFile, - named: fileName, - to: directoryURL) - return .fileName(fileName) - } - return nil - } - - /// Fetches group member information by member ID. - private func fetchGroupMemberInfo(memberId: Int64, from db: Database) throws -> SenderInfo? { - if let groupMember = try GroupMember.fetchGroupMember(byId: memberId, from: db) { - return try obtainSenderInfo(jid: groupMember.memberJid, contactNameGroupMember: groupMember.contactName, from: db) - } - return nil - } - - /// Fetches sender information for individual chats. - private func fetchIndividualChatSenderInfo(chatSessionId: Int64, from db: Database) throws -> SenderInfo { - let chatSession = try ChatSession.fetchChat(byId: Int(chatSessionId), from: db) - let senderPhone = chatSession.contactJid.extractedPhone - let senderName = chatSession.partnerName - return (senderName, senderPhone) - } - - /// Fetches the duration of media content. - private func fetchDuration(mediaItemId: Int64, from db: Database) throws -> Int? { - if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), - let duration = mediaItem.movieDuration { - return Int(duration) - } - return nil - } - - /// Fetches reactions to a message. - private func fetchReactions(forMessageId messageId: Int, - from db: Database) throws -> [Reaction]? { - // Tras la refactorización, MessageInfoTable adoptó FetchableByID, - // por lo que el método adecuado es `fetch(by:from:)`. - if let messageInfo = try MessageInfoTable.fetch(by: messageId, from: db), - let reactionsData = messageInfo.receiptInfo { - return ReactionParser.parse(reactionsData) - } - return nil - } - - private func describeStatusSync(for message: Message, - senderInfo: SenderInfo?) -> String { - if message.isFromMe { - return "Status sync from me" - } - - if let name = senderInfo?.senderName?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty { - return "Status sync from \(name)" - } - - if let phone = senderInfo?.senderPhone, !phone.isEmpty { - return "Status sync from \(phone)" - } - - if let fromJid = message.fromJid, !fromJid.isEmpty { - if fromJid.isIndividualJid || fromJid.isGroupJid { - let identifier = fromJid.extractedPhone - if !identifier.isEmpty { - return "Status sync from \(identifier)" - } - } - return "Status sync from \(fromJid)" - } - - return "Status sync notification" - } - - private func resolveMessageText(for message: Message, - messageType: SupportedMessageType, - senderInfo: SenderInfo?) -> String? { - switch messageType { - case .status: - guard let eventType = message.groupEventType else { return message.text } - switch eventType { - case 38: - return "This is a business chat" - case 2: - if let current = message.text, - !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return current - } - return describeStatusSync(for: message, senderInfo: senderInfo) - default: - return message.text - } - default: - return message.text - } - } - - - - /// Fetches the caption for a media item. - private func fetchCaption(mediaItemId: Int64, from db: Database) throws -> String? { - if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), - let caption = mediaItem.title, !caption.isEmpty { - return caption - } - return nil - } - - /// Fetches location data for a media item. - private func fetchLocation(mediaItemId: Int64, from db: Database) throws -> (Double, Double) { - if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db) { - let latitude = mediaItem.latitude ?? 0.0 - let longitude = mediaItem.longitude ?? 0.0 - return (latitude, longitude) - } - return (0.0, 0.0) - } - - /// Enum representing the result of fetching a media filename. - enum MediaFilename { - case fileName(String) - case error(String) - } - - /// Obtains sender information based on the JID. - private func obtainSenderInfo(jid: String, - contactNameGroupMember: String?, - from db: Database) throws -> SenderInfo { - let senderPhone = jid.extractedPhone - if let senderName = try ChatSession.fetchChatSessionName(for: jid, from: db) { - return (senderName, senderPhone) - } else if let pushName = try ProfilePushName.pushName(for: jid, from: db) { - return ("~" + pushName, senderPhone) - } else { - return (contactNameGroupMember, senderPhone) - } - } - - /// Sorts messages by date in descending order. - private func sortMessagesByDate(_ messages: [MessageInfo]) -> [MessageInfo] { - return messages.sorted { $0.date > $1.date } - } - -// MARK: - Contact-Related Methods - - /// Devuelve el nombre de archivo de la foto del chat y lo copia al directorio indicado. - private func fetchChatPhotoFilename(for contactJid: String, - chatId: Int, - to directory: URL, - from backup: IPhoneBackup) throws -> String? { - - // 1. Construir la ruta base según tipo de JID - let basePath: String - if contactJid.isIndividualJid { - basePath = "Media/Profile/\(contactJid.extractedPhone)" - } else if contactJid.isGroupJid { - let groupId = contactJid.components(separatedBy: "@").first ?? contactJid - basePath = "Media/Profile/\(groupId)" - } else { - print("⚠️ ContactJid '\(contactJid)' has unsupported format. No image will be retrieved.") - return nil - } - - // 2. Localizar el fichero más reciente (.jpg o .thumb) - let files = backup.fetchWAFileDetails(contains: basePath) - guard let latest = FileUtils.latestFile(for: basePath, fileExtension: "jpg", in: files) - ?? FileUtils.latestFile(for: basePath, fileExtension: "thumb", in: files) else { - let type = contactJid.isGroupJid ? "Group" : "Individual" - print("📭 No image found for \(type) chat [ID: \(chatId), JID: \(contactJid)]") - return nil - } - - // 3. Nombre destino “chat_.ext” y copia mediante MediaCopier - let ext = latest.filename.hasSuffix(".jpg") ? ".jpg" : ".thumb" - let fileName = "chat_\(chatId)\(ext)" - - try mediaCopier?.copy(hash: latest.fileHash, - named: fileName, - to: directory) - - return fileName - } - - private func buildContactList(for chatInfo: ChatInfo, - from dbQueue: DatabaseQueue, - iPhoneBackup: IPhoneBackup, - directory: URL?) throws -> [ContactInfo] { - var contacts: [ContactInfo] = [] - - // Añadir el usuario (owner) - let ownerPhone: String = ownerJid?.extractedPhone ?? "" - var ownerContact = ContactInfo(name: "Me", phone: ownerPhone) - if let directory = directory { - ownerContact = try copyContactMedia(for: ownerContact, from: iPhoneBackup, to: directory) - } - contacts.append(ownerContact) - - try dbQueue.read { db in - switch chatInfo.chatType { - case .individual: - let otherPhone = chatInfo.contactJid.extractedPhone - if otherPhone != ownerPhone { - var otherContact = ContactInfo(name: chatInfo.name, phone: otherPhone) - if let directory = directory { - otherContact = try copyContactMedia(for: otherContact, from: iPhoneBackup, to: directory) - } - contacts.append(otherContact) - } - - case .group: - let memberIds = try GroupMember.fetchGroupMemberIds(forChatId: chatInfo.id, from: db) - for memberId in memberIds { - if let senderInfo = try fetchGroupMemberInfo(memberId: memberId, from: db), - let phone = senderInfo.senderPhone, - phone != ownerPhone { - var contact = ContactInfo(name: senderInfo.senderName ?? "", phone: phone) - if let directory = directory { - contact = try copyContactMedia(for: contact, from: iPhoneBackup, to: directory) - } - contacts.append(contact) - } - } - } - } - - return contacts - } - - /// Copies contact media files if available. - private func copyContactMedia(for contact: ContactInfo, - from iPhoneBackup: IPhoneBackup, - to directory: URL?) throws -> ContactInfo { - - var updated = contact - let prefix = "Media/Profile/\(contact.phone)" - let files = iPhoneBackup.fetchWAFileDetails(contains: prefix) - - let latest = FileUtils.latestFile(for: prefix, fileExtension: "jpg", in: files) - ?? FileUtils.latestFile(for: prefix, fileExtension: "thumb", in: files) - if let (fileName, hash) = latest { - let targetFileName = contact.phone + (fileName.hasSuffix(".jpg") ? ".jpg" : ".thumb") - try mediaCopier?.copy(hash: hash, named: targetFileName, to: directory) - updated.photoFilename = targetFileName - } - return updated + /// Creates a backup explorer rooted at the provided iPhone backup directory. + public init(backupPath: String = "~/Library/Application Support/MobileSync/Backup/") { + self.phoneBackup = BackupManager(backupPath: backupPath) } } diff --git a/Sources/SwiftWABackupAPI/WABackup+Chats.swift b/Sources/SwiftWABackupAPI/WABackup+Chats.swift new file mode 100644 index 0000000..085d5f5 --- /dev/null +++ b/Sources/SwiftWABackupAPI/WABackup+Chats.swift @@ -0,0 +1,96 @@ +// +// WABackup+Chats.swift +// SwiftWABackupAPI +// + +import Foundation +import GRDB + +public extension WABackup { + /// Retrieves all supported chats from the connected WhatsApp database. + func getChats(directoryToSavePhotos directory: URL? = nil) throws -> [ChatInfo] { + guard let dbQueue = chatDatabase, let iPhoneBackup = iPhoneBackup else { + throw DatabaseErrorWA.connection(DatabaseError(message: "Database not connected")) + } + + let chatInfos = try dbQueue.performRead { db -> [ChatInfo] in + let chatSessions = try ChatSession.fetchAllChats(from: db) + + return try chatSessions.compactMap { chatSession -> ChatInfo? in + guard chatSession.sessionType != 5 else { + return nil + } + + let chatName = resolvedChatName(for: chatSession) + let photoFilename: String? + + if let directory { + photoFilename = try fetchChatPhotoFilename( + for: chatSession.contactJid, + chatId: Int(chatSession.id), + to: directory, + from: iPhoneBackup + ) + } else { + photoFilename = nil + } + + return ChatInfo( + id: Int(chatSession.id), + contactJid: chatSession.contactJid, + name: chatName, + numberMessages: Int(chatSession.messageCounter), + lastMessageDate: chatSession.lastMessageDate, + isArchived: chatSession.isArchived, + photoFilename: photoFilename + ) + } + } + + return sortChatsByDate(chatInfos) + } +} + +extension WABackup { + func resolvedChatName(for chatSession: ChatSession) -> String { + if let ownerJid, chatSession.contactJid == ownerJid { + return "Me" + } + + return chatSession.partnerName + } + + func sortChatsByDate(_ chats: [ChatInfo]) -> [ChatInfo] { + chats.sorted { $0.lastMessageDate > $1.lastMessageDate } + } + + func fetchChatPhotoFilename( + for contactJid: String, + chatId: Int, + to directory: URL, + from backup: IPhoneBackup + ) throws -> String? { + let basePath: String + + if contactJid.isIndividualJid { + basePath = "Media/Profile/\(contactJid.extractedPhone)" + } else if contactJid.isGroupJid { + let groupId = contactJid.components(separatedBy: "@").first ?? contactJid + basePath = "Media/Profile/\(groupId)" + } else { + return nil + } + + let files = backup.fetchWAFileDetails(contains: basePath) + guard let latest = FileUtils.latestFile(for: basePath, fileExtension: "jpg", in: files) + ?? FileUtils.latestFile(for: basePath, fileExtension: "thumb", in: files) else { + return nil + } + + let ext = latest.filename.hasSuffix(".jpg") ? ".jpg" : ".thumb" + let fileName = "chat_\(chatId)\(ext)" + + try mediaCopier?.copy(hash: latest.fileHash, named: fileName, to: directory) + return fileName + } +} diff --git a/Sources/SwiftWABackupAPI/WABackup+Connection.swift b/Sources/SwiftWABackupAPI/WABackup+Connection.swift new file mode 100644 index 0000000..44d5adc --- /dev/null +++ b/Sources/SwiftWABackupAPI/WABackup+Connection.swift @@ -0,0 +1,49 @@ +// +// WABackup+Connection.swift +// SwiftWABackupAPI +// + +import Foundation +import GRDB + +public extension WABackup { + /// Discovers iPhone backups under the configured backup path. + func getBackups() throws -> BackupFetchResult { + do { + return try phoneBackup.getBackups() + } catch { + throw BackupError.directoryAccess(error) + } + } + + /// Connects the API to the WhatsApp `ChatStorage.sqlite` database contained in a backup. + func connectChatStorageDb(from backup: IPhoneBackup) throws { + let chatStorageHash = try backup.fetchWAFileHash(endsWith: "ChatStorage.sqlite") + let chatStorageUrl = backup.getUrl(fileHash: chatStorageHash) + let dbQueue = try DatabaseQueue(path: chatStorageUrl.path) + + try checkSchema(of: dbQueue) + + chatDatabase = dbQueue + iPhoneBackup = backup + ownerJid = try dbQueue.performRead { try Message.fetchOwnerJid(from: $0) } + mediaCopier = MediaCopier(backup: backup, delegate: delegate) + } +} + +extension WABackup { + func checkSchema(of dbQueue: DatabaseQueue) throws { + do { + try dbQueue.performRead { db in + try Message.checkSchema(in: db) + try ChatSession.checkSchema(in: db) + try GroupMember.checkSchema(in: db) + try ProfilePushName.checkSchema(in: db) + try MediaItem.checkSchema(in: db) + try MessageInfoTable.checkSchema(in: db) + } + } catch { + throw DatabaseErrorWA.unsupportedSchema(reason: "Incorrect WA Database Schema") + } + } +} diff --git a/Sources/SwiftWABackupAPI/WABackup+Contacts.swift b/Sources/SwiftWABackupAPI/WABackup+Contacts.swift new file mode 100644 index 0000000..7249979 --- /dev/null +++ b/Sources/SwiftWABackupAPI/WABackup+Contacts.swift @@ -0,0 +1,76 @@ +// +// WABackup+Contacts.swift +// SwiftWABackupAPI +// + +import Foundation +import GRDB + +extension WABackup { + func buildContactList( + for chatInfo: ChatInfo, + from dbQueue: DatabaseQueue, + iPhoneBackup: IPhoneBackup, + directory: URL? + ) throws -> [ContactInfo] { + var contacts: [ContactInfo] = [] + let ownerPhone = ownerJid?.extractedPhone ?? "" + + var ownerContact = ContactInfo(name: "Me", phone: ownerPhone) + if let directory { + ownerContact = try copyContactMedia(for: ownerContact, from: iPhoneBackup, to: directory) + } + contacts.append(ownerContact) + + try dbQueue.read { db in + switch chatInfo.chatType { + case .individual: + let otherPhone = chatInfo.contactJid.extractedPhone + if otherPhone != ownerPhone { + var otherContact = ContactInfo(name: chatInfo.name, phone: otherPhone) + if let directory { + otherContact = try copyContactMedia(for: otherContact, from: iPhoneBackup, to: directory) + } + contacts.append(otherContact) + } + + case .group: + let memberIds = try GroupMember.fetchGroupMemberIds(forChatId: chatInfo.id, from: db) + for memberId in memberIds { + if let senderInfo = try fetchGroupMemberInfo(memberId: memberId, from: db), + let phone = senderInfo.senderPhone, + phone != ownerPhone { + var contact = ContactInfo(name: senderInfo.senderName ?? "", phone: phone) + if let directory { + contact = try copyContactMedia(for: contact, from: iPhoneBackup, to: directory) + } + contacts.append(contact) + } + } + } + } + + return contacts + } + + func copyContactMedia( + for contact: ContactInfo, + from iPhoneBackup: IPhoneBackup, + to directory: URL? + ) throws -> ContactInfo { + var updated = contact + let prefix = "Media/Profile/\(contact.phone)" + let files = iPhoneBackup.fetchWAFileDetails(contains: prefix) + + let latest = FileUtils.latestFile(for: prefix, fileExtension: "jpg", in: files) + ?? FileUtils.latestFile(for: prefix, fileExtension: "thumb", in: files) + + if let (fileName, hash) = latest { + let targetFileName = contact.phone + (fileName.hasSuffix(".jpg") ? ".jpg" : ".thumb") + try mediaCopier?.copy(hash: hash, named: targetFileName, to: directory) + updated.photoFilename = targetFileName + } + + return updated + } +} diff --git a/Sources/SwiftWABackupAPI/WABackup+Messages.swift b/Sources/SwiftWABackupAPI/WABackup+Messages.swift new file mode 100644 index 0000000..a93d65b --- /dev/null +++ b/Sources/SwiftWABackupAPI/WABackup+Messages.swift @@ -0,0 +1,373 @@ +// +// WABackup+Messages.swift +// SwiftWABackupAPI +// + +import Foundation +import GRDB + +public extension WABackup { + /// Retrieves a full chat export using the legacy tuple payload. + func getChat(chatId: Int, directoryToSaveMedia directory: URL?) throws -> ChatDump { + guard let dbQueue = chatDatabase, let iPhoneBackup = iPhoneBackup else { + throw DatabaseErrorWA.connection(DatabaseError(message: "Database or backup not found")) + } + + let chatInfo = try fetchChatInfo(id: chatId, from: dbQueue) + let messages = try fetchMessagesFromDatabase(chatId: chatId, from: dbQueue) + let processedMessages = try processMessages( + messages, + chatType: chatInfo.chatType, + directoryToSaveMedia: directory, + iPhoneBackup: iPhoneBackup, + from: dbQueue + ) + let contacts = try buildContactList( + for: chatInfo, + from: dbQueue, + iPhoneBackup: iPhoneBackup, + directory: directory + ) + + return (chatInfo, processedMessages, contacts) + } + + /// Retrieves a full chat export wrapped in an `Encodable` payload. + func getChatPayload(chatId: Int, directoryToSaveMedia directory: URL?) throws -> ChatDumpPayload { + try ChatDumpPayload(getChat(chatId: chatId, directoryToSaveMedia: directory)) + } +} + +extension WABackup { + func fetchChatInfo(id: Int, from dbQueue: DatabaseQueue) throws -> ChatInfo { + try dbQueue.performRead { db in + let chatSession = try ChatSession.fetchChat(byId: id, from: db) + + return ChatInfo( + id: Int(chatSession.id), + contactJid: chatSession.contactJid, + name: resolvedChatName(for: chatSession), + numberMessages: Int(chatSession.messageCounter), + lastMessageDate: chatSession.lastMessageDate, + isArchived: chatSession.isArchived + ) + } + } + + func fetchMessagesFromDatabase(chatId: Int, from dbQueue: DatabaseQueue) throws -> [Message] { + try dbQueue.performRead { db in + try Message.fetchMessages(forChatId: chatId, from: db) + } + } + + func processMessages( + _ messages: [Message], + chatType: ChatInfo.ChatType, + directoryToSaveMedia: URL?, + iPhoneBackup: IPhoneBackup, + from dbQueue: DatabaseQueue + ) throws -> [MessageInfo] { + var messagesInfo: [MessageInfo] = [] + + try dbQueue.read { db in + for message in messages { + let messageInfo = try processSingleMessage( + message, + chatType: chatType, + directoryToSaveMedia: directoryToSaveMedia, + iPhoneBackup: iPhoneBackup, + from: db + ) + messagesInfo.append(messageInfo) + } + } + + return messagesInfo + } + + func processSingleMessage( + _ message: Message, + chatType: ChatInfo.ChatType, + directoryToSaveMedia: URL?, + iPhoneBackup: IPhoneBackup, + from db: Database + ) throws -> MessageInfo { + guard let messageType = SupportedMessageType(rawValue: message.messageType) else { + throw DomainError.unexpected(reason: "Unsupported message type") + } + + let senderInfo = try fetchSenderInfo(for: message, chatType: chatType, from: db) + let messageText = resolveMessageText( + for: message, + messageType: messageType, + senderInfo: senderInfo + ) + + var messageInfo = MessageInfo( + id: Int(message.id), + chatId: Int(message.chatSessionId), + message: messageText, + date: message.date, + isFromMe: message.isFromMe, + messageType: messageType.description + ) + + if let senderInfo { + messageInfo.senderName = senderInfo.senderName + messageInfo.senderPhone = senderInfo.senderPhone + } + + if let replyMessageId = try fetchReplyMessageId(for: message, from: db) { + messageInfo.replyTo = Int(replyMessageId) + } + + if let mediaInfo = try handleMedia( + for: message, + directoryToSaveMedia: directoryToSaveMedia, + iPhoneBackup: iPhoneBackup, + from: db + ) { + messageInfo.mediaFilename = mediaInfo.mediaFilename + messageInfo.caption = mediaInfo.caption + messageInfo.seconds = mediaInfo.seconds + messageInfo.latitude = mediaInfo.latitude + messageInfo.longitude = mediaInfo.longitude + messageInfo.error = mediaInfo.error + } + + messageInfo.reactions = try fetchReactions(forMessageId: Int(message.id), from: db) + return messageInfo + } + + func fetchSenderInfo( + for message: Message, + chatType: ChatInfo.ChatType, + from db: Database + ) throws -> (senderName: String?, senderPhone: String?)? { + if message.isFromMe { + return nil + } + + switch chatType { + case .group: + if let memberId = message.groupMemberId { + return try fetchGroupMemberInfo(memberId: memberId, from: db) + } + case .individual: + return try fetchIndividualChatSenderInfo(chatSessionId: message.chatSessionId, from: db) + } + + return nil + } + + func fetchReplyMessageId(for message: Message, from db: Database) throws -> Int64? { + if let mediaItemId = message.mediaItemId, + let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), + let stanzaId = mediaItem.extractReplyStanzaId() { + return try Message.fetchMessageId(byStanzaId: stanzaId, from: db) + } + + return nil + } + + func handleMedia( + for message: Message, + directoryToSaveMedia: URL?, + iPhoneBackup: IPhoneBackup, + from db: Database + ) throws -> ( + mediaFilename: String?, + caption: String?, + seconds: Int?, + latitude: Double?, + longitude: Double?, + error: String? + )? { + guard let mediaItemId = message.mediaItemId else { + return nil + } + + let mediaFilename = try fetchMediaFilename( + forMediaItem: mediaItemId, + from: iPhoneBackup, + toDirectory: directoryToSaveMedia, + from: db + ) + let caption = try fetchCaption(mediaItemId: mediaItemId, from: db) + + let seconds: Int? + let latitude: Double? + let longitude: Double? + + if let messageType = SupportedMessageType(rawValue: message.messageType), + messageType == .video || messageType == .audio { + seconds = try fetchDuration(mediaItemId: mediaItemId, from: db) + } else { + seconds = nil + } + + if let messageType = SupportedMessageType(rawValue: message.messageType), + messageType == .location { + let location = try fetchLocation(mediaItemId: mediaItemId, from: db) + latitude = location.0 + longitude = location.1 + } else { + latitude = nil + longitude = nil + } + + return (mediaFilename, caption, seconds, latitude, longitude, nil) + } + + func fetchMediaFilename( + forMediaItem mediaItemId: Int64, + from iPhoneBackup: IPhoneBackup, + toDirectory directoryURL: URL?, + from db: Database + ) throws -> String? { + if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), + let mediaLocalPath = mediaItem.localPath, + let hashFile = try? iPhoneBackup.fetchWAFileHash(endsWith: mediaLocalPath) { + let fileName = URL(fileURLWithPath: mediaLocalPath).lastPathComponent + try mediaCopier?.copy(hash: hashFile, named: fileName, to: directoryURL) + return fileName + } + + return nil + } + + func fetchGroupMemberInfo( + memberId: Int64, + from db: Database + ) throws -> (senderName: String?, senderPhone: String?)? { + if let groupMember = try GroupMember.fetchGroupMember(byId: memberId, from: db) { + return try obtainSenderInfo( + jid: groupMember.memberJid, + contactNameGroupMember: groupMember.contactName, + from: db + ) + } + + return nil + } + + func fetchIndividualChatSenderInfo( + chatSessionId: Int64, + from db: Database + ) throws -> (senderName: String?, senderPhone: String?) { + let chatSession = try ChatSession.fetchChat(byId: Int(chatSessionId), from: db) + return (chatSession.partnerName, chatSession.contactJid.extractedPhone) + } + + func fetchDuration(mediaItemId: Int64, from db: Database) throws -> Int? { + if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), + let duration = mediaItem.movieDuration { + return Int(duration) + } + + return nil + } + + func fetchReactions(forMessageId messageId: Int, from db: Database) throws -> [Reaction]? { + if let messageInfo = try MessageInfoTable.fetch(by: messageId, from: db), + let reactionsData = messageInfo.receiptInfo { + return ReactionParser.parse(reactionsData) + } + + return nil + } + + func describeStatusSync( + for message: Message, + senderInfo: (senderName: String?, senderPhone: String?)? + ) -> String { + if message.isFromMe { + return "Status sync from me" + } + + if let name = senderInfo?.senderName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return "Status sync from \(name)" + } + + if let phone = senderInfo?.senderPhone, !phone.isEmpty { + return "Status sync from \(phone)" + } + + if let fromJid = message.fromJid, !fromJid.isEmpty { + if fromJid.isIndividualJid || fromJid.isGroupJid { + let identifier = fromJid.extractedPhone + if !identifier.isEmpty { + return "Status sync from \(identifier)" + } + } + + return "Status sync from \(fromJid)" + } + + return "Status sync notification" + } + + func resolveMessageText( + for message: Message, + messageType: SupportedMessageType, + senderInfo: (senderName: String?, senderPhone: String?)? + ) -> String? { + switch messageType { + case .status: + guard let eventType = message.groupEventType else { + return message.text + } + + switch eventType { + case 38: + return "This is a business chat" + case 2: + if let current = message.text, + !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return current + } + + return describeStatusSync(for: message, senderInfo: senderInfo) + default: + return message.text + } + default: + return message.text + } + } + + func fetchCaption(mediaItemId: Int64, from db: Database) throws -> String? { + if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db), + let caption = mediaItem.title, + !caption.isEmpty { + return caption + } + + return nil + } + + func fetchLocation(mediaItemId: Int64, from db: Database) throws -> (Double, Double) { + if let mediaItem = try MediaItem.fetchMediaItem(byId: mediaItemId, from: db) { + return (mediaItem.latitude ?? 0.0, mediaItem.longitude ?? 0.0) + } + + return (0.0, 0.0) + } + + func obtainSenderInfo( + jid: String, + contactNameGroupMember: String?, + from db: Database + ) throws -> (senderName: String?, senderPhone: String?) { + let senderPhone = jid.extractedPhone + + if let senderName = try ChatSession.fetchChatSessionName(for: jid, from: db) { + return (senderName, senderPhone) + } else if let pushName = try ProfilePushName.pushName(for: jid, from: db) { + return ("~" + pushName, senderPhone) + } else { + return (contactNameGroupMember, senderPhone) + } + } +} diff --git a/Tests/Data/JSONContract/chat_dump_payload.json b/Tests/Data/JSONContract/chat_dump_payload.json new file mode 100644 index 0000000..fc1475b --- /dev/null +++ b/Tests/Data/JSONContract/chat_dump_payload.json @@ -0,0 +1,38 @@ +{ + "chatInfo" : { + "chatType" : "individual", + "contactJid" : "34636104084@s.whatsapp.net", + "id" : 44, + "isArchived" : false, + "lastMessageDate" : "2024-04-03T11:24:16Z", + "name" : "Aitor Medrano", + "numberMessages" : 1, + "photoFilename" : "chat_44.jpg" + }, + "contacts" : [ + { + "name" : "Aitor Medrano", + "phone" : "34636104084", + "photoFilename" : "34636104084.jpg" + } + ], + "messages" : [ + { + "chatId" : 44, + "date" : "2024-04-03T11:24:16Z", + "id" : 125482, + "isFromMe" : false, + "message" : "Claro, cada vez que vaya a la UA te aviso.", + "messageType" : "Text", + "reactions" : [ + { + "emoji" : "👍", + "senderPhone" : "Me" + } + ], + "replyTo" : 125479, + "senderName" : "Aitor Medrano", + "senderPhone" : "34636104084" + } + ] +} diff --git a/Tests/Data/JSONContract/chat_info.json b/Tests/Data/JSONContract/chat_info.json new file mode 100644 index 0000000..8a758bf --- /dev/null +++ b/Tests/Data/JSONContract/chat_info.json @@ -0,0 +1,10 @@ +{ + "chatType" : "individual", + "contactJid" : "34636104084@s.whatsapp.net", + "id" : 44, + "isArchived" : false, + "lastMessageDate" : "2024-04-03T11:24:16Z", + "name" : "Aitor Medrano", + "numberMessages" : 153, + "photoFilename" : "chat_44.jpg" +} diff --git a/Tests/Data/JSONContract/contact_info.json b/Tests/Data/JSONContract/contact_info.json new file mode 100644 index 0000000..5ce7e32 --- /dev/null +++ b/Tests/Data/JSONContract/contact_info.json @@ -0,0 +1,5 @@ +{ + "name" : "Aitor Medrano", + "phone" : "34636104084", + "photoFilename" : "34636104084.jpg" +} diff --git a/Tests/Data/JSONContract/message_info.json b/Tests/Data/JSONContract/message_info.json new file mode 100644 index 0000000..4e01852 --- /dev/null +++ b/Tests/Data/JSONContract/message_info.json @@ -0,0 +1,22 @@ +{ + "caption" : "Example caption", + "chatId" : 44, + "date" : "2024-04-03T11:24:16Z", + "id" : 125482, + "isFromMe" : false, + "latitude" : 38.3456, + "longitude" : -0.4815, + "mediaFilename" : "example.jpg", + "message" : "Claro, cada vez que vaya a la UA te aviso.", + "messageType" : "Text", + "reactions" : [ + { + "emoji" : "👍", + "senderPhone" : "Me" + } + ], + "replyTo" : 125479, + "seconds" : 12, + "senderName" : "Aitor Medrano", + "senderPhone" : "34636104084" +} diff --git a/Tests/Data/JSONContract/reaction.json b/Tests/Data/JSONContract/reaction.json new file mode 100644 index 0000000..27b26e6 --- /dev/null +++ b/Tests/Data/JSONContract/reaction.json @@ -0,0 +1,4 @@ +{ + "emoji" : "👍", + "senderPhone" : "34636104084" +} diff --git a/Tests/SwiftWABackupAPITests/BasicIntegrationTests.swift b/Tests/SwiftWABackupAPITests/BasicIntegrationTests.swift new file mode 100644 index 0000000..0127e72 --- /dev/null +++ b/Tests/SwiftWABackupAPITests/BasicIntegrationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import XCTest +@testable import SwiftWABackupAPI + +final class BackupDiscoveryTests: XCTestCase { + func testBackupDiscoveryFindsGeneratedBackup() throws { + let fixture = try TestSupport.makeSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let waBackup = WABackup(backupPath: fixture.rootURL.path) + let backups = try waBackup.getBackups() + + XCTAssertEqual(backups.validBackups.count, 1, "Expected exactly one generated valid backup") + XCTAssertTrue(backups.invalidBackups.isEmpty) + XCTAssertEqual(backups.validBackups[0].identifier, fixture.backup.identifier) + XCTAssertEqual( + URL(fileURLWithPath: backups.validBackups[0].path).standardizedFileURL.path, + URL(fileURLWithPath: fixture.backup.path).standardizedFileURL.path + ) + } + + func testConnectChatStorageDatabase() throws { + let fixture = try TestSupport.makeSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let waBackup = WABackup(backupPath: fixture.rootURL.path) + + XCTAssertNoThrow(try waBackup.connectChatStorageDb(from: fixture.backup)) + } +} + +final class ChatSmokeTests: XCTestCase { + func testGetChatsReturnsExpectedCounts() throws { + let (waBackup, fixture) = try TestSupport.makeConnectedSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let chats = try waBackup.getChats() + + XCTAssertEqual(chats.count, 2, "Expected the generated sample backup to expose two chats") + XCTAssertEqual(chats.filter { !$0.isArchived }.count, 2) + XCTAssertEqual(chats.filter(\.isArchived).count, 0) + XCTAssertEqual(chats.map(\.id), [44, 593]) + XCTAssertEqual(Set(chats.map(\.name)), ["Aitor Medrano", "Business Contact"]) + XCTAssertEqual(chats.first?.numberMessages, 3) + } + + func testBusinessChatStatusMessageIsNormalized() throws { + let (waBackup, fixture) = try TestSupport.makeConnectedSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let chatDump = try waBackup.getChat(chatId: 593, directoryToSaveMedia: nil) + + XCTAssertTrue( + chatDump.messages.contains(where: { $0.message == "This is a business chat" }), + "Expected at least one normalized business-chat status message" + ) + } + + func testGetChatPayloadWrapsLegacyChatDump() throws { + let (waBackup, fixture) = try TestSupport.makeConnectedSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let legacyDump = try waBackup.getChat(chatId: 44, directoryToSaveMedia: nil) + let payload = try waBackup.getChatPayload(chatId: 44, directoryToSaveMedia: nil) + + XCTAssertEqual(payload.chatInfo.id, legacyDump.chatInfo.id) + XCTAssertEqual(payload.chatInfo.name, legacyDump.chatInfo.name) + XCTAssertEqual(payload.messages.count, legacyDump.messages.count) + XCTAssertEqual(payload.contacts.count, legacyDump.contacts.count) + } + + func testKnownReplyIsResolved() throws { + let (waBackup, fixture) = try TestSupport.makeConnectedSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let chatDump = try waBackup.getChat(chatId: 44, directoryToSaveMedia: nil) + let knownReply = try XCTUnwrap(chatDump.messages.first(where: { $0.id == 125482 })) + + XCTAssertEqual(knownReply.replyTo, 125479) + XCTAssertEqual(knownReply.senderPhone, "34636104084") + } +} + +final class MediaExportSmokeTests: XCTestCase { + func testMediaExportNotifiesDelegateSetAfterConnecting() throws { + let (waBackup, fixture) = try TestSupport.makeConnectedSampleBackup() + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let delegate = MediaWriteDelegateSpy() + let temporaryDirectory = try TestSupport.makeTemporaryDirectory(prefix: "SwiftWABackupAPI-media-export") + defer { try? TestSupport.removeItemIfExists(at: temporaryDirectory) } + + waBackup.delegate = delegate + _ = try waBackup.getChat(chatId: 44, directoryToSaveMedia: temporaryDirectory) + + XCTAssertFalse(delegate.fileNames.isEmpty, "Expected at least one media export callback") + XCTAssertTrue( + delegate.fileNames.contains("fea35851-6a2c-45a3-a784-003d25576b45.pdf"), + "Expected the known document export to be reported by the delegate" + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: temporaryDirectory.appendingPathComponent("fea35851-6a2c-45a3-a784-003d25576b45.pdf").path + ) + ) + } +} diff --git a/Tests/SwiftWABackupAPITests/ErrorHandlingTests.swift b/Tests/SwiftWABackupAPITests/ErrorHandlingTests.swift new file mode 100644 index 0000000..e669974 --- /dev/null +++ b/Tests/SwiftWABackupAPITests/ErrorHandlingTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +@testable import SwiftWABackupAPI + +final class ErrorHandlingTests: XCTestCase { + func testGetBackupsThrowsForMissingRootDirectory() { + let waBackup = WABackup(backupPath: "/tmp/SwiftWABackupAPI/non-existent-\(UUID().uuidString)") + + XCTAssertThrowsError(try waBackup.getBackups()) { error in + guard case BackupError.directoryAccess = error else { + return XCTFail("Expected BackupError.directoryAccess, got \(error)") + } + } + } + + func testGetBackupsReportsIncompleteBackupAsInvalid() throws { + let rootURL = try TestSupport.makeTemporaryDirectory(prefix: "SwiftWABackupAPI-invalid-backup") + defer { try? TestSupport.removeItemIfExists(at: rootURL) } + + let backupURL = rootURL.appendingPathComponent("incomplete-backup", isDirectory: true) + try FileManager.default.createDirectory(at: backupURL, withIntermediateDirectories: true) + try Data().write(to: backupURL.appendingPathComponent("Info.plist")) + + let waBackup = WABackup(backupPath: rootURL.path) + let backups = try waBackup.getBackups() + + XCTAssertTrue(backups.validBackups.isEmpty) + XCTAssertEqual( + backups.invalidBackups.map { $0.standardizedFileURL.path }, + [backupURL.standardizedFileURL.path] + ) + } + + func testGetChatsFailsWhenDatabaseIsNotConnected() { + let waBackup = WABackup(backupPath: FileManager.default.temporaryDirectory.path) + + XCTAssertThrowsError(try waBackup.getChats()) { error in + guard case DatabaseErrorWA.connection = error else { + return XCTFail("Expected DatabaseErrorWA.connection, got \(error)") + } + } + } + + func testGetChatFailsWhenDatabaseIsNotConnected() { + let waBackup = WABackup(backupPath: FileManager.default.temporaryDirectory.path) + + XCTAssertThrowsError(try waBackup.getChat(chatId: 44, directoryToSaveMedia: nil)) { error in + guard case DatabaseErrorWA.connection = error else { + return XCTFail("Expected DatabaseErrorWA.connection, got \(error)") + } + } + } + + func testConnectChatStorageDbRejectsUnsupportedSchema() throws { + let fixture = try TestSupport.makeTemporaryBackup { db in + try db.execute(sql: "CREATE TABLE NotWhatsApp (id INTEGER PRIMARY KEY)") + } + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + let waBackup = WABackup(backupPath: fixture.rootURL.path) + + XCTAssertThrowsError(try waBackup.connectChatStorageDb(from: fixture.backup)) { error in + guard case DatabaseErrorWA.unsupportedSchema = error else { + return XCTFail("Expected DatabaseErrorWA.unsupportedSchema, got \(error)") + } + } + } + + func testFetchWAFileHashThrowsWhenMediaIsMissing() throws { + let fixture = try TestSupport.makeTemporaryBackup { _ in } + defer { try? TestSupport.removeItemIfExists(at: fixture.rootURL) } + + XCTAssertThrowsError(try fixture.backup.fetchWAFileHash(endsWith: "Media/DefinitelyMissing/nope.bin")) { error in + guard case DatabaseErrorWA.connection(let underlying) = error else { + return XCTFail("Expected DatabaseErrorWA.connection, got \(error)") + } + + guard case DomainError.mediaNotFound(let path) = underlying else { + return XCTFail("Expected underlying DomainError.mediaNotFound, got \(underlying)") + } + + XCTAssertEqual(path, "Media/DefinitelyMissing/nope.bin") + } + } +} diff --git a/Tests/SwiftWABackupAPITests/InternalHelperTests.swift b/Tests/SwiftWABackupAPITests/InternalHelperTests.swift new file mode 100644 index 0000000..5923afc --- /dev/null +++ b/Tests/SwiftWABackupAPITests/InternalHelperTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import SwiftWABackupAPI +import GRDB + +final class InternalHelperTests: XCTestCase { + func testJidHelpersDetectSupportedFormats() { + XCTAssertEqual("34600111222@s.whatsapp.net".jidUser, "34600111222") + XCTAssertEqual("34600111222@s.whatsapp.net".jidDomain, "s.whatsapp.net") + XCTAssertTrue("34600111222@s.whatsapp.net".isIndividualJid) + XCTAssertFalse("34600111222@s.whatsapp.net".isGroupJid) + XCTAssertTrue("34600111222-123456@g.us".isGroupJid) + XCTAssertEqual("34600111222-123456@g.us".extractedPhone, "34600111222-123456") + } + + func testQuestionMarksProducesSQLPlaceholderList() { + XCTAssertEqual(1.questionMarks, "?") + XCTAssertEqual(3.questionMarks, "?, ?, ?") + XCTAssertEqual(0.questionMarks, "") + } + + func testLatestFileReturnsHighestTimestampMatch() { + let files: [FilenameAndHash] = [ + ("Media/Profile/123-100.jpg", "hash-old"), + ("Media/Profile/123-250.jpg", "hash-new"), + ("Media/Profile/123-150.jpg", "hash-mid") + ] + + let latest = FileUtils.latestFile(for: "Media/Profile/123", fileExtension: "jpg", in: files) + + XCTAssertEqual(latest?.filename, "Media/Profile/123-250.jpg") + XCTAssertEqual(latest?.fileHash, "hash-new") + } + + func testCheckTableSchemaRejectsMissingColumns() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try db.execute(sql: "CREATE TABLE Demo (id INTEGER PRIMARY KEY, name TEXT)") + } + + XCTAssertThrowsError(try dbQueue.read { db in + try checkTableSchema(tableName: "Demo", expectedColumns: ["ID", "MISSING"], in: db) + }) { error in + guard case DatabaseErrorWA.unsupportedSchema = error else { + return XCTFail("Expected DatabaseErrorWA.unsupportedSchema, got \(error)") + } + } + } + + func testReactionParserParsesKnownFixtureReaction() throws { + let receiptInfo = TestSupport.sampleReactionReceiptInfo(emoji: "😢", senderPhone: "34636104084") + let reactions = ReactionParser.parse(receiptInfo) + + XCTAssertEqual(reactions?.count, 1) + XCTAssertEqual(reactions?.first?.emoji, "😢") + XCTAssertEqual(reactions?.first?.senderPhone, "34636104084") + } +} diff --git a/Tests/SwiftWABackupAPITests/JSONContractTests.swift b/Tests/SwiftWABackupAPITests/JSONContractTests.swift new file mode 100644 index 0000000..40bad3e --- /dev/null +++ b/Tests/SwiftWABackupAPITests/JSONContractTests.swift @@ -0,0 +1,94 @@ +import Foundation +import XCTest +@testable import SwiftWABackupAPI + +final class JSONContractTests: XCTestCase { + func testReactionJSONSnapshot() throws { + let reaction = Reaction(emoji: "👍", senderPhone: "34636104084") + let json = try TestSupport.canonicalJSONString(reaction) + + XCTAssertEqual(json, try TestSupport.loadFixture(named: "JSONContract/reaction.json")) + } + + func testChatInfoJSONSnapshot() throws { + let date = Date(timeIntervalSince1970: 1_712_143_456) + let chatInfo = ChatInfo( + id: 44, + contactJid: "34636104084@s.whatsapp.net", + name: "Aitor Medrano", + numberMessages: 153, + lastMessageDate: date, + isArchived: false, + photoFilename: "chat_44.jpg" + ) + + let json = try TestSupport.canonicalJSONString(chatInfo) + XCTAssertEqual(json, try TestSupport.loadFixture(named: "JSONContract/chat_info.json")) + } + + func testMessageInfoJSONSnapshot() throws { + let date = Date(timeIntervalSince1970: 1_712_143_456) + var messageInfo = MessageInfo( + id: 125482, + chatId: 44, + message: "Claro, cada vez que vaya a la UA te aviso.", + date: date, + isFromMe: false, + messageType: "Text" + ) + messageInfo.senderName = "Aitor Medrano" + messageInfo.senderPhone = "34636104084" + messageInfo.caption = "Example caption" + messageInfo.replyTo = 125479 + messageInfo.mediaFilename = "example.jpg" + messageInfo.reactions = [Reaction(emoji: "👍", senderPhone: "Me")] + messageInfo.seconds = 12 + messageInfo.latitude = 38.3456 + messageInfo.longitude = -0.4815 + + let json = try TestSupport.canonicalJSONString(messageInfo) + XCTAssertEqual(json, try TestSupport.loadFixture(named: "JSONContract/message_info.json")) + } + + func testContactInfoJSONSnapshot() throws { + let contact = ContactInfo(name: "Aitor Medrano", phone: "34636104084", photoFilename: "34636104084.jpg") + let json = try TestSupport.canonicalJSONString(contact) + + XCTAssertEqual(json, try TestSupport.loadFixture(named: "JSONContract/contact_info.json")) + } + + func testChatDumpPayloadJSONSnapshot() throws { + let date = Date(timeIntervalSince1970: 1_712_143_456) + let chatInfo = ChatInfo( + id: 44, + contactJid: "34636104084@s.whatsapp.net", + name: "Aitor Medrano", + numberMessages: 1, + lastMessageDate: date, + isArchived: false, + photoFilename: "chat_44.jpg" + ) + + var messageInfo = MessageInfo( + id: 125482, + chatId: 44, + message: "Claro, cada vez que vaya a la UA te aviso.", + date: date, + isFromMe: false, + messageType: "Text" + ) + messageInfo.senderName = "Aitor Medrano" + messageInfo.senderPhone = "34636104084" + messageInfo.replyTo = 125479 + messageInfo.reactions = [Reaction(emoji: "👍", senderPhone: "Me")] + + let payload = ChatDumpPayload( + chatInfo: chatInfo, + messages: [messageInfo], + contacts: [ContactInfo(name: "Aitor Medrano", phone: "34636104084", photoFilename: "34636104084.jpg")] + ) + + let json = try TestSupport.canonicalJSONString(payload) + XCTAssertEqual(json, try TestSupport.loadFixture(named: "JSONContract/chat_dump_payload.json")) + } +} diff --git a/Tests/SwiftWABackupAPITests/SwiftWABackupAPITests.swift b/Tests/SwiftWABackupAPITests/SwiftWABackupAPITests.swift new file mode 100644 index 0000000..bd60b1e --- /dev/null +++ b/Tests/SwiftWABackupAPITests/SwiftWABackupAPITests.swift @@ -0,0 +1,3763 @@ +import XCTest +@testable import SwiftWABackupAPI + +struct ExpectedMessage { + let id: Int + let chatId: Int + let messageType: String + let isFromMe: Bool + let message: String? + let senderName: String? + let senderPhone: String? + let caption: String? + let mediaFilename: String? + let replyTo: Int? + let reactions: [Reaction]? +} + +final class FixtureRegressionTests: XCTestCase { + override func setUpWithError() throws { + try TestSupport.requireFullFixtureRun() + } + + func testChatNames() throws { + let chatNames = ["+34 609 43 60 10", "Jose Bonora", "+34 644 40 75 59", + "Pilar - Lucía López", "+34 607 37 19 32", "+34 691 99 12 53", + "+34 650 56 32 56", "+34 689 25 68 19", "OICV Alicante", + "Elías - Elena", "La Pequeña Bodeguita", "+34 607 72 96 77", + "Jose Luis Vecino", "Cumpleaños de Juan", "Libreria Valencia", + "Llepaplats", "Mari Trini", "Piedad", "+34 618 58 57 21", + "Sonia Informatica", "Furbolín", "+34 635 67 23 02", + "Vigilancia PA", "Dato", "+34 643 30 34 04", "+34 603 69 84 20", + "+34 667 43 12 54", "Muerte del padre de Luis", "+34 676 53 40 61", + "+34 663 39 08 06", "+34 609 58 33 90", "+34 657 46 88 50", + "Mercedes", "Regalo de navidad de mamá", "Diego", "+34 634 49 07 36", + "+54 9 11 2392-2291", "Cena Nochevieja general 😜", + "Jose Antonio Belda", "Clara - Sara", "Fernando Padre Gorka", + "Angelín", "Otto", "+34 911 65 01 48", "+34 685 43 14 34", + "+34 654 73 44 38", "+34 640 80 74 27", "Sandra - Renault ", + "Francisco Martinez", "Bufete Sanz Abogados", "Berto Romero", + "Juan Padre", "+34 638 10 10 04", "Jesús Peral", "Hugo", + "Miguel Cazorla", "Elisa", "Maite", "+34 670 08 64 11", + "+34 671 24 67 93", "+39 335 577 0107", "+34 655 44 50 43", + "+34 600 86 99 07", "Sonia - Berta", "Me", "Simón Picó", + "Nelly", "Jose Luis La Red", "Miguel Ángel Lozano", "Angel", + "Mario Profesor Guitarra", "Raquel", "SmartRent", "+34 623 13 94 19", + "+34 646 00 89 74", "Juan Puchol", "LPP", "Manolo DCCIA", + "+34 626 00 19 34", "Los Clásicos - La Clásica", + "Jose Luis Vicedo", "Pierre", "Mábel", "Cristina", "Carmen 💍", + "Ana Ruiz - Compañera Piso Lucia", "Ismael - Amigo Lucía", + "Marcial", "Comida Experto Java", "Eli", "Aitor Medrano", + "✨Familia Gallardo✨", "+34 661 72 41 53", "+34 666 13 17 61", + "Julio", "+34 683 77 21 44", "Jose Maria Primo", + "+34 973 90 18 71", "Marisa", "+34 616 20 36 56", "Carlos", + "Muñaqui", "Fran García", "+34 681 64 90 28", "Miriam Amiga Lucía", + "+34 638 74 88 18", "Canadá", "Guillermo Primo", + "Comunión de Pablo 2-6-18", "+34 626 06 65 29", "+34 615 45 74 28", + "María casera", "Farmacia", "Antonio Botía", "Ofeli - Renault", + "Elias primo Conso", "Cristales CristAlacant", "+34 602 40 85 10", + "Pablo", "Recogidas Reto", "+34 654 01 18 39", "Casa Rural León", + "Apartamento Ponferrada", "Fernando - Taller", "+593 98 722 2270", + "Manri", "Jesús", "+34 660 40 49 13", "Juan Gabriel", + "Cristobal Infantes", "Casa Rural Valdepielago", "+34 638 70 39 82", + "Fisioterapia María Monpó", "Vicente amigo Lucía", "Raquel - Alicia", + "+34 687 90 59 73", "Jose Luis Zamora", "Felipe", "Alfons Delaxarxa", + "+34 655 96 77 76", "Jose amigo Lucía", "+34 601 27 93 18", "Ana Prima", + "OICV 2024 ALC", "OICV", "+92 302 3158598", "Jose A", "+34 622 58 49 76", + "Gorka", "Recetas 😋", "60 Años🥳", "Cumple MARI TRINI REGALO", + "Paco Moreno", "Luis", "Vinos y risas", "Alejandro Such", "Jorge Calvo", + "Vigilancia PPSS", "+34 670 10 35 54", "Fran García", "+34 600 51 29 30", + "Miguel Angel", "+34 654 79 31 20", "+34 660 70 93 16", + "Los Lopez - primos ", "María Prima", "Perugia", "Mamá", "Family❤️", + "Conso", "Lucía 💗", "Instituto Juan Gil Albert", "SENIOR UA", + "Anabel 🧡", "Cristina Pomares", "María Pastor"] + let testBackupPath = TestSupport.fixtureRoot.path + let waBackup = WABackup(backupPath: testBackupPath) + do { + let backups = try waBackup.getBackups() + guard let iPhoneBackup = backups.validBackups.first else { + XCTFail("No valid backups found") + return + } + try waBackup.connectChatStorageDb(from: iPhoneBackup) + let chats = try waBackup.getChats() + + // Compare names in chats in the backup with names in the array chatNames. + // Error if there are some names in backup that are not in the array or + // there are some names in the array that are not in the backup + // Extract chat names from the backup + let activeChats = chats.filter { !$0.isArchived} + let backupChatNames = Set(activeChats.map { $0.name }) + let expectedChatNames = Set(chatNames) + + // Identify extra names in the backup that are not expected + let extraNames = backupChatNames.subtracting(expectedChatNames) + + // Identify missing names that are expected but not found in the backup + let missingNames = expectedChatNames.subtracting(backupChatNames) + + // Assert that there are no extra names + if !extraNames.isEmpty { + XCTFail("Found unexpected chat names in backup: \(extraNames)") + } + + // Assert that there are no missing names + if !missingNames.isEmpty { + XCTFail("Expected chat names not found in backup: \(missingNames)") + } + + // Finally, assert that both sets are equal + //XCTAssertEqual(backupChatNames, expectedChatNames, "Chat names in backup do not // match the expected names.") + } catch { + XCTFail("Error fetching chats: \(error)") + } + } + + func testChatIdsAndMessageCounts() throws { + let testBackupPath = TestSupport.fixtureRoot.path + let waBackup = WABackup(backupPath: testBackupPath) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + // Definir los chats esperados + + let expectedChats: [ChatInfo] = [ + ChatInfo( + id: 224, + contactJid: "34601180041-1392988325@g.us", + name: "Family❤️", + numberMessages: 31249, + lastMessageDate: dateFormatter.date(from: "2024-10-01 09:42:58")!, + isArchived: false ), + + ChatInfo( + id: 548, + contactJid: "34608100195@s.whatsapp.net", + name: "Instituto Juan Gil Albert", + numberMessages: 523, + lastMessageDate: dateFormatter.date(from: "2024-09-30 17:21:22")!, + isArchived: false + ), + + ChatInfo( + id: 628, + contactJid: "120363023390396669@g.us", + name: "SENIOR UA", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2024-09-30 13:15:57")!, + isArchived: false + ), + + ChatInfo( + id: 639, + contactJid: "34693206402@s.whatsapp.net", + name: "Me", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-09-29 09:30:26")!, + isArchived: false + ), + + ChatInfo( + id: 8, + contactJid: "34601180041@s.whatsapp.net", + name: "Lucía 💗", + numberMessages: 3458, + lastMessageDate: dateFormatter.date(from: "2024-09-28 10:21:14")!, + isArchived: false + ), + + ChatInfo( + id: 398, + contactJid: "34686275599@s.whatsapp.net", + name: "Conso", + numberMessages: 1533, + lastMessageDate: dateFormatter.date(from: "2024-09-27 13:36:07")!, + isArchived: false + ), + + ChatInfo( + id: 629, + contactJid: "34623139419@s.whatsapp.net", + name: "+34 623 13 94 19", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2024-09-25 18:02:53")!, + isArchived: false ), + + ChatInfo( + id: 3, + contactJid: "34662499450@s.whatsapp.net", + name: "Anabel 🧡", + numberMessages: 2927, + lastMessageDate: dateFormatter.date(from: "2024-09-22 20:08:43")!, + isArchived: false ), + + ChatInfo( + id: 637, + contactJid: "34654638410@s.whatsapp.net", + name: "María Pastor", + numberMessages: 17, + lastMessageDate: dateFormatter.date(from: "2024-09-22 10:30:09")!, + isArchived: false ), + + ChatInfo( + id: 82, + contactJid: "34646412707@s.whatsapp.net", + name: "Mamá", + numberMessages: 1154, + lastMessageDate: dateFormatter.date(from: "2024-09-18 21:09:03")!, + isArchived: false ), + + ChatInfo( + id: 7, + contactJid: "34655468076@s.whatsapp.net", + name: "Cristina Pomares", + numberMessages: 3155, + lastMessageDate: dateFormatter.date(from: "2024-09-12 11:34:45")!, + isArchived: false ), + + ChatInfo( + id: 634, + contactJid: "34747424369@s.whatsapp.net", + name: "SmartRent", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-09-11 17:36:30")!, + isArchived: false ), + + ChatInfo( + id: 599, + contactJid: "34646008974@s.whatsapp.net", + name: "+34 646 00 89 74", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2024-09-10 12:48:02")!, + isArchived: false ), + + ChatInfo( + id: 250, + contactJid: "34622050558@s.whatsapp.net", + name: "Juan Puchol", + numberMessages: 14, + lastMessageDate: dateFormatter.date(from: "2024-09-08 18:19:56")!, + isArchived: false ), + + ChatInfo( + id: 128, + contactJid: "34693206402-1489144420@g.us", + name: "LPP", + numberMessages: 22774, + lastMessageDate: dateFormatter.date(from: "2024-09-06 20:29:24")!, + isArchived: false ), + + ChatInfo( + id: 627, + contactJid: "34669979624@s.whatsapp.net", + name: "Manolo DCCIA", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-09-06 17:12:05")!, + isArchived: false ), + + ChatInfo( + id: 586, + contactJid: "34626001934@s.whatsapp.net", + name: "+34 626 00 19 34", + numberMessages: 15, + lastMessageDate: dateFormatter.date(from: "2024-08-29 17:12:10")!, + isArchived: false ), + + ChatInfo( + id: 197, + contactJid: "34699074946-1562514185@g.us", + name: "Los Clásicos - La Clásica", + numberMessages: 1206, + lastMessageDate: dateFormatter.date(from: "2024-08-27 21:47:52")!, + isArchived: false ), + + ChatInfo( + id: 578, + contactJid: "34610313276@s.whatsapp.net", + name: "Marcial", + numberMessages: 64, + lastMessageDate: dateFormatter.date(from: "2024-08-25 17:56:39")!, + isArchived: false ), + + ChatInfo( + id: 416, + contactJid: "120363041475816933@g.us", + name: "Comida Experto Java", + numberMessages: 468, + lastMessageDate: dateFormatter.date(from: "2024-07-26 13:07:00")!, + isArchived: false ), + + ChatInfo( + id: 88, + contactJid: "34672351765@s.whatsapp.net", + name: "Eli", + numberMessages: 395, + lastMessageDate: dateFormatter.date(from: "2024-07-18 18:04:21")!, + isArchived: false ), + + ChatInfo( + id: 44, + contactJid: "34636104084@s.whatsapp.net", + name: "Aitor Medrano", + numberMessages: 153, + lastMessageDate: dateFormatter.date(from: "2024-07-16 13:45:25")!, + isArchived: false ), + + ChatInfo( + id: 226, + contactJid: "34693206402-1435507670@g.us", + name: "✨Familia Gallardo✨", + numberMessages: 1969, + lastMessageDate: dateFormatter.date(from: "2024-07-11 21:27:22")!, + isArchived: false ), + + ChatInfo( + id: 626, + contactJid: "34661724153@s.whatsapp.net", + name: "+34 661 72 41 53", + numberMessages: 7, + lastMessageDate: dateFormatter.date(from: "2024-07-11 12:30:35")!, + isArchived: false ), + + ChatInfo( + id: 625, + contactJid: "34666131761@s.whatsapp.net", + name: "+34 666 13 17 61", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-07-09 08:14:38")!, + isArchived: false ), + + ChatInfo( + id: 10, + contactJid: "34657572413@s.whatsapp.net", + name: "Julio", + numberMessages: 1004, + lastMessageDate: dateFormatter.date(from: "2024-07-06 19:15:33")!, + isArchived: false ), + + ChatInfo( + id: 580, + contactJid: "34610818214@s.whatsapp.net", + name: "Simón Picó", + numberMessages: 20, + lastMessageDate: dateFormatter.date(from: "2024-07-01 11:21:02")!, + isArchived: false ), + + ChatInfo( + id: 621, + contactJid: "34624461608@s.whatsapp.net", + name: "Nelly", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2024-06-21 20:06:16")!, + isArchived: false ), + + ChatInfo( + id: 575, + contactJid: "34653018902@s.whatsapp.net", + name: "Jose Luis La Red", + numberMessages: 8, + lastMessageDate: dateFormatter.date(from: "2024-06-17 19:01:21")!, + isArchived: false ), + + ChatInfo( + id: 91, + contactJid: "34622173664@s.whatsapp.net", + name: "Miguel Ángel Lozano", + numberMessages: 143, + lastMessageDate: dateFormatter.date(from: "2024-06-10 12:37:07")!, + isArchived: false ), + + ChatInfo( + id: 620, + contactJid: "62838319909242@s.whatsapp.net", + name: "+62 838-3199-09242", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2024-06-07 18:11:46")!, + isArchived: true ), + + ChatInfo( + id: 34, + contactJid: "34605100221@s.whatsapp.net", + name: "Angel", + numberMessages: 2046, + lastMessageDate: dateFormatter.date(from: "2024-06-03 22:46:50")!, + isArchived: false ), + + ChatInfo( + id: 205, + contactJid: "34647748984@s.whatsapp.net", + name: "Mario Profesor Guitarra", + numberMessages: 115, + lastMessageDate: dateFormatter.date(from: "2024-05-27 13:36:07")!, + isArchived: false ), + + ChatInfo( + id: 11, + contactJid: "34690227762@s.whatsapp.net", + name: "Raquel", + numberMessages: 126, + lastMessageDate: dateFormatter.date(from: "2024-05-20 13:19:19")!, + isArchived: false ), + + ChatInfo( + id: 587, + contactJid: "34660975831@s.whatsapp.net", + name: "María casera", + numberMessages: 60, + lastMessageDate: dateFormatter.date(from: "2024-05-17 19:06:08")!, + isArchived: false ), + + ChatInfo( + id: 594, + contactJid: "34680656093@s.whatsapp.net", + name: "Farmacia", + numberMessages: 15, + lastMessageDate: dateFormatter.date(from: "2024-05-16 13:26:47")!, + isArchived: false ), + + ChatInfo( + id: 121, + contactJid: "34690103286@s.whatsapp.net", + name: "Antonio Botía", + numberMessages: 1731, + lastMessageDate: dateFormatter.date(from: "2024-05-14 11:37:42")!, + isArchived: false ), + + ChatInfo( + id: 617, + contactJid: "34677779325@s.whatsapp.net", + name: "Ofeli - Renault", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-05-13 14:58:28")!, + isArchived: false ), + + ChatInfo( + id: 512, + contactJid: "34633291418@s.whatsapp.net", + name: "Elias primo Conso", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2024-05-12 12:25:01")!, + isArchived: false ), + + ChatInfo( + id: 616, + contactJid: "34611071269@s.whatsapp.net", + name: "Cristales CristAlacant", + numberMessages: 22, + lastMessageDate: dateFormatter.date(from: "2024-05-08 12:03:15")!, + isArchived: false ), + + ChatInfo( + id: 477, + contactJid: "34602408510@s.whatsapp.net", + name: "+34 602 40 85 10", + numberMessages: 31, + lastMessageDate: dateFormatter.date(from: "2024-05-07 12:56:06")!, + isArchived: false ), + + ChatInfo( + id: 260, + contactJid: "34639879552@s.whatsapp.net", + name: "Pablo", + numberMessages: 80, + lastMessageDate: dateFormatter.date(from: "2024-05-06 18:16:44")!, + isArchived: false ), + + ChatInfo( + id: 611, + contactJid: "34661716484@s.whatsapp.net", + name: "Sandra - Renault ", + numberMessages: 34, + lastMessageDate: dateFormatter.date(from: "2024-05-03 13:29:39")!, + isArchived: false ), + + ChatInfo( + id: 135, + contactJid: "34649427291@s.whatsapp.net", + name: "Francisco Martinez", + numberMessages: 288, + lastMessageDate: dateFormatter.date(from: "2024-04-15 11:57:12")!, + isArchived: false ), + + ChatInfo( + id: 608, + contactJid: "34645967879@s.whatsapp.net", + name: "Bufete Sanz Abogados", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2024-03-28 12:58:31")!, + isArchived: false ), + + ChatInfo( + id: 607, + contactJid: "34606373058@s.whatsapp.net", + name: "Berto Romero", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2024-03-20 07:23:06")!, + isArchived: false ), + + ChatInfo( + id: 97, + contactJid: "34655784262@s.whatsapp.net", + name: "Juan Padre", + numberMessages: 348, + lastMessageDate: dateFormatter.date(from: "2024-03-15 14:13:03")!, + isArchived: false ), + + ChatInfo( + id: 593, + contactJid: "34683772144@s.whatsapp.net", + name: "+34 683 77 21 44", + numberMessages: 7, + lastMessageDate: dateFormatter.date(from: "2024-02-23 20:37:33")!, + isArchived: false ), + + ChatInfo( + id: 596, + contactJid: "34636354852@s.whatsapp.net", + name: "Jesús Peral", + numberMessages: 8, + lastMessageDate: dateFormatter.date(from: "2024-02-23 11:55:16")!, + isArchived: false ), + + ChatInfo( + id: 544, + contactJid: "120363027466609818@g.us", + name: "OICV 2024 ALC", + numberMessages: 248, + lastMessageDate: dateFormatter.date(from: "2024-01-26 18:46:56")!, + isArchived: false ), + + ChatInfo( + id: 363, + contactJid: "120363039560737011@g.us", + name: "OICV", + numberMessages: 229, + lastMessageDate: dateFormatter.date(from: "2024-01-26 12:25:04")!, + isArchived: false ), + + ChatInfo( + id: 597, + contactJid: "923023158598@s.whatsapp.net", + name: "+92 302 3158598", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2024-01-15 12:49:42")!, + isArchived: false ), + + ChatInfo( + id: 90, + contactJid: "34660858925@s.whatsapp.net", + name: "Jose A", + numberMessages: 123, + lastMessageDate: dateFormatter.date(from: "2023-12-30 11:55:32")!, + isArchived: false ), + + ChatInfo( + id: 261, + contactJid: "34622584976@s.whatsapp.net", + name: "+34 622 58 49 76", + numberMessages: 17, + lastMessageDate: dateFormatter.date(from: "2023-12-26 19:45:33")!, + isArchived: false ), + + ChatInfo( + id: 201, + contactJid: "34682867709@s.whatsapp.net", + name: "Gorka", + numberMessages: 229, + lastMessageDate: dateFormatter.date(from: "2023-12-26 15:05:18")!, + isArchived: false ), + + ChatInfo( + id: 180, + contactJid: "34662499450-1552740247@g.us", + name: "Recetas 😋", + numberMessages: 26, + lastMessageDate: dateFormatter.date(from: "2023-12-24 19:15:18")!, + isArchived: false ), + + ChatInfo( + id: 433, + contactJid: "120363025789047013@g.us", + name: "60 Años🥳", + numberMessages: 317, + lastMessageDate: dateFormatter.date(from: "2023-12-24 15:31:44")!, + isArchived: false ), + + ChatInfo( + id: 492, + contactJid: "34622646182@s.whatsapp.net", + name: "Jose Luis Vicedo", + numberMessages: 41, + lastMessageDate: dateFormatter.date(from: "2023-12-18 09:28:37")!, + isArchived: false ), + + ChatInfo( + id: 543, + contactJid: "34620617398@s.whatsapp.net", + name: "Pierre", + numberMessages: 31, + lastMessageDate: dateFormatter.date(from: "2023-12-01 10:56:39")!, + isArchived: false ), + + ChatInfo( + id: 592, + contactJid: "34658205668@s.whatsapp.net", + name: "Mábel", + numberMessages: 14, + lastMessageDate: dateFormatter.date(from: "2023-11-16 20:01:25")!, + isArchived: false ), + + ChatInfo( + id: 53, + contactJid: "34652621675@s.whatsapp.net", + name: "Cristina", + numberMessages: 244, + lastMessageDate: dateFormatter.date(from: "2023-11-15 08:22:54")!, + isArchived: false ), + + ChatInfo( + id: 590, + contactJid: "34620172662@s.whatsapp.net", + name: "Carmen 💍", + numberMessages: 6, + lastMessageDate: dateFormatter.date(from: "2023-11-12 00:42:16")!, + isArchived: false ), + + ChatInfo( + id: 589, + contactJid: "34674264300@s.whatsapp.net", + name: "Ana Ruiz - Compañera Piso Lucia", + numberMessages: 23, + lastMessageDate: dateFormatter.date(from: "2023-11-12 00:35:29")!, + isArchived: false ), + + ChatInfo( + id: 588, + contactJid: "34606170479@s.whatsapp.net", + name: "Ismael - Amigo Lucía", + numberMessages: 32, + lastMessageDate: dateFormatter.date(from: "2023-11-12 00:25:53")!, + isArchived: false ), + + ChatInfo( + id: 256, + contactJid: "34638101004@s.whatsapp.net", + name: "+34 638 10 10 04", + numberMessages: 263, + lastMessageDate: dateFormatter.date(from: "2023-11-07 14:26:46")!, + isArchived: false ), + + ChatInfo( + id: 581, + contactJid: "34659219578@s.whatsapp.net", + name: "Jose Maria Primo", + numberMessages: 24, + lastMessageDate: dateFormatter.date(from: "2023-10-05 18:40:08")!, + isArchived: false ), + + ChatInfo( + id: 561, + contactJid: "34973901871@s.whatsapp.net", + name: "+34 973 90 18 71", + numberMessages: 16, + lastMessageDate: dateFormatter.date(from: "2023-09-22 11:29:46")!, + isArchived: false ), + + ChatInfo( + id: 99, + contactJid: "34665339124@s.whatsapp.net", + name: "Marisa", + numberMessages: 65, + lastMessageDate: dateFormatter.date(from: "2023-09-01 21:01:34")!, + isArchived: false ), + + ChatInfo( + id: 579, + contactJid: "34616203656@s.whatsapp.net", + name: "+34 616 20 36 56", + numberMessages: 21, + lastMessageDate: dateFormatter.date(from: "2023-08-10 13:42:02")!, + isArchived: false ), + + ChatInfo( + id: 6, + contactJid: "34693330041@s.whatsapp.net", + name: "Carlos", + numberMessages: 423, + lastMessageDate: dateFormatter.date(from: "2023-07-14 21:53:51")!, + isArchived: false ), + + ChatInfo( + id: 92, + contactJid: "34606287943@s.whatsapp.net", + name: "Muñaqui", + numberMessages: 79, + lastMessageDate: dateFormatter.date(from: "2023-06-26 19:56:47")!, + isArchived: false ), + + ChatInfo( + id: 157, + contactJid: "34677819474@s.whatsapp.net", + name: "Fran García", + numberMessages: 153, + lastMessageDate: dateFormatter.date(from: "2023-05-10 14:02:43")!, + isArchived: false ), + + ChatInfo( + id: 131, + contactJid: "34676613101@s.whatsapp.net", + name: "Fernando Padre Gorka", + numberMessages: 57, + lastMessageDate: dateFormatter.date(from: "2023-03-03 12:05:36")!, + isArchived: false ), + + ChatInfo( + id: 556, + contactJid: "34666551935@s.whatsapp.net", + name: "Angelín", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2023-02-28 12:06:30")!, + isArchived: false ), + + ChatInfo( + id: 54, + contactJid: "34626785395@s.whatsapp.net", + name: "Otto", + numberMessages: 111, + lastMessageDate: dateFormatter.date(from: "2023-02-17 19:49:58")!, + isArchived: false ), + + ChatInfo( + id: 466, + contactJid: "34911650148@s.whatsapp.net", + name: "+34 911 65 01 48", + numberMessages: 12, + lastMessageDate: dateFormatter.date(from: "2023-02-17 10:34:34")!, + isArchived: false ), + + ChatInfo( + id: 171, + contactJid: "34685431434@s.whatsapp.net", + name: "+34 685 43 14 34", + numberMessages: 69, + lastMessageDate: dateFormatter.date(from: "2023-01-10 15:52:51")!, + isArchived: false ), + + ChatInfo( + id: 517, + contactJid: "34654734438@s.whatsapp.net", + name: "+34 654 73 44 38", + numberMessages: 14, + lastMessageDate: dateFormatter.date(from: "2022-12-22 12:53:07")!, + isArchived: false ), + + ChatInfo( + id: 498, + contactJid: "34640807427@s.whatsapp.net", + name: "+34 640 80 74 27", + numberMessages: 8, + lastMessageDate: dateFormatter.date(from: "2022-12-21 09:16:33")!, + isArchived: false ), + + ChatInfo( + id: 116, + contactJid: "34648936892@s.whatsapp.net", + name: "Hugo", + numberMessages: 159, + lastMessageDate: dateFormatter.date(from: "2022-12-15 12:38:38")!, + isArchived: false ), + + ChatInfo( + id: 18, + contactJid: "34662004274@s.whatsapp.net", + name: "Miguel Cazorla", + numberMessages: 130, + lastMessageDate: dateFormatter.date(from: "2022-12-08 22:32:18")!, + isArchived: false ), + + ChatInfo( + id: 93, + contactJid: "34647719094@s.whatsapp.net", + name: "Elisa", + numberMessages: 79, + lastMessageDate: dateFormatter.date(from: "2022-11-03 14:36:46")!, + isArchived: false ), + + ChatInfo( + id: 59, + contactJid: "34636885959@s.whatsapp.net", + name: "Maite", + numberMessages: 27, + lastMessageDate: dateFormatter.date(from: "2022-10-16 12:33:27")!, + isArchived: false ), + + ChatInfo( + id: 458, + contactJid: "34670086411@s.whatsapp.net", + name: "+34 670 08 64 11", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2022-09-18 12:24:48")!, + isArchived: false ), + + ChatInfo( + id: 457, + contactJid: "34671246793@s.whatsapp.net", + name: "+34 671 24 67 93", + numberMessages: 13, + lastMessageDate: dateFormatter.date(from: "2022-09-13 09:32:22")!, + isArchived: false ), + + ChatInfo( + id: 448, + contactJid: "393355770107@s.whatsapp.net", + name: "+39 335 577 0107", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2022-08-25 10:19:10")!, + isArchived: false ), + + ChatInfo( + id: 435, + contactJid: "120363044545720321@g.us", + name: "Cumple MARI TRINI REGALO", + numberMessages: 476, + lastMessageDate: dateFormatter.date(from: "2022-08-01 20:21:19")!, + isArchived: false ), + + ChatInfo( + id: 32, + contactJid: "34670528038@s.whatsapp.net", + name: "Paco Moreno", + numberMessages: 60, + lastMessageDate: dateFormatter.date(from: "2022-07-22 12:11:06")!, + isArchived: false ), + + ChatInfo( + id: 302, + contactJid: "34689021600@s.whatsapp.net", + name: "Luis", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2022-06-17 18:45:57")!, + isArchived: false ), + + ChatInfo( + id: 417, + contactJid: "34601344841@s.whatsapp.net", + name: "Vinos y risas", + numberMessages: 23, + lastMessageDate: dateFormatter.date(from: "2022-06-14 12:10:21")!, + isArchived: false ), + + ChatInfo( + id: 19, + contactJid: "34665816929@s.whatsapp.net", + name: "Alejandro Such", + numberMessages: 180, + lastMessageDate: dateFormatter.date(from: "2022-06-08 19:34:27")!, + isArchived: false ), + + ChatInfo( + id: 361, + contactJid: "34666837771@s.whatsapp.net", + name: "Jorge Calvo", + numberMessages: 89, + lastMessageDate: dateFormatter.date(from: "2022-05-27 13:47:40")!, + isArchived: false ), + + ChatInfo( + id: 391, + contactJid: "447585174537@s.whatsapp.net", + name: "+44 7585 174537", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2022-04-28 16:48:26")!, + isArchived: true ), + + ChatInfo( + id: 376, + contactJid: "34672351765-1490792654@g.us", + name: "Vigilancia PPSS", + numberMessages: 103, + lastMessageDate: dateFormatter.date(from: "2022-03-24 12:26:53")!, + isArchived: false ), + + ChatInfo( + id: 339, + contactJid: "34670103554@s.whatsapp.net", + name: "+34 670 10 35 54", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2022-02-01 14:19:45")!, + isArchived: false ), + + ChatInfo( + id: 365, + contactJid: "120363036963223997@g.us", + name: "OICV Alicante", + numberMessages: 382, + lastMessageDate: dateFormatter.date(from: "2022-01-28 14:58:58")!, + isArchived: false ), + + ChatInfo( + id: 133, + contactJid: "34630155981@s.whatsapp.net", + name: "Elías - Elena", + numberMessages: 28, + lastMessageDate: dateFormatter.date(from: "2022-01-11 12:29:40")!, + isArchived: false ), + + ChatInfo( + id: 342, + contactJid: "34636680185@s.whatsapp.net", + name: "La Pequeña Bodeguita", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2021-12-24 18:56:58")!, + isArchived: false ), + + ChatInfo( + id: 327, + contactJid: "34607729677@s.whatsapp.net", + name: "+34 607 72 96 77", + numberMessages: 6, + lastMessageDate: dateFormatter.date(from: "2021-12-02 12:27:40")!, + isArchived: false ), + + ChatInfo( + id: 316, + contactJid: "34617664920@s.whatsapp.net", + name: "Jose Luis Vecino", + numberMessages: 8, + lastMessageDate: dateFormatter.date(from: "2021-12-01 17:18:01")!, + isArchived: false ), + + ChatInfo( + id: 299, + contactJid: "34655784262-1634044334@g.us", + name: "Cumpleaños de Juan", + numberMessages: 16, + lastMessageDate: dateFormatter.date(from: "2021-10-13 22:08:48")!, + isArchived: false ), + + ChatInfo( + id: 263, + contactJid: "34658805146@s.whatsapp.net", + name: "Libreria Valencia", + numberMessages: 24, + lastMessageDate: dateFormatter.date(from: "2021-09-01 20:28:18")!, + isArchived: false ), + + ChatInfo( + id: 225, + contactJid: "34670528038-1412460666@g.us", + name: "Llepaplats", + numberMessages: 830, + lastMessageDate: dateFormatter.date(from: "2021-08-14 15:27:36")!, + isArchived: false ), + + ChatInfo( + id: 21, + contactJid: "34652197580@s.whatsapp.net", + name: "Mari Trini", + numberMessages: 45, + lastMessageDate: dateFormatter.date(from: "2021-08-08 10:04:44")!, + isArchived: false ), + + ChatInfo( + id: 212, + contactJid: "34646769232@s.whatsapp.net", + name: "Piedad", + numberMessages: 12, + lastMessageDate: dateFormatter.date(from: "2021-08-04 18:51:27")!, + isArchived: false ), + + ChatInfo( + id: 254, + contactJid: "34618585721@s.whatsapp.net", + name: "+34 618 58 57 21", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2021-04-22 15:42:58")!, + isArchived: false ), + + ChatInfo( + id: 251, + contactJid: "34616145202@s.whatsapp.net", + name: "Sonia Informatica", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2021-03-26 13:11:41")!, + isArchived: false ), + + ChatInfo( + id: 110, + contactJid: "34670528038-1474731097@g.us", + name: "Furbolín", + numberMessages: 698, + lastMessageDate: dateFormatter.date(from: "2021-01-31 18:38:39")!, + isArchived: false ), + + ChatInfo( + id: 211, + contactJid: "34635672302@s.whatsapp.net", + name: "+34 635 67 23 02", + numberMessages: 29, + lastMessageDate: dateFormatter.date(from: "2021-01-29 11:09:10")!, + isArchived: false ), + + ChatInfo( + id: 229, + contactJid: "34672351765-1604417953@g.us", + name: "Vigilancia PA", + numberMessages: 101, + lastMessageDate: dateFormatter.date(from: "2021-01-28 09:13:53")!, + isArchived: false ), + + ChatInfo( + id: 249, + contactJid: "34665166632@s.whatsapp.net", + name: "Dato", + numberMessages: 16, + lastMessageDate: dateFormatter.date(from: "2021-01-24 09:10:59")!, + isArchived: false ), + + ChatInfo( + id: 87, + contactJid: "34649254458@s.whatsapp.net", + name: "Mercedes", + numberMessages: 92, + lastMessageDate: dateFormatter.date(from: "2021-01-01 12:24:41")!, + isArchived: false ), + + ChatInfo( + id: 230, + contactJid: "34693206402-1607973235@g.us", + name: "Regalo de navidad de mamá", + numberMessages: 29, + lastMessageDate: dateFormatter.date(from: "2020-12-19 13:43:17")!, + isArchived: false ), + + ChatInfo( + id: 114, + contactJid: "34633285989@s.whatsapp.net", + name: "Diego", + numberMessages: 191, + lastMessageDate: dateFormatter.date(from: "2020-09-24 19:17:25")!, + isArchived: false ), + + ChatInfo( + id: 222, + contactJid: "34634490736@s.whatsapp.net", + name: "+34 634 49 07 36", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2020-07-10 12:25:21")!, + isArchived: false ), + + ChatInfo( + id: 221, + contactJid: "34670925836@s.whatsapp.net", + name: "+34 670 92 58 36", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2020-06-29 17:43:03")!, + isArchived: true ), + + ChatInfo( + id: 214, + contactJid: "5491123922291@s.whatsapp.net", + name: "+54 9 11 2392-2291", + numberMessages: 13, + lastMessageDate: dateFormatter.date(from: "2020-02-18 14:47:42")!, + isArchived: false ), + + ChatInfo( + id: 213, + contactJid: "34610787322@s.whatsapp.net", + name: "+34 610 78 73 22", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2020-01-15 20:56:02")!, + isArchived: true ), + + ChatInfo( + id: 172, + contactJid: "34654638410-1545999508@g.us", + name: "Cena Nochevieja general 😜", + numberMessages: 149, + lastMessageDate: dateFormatter.date(from: "2020-01-05 19:00:45")!, + isArchived: false ), + + ChatInfo( + id: 57, + contactJid: "34646694303@s.whatsapp.net", + name: "Jose Antonio Belda", + numberMessages: 25, + lastMessageDate: dateFormatter.date(from: "2019-10-18 18:31:25")!, + isArchived: false ), + + ChatInfo( + id: 17, + contactJid: "34606537761@s.whatsapp.net", + name: "Clara - Sara", + numberMessages: 27, + lastMessageDate: dateFormatter.date(from: "2019-09-21 00:45:53")!, + isArchived: false ), + + ChatInfo( + id: 206, + contactJid: "34691598733@s.whatsapp.net", + name: "Recogidas Reto", + numberMessages: 24, + lastMessageDate: dateFormatter.date(from: "2019-09-19 15:01:37")!, + isArchived: false ), + + ChatInfo( + id: 155, + contactJid: "34654011839@s.whatsapp.net", + name: "+34 654 01 18 39", + numberMessages: 16, + lastMessageDate: dateFormatter.date(from: "2019-09-06 08:18:13")!, + isArchived: false ), + + ChatInfo( + id: 204, + contactJid: "34673006909@s.whatsapp.net", + name: "Casa Rural León", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2019-08-16 15:49:52")!, + isArchived: false ), + + ChatInfo( + id: 185, + contactJid: "34686037437@s.whatsapp.net", + name: "Apartamento Ponferrada", + numberMessages: 34, + lastMessageDate: dateFormatter.date(from: "2019-08-16 09:43:05")!, + isArchived: false ), + + ChatInfo( + id: 203, + contactJid: "34692211619@s.whatsapp.net", + name: "Fernando - Taller", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2019-08-05 19:06:27")!, + isArchived: false ), + + ChatInfo( + id: 202, + contactJid: "593987222270@s.whatsapp.net", + name: "+593 98 722 2270", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2019-08-04 23:46:08")!, + isArchived: false ), + + ChatInfo( + id: 35, + contactJid: "34633121331@s.whatsapp.net", + name: "Manri", + numberMessages: 42, + lastMessageDate: dateFormatter.date(from: "2019-07-22 20:44:22")!, + isArchived: false ), + + ChatInfo( + id: 199, + contactJid: "34699074946@s.whatsapp.net", + name: "Jesús", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2019-07-07 20:02:02")!, + isArchived: false ), + + ChatInfo( + id: 45, + contactJid: "34660404913@s.whatsapp.net", + name: "+34 660 40 49 13", + numberMessages: 15, + lastMessageDate: dateFormatter.date(from: "2019-07-07 19:31:41")!, + isArchived: false ), + + ChatInfo( + id: 189, + contactJid: "34655547719@s.whatsapp.net", + name: "Juan Gabriel", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2019-05-14 20:53:05")!, + isArchived: false ), + + ChatInfo( + id: 188, + contactJid: "34607462377@s.whatsapp.net", + name: "Cristobal Infantes", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2019-05-13 19:07:25")!, + isArchived: false ), + + ChatInfo( + id: 184, + contactJid: "34667527531@s.whatsapp.net", + name: "Casa Rural Valdepielago", + numberMessages: 6, + lastMessageDate: dateFormatter.date(from: "2019-05-02 13:38:46")!, + isArchived: false ), + + ChatInfo( + id: 183, + contactJid: "34638703982@s.whatsapp.net", + name: "+34 638 70 39 82", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2019-04-18 13:09:46")!, + isArchived: false ), + + ChatInfo( + id: 175, + contactJid: "34655280429@s.whatsapp.net", + name: "Fisioterapia María Monpó", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2019-02-06 12:04:13")!, + isArchived: false ), + + ChatInfo( + id: 111, + contactJid: "34600012880@s.whatsapp.net", + name: "Vicente amigo Lucía", + numberMessages: 11, + lastMessageDate: dateFormatter.date(from: "2018-12-26 21:28:23")!, + isArchived: false ), + + ChatInfo( + id: 148, + contactJid: "34655157634@s.whatsapp.net", + name: "Raquel - Alicia", + numberMessages: 20, + lastMessageDate: dateFormatter.date(from: "2018-12-08 11:36:49")!, + isArchived: false ), + + ChatInfo( + id: 169, + contactJid: "34681649028@s.whatsapp.net", + name: "+34 681 64 90 28", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2018-11-29 18:13:05")!, + isArchived: false ), + + ChatInfo( + id: 168, + contactJid: "34671964643@s.whatsapp.net", + name: "Miriam Amiga Lucía", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2018-11-28 09:51:01")!, + isArchived: false ), + + ChatInfo( + id: 154, + contactJid: "34638748818@s.whatsapp.net", + name: "+34 638 74 88 18", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2018-08-31 09:36:45")!, + isArchived: false ), + + ChatInfo( + id: 149, + contactJid: "34652621675-1531292568@g.us", + name: "Canadá", + numberMessages: 46, + lastMessageDate: dateFormatter.date(from: "2018-08-06 11:59:50")!, + isArchived: false ), + + ChatInfo( + id: 142, + contactJid: "34637629196@s.whatsapp.net", + name: "Guillermo Primo", + numberMessages: 60, + lastMessageDate: dateFormatter.date(from: "2018-07-23 18:21:37")!, + isArchived: false ), + + ChatInfo( + id: 146, + contactJid: "34652621675-1526737884@g.us", + name: "Comunión de Pablo 2-6-18", + numberMessages: 144, + lastMessageDate: dateFormatter.date(from: "2018-06-03 18:11:03")!, + isArchived: false ), + + ChatInfo( + id: 145, + contactJid: "34626066529@s.whatsapp.net", + name: "+34 626 06 65 29", + numberMessages: 5, + lastMessageDate: dateFormatter.date(from: "2018-04-03 11:54:56")!, + isArchived: false ), + + ChatInfo( + id: 144, + contactJid: "34615457428@s.whatsapp.net", + name: "+34 615 45 74 28", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2018-04-01 12:09:37")!, + isArchived: false ), + + ChatInfo( + id: 132, + contactJid: "34687905973@s.whatsapp.net", + name: "+34 687 90 59 73", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2018-03-09 22:04:24")!, + isArchived: false ), + + ChatInfo( + id: 140, + contactJid: "34658659515@s.whatsapp.net", + name: "Jose Luis Zamora", + numberMessages: 22, + lastMessageDate: dateFormatter.date(from: "2018-03-01 19:18:45")!, + isArchived: false ), + + ChatInfo( + id: 129, + contactJid: "34681241899@s.whatsapp.net", + name: "Felipe", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2017-12-31 15:40:38")!, + isArchived: false ), + + ChatInfo( + id: 127, + contactJid: "34665497594@s.whatsapp.net", + name: "Alfons Delaxarxa", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2017-02-27 21:33:47")!, + isArchived: false ), + + ChatInfo( + id: 119, + contactJid: "34615203128@s.whatsapp.net", + name: "+34 615 20 31 28", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2017-02-11 16:06:19")!, + isArchived: true ), + + ChatInfo( + id: 118, + contactJid: "34655967776@s.whatsapp.net", + name: "+34 655 96 77 76", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2017-01-07 21:26:44")!, + isArchived: false ), + + ChatInfo( + id: 112, + contactJid: "34663024302@s.whatsapp.net", + name: "Jose amigo Lucía", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2016-12-03 21:06:50")!, + isArchived: false ), + + ChatInfo( + id: 16, + contactJid: "34601279318@s.whatsapp.net", + name: "+34 601 27 93 18", + numberMessages: 99, + lastMessageDate: dateFormatter.date(from: "2016-12-03 20:25:18")!, + isArchived: false ), + + ChatInfo( + id: 109, + contactJid: "34608230523@s.whatsapp.net", + name: "Ana Prima", + numberMessages: 12, + lastMessageDate: dateFormatter.date(from: "2016-08-27 10:25:48")!, + isArchived: false ), + + ChatInfo( + id: 5, + contactJid: "447707711196@s.whatsapp.net", + name: "Fran García", + numberMessages: 88, + lastMessageDate: dateFormatter.date(from: "2016-08-05 17:30:18")!, + isArchived: false ), + + ChatInfo( + id: 104, + contactJid: "34600512930@s.whatsapp.net", + name: "+34 600 51 29 30", + numberMessages: 21, + lastMessageDate: dateFormatter.date(from: "2016-08-03 20:08:11")!, + isArchived: false ), + + ChatInfo( + id: 102, + contactJid: "34670768209@s.whatsapp.net", + name: "Miguel Angel", + numberMessages: 3, + lastMessageDate: dateFormatter.date(from: "2016-06-26 12:40:55")!, + isArchived: false ), + + ChatInfo( + id: 98, + contactJid: "34654793120@s.whatsapp.net", + name: "+34 654 79 31 20", + numberMessages: 17, + lastMessageDate: dateFormatter.date(from: "2016-05-27 20:58:34")!, + isArchived: false ), + + ChatInfo( + id: 83, + contactJid: "34660709316@s.whatsapp.net", + name: "+34 660 70 93 16", + numberMessages: 77, + lastMessageDate: dateFormatter.date(from: "2016-05-26 20:06:33")!, + isArchived: false ), + + ChatInfo( + id: 55, + contactJid: "34666551935-1419436152@g.us", + name: "Los Lopez - primos ", + numberMessages: 106, + lastMessageDate: dateFormatter.date(from: "2016-05-01 02:02:34")!, + isArchived: false ), + + ChatInfo( + id: 94, + contactJid: "34675206121@s.whatsapp.net", + name: "María Prima", + numberMessages: 18, + lastMessageDate: dateFormatter.date(from: "2016-04-22 13:17:59")!, + isArchived: false ), + + ChatInfo( + id: 96, + contactJid: "34655445043-1460353359@g.us", + name: "Perugia", + numberMessages: 126, + lastMessageDate: dateFormatter.date(from: "2016-04-15 23:05:30")!, + isArchived: false ), + + ChatInfo( + id: 95, + contactJid: "34609436010@s.whatsapp.net", + name: "+34 609 43 60 10", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2016-04-01 22:11:12")!, + isArchived: false ), + + ChatInfo( + id: 40, + contactJid: "34686489843@s.whatsapp.net", + name: "Jose Bonora", + numberMessages: 34, + lastMessageDate: dateFormatter.date(from: "2015-07-23 22:47:27")!, + isArchived: false ), + + ChatInfo( + id: 68, + contactJid: "34644407559@s.whatsapp.net", + name: "+34 644 40 75 59", + numberMessages: 33, + lastMessageDate: dateFormatter.date(from: "2015-06-20 20:25:02")!, + isArchived: false ), + + ChatInfo( + id: 30, + contactJid: "34619812911@s.whatsapp.net", + name: "Pilar - Lucía López", + numberMessages: 30, + lastMessageDate: dateFormatter.date(from: "2015-06-20 19:55:28")!, + isArchived: false ), + + ChatInfo( + id: 81, + contactJid: "34607371932@s.whatsapp.net", + name: "+34 607 37 19 32", + numberMessages: 19, + lastMessageDate: dateFormatter.date(from: "2015-06-15 12:23:07")!, + isArchived: false ), + + ChatInfo( + id: 27, + contactJid: "34691991253@s.whatsapp.net", + name: "+34 691 99 12 53", + numberMessages: 146, + lastMessageDate: dateFormatter.date(from: "2015-05-28 23:58:35")!, + isArchived: false ), + + ChatInfo( + id: 67, + contactJid: "34650563256@s.whatsapp.net", + name: "+34 650 56 32 56", + numberMessages: 6, + lastMessageDate: dateFormatter.date(from: "2015-02-23 09:24:12")!, + isArchived: false ), + + ChatInfo( + id: 65, + contactJid: "34689256819@s.whatsapp.net", + name: "+34 689 25 68 19", + numberMessages: 8, + lastMessageDate: dateFormatter.date(from: "2015-02-15 02:25:40")!, + isArchived: false ), + + ChatInfo( + id: 61, + contactJid: "34643303404@s.whatsapp.net", + name: "+34 643 30 34 04", + numberMessages: 25, + lastMessageDate: dateFormatter.date(from: "2015-02-11 14:54:18")!, + isArchived: false ), + + ChatInfo( + id: 64, + contactJid: "34603698420@s.whatsapp.net", + name: "+34 603 69 84 20", + numberMessages: 63, + lastMessageDate: dateFormatter.date(from: "2015-02-10 17:08:57")!, + isArchived: false ), + + ChatInfo( + id: 63, + contactJid: "34667431254@s.whatsapp.net", + name: "+34 667 43 12 54", + numberMessages: 12, + lastMessageDate: dateFormatter.date(from: "2015-02-10 00:03:04")!, + isArchived: false ), + + ChatInfo( + id: 60, + contactJid: "34665497594-1421237760@g.us", + name: "Muerte del padre de Luis", + numberMessages: 19, + lastMessageDate: dateFormatter.date(from: "2015-01-15 11:40:44")!, + isArchived: false ), + + ChatInfo( + id: 49, + contactJid: "34676534061@s.whatsapp.net", + name: "+34 676 53 40 61", + numberMessages: 10, + lastMessageDate: dateFormatter.date(from: "2014-11-20 14:44:59")!, + isArchived: false ), + + ChatInfo( + id: 48, + contactJid: "34663390806@s.whatsapp.net", + name: "+34 663 39 08 06", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2014-11-13 10:51:24")!, + isArchived: false ), + + ChatInfo( + id: 47, + contactJid: "34609583390@s.whatsapp.net", + name: "+34 609 58 33 90", + numberMessages: 9, + lastMessageDate: dateFormatter.date(from: "2014-10-10 15:47:48")!, + isArchived: false ), + + ChatInfo( + id: 41, + contactJid: "34657468850@s.whatsapp.net", + name: "+34 657 46 88 50", + numberMessages: 25, + lastMessageDate: dateFormatter.date(from: "2014-07-11 23:34:13")!, + isArchived: false ), + + ChatInfo( + id: 37, + contactJid: "34655445043@s.whatsapp.net", + name: "+34 655 44 50 43", + numberMessages: 2, + lastMessageDate: dateFormatter.date(from: "2014-06-16 17:00:42")!, + isArchived: false ), + + ChatInfo( + id: 33, + contactJid: "34600869907@s.whatsapp.net", + name: "+34 600 86 99 07", + numberMessages: 18, + lastMessageDate: dateFormatter.date(from: "2014-04-27 18:11:35")!, + isArchived: false ), + + ChatInfo( + id: 25, + contactJid: "34605367031@s.whatsapp.net", + name: "Sonia - Berta", + numberMessages: 4, + lastMessageDate: dateFormatter.date(from: "2013-12-06 04:12:08")!, + isArchived: false + ), + + ] + + do { + let backups = try waBackup.getBackups() + guard let iPhoneBackup = backups.validBackups.first else { + XCTFail("No se encontraron backups válidos") + return + } + try waBackup.connectChatStorageDb(from: iPhoneBackup) + let chats = try waBackup.getChats() + + // Crear un diccionario de chats por id + let backupChatsById = Dictionary(uniqueKeysWithValues: chats.map { ($0.id, $0) }) + let expectedChatsById = Dictionary(uniqueKeysWithValues: expectedChats.map { ($0.id, $0) }) + + for (id, expectedChat) in expectedChatsById { + if let backupChat = backupChatsById[id] { + // Comparar número de mensajes + if backupChat.numberMessages != expectedChat.numberMessages { + XCTFail("Chat id \(id) (\(expectedChat.name)): se esperaban \(expectedChat.numberMessages) mensajes, se encontraron \(backupChat.numberMessages)") + } + } else { + XCTFail("No se encontró el chat esperado con id \(id) (\(expectedChat.name)) en el backup") + } + } + + // Verificar si hay chats extra en el backup que no se esperaban + let extraChatIds = Set(backupChatsById.keys).subtracting(expectedChatsById.keys) + if !extraChatIds.isEmpty { + let extraChats = extraChatIds.compactMap { backupChatsById[$0]?.name } + XCTFail("Se encontraron chats extra en el backup que no se esperaban: \(extraChats)") + } + } catch { + XCTFail("Error al obtener los chats: \(error)") + } + } + + func testChatMessages() throws { + let testBackupPath = TestSupport.fixtureRoot.path + let waBackup = WABackup(backupPath: testBackupPath) + do { + let backups = try waBackup.getBackups() + guard let iPhoneBackup = backups.validBackups.first else { + XCTFail("No valid backups found") + return + } + try waBackup.connectChatStorageDb(from: iPhoneBackup) + let chats = try waBackup.getChats() + + // Initialize counts + var messageTypeCounts: [String: Int] = [:] + var totalMessages = 0 + + for chat in chats { + let chatDump = try waBackup.getChat(chatId: chat.id, directoryToSaveMedia: nil) + let messages = chatDump.messages + XCTAssertEqual(messages.count, chat.numberMessages, "Incorrect number of messages in chat ID \(chat.id)") + + totalMessages += messages.count + + // Process messages + for message in messages { + let messageType = message.messageType + messageTypeCounts[messageType, default: 0] += 1 + } + } + + XCTAssertEqual(totalMessages, 85831, "Incorrect number of messages") + + // Expected quantities (replace these with your actual expected values) + let expectedCounts: [String: Int] = [ + "Text": 73617, + "Image": 5281, + "Video": 489, + "Audio": 4942, + "Contact": 32, + "Location": 51, + "Link": 754, + "Document": 144, + "Status": 264, + "GIF": 46, + "Sticker": 211 + ] + + // Check counts against expected quantities + for (messageType, expectedCount) in expectedCounts { + let actualCount = messageTypeCounts[messageType] ?? 0 + XCTAssertEqual(actualCount, expectedCount, "Incorrect number of \(messageType) messages") + } + + } catch { + XCTFail("Error retrieving messages: \(error)") + } + } + + func testChatContacts() throws { + let testBackupPath = TestSupport.fixtureRoot.path + let waBackup = WABackup(backupPath: testBackupPath) + + do { + let backups = try waBackup.getBackups() + guard let iPhoneBackup = backups.validBackups.first else { + XCTFail("No valid backups found") + return + } + try waBackup.connectChatStorageDb(from: iPhoneBackup) + let chats = try waBackup.getChats() + + // Crear directorio temporal para imágenes de contacto + let fileManager = FileManager.default + let tmpDir = fileManager.temporaryDirectory.appendingPathComponent("ContactImages", isDirectory: true) + if !fileManager.fileExists(atPath: tmpDir.path) { + try fileManager.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + var allContacts: Set = [] + + for chat in chats { + let chatDump = try waBackup.getChat(chatId: chat.id, directoryToSaveMedia: tmpDir) + let contacts = chatDump.contacts + allContacts.formUnion(contacts) + } + + let contactsWithImage = allContacts.filter { $0.photoFilename != nil } + let contactsWithoutImage = allContacts.filter { $0.photoFilename == nil } + + // Aserciones del test + XCTAssertEqual(chats.count, 181, "Número de chats distinto del esperado") + XCTAssertEqual(allContacts.count, 225, "Número de contactos únicos distinto del esperado") + XCTAssertEqual(contactsWithImage.count, 192, "Número de contactos con imagen distinto del esperado") + XCTAssertEqual(contactsWithoutImage.count, 33, "Número de contactos sin imagen distinto del esperado") + + // Limpieza + try fileManager.removeItem(at: tmpDir) + + } catch { + XCTFail("Error retrieving contacts: \(error)") + } + } + + func testMessageContentExtraction() throws { + let testBackupPath = TestSupport.fixtureRoot.path + let waBackup = WABackup(backupPath: testBackupPath) + + do { + let backups = try waBackup.getBackups() + guard let iPhoneBackup = backups.validBackups.first else { + XCTFail("No valid backups found") + return + } + try waBackup.connectChatStorageDb(from: iPhoneBackup) + let chatDump = try waBackup.getChat(chatId: 44, directoryToSaveMedia: nil) + let messages = chatDump.messages + + let expectedMessages: [ExpectedMessage] = [ + ExpectedMessage( + id: 131767, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Claro!!! Habrá más cafés y comidas 😄👍👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 131764, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Un placer, Domingo. Ha sido un lujo trabajar contigo , y siempre estaré en deuda contigo. \nY no es un adiós ni mucho menos. Ahora que tienes más tiempo podrás organizar más comidas, cafés o lo que se tercie.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 131730, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Michas gracias por todo otra vez, ha sido una pasada 😄🙌🙌", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 131729, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Buenas Aitor, ya me he descargado lo del vídeo !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 126333, + chatId: 44, + messageType: "Link", + isFromMe: false, + message: "https://piafplara.es/?p=940", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: "Entrevista en À Punt - Proyecto de Investigación Aplicada - LARA", + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 126334, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "En la radio de Apunt ya hemos salido ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 126332, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "De aquí a APunt, y después a RTVE !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 126331, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Qué chulo !!! Estáis consiguiendo darle mucha visibilidad al proyecto 👏👏👏", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 126279, + chatId: 44, + messageType: "Document", + isFromMe: false, + message: "DIARIO INFORMACION PIA LARA.pdf", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: "fea35851-6a2c-45a3-a784-003d25576b45.pdf", + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125482, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Claro, cada vez que vaya a la UA te aviso.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 125479, + reactions: nil + ), + ExpectedMessage( + id: 125487, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Jo, qué pena!", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 125486, + reactions: nil + ), + ExpectedMessage( + id: 125479, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Avísame también la próxima vez porfa, a ver si hay más suerte", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125485, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Una pena no poder ir !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125478, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Y el cartel ha quedado muy bien", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125481, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Enhorabuena por el trabajo 😀👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125486, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Qué chulo !! Muchas gracias por avisar, pero no voy a poder... me coincide con clases", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125439, + chatId: 44, + messageType: "Image", + isFromMe: false, + message: nil, + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: "bcb30316-b72d-47a4-862e-d99c37ecb7ed.jpg", + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 125436, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Por si te interesa y estás por allí:", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119188, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pues sí, habrá que volver al modelo antiguo", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: [Reaction(emoji: "😢", senderPhone: "34636104084")] + ), + ExpectedMessage( + id: 119187, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Me sale a cuenta comprar los libros que me vaya a leer", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119186, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Sí, carete es", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119185, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "50$ mes ...", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119184, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Es bastante caro, ya te digo", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119183, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "lo uso más que la Play, que ya es decir ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119182, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Si no, me tocará pagar", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119181, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pufff pues a ver", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119180, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Yo es que lo uso casi todos los dias ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119179, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Ok. Mil gracias", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119178, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Lo pregunto el lunes y te digo algo", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119177, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Madre mía, me suena que dijeron que iban a reducir el presupuesto de la biblioteca, pero no pensé que se referían a esto 🤦‍♂️", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119176, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Ostras, también !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119175, + chatId: 44, + messageType: "Image", + isFromMe: true, + message: nil, + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: "e92bd345-9d30-49c4-9a41-0eb1d7b4351e.jpg", + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 119173, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hola Domingo ... puedes probar si te funciona el usuario de O'reilly? es que dice que mi cuenta ha caducado", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113948, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "OK! lo voy mirando yo también ... gracias!! 😉", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113949, + chatId: 44, + messageType: "Link", + isFromMe: false, + message: "https://squidfunk.github.io/mkdocs-material/setup/setting-up-a-blog/#rss", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: "Setting up a blog - Material for MkDocs", + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113947, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Cuando vea que soy constante, lo añadiré", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113946, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Se puede, pero no lo he puesto/probado", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 113944, + reactions: nil + ), + ExpectedMessage( + id: 113940, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Es que yo también quería probar algo, pero me gustaría que tuviera soporte para RSS", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113944, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "¿Tiene RSS lo de Material?", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113943, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "jajaja ese es el problema", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113938, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Tb me sirve a modo de recuerdo", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113941, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Gracias! A ver si no desfallezco y escribo al menos una vez al mes", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113942, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Y muy chulo tu blog :)", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113945, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Lo miro y ya te digo algo !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113939, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pues sí, es una buena idea", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113928, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "y ahí meterlo todo", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113931, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Un GitHub del expertojavaua", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113930, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Es otra posibilidad", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113937, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sí, es verdad !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113929, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "En GitHub", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113935, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "¿Tú donde tienes tus apuntes?", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113932, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "No, del departamento", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113936, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "alguno de la eps", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113934, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "jajajaj por eso te pregunto", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113933, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pero yo creo que sí", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113919, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Ese es otro tema, que aquí cada vez están cerrando más lo de los servidores y tendría que \"negociarlo\"", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113925, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Tenéis un servidor web donde os dejen publicarlos?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113921, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pero como ahora estaban todos abiertos habría que repasar las URLs y ya está", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113923, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Estaban bajo una aplicación web que hizo Miguel Ángel y que gestionaba los permisos", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113927, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Lo único informar a Google que han cambiado las direcciones", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113922, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sí, exacto", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113924, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Como todo es estático entiendo que no será dificil", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113926, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sí, a mi también, a ver le hecho un vistazo y miro a ver si no es muy complicado hacer una migración", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113918, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Pero me da pena que no estén", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113920, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "No, tranqui, yo ya hice mi copia y lo cogí todo … es más, los reescribí enteros con Mkdocs", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113912, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Podría buscar los HTML y pasártelos", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113916, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "¿Necesitas urgente lo de NoSQL?", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113914, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sí, a mi me gustaría también migrarlos, aunque fuera solo el último curso", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113917, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "A mí me da pena que desaparezcan sin más", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113910, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Entonces ya son historia ? O los podemos migrar?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113909, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Ahora hay que pensar qué hacemos", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113911, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Ostras", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113913, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Hace un par de semana que los del servicio de informática de la UA nos han obligado a cerrarlos porque no tenían el https", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113915, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sii, se me ha pasado decíroslo", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113908, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hola Domingo … los apuntes de expertojava.ua.es están caídos...http://expertojava.ua.es/si/nosql.html", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113176, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Una pena. Un abrazo!", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: [Reaction(emoji: "🤗", senderPhone: "Me")] + ), + ExpectedMessage( + id: 113175, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Gracias por decírmelo! A la próxima !", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113174, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pues es una pena, pero no voy a poder ir. Precisamente los miércoles tengo toda las mañana con clase ☹️", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113173, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Hola Aitor! Gracias por comentármelo, la verdad es que no me había enterado", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113165, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Voy a intentar ir con el alumnado de IABD", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113164, + chatId: 44, + messageType: "Link", + isFromMe: false, + message: "https://www.parquecientificoumh.es/eventos/transformando-la-investigacion-e-innovacion-con-inteligencia-artificial", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: "www.parquecientificoumh.es", + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113163, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Supongo que te has enterado, pero por si acaso:", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 113162, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hola Domingo", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109680, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Muy buena definición", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109679, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Merece la pena, no es muy caro", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109678, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Sí, exacto, es lo que era HBO al principio", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109677, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "El problema es que el catálogo no es muy extenso, pero el que hay, es bueno", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109676, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Tengo hasta el 12 de Nov la suscripción, y no descarto pagar un poco más", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109675, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Siii 👏👏", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109674, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Esta la hemos devorado mi hijo y yo ... nos ha gustado muchísimo .. tiene un gustillo a 24 brutal", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 109673, + reactions: nil + ), + ExpectedMessage( + id: 109673, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Y otra que podéis ver toda la familia es Secuestro en el Aire, un thriller que también te deja con ganas de ver más de un episodio seguido 😄", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109672, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Buenísima, una de espías que engancha y que se hace muy corta", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109671, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Después ya veré Slow Horses", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109670, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "😄👍👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109669, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Perfecto ... pues ya tenemos serie para toda la familia", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109668, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Creo que la podéis ver sin problema también con los niños", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109667, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Un Who do it muy gracioso con cada episodio contado desde un punto de vista", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109666, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Siii, es muy buena, muy graciosa", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109665, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Me alegro de lo de Bad sisters 😄", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109664, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Has visto \"Afterparty\" ? ... la recomiendan bastante.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 109657, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hola Domingo ... hace un par de días que acabamos \"Hermanas hasta la muerte\" y he tenido a mi mujer enganchada, que quería que cada día viéramos dos capítulos del tiróin!", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108050, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Es majo y trabajador", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108049, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Se debe de acordar de mi", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108048, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Ah, estuvo de subdirector de Informática y estuve con él un par de años preparando las olimpiadas informáticas", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108047, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Bien, a ver si conseguimos darle más visibilidad al proyecto", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108046, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Es uno de los jefazos 😄", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108045, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Cuando lo vea/hablé con él, le diré algo.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108044, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Ha estado metido en bastantes líos directivos en la EPS", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108043, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Lo conozco de vista y de haber estado en algún tribunal de TFG", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: [Reaction(emoji: "👍", senderPhone: "34636104084")] + ), + ExpectedMessage( + id: 108041, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Es del DLSI", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108040, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Espera que lo busco", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 108039, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Otra cosa, aprovecho que te tengo por aquí. Conoces a un tal Jose Norberto de la UA?\nEs que tendré una reunión con él por un tema de datos para el proyecto Lara.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 84001, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 84000, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Acabo de aparcar cerca del aulario de Derecho. Voy para allá.", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83990, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Perfecto!! Nos vemos mañana 👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83989, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "10:30 mejor. Hay menos gente en la cafetería 😄", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83988, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Si, claro. 10:30? 11:00?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83987, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Hola Aitor!! ¿Quedamos entonces mañana? ¿Cómo te viene?", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83380, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "😂👍🏼", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83379, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Jajaja Sí, genial. Que pena no ser jugón y no poder disfrutarlo. Ya me cuentas!! Me anoto el jueves 24 😄👍", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83378, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Así tengo más Elden Ring que contarte ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83377, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Pues lo dejamos para el Jueves 24", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83376, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Pues sí, es que tengo que llevar a mi hija a Gandía, que ha quedado con unos amigos", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83375, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Lo dejamos para la otra semana, no pasa nada", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83374, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Mañana no puedo, sorry", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83373, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "¿Podrías mañana?", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83372, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Lo acabo de ver+", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83371, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Aitor!! Te acabo de enviar un correo ! Resulta que este jueves no puedo", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 83370, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hola Domingo ... nos vemos este Jueves. Intentaré llegar sobre las 10:30 pasadas y te invito a almorzar ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 41198, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Claro!!! A ver si nos vemos un día. Tenemos que quedar!", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: 41195, + reactions: nil + ), + ExpectedMessage( + id: 41197, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Buen viaje", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 41193, + reactions: nil + ), + ExpectedMessage( + id: 41196, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Me pasaré y si veo a alguien saludo", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: 41194, + reactions: nil + ), + ExpectedMessage( + id: 41195, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Una pena. Me apunté ayer a última hora. A ver si nos vemos algún día, me acerco una tarde y nos tomamos un té", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 41194, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Igual está Miguel Ángel o Otto", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 41193, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Muy buenas!! Qué pena, porque hoy no he subido a la uni. Me voy a Valencia a llevarle unas cosas a Anabel ☹️☹️", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 41192, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Hey, buenos días. Esta mañana estaré por la UA con los alumnos. Estarás por el despacho a alguna hora?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 38590, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Qué guay!! Dale recuerdos a Rubén!! Ni me acuerdo de lo del eDarling. Mi mente lo ha borrado todo 🤣", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 38589, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Estoy con Ruben Inoto. Nos ha contado que frustraste su proyecto fin de carrera de hacer un eDarling/ Tinder 😂\nOs podíais haber forrado!", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 18023, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Voy", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 18022, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Estamos en la esquina", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 18021, + chatId: 44, + messageType: "Location", + isFromMe: true, + message: nil, + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 18020, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Estoy en la calle peatonal", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 18019, + chatId: 44, + messageType: "Location", + isFromMe: false, + message: nil, + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 16987, + chatId: 44, + messageType: "Status", + isFromMe: false, + message: "Status sync from Aitor Medrano", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 9979, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Tarde. Te lo llevo el viernes?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 9978, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "😟😟", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 9977, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "De la clase de teoría a la de práctica", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 9976, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Estoy yendo del Aulario II a la politécnica", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 9975, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Estas?", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 4474, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Qué malas son las vacaciones ;)", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 4473, + chatId: 44, + messageType: "Text", + isFromMe: false, + message: "Jajaja ya, no se ni en que día vivo ;)", + senderName: "Aitor Medrano", + senderPhone: "34636104084", + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 4472, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Te lo he dicho por el Slack, pero no sé si lo has leído", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ExpectedMessage( + id: 4471, + chatId: 44, + messageType: "Text", + isFromMe: true, + message: "Aitor, que la reunión no es mañana, es el jueves", + senderName: nil, + senderPhone: nil, + caption: nil, + mediaFilename: nil, + replyTo: nil, + reactions: nil + ), + ] + + for expectedMessage in expectedMessages { + + // Find the message with the expected ID + if let actualMessage = messages.first(where: { $0.id == expectedMessage.id }) { + // Compare fields + XCTAssertEqual(actualMessage.messageType, expectedMessage.messageType, "Message type mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.isFromMe, expectedMessage.isFromMe, "isFromMe mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.message, expectedMessage.message, "Message text mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.senderName, expectedMessage.senderName, "Sender name mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.senderPhone, expectedMessage.senderPhone, "Sender phone mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.caption, expectedMessage.caption, "Caption mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.mediaFilename, expectedMessage.mediaFilename, "Media filename mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualMessage.replyTo, expectedMessage.replyTo, "ReplyTo mismatch for message ID \(expectedMessage.id)") + + // Compare reactions if applicable + if let expectedReactions = expectedMessage.reactions { + XCTAssertNotNil(actualMessage.reactions, "Reactions should not be nil for message ID \(expectedMessage.id)") + if let actualReactions = actualMessage.reactions { + XCTAssertEqual(actualReactions.count, expectedReactions.count, "Number of reactions mismatch for message ID \(expectedMessage.id)") + for (expectedReaction, actualReaction) in zip(expectedReactions, actualReactions) { + XCTAssertEqual(actualReaction.emoji, expectedReaction.emoji, "Reaction emoji mismatch for message ID \(expectedMessage.id)") + XCTAssertEqual(actualReaction.senderPhone, expectedReaction.senderPhone, "Reaction sender phone mismatch for message ID \(expectedMessage.id)") + } + } + } else { + XCTAssertNil(actualMessage.reactions, "Reactions should be nil for message ID \(expectedMessage.id)") + } + + // Add other field comparisons as needed + } else { + XCTFail("Message with ID \(expectedMessage.id) not found in chat ID \(expectedMessage.chatId)") + } + } + } catch { + XCTFail("Error during test: \(error)") + } + } +} diff --git a/Tests/SwiftWABackupAPITests/TestSupport.swift b/Tests/SwiftWABackupAPITests/TestSupport.swift new file mode 100644 index 0000000..d1fc019 --- /dev/null +++ b/Tests/SwiftWABackupAPITests/TestSupport.swift @@ -0,0 +1,450 @@ +import Foundation +import XCTest +@testable import SwiftWABackupAPI +import GRDB + +enum TestSupport { + static let bundledBackupIdentifier = "00008101-000478893600801E" + + static let fixtureRoot: URL = { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Data") + .standardizedFileURL + }() + + static let bundledBackupDirectory: URL = fixtureRoot.appendingPathComponent(bundledBackupIdentifier) + + static func makeWABackup() -> WABackup { + WABackup(backupPath: fixtureRoot.path) + } + + static func firstBundledBackup() throws -> IPhoneBackup { + let waBackup = makeWABackup() + let backups = try waBackup.getBackups() + return try XCTUnwrap(backups.validBackups.first, "Expected the bundled fixture backup to exist") + } + + static func makeConnectedBackup() throws -> (waBackup: WABackup, backup: IPhoneBackup) { + let waBackup = makeWABackup() + let backup = try firstBundledBackup() + try waBackup.connectChatStorageDb(from: backup) + return (waBackup, backup) + } + + static func makeSampleBackup() throws -> TemporaryBackupFixture { + let documentPath = "Media/Document/fea35851-6a2c-45a3-a784-003d25576b45.pdf" + + return try makeTemporaryBackup( + name: "sample-backup", + additionalManifestEntries: [ + BackupStoredFile( + relativePath: documentPath, + fileHash: "cd1234567890sampledocument", + contents: Data("Sample PDF contents".utf8) + ) + ] + ) { db in + try db.execute(sql: """ + CREATE TABLE ZWACHATSESSION ( + Z_PK INTEGER PRIMARY KEY, + ZCONTACTJID TEXT, + ZPARTNERNAME TEXT, + ZLASTMESSAGEDATE DOUBLE, + ZMESSAGECOUNTER INTEGER, + ZSESSIONTYPE INTEGER, + ZARCHIVED INTEGER + ) + """) + try db.execute(sql: """ + CREATE TABLE ZWAMESSAGE ( + Z_PK INTEGER PRIMARY KEY, + ZTOJID TEXT, + ZMESSAGETYPE INTEGER, + ZGROUPMEMBER INTEGER, + ZCHATSESSION INTEGER, + ZTEXT TEXT, + ZMESSAGEDATE DOUBLE, + ZFROMJID TEXT, + ZMEDIAITEM INTEGER, + ZISFROMME INTEGER, + ZGROUPEVENTTYPE INTEGER, + ZSTANZAID TEXT + ) + """) + try db.execute(sql: """ + CREATE TABLE ZWAGROUPMEMBER ( + Z_PK INTEGER PRIMARY KEY, + ZMEMBERJID TEXT, + ZCONTACTNAME TEXT + ) + """) + try db.execute(sql: """ + CREATE TABLE ZWAPROFILEPUSHNAME ( + ZPUSHNAME TEXT, + ZJID TEXT + ) + """) + try db.execute(sql: """ + CREATE TABLE ZWAMEDIAITEM ( + Z_PK INTEGER PRIMARY KEY, + ZMETADATA BLOB, + ZTITLE TEXT, + ZMEDIALOCALPATH TEXT, + ZMOVIEDURATION INTEGER, + ZLATITUDE DOUBLE, + ZLONGITUDE DOUBLE + ) + """) + try db.execute(sql: """ + CREATE TABLE ZWAMESSAGEINFO ( + Z_PK INTEGER PRIMARY KEY, + ZRECEIPTINFO BLOB, + ZMESSAGE INTEGER + ) + """) + + let chat44Latest = referenceDateTimestamp(year: 2024, month: 4, day: 3, hour: 11, minute: 24, second: 16) + let chat593Latest = referenceDateTimestamp(year: 2024, month: 4, day: 2, hour: 10, minute: 0, second: 0) + + try db.execute( + sql: """ + INSERT INTO ZWACHATSESSION + (Z_PK, ZCONTACTJID, ZPARTNERNAME, ZLASTMESSAGEDATE, ZMESSAGECOUNTER, ZSESSIONTYPE, ZARCHIVED) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [44, "34636104084@s.whatsapp.net", "Aitor Medrano", chat44Latest, 3, 0, 0] + ) + try db.execute( + sql: """ + INSERT INTO ZWACHATSESSION + (Z_PK, ZCONTACTJID, ZPARTNERNAME, ZLASTMESSAGEDATE, ZMESSAGECOUNTER, ZSESSIONTYPE, ZARCHIVED) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [593, "34600000001@s.whatsapp.net", "Business Contact", chat593Latest, 2, 0, 0] + ) + + try db.execute( + sql: """ + INSERT INTO ZWAMEDIAITEM + (Z_PK, ZMETADATA, ZTITLE, ZMEDIALOCALPATH, ZMOVIEDURATION, ZLATITUDE, ZLONGITUDE) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [9001, sampleReplyMetadata(replyingTo: "orig-1"), nil, nil, nil, nil, nil] + ) + try db.execute( + sql: """ + INSERT INTO ZWAMEDIAITEM + (Z_PK, ZMETADATA, ZTITLE, ZMEDIALOCALPATH, ZMOVIEDURATION, ZLATITUDE, ZLONGITUDE) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + arguments: [9002, nil, nil, documentPath, nil, nil, nil] + ) + + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGE + (Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 125479, + "34636104084@s.whatsapp.net", + 0, + nil, + 44, + "Original message", + referenceDateTimestamp(year: 2024, month: 4, day: 3, hour: 11, minute: 0, second: 0), + nil, + nil, + 1, + nil, + "orig-1" + ] + ) + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGE + (Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 125482, + "34693206402@s.whatsapp.net", + 0, + nil, + 44, + "Claro, cada vez que vaya a la UA te aviso.", + chat44Latest, + "34636104084@s.whatsapp.net", + 9001, + 0, + nil, + "reply-1" + ] + ) + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGE + (Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 126279, + "34693206402@s.whatsapp.net", + 8, + nil, + 44, + "DIARIO INFORMACION PIA LARA.pdf", + referenceDateTimestamp(year: 2024, month: 4, day: 3, hour: 10, minute: 30, second: 0), + "34636104084@s.whatsapp.net", + 9002, + 0, + nil, + "doc-1" + ] + ) + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGE + (Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 200001, + "34693206402@s.whatsapp.net", + 10, + nil, + 593, + nil, + chat593Latest, + "34600000001@s.whatsapp.net", + nil, + 0, + 38, + "status-1" + ] + ) + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGE + (Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 200002, + "34693206402@s.whatsapp.net", + 0, + nil, + 593, + "Hello from business", + referenceDateTimestamp(year: 2024, month: 4, day: 2, hour: 9, minute: 0, second: 0), + "34600000001@s.whatsapp.net", + nil, + 0, + nil, + "business-text-1" + ] + ) + + try db.execute( + sql: """ + INSERT INTO ZWAMESSAGEINFO + (Z_PK, ZRECEIPTINFO, ZMESSAGE) + VALUES (?, ?, ?) + """, + arguments: [1, sampleReactionReceiptInfo(emoji: "😢", senderPhone: "34636104084"), 125482] + ) + } + } + + static func makeConnectedSampleBackup() throws -> (waBackup: WABackup, fixture: TemporaryBackupFixture) { + let fixture = try makeSampleBackup() + let waBackup = WABackup(backupPath: fixture.rootURL.path) + try waBackup.connectChatStorageDb(from: fixture.backup) + return (waBackup, fixture) + } + + static func requireFullFixtureRun() throws { + if ProcessInfo.processInfo.environment["SWIFT_WA_RUN_FULL_FIXTURE_TESTS"] != "1" { + throw XCTSkip("Skipping full regression fixture suite. Set SWIFT_WA_RUN_FULL_FIXTURE_TESTS=1 to enable it.") + } + + if !FileManager.default.fileExists(atPath: bundledBackupDirectory.path) { + throw XCTSkip("Skipping full regression fixture suite because the large local backup fixture is not available.") + } + } + + static func makeCanonicalJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + static func canonicalJSONString(_ value: T) throws -> String { + let data = try makeCanonicalJSONEncoder().encode(value) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + + static func loadFixture(named relativePath: String) throws -> String { + let url = fixtureRoot.appendingPathComponent(relativePath) + let contents = try String(contentsOf: url, encoding: .utf8) + if contents.hasSuffix("\n") { + return String(contents.dropLast()) + } + return contents + } + + static func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "\(prefix)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func removeItemIfExists(at url: URL) throws { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } + + static func makeTemporaryBackup( + name: String = UUID().uuidString, + additionalManifestEntries: [BackupStoredFile] = [], + chatStorageSetup: (Database) throws -> Void + ) throws -> TemporaryBackupFixture { + let rootURL = try makeTemporaryDirectory(prefix: "SwiftWABackupAPI-tests") + let backupURL = rootURL.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: backupURL, withIntermediateDirectories: true) + + let creationDate = Date(timeIntervalSince1970: 1_711_267_200) + try writePlist([:], to: backupURL.appendingPathComponent("Info.plist")) + try writePlist(["Date": creationDate], to: backupURL.appendingPathComponent("Status.plist")) + + let fileHash = "ab1234567890chatstorage" + try createManifestDatabase( + at: backupURL.appendingPathComponent("Manifest.db"), + chatStorageHash: fileHash + ) + + for storedFile in additionalManifestEntries { + try addManifestEntry(storedFile, toBackupAt: backupURL) + } + + let hashDirectory = backupURL.appendingPathComponent(String(fileHash.prefix(2)), isDirectory: true) + try FileManager.default.createDirectory(at: hashDirectory, withIntermediateDirectories: true) + let chatStorageURL = hashDirectory.appendingPathComponent(fileHash) + let chatStorageQueue = try DatabaseQueue(path: chatStorageURL.path) + try chatStorageQueue.write(chatStorageSetup) + + let backup = IPhoneBackup(url: backupURL, creationDate: creationDate) + return TemporaryBackupFixture(rootURL: rootURL, backupURL: backupURL, backup: backup) + } + + private static func writePlist(_ object: Any, to url: URL) throws { + let data = try PropertyListSerialization.data(fromPropertyList: object, format: .xml, options: 0) + try data.write(to: url) + } + + private static func createManifestDatabase(at url: URL, chatStorageHash: String) throws { + let manifestQueue = try DatabaseQueue(path: url.path) + try manifestQueue.write { db in + try db.execute(sql: """ + CREATE TABLE Files ( + fileID TEXT, + relativePath TEXT, + domain TEXT + ) + """) + + try db.execute( + sql: """ + INSERT INTO Files (fileID, relativePath, domain) + VALUES (?, ?, ?) + """, + arguments: [ + chatStorageHash, + "ChatStorage.sqlite", + "AppDomainGroup-group.net.whatsapp.WhatsApp.shared" + ] + ) + } + } + + private static func addManifestEntry(_ storedFile: BackupStoredFile, toBackupAt backupURL: URL) throws { + let manifestQueue = try DatabaseQueue(path: backupURL.appendingPathComponent("Manifest.db").path) + try manifestQueue.write { db in + try db.execute( + sql: """ + INSERT INTO Files (fileID, relativePath, domain) + VALUES (?, ?, ?) + """, + arguments: [ + storedFile.fileHash, + storedFile.relativePath, + "AppDomainGroup-group.net.whatsapp.WhatsApp.shared" + ] + ) + } + + let hashDirectory = backupURL.appendingPathComponent(String(storedFile.fileHash.prefix(2)), isDirectory: true) + try FileManager.default.createDirectory(at: hashDirectory, withIntermediateDirectories: true) + try storedFile.contents.write(to: hashDirectory.appendingPathComponent(storedFile.fileHash)) + } + + private static func referenceDateTimestamp( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int + ) -> TimeInterval { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(secondsFromGMT: 0) + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + + let date = components.date ?? Date(timeIntervalSinceReferenceDate: 0) + return date.timeIntervalSinceReferenceDate + } + + private static func sampleReplyMetadata(replyingTo stanzaID: String) -> Data { + Data([0x00, 0x00, 0x20] + Array(stanzaID.utf8) + [0x32, 0x1A]) + } + + static func sampleReactionReceiptInfo(emoji: String, senderPhone: String) -> Data { + let sender = Array("\(senderPhone)@s.whatsapp.net".utf8) + let emojiBytes = Array(emoji.utf8) + return Data(sender + [0x00, UInt8(emojiBytes.count)] + emojiBytes) + } +} + +struct TemporaryBackupFixture { + let rootURL: URL + let backupURL: URL + let backup: IPhoneBackup +} + +struct BackupStoredFile { + let relativePath: String + let fileHash: String + let contents: Data +} + +final class MediaWriteDelegateSpy: WABackupDelegate { + private(set) var fileNames: [String] = [] + + func didWriteMediaFile(fileName: String) { + fileNames.append(fileName) + } +}