diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f69b01..4036beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.3.0 + +- Added SMS import from SMS xml export + ## 1.2.1 - Fixed some messages are not shown after WhatsApp import from DB diff --git a/README.md b/README.md index 946a768..307d8d0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ See: [Signal](docs/Signal.md) Import messages from: +- [SMS](docs/Sms.md) - [Telegram](docs/Telegram.md) - [WhatApp DB](docs/WhatApp_DB.md) - [WhatApp export](docs/WhatApp_Export.md) @@ -39,13 +40,15 @@ bin/move_to_signal.dart \ ## Feature Map -| Name | Telegram | WhatApp DB | WhatApp export | -| :------------------------- | :------: | :--------: | :------------: | -| All 1-on-1 text messages | ✅ | ✅ | ❌ | -| Group chats | ❌ | ❌ | ❌ | -| Original timestamps | ✅ | ✅ | ❌ | -| Reactions (emoji) | ❌ | ✅ | ❌ | -| Media (images/audio/links) | ❌ | ❌ | ❌ | +| Name | SMS | Telegram | WhatApp DB | WhatApp export | +| :------------------------- | :-: | :------: | :--------: | :------------: | +| All 1-on-1 text messages | ✅ | ✅ | ✅ | ❌ | +| Group chats | ❌ | ❌ | ❌ | ❌ | +| Original timestamps | ✅ | ✅ | ✅ | ❌ | +| Reactions (emoji) | ❌ | ❌ | ✅ | ❌ | +| MMS Text | ❌ | - | - | - | +| MMS (images/audio/links) | ❌ | - | - | - | +| Media (images/audio/links) | ❌ | ❌ | ❌ | ❌ | ## Known issues diff --git a/bin/move_to_signal.dart b/bin/move_to_signal.dart index 067e3f5..ae2278f 100644 --- a/bin/move_to_signal.dart +++ b/bin/move_to_signal.dart @@ -1,4 +1,5 @@ import 'package:move_to_signal/import/signal.dart'; +import 'package:move_to_signal/source/sms.dart'; import 'package:move_to_signal/source/telegram.dart'; import 'package:move_to_signal/source/whats_app_db.dart'; import 'package:move_to_signal/source/whats_app_export.dart'; @@ -18,6 +19,12 @@ void main(List arguments) { } switch (command) { + case 'ImportSms': + final smsImport = Sms(); + smsImport.verbose = verbose; + smsImport.run(arguments); + + break; case 'ImportTelegram': final telegramImport = Telegram(); telegramImport.verbose = verbose; diff --git a/docs/Sms.md b/docs/Sms.md new file mode 100644 index 0000000..908f71e --- /dev/null +++ b/docs/Sms.md @@ -0,0 +1,92 @@ +# SMS + +Always start by creating a [Signal](docs/Signal.md) backup. + +1. Create SMS backup with [SMS Backup & Restore](https://play.google.com/store/apps/details?id=com.riteshsahu.SMSBackupRestore&pli=1) and copy the sms-(timestamp).xml to the working folder. + +2. Run MoveToSignal in terminal for prepare the import + + Mac arm64 binary + + ```bash + cd path/to/working/folder/ + + path/to/MoveToSignal/move_to_signal_Darwin_arm64 \ + --command=ImportSms \ + --signalBackup=./signal-YYYY-MM-DD-HH-mm-ss.backup \ + --signalBackupKey=123451234512345123451234512345 \ + --signalPhoneNumber=+49123456789 \ + --smsXml="path/to/sms-(timestamp).xml" \ + --smsExports=. \ + --smsMode=Prepare \ + --verbose + ``` + + From source + + ```bash + cd path/to/working/folder/ + + dart run path/to/MoveToSignal/bin/move_to_signal.dart \ + --command=ImportSms \ + --signalBackup=./signal-YYYY-MM-DD-HH-mm-ss.backup \ + --signalBackupKey=123451234512345123451234512345 \ + --signalPhoneNumber=+49123456789 \ + --smsXml="path/to/sms-(timestamp).xml" \ + --smsExports=. \ + --smsMode=Prepare \ + --verbose + ``` + + A new folder named SmsExportsFolder will be created for the export files. + SMS exports will be named eg: +4912345678-(Screen name if found).txt + +3. Rename exports + + Please review the all .txt files and make sure to file names start with the contact phone number the user uses with Signal. + At this point you can also merge files into one, if a user had multiple SMS identities. + Please delete all files you don't want to import. + + All SMS export files must be renamed like: + contactPhoneNumber-Screen Name.txt + + eg: +49123456789-Max ExampleName.txt + + Only the phone number is important for SMS imports. + The phone number needs to in international format starting with + and must only contain numbers. + +4. Run MoveToSignal in terminal to import the prepared messages + + Mac arm64 binary + + ```bash + cd path/to/working/folder/ + + path/to/MoveToSignal/move_to_signal_Darwin_arm64 \ + --command=ImportSms \ + --signalBackup=./signal-YYYY-MM-DD-HH-mm-ss.backup \ + --signalBackupKey=123451234512345123451234512345 \ + --signalPhoneNumber=+49123456789 \ + --smsExports=. \ + --smsMode=Import \ + --verbose + ``` + + From source + + ```bash + cd path/to/working/folder/ + + dart run path/to/MoveToSignal/bin/move_to_signal.dart \ + --command=ImportSms \ + --signalBackup=./signal-YYYY-MM-DD-HH-mm-ss.backup \ + --signalBackupKey=123451234512345123451234512345 \ + --signalPhoneNumber=+49123456789 \ + --smsExports=. \ + --smsMode=Import \ + --verbose + ``` + + Once done, a new Signal backup file is created, like: signal-signal-YYYY-MM-DD-HH-mm-ss.backup (new timestamp) + +5. Follow the "After importing all messages" steps from [Signal](docs/Signal.md) diff --git a/lib/model/sms_message.dart b/lib/model/sms_message.dart new file mode 100644 index 0000000..5135a20 --- /dev/null +++ b/lib/model/sms_message.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +class SmsMessage { + int date = 0; + String from = ''; + String text = ''; + bool received = true; + + @override + String toString() => { + '"date"': date, + '"from"': jsonEncode(from), + '"text"': jsonEncode(text), + '"received"': received, + }.toString(); +} diff --git a/lib/model/sms_thread.dart b/lib/model/sms_thread.dart new file mode 100644 index 0000000..d5f1927 --- /dev/null +++ b/lib/model/sms_thread.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +import 'package:move_to_signal/model/sms_message.dart'; + +class SmsThread { + String name = ''; + String phoneNumber = ''; + List messages = []; + + @override + String toString() => { + '"name"': jsonEncode(name), + '"phoneNumber"': jsonEncode(phoneNumber), + '"messages"': messages, + }.toString(); +} diff --git a/lib/source/sms.dart b/lib/source/sms.dart new file mode 100644 index 0000000..4dc2c1b --- /dev/null +++ b/lib/source/sms.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:move_to_signal/model/signal_message.dart'; +import 'package:move_to_signal/model/sms_message.dart'; +import 'package:move_to_signal/model/sms_thread.dart'; +import 'package:path/path.dart' as path; +import 'package:move_to_signal/import/signal.dart'; +import 'package:xml/xml.dart'; + +class Sms extends Signal { + String _smsMode = 'Prepare'; + String _smsExports = ''; + Directory _smsExportsFolder = Directory('./SmsExportsFolder'); + File? _smsXml; + + final Map _smsThreads = {}; + + @override + run(List arguments) { + // Read all arguments + for (final argument in arguments) { + if (argument.startsWith('--smsXml=')) { + _smsXml = File(argument.split('=').last); + } + if (argument.startsWith('--smsMode=')) { + _smsMode = argument.split('=').last; + } + if (argument.startsWith('--smsExports=')) { + _smsExports = argument.split('=').last; + } + } + + if (verbose) print('Check missing general SMS arguments'); + + if (_smsExports.isEmpty) { + print('Missing argument --smsExports'); + return; + } + + if (verbose) print('Check SMS Exports folder'); + + if (!Directory(_smsExports).existsSync()) { + print('--smsExports=$_smsExports folder not found'); + return; + } + + _smsExportsFolder = + Directory(path.join(_smsExports, _smsExportsFolder.path)); + + if (_smsMode == 'Prepare') { + if (verbose) print('Run in SMS prepare mode'); + + if (verbose) print('Check missing prepare SMS arguments'); + + if (_smsXml == null) { + print('Missing argument --smsXml'); + return; + } + + if (!_smsXml!.existsSync()) { + print('--smsXml=${_smsXml!.path} file not found'); + return; + } + + if (verbose) print('Parse SMS XML file'); + + _parseSmsXml(); + + if (verbose) print('Write parsed SMS XML to tmp folder'); + + _writeSmsExport(); + + print(''); + print(''); + print('Messages threads exported to: ${_smsExportsFolder.path}'); + print(''); + print( + 'Please review the all .txt files and make sure to file names start with the contact phone number the user uses with Signal.'); + print( + 'At this point you can also merge files into one, if a user had multiple SMS identities.'); + print('Please delete all files you don\'t want to import.'); + print(''); + print('A valid file name looks like: +4912345678-Contact Name.txt'); + print( + 'The phone number needs to in international format starting with + and must only contain numbers.'); + print(''); + print('Once you are done, you can start the import process.'); + print(''); + } + + if (_smsMode == 'Import') { + if (verbose) print('Run in SMS import mode'); + + if (!_smsExportsFolder.existsSync()) { + print( + 'Folder $_smsExportsFolder not found. Did you run prepare mode first?'); + return; + } + + super.run(arguments); + + _smsExportsFolder.listSync().forEach((smsExport) { + if (smsExport is File && smsExport.path.endsWith('.txt')) { + _parseSmsExport(smsExport); + } + }); + + signalImport(); + } + } + + void _parseSmsExport(File smsExport) { + if (verbose) { + print('Parse SMS export: ${path.basename(smsExport.path)}'); + } + + var filename = path.basenameWithoutExtension(smsExport.path); + var filenameParts = filename.split('-'); + + if (filenameParts.length != 2) { + print('File name format error ${smsExport.path}'); + return; + } + + // Get contact date from filename + final contactNumber = filenameParts[0]; + final contactSignalId = signalGetRecipientID(contactNumber); + if (contactSignalId == 0) { + print( + 'No RecipientID was found for contact "$contactNumber" in Signal backup'); + return; + } + + final contactSignalThreadId = signalGetThreadID(contactSignalId); + if (contactSignalThreadId == 0) { + print( + 'No ThreadId was found for contact "$contactNumber" in Signal backup'); + return; + } + + // Init new SignalMessage + var signalMessage = SignalMessage(); + + // Read SMS export file + final messages = jsonDecode(smsExport.readAsStringSync()); + + for (final message in messages) { + signalMessage.messageDateTime = message['date'] * 1000; + signalMessage.body = message['text']; + + if (message['received']) { + // Message was received + + signalMessage.threadId = contactSignalThreadId; + signalMessage.fromRecipientId = contactSignalId; + signalMessage.toRecipientId = signalUserID; + signalMessage.setReceived(); + } else { + // Message was sent + + signalMessage.threadId = contactSignalThreadId; + signalMessage.fromRecipientId = signalUserID; + signalMessage.toRecipientId = contactSignalId; + signalMessage.setSend(); + } + + signalAddMessage(signalMessage); + signalMessage = SignalMessage(); + } + } + + void _parseSmsXml() { + // Read SMS XML file + final smsXmlFile = _smsXml!.readAsStringSync(); + + // Decode json string to object + final smsXmlDocument = XmlDocument.parse(smsXmlFile); + + final smses = smsXmlDocument.findAllElements('sms'); + + for (final sms in smses) { + final smsMessage = SmsMessage(); + smsMessage.date = int.parse(sms.getAttribute('date') ?? '0'); + + smsMessage.from = sms.getAttribute('address') ?? ''; + + // Filter numbers and + + smsMessage.from = smsMessage.from.replaceAll(RegExp(r'[^0-9+]+'), ''); + + // if number start with 00 replace by + + smsMessage.from = smsMessage.from.replaceAll(RegExp(r'^00'), '+'); + + smsMessage.text = sms.getAttribute('body') ?? ''; + + if ((sms.getAttribute('type') ?? '') == '2') { + smsMessage.received = false; + } + + var smsThread = _smsThreads[smsMessage.from]; + if (smsThread == null) { + smsThread = SmsThread(); + smsThread.phoneNumber = smsMessage.from; + smsThread.name = sms.getAttribute('contact_name') ?? ''; + smsThread.messages.add(smsMessage); + _smsThreads[smsMessage.from] = smsThread; + } else { + smsThread.messages.add(smsMessage); + } + } + } + + void _writeSmsExport() { + if (verbose) print('Create SMS export folder.'); + + if (_smsExportsFolder.existsSync()) { + _smsExportsFolder.deleteSync(recursive: true); + } + + _smsExportsFolder.createSync(); + + if (verbose) print('Export SMS threads to files.'); + + for (final smsThread in _smsThreads.values) { + String fileName = smsThread.phoneNumber; + + fileName = '$fileName-${smsThread.name}.txt'; + + final filePath = path.join(_smsExportsFolder.path, fileName); + final export = File(filePath).openSync(mode: FileMode.writeOnlyAppend); + + if (verbose) print('Export: $fileName'); + + export.writeStringSync("[\n"); + var firstLine = true; + for (final message in smsThread.messages) { + if (!firstLine) { + export.writeStringSync(",\n"); + } else { + firstLine = false; + } + export.writeStringSync(message.toString()); + } + export.writeStringSync("\n]"); + + export.closeSync(); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 9bcc59e..113f2b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" pool: dependency: transitive description: @@ -393,6 +401,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + xml: + dependency: "direct main" + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7016753..142085d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: move_to_signal description: Import messages from other apps like WhatsApp to Signal. -version: 1.2.1 +version: 1.3.0 repository: https://github.com/de-nets/MoveToSignal environment: @@ -10,6 +10,7 @@ dependencies: intl: ^0.19.0 path: ^1.8.3 sqlite3: ^2.1.0 + xml: ^6.5.0 dev_dependencies: lints: ^2.0.0