diff --git a/app/build.gradle b/app/build.gradle index 261d359d..24c62f3e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,10 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' -apply plugin: 'kotlin-kapt' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-android-extensions' + id 'kotlin-kapt' + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" +} def keystorePropertiesFile = rootProject.file("keystore.properties") def keystoreProperties = new Properties() @@ -71,6 +74,7 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3' implementation 'androidx.lifecycle:lifecycle-process:2.5.1' + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" kapt "androidx.room:room-compiler:2.5.2" implementation "androidx.room:room-runtime:2.5.2" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e6c59860..2c52942d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -4,3 +4,27 @@ @org.greenrobot.eventbus.Subscribe ; } -keep enum org.greenrobot.eventbus.ThreadMode { *; } + +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt index 141dad6a..3f12776f 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt @@ -3,19 +3,15 @@ package com.simplemobiletools.smsmessenger.activities import android.annotation.SuppressLint import android.app.Activity import android.app.role.RoleManager -import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable -import android.net.Uri import android.os.Bundle import android.provider.Telephony import android.text.TextUtils -import android.widget.Toast import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.simplemobiletools.commons.dialogs.FilePickerDialog import com.simplemobiletools.commons.dialogs.PermissionRequiredDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.* @@ -25,8 +21,6 @@ import com.simplemobiletools.smsmessenger.BuildConfig import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.adapters.ConversationsAdapter import com.simplemobiletools.smsmessenger.adapters.SearchResultsAdapter -import com.simplemobiletools.smsmessenger.dialogs.ExportMessagesDialog -import com.simplemobiletools.smsmessenger.dialogs.ImportMessagesDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.* import com.simplemobiletools.smsmessenger.models.Conversation @@ -37,19 +31,14 @@ import kotlinx.android.synthetic.main.activity_main.* import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import java.io.FileOutputStream -import java.io.OutputStream class MainActivity : SimpleActivity() { private val MAKE_DEFAULT_APP_REQUEST = 1 - private val PICK_IMPORT_SOURCE_INTENT = 11 - private val PICK_EXPORT_FILE_INTENT = 21 private var storedTextColor = 0 private var storedFontSize = 0 private var lastSearchedText = "" private var bus: EventBus? = null - private val smsExporter by lazy { MessagesExporter(this) } private var wasProtectionHandled = false @SuppressLint("InlinedApi") @@ -174,8 +163,6 @@ class MainActivity : SimpleActivity() { main_menu.getToolbar().setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { - R.id.import_messages -> tryImportMessages() - R.id.export_messages -> tryToExportMessages() R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() R.id.show_archived -> launchArchivedConversations() R.id.settings -> launchSettings() @@ -200,11 +187,6 @@ class MainActivity : SimpleActivity() { } else { finish() } - } else if (requestCode == PICK_IMPORT_SOURCE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { - tryImportMessagesFromFile(resultData.data!!) - } else if (requestCode == PICK_EXPORT_FILE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { - val outputStream = contentResolver.openOutputStream(resultData.data!!) - exportMessagesTo(outputStream) } } @@ -593,119 +575,6 @@ class MainActivity : SimpleActivity() { startAboutActivity(R.string.app_name, licenses, BuildConfig.VERSION_NAME, faqItems, true) } - private fun tryToExportMessages() { - if (isQPlus()) { - ExportMessagesDialog(this, config.lastExportPath, true) { file -> - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - type = JSON_MIME_TYPE - putExtra(Intent.EXTRA_TITLE, file.name) - addCategory(Intent.CATEGORY_OPENABLE) - - try { - startActivityForResult(this, PICK_EXPORT_FILE_INTENT) - } catch (e: ActivityNotFoundException) { - toast(R.string.system_service_disabled, Toast.LENGTH_LONG) - } catch (e: Exception) { - showErrorToast(e) - } - } - } - } else { - handlePermission(PERMISSION_WRITE_STORAGE) { - if (it) { - ExportMessagesDialog(this, config.lastExportPath, false) { file -> - getFileOutputStream(file.toFileDirItem(this), true) { outStream -> - exportMessagesTo(outStream) - } - } - } - } - } - } - - private fun exportMessagesTo(outputStream: OutputStream?) { - toast(R.string.exporting) - ensureBackgroundThread { - smsExporter.exportMessages(outputStream) { - val toastId = when (it) { - MessagesExporter.ExportResult.EXPORT_OK -> R.string.exporting_successful - else -> R.string.exporting_failed - } - - toast(toastId) - } - } - } - - private fun tryImportMessages() { - if (isQPlus()) { - Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = JSON_MIME_TYPE - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(JSON_MIME_TYPE, XML_MIME_TYPE, TXT_MIME_TYPE)) - - try { - startActivityForResult(this, PICK_IMPORT_SOURCE_INTENT) - } catch (e: ActivityNotFoundException) { - toast(R.string.system_service_disabled, Toast.LENGTH_LONG) - } catch (e: Exception) { - showErrorToast(e) - } - } - } else { - handlePermission(PERMISSION_READ_STORAGE) { - if (it) { - importMessages() - } - } - } - } - - private fun importMessages() { - FilePickerDialog(this) { - showImportMessagesDialog(it) - } - } - - private fun showImportMessagesDialog(path: String) { - ImportMessagesDialog(this, path) - } - - private fun tryImportMessagesFromFile(uri: Uri) { - when (uri.scheme) { - "file" -> showImportMessagesDialog(uri.path!!) - "content" -> { - var tempFile = getTempFile("messages", "backup.json") - if (tempFile == null) { - toast(R.string.unknown_error_occurred) - return - } - - try { - val inputStream = contentResolver.openInputStream(uri) - val out = FileOutputStream(tempFile) - inputStream!!.copyTo(out) - // Check is XML and properly rename - tempFile.bufferedReader().use { - if (it.readLine().startsWith(" toast(R.string.invalid_file_format) - } - } - @Subscribe(threadMode = ThreadMode.MAIN) fun refreshMessages(event: Events.RefreshMessages) { initMessenger() diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt index 38cc7388..12c845f6 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/SettingsActivity.kt @@ -2,21 +2,30 @@ package com.simplemobiletools.smsmessenger.activities import android.annotation.TargetApi import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import com.simplemobiletools.commons.activities.ManageBlockedNumbersActivity import com.simplemobiletools.commons.dialogs.* import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.models.RadioItem import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.dialogs.ExportMessagesDialog import com.simplemobiletools.smsmessenger.extensions.config import com.simplemobiletools.smsmessenger.helpers.* +import com.simplemobiletools.smsmessenger.models.* import kotlinx.android.synthetic.main.activity_settings.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.util.* +import kotlin.system.exitProcess class SettingsActivity : SimpleActivity() { private var blockedNumbersAtPause = -1 + private val messagesFileType = "application/json" + private val messageImportFileTypes = listOf("application/json", "application/xml", "text/xml") override fun onCreate(savedInstanceState: Bundle?) { isMaterialActivity = true @@ -49,6 +58,8 @@ class SettingsActivity : SimpleActivity() { setupLockScreenVisibility() setupMMSFileSizeLimit() setupAppPasswordProtection() + setupMessagesExport() + setupMessagesImport() updateTextColors(settings_nested_scrollview) if (blockedNumbersAtPause != -1 && blockedNumbersAtPause != getBlockedNumbers().hashCode()) { @@ -60,12 +71,63 @@ class SettingsActivity : SimpleActivity() { settings_general_settings_label, settings_outgoing_messages_label, settings_notifications_label, - settings_security_label + settings_security_label, + settings_migrating_label ).forEach { it.setTextColor(getProperPrimaryColor()) } } + private val getContent = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + MessagesImporter(this).importMessages(uri) + } + } + + private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument(messagesFileType)) { uri -> + if (uri != null) { + toast(R.string.exporting) + exportMessages(uri) + } + } + + private fun setupMessagesExport() { + settings_export_messages_holder.setOnClickListener { + ExportMessagesDialog(this) { fileName -> + saveDocument.launch(fileName) + } + } + } + + private fun setupMessagesImport() { + settings_import_messages_holder.setOnClickListener { + getContent.launch(messageImportFileTypes.toTypedArray()) + } + } + + private fun exportMessages(uri: Uri) { + ensureBackgroundThread { + try { + MessagesReader(this).getMessagesToExport(config.exportSms, config.exportMms) { messagesToExport -> + if (messagesToExport.isEmpty()) { + toast(R.string.no_entries_for_exporting) + return@getMessagesToExport + } + val json = Json { encodeDefaults = true } + val jsonString = json.encodeToString(messagesToExport) + val outputStream = contentResolver.openOutputStream(uri)!! + + outputStream.use { + it.write(jsonString.toByteArray()) + } + toast(R.string.exporting_successful) + } + } catch (e: Exception) { + showErrorToast(e) + } + } + } + override fun onPause() { super.onPause() blockedNumbersAtPause = getBlockedNumbers().hashCode() @@ -98,7 +160,7 @@ class SettingsActivity : SimpleActivity() { settings_use_english_holder.setOnClickListener { settings_use_english.toggle() config.useEnglish = settings_use_english.isChecked - System.exit(0) + exitProcess(0) } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ExportMessagesDialog.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ExportMessagesDialog.kt index 86067596..b25556e0 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ExportMessagesDialog.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ExportMessagesDialog.kt @@ -2,42 +2,27 @@ package com.simplemobiletools.smsmessenger.dialogs import android.view.ViewGroup import androidx.appcompat.app.AlertDialog -import com.simplemobiletools.commons.dialogs.FilePickerDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.activities.SimpleActivity import com.simplemobiletools.smsmessenger.extensions.config -import com.simplemobiletools.smsmessenger.helpers.JSON_FILE_EXTENSION -import kotlinx.android.synthetic.main.dialog_export_messages.view.* -import java.io.File +import kotlinx.android.synthetic.main.dialog_export_messages.view.export_messages_filename +import kotlinx.android.synthetic.main.dialog_export_messages.view.export_mms_checkbox +import kotlinx.android.synthetic.main.dialog_export_messages.view.export_sms_checkbox class ExportMessagesDialog( private val activity: SimpleActivity, - private val path: String, - private val hidePath: Boolean, - private val callback: (file: File) -> Unit, + private val callback: (fileName: String) -> Unit, ) { - private var realPath = if (path.isEmpty()) activity.internalStoragePath else path private val config = activity.config init { val view = (activity.layoutInflater.inflate(R.layout.dialog_export_messages, null) as ViewGroup).apply { - export_messages_folder.setText(activity.humanizePath(realPath)) - export_messages_filename.setText("${activity.getString(R.string.messages)}_${activity.getCurrentFormattedDateTime()}") export_sms_checkbox.isChecked = config.exportSms export_mms_checkbox.isChecked = config.exportMms - - if (hidePath) { - export_messages_folder_hint.beGone() - } else { - export_messages_folder.setOnClickListener { - activity.hideKeyboard(export_messages_filename) - FilePickerDialog(activity, realPath, false, showFAB = true) { - export_messages_folder.setText(activity.humanizePath(it)) - realPath = it - } - } - } + export_messages_filename.setText( + activity.getString(R.string.messages) + "_" + activity.getCurrentFormattedDateTime() + ) } activity.getAlertDialogBuilder() @@ -45,29 +30,17 @@ class ExportMessagesDialog( .setNegativeButton(R.string.cancel, null) .apply { activity.setupDialogStuff(view, this, R.string.export_messages) { alertDialog -> - alertDialog.showKeyboard(view.export_messages_filename) alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + config.exportSms = view.export_sms_checkbox.isChecked + config.exportMms = view.export_mms_checkbox.isChecked val filename = view.export_messages_filename.value when { filename.isEmpty() -> activity.toast(R.string.empty_name) filename.isAValidFilename() -> { - val file = File(realPath, "$filename$JSON_FILE_EXTENSION") - if (!hidePath && file.exists()) { - activity.toast(R.string.name_taken) - return@setOnClickListener - } - - if (!view.export_sms_checkbox.isChecked && !view.export_mms_checkbox.isChecked) { - activity.toast(R.string.no_option_selected) - return@setOnClickListener - } - - config.exportSms = view.export_sms_checkbox.isChecked - config.exportMms = view.export_mms_checkbox.isChecked - config.lastExportPath = file.absolutePath.getParentPath() - callback(file) + callback(filename) alertDialog.dismiss() } + else -> activity.toast(R.string.invalid_name) } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ImportMessagesDialog.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ImportMessagesDialog.kt index 3a493dc6..626115fa 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ImportMessagesDialog.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ImportMessagesDialog.kt @@ -10,13 +10,13 @@ import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.activities.SimpleActivity import com.simplemobiletools.smsmessenger.extensions.config import com.simplemobiletools.smsmessenger.helpers.MessagesImporter -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_OK -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_PARTIAL +import com.simplemobiletools.smsmessenger.models.MessagesBackup +import com.simplemobiletools.smsmessenger.models.ImportResult import kotlinx.android.synthetic.main.dialog_import_messages.view.* class ImportMessagesDialog( private val activity: SimpleActivity, - private val path: String, + private val messages: List, ) { private val config = activity.config @@ -48,7 +48,7 @@ class ImportMessagesDialog( config.importSms = view.import_sms_checkbox.isChecked config.importMms = view.import_mms_checkbox.isChecked ensureBackgroundThread { - MessagesImporter(activity).importMessages(path) { + MessagesImporter(activity).restoreMessages(messages) { handleParseResult(it) alertDialog.dismiss() } @@ -58,11 +58,12 @@ class ImportMessagesDialog( } } - private fun handleParseResult(result: MessagesImporter.ImportResult) { + private fun handleParseResult(result: ImportResult) { activity.toast( when (result) { - IMPORT_OK -> R.string.importing_successful - IMPORT_PARTIAL -> R.string.importing_some_entries_failed + ImportResult.IMPORT_OK -> R.string.importing_successful + ImportResult.IMPORT_PARTIAL -> R.string.importing_some_entries_failed + ImportResult.IMPORT_FAIL -> R.string.importing_failed else -> R.string.no_items_found } ) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt deleted file mode 100644 index 49c2d4b6..00000000 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.simplemobiletools.smsmessenger.helpers - -import android.content.Context -import com.google.gson.Gson -import com.google.gson.stream.JsonWriter -import com.simplemobiletools.commons.helpers.ensureBackgroundThread -import com.simplemobiletools.smsmessenger.extensions.config -import com.simplemobiletools.smsmessenger.extensions.getConversationIds -import java.io.OutputStream - -class MessagesExporter(private val context: Context) { - enum class ExportResult { - EXPORT_FAIL, EXPORT_OK - } - - private val config = context.config - private val messageReader = MessagesReader(context) - private val gson = Gson() - - fun exportMessages(outputStream: OutputStream?, onProgress: (total: Int, current: Int) -> Unit = { _, _ -> }, callback: (result: ExportResult) -> Unit) { - ensureBackgroundThread { - if (outputStream == null) { - callback.invoke(ExportResult.EXPORT_FAIL) - return@ensureBackgroundThread - } - val writer = JsonWriter(outputStream.bufferedWriter()) - writer.use { - try { - var written = 0 - writer.beginArray() - val conversationIds = context.getConversationIds() - val totalMessages = messageReader.getMessagesCount() - for (threadId in conversationIds) { - writer.beginObject() - if (config.exportSms && messageReader.getSmsCount() > 0) { - writer.name("sms") - writer.beginArray() - messageReader.forEachSms(threadId) { - writer.jsonValue(gson.toJson(it)) - written++ - onProgress.invoke(totalMessages, written) - } - writer.endArray() - } - - if (config.exportMms && messageReader.getMmsCount() > 0) { - writer.name("mms") - writer.beginArray() - messageReader.forEachMms(threadId) { - writer.jsonValue(gson.toJson(it)) - written++ - onProgress.invoke(totalMessages, written) - } - - writer.endArray() - } - - writer.endObject() - } - writer.endArray() - callback.invoke(ExportResult.EXPORT_OK) - } catch (e: Exception) { - callback.invoke(ExportResult.EXPORT_FAIL) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesImporter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesImporter.kt index 28ac0687..af95f391 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesImporter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesImporter.kt @@ -1,172 +1,145 @@ package com.simplemobiletools.smsmessenger.helpers -import android.content.Context -import android.util.JsonToken +import android.net.Uri import android.util.Xml -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.simplemobiletools.commons.extensions.showErrorToast +import com.simplemobiletools.commons.extensions.toast import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.activities.SimpleActivity +import com.simplemobiletools.smsmessenger.dialogs.ImportMessagesDialog import com.simplemobiletools.smsmessenger.extensions.config -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_FAIL -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_NOTHING_NEW -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_OK -import com.simplemobiletools.smsmessenger.helpers.MessagesImporter.ImportResult.IMPORT_PARTIAL -import com.simplemobiletools.smsmessenger.models.MmsBackup -import com.simplemobiletools.smsmessenger.models.SmsBackup +import com.simplemobiletools.smsmessenger.models.* +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.xmlpull.v1.XmlPullParser -import java.io.File import java.io.InputStream -class MessagesImporter(private val context: Context) { - enum class ImportResult { - IMPORT_FAIL, IMPORT_OK, IMPORT_PARTIAL, IMPORT_NOTHING_NEW - } - private val gson = Gson() - private val messageWriter = MessagesWriter(context) - private val config = context.config +class MessagesImporter(private val activity: SimpleActivity) { + + private val messageWriter = MessagesWriter(activity) + private val config = activity.config private var messagesImported = 0 private var messagesFailed = 0 - fun importMessages(path: String, onProgress: (total: Int, current: Int) -> Unit = { _, _ -> }, callback: (result: ImportResult) -> Unit) { + fun importMessages(uri: Uri) { + try { + val fileType = activity.contentResolver.getType(uri).orEmpty() + val isXml = isXmlMimeType(fileType) || (uri.path?.endsWith("txt") == true && isFileXml(uri)) + if (isXml) { + activity.toast(R.string.importing) + getInputStreamFromUri(uri)!!.importXml() + } else { + importJson(uri) + } + } catch (e: Exception) { + activity.showErrorToast(e) + } + } + + private fun importJson(uri: Uri) { + try { + val jsonString = activity.contentResolver.openInputStream(uri)!!.use { inputStream -> + inputStream.bufferedReader().readText() + } + + val deserializedList = Json.decodeFromString>(jsonString) + if (deserializedList.isEmpty()) { + activity.toast(R.string.no_entries_for_importing) + return + } + ImportMessagesDialog(activity, deserializedList) + } catch (e: SerializationException) { + activity.toast(R.string.invalid_file_format) + } catch (e: IllegalArgumentException) { + activity.toast(R.string.invalid_file_format) + } catch (e: Exception) { + activity.showErrorToast(e) + } + } + + fun restoreMessages(messagesBackup: List, callback: (ImportResult) -> Unit) { ensureBackgroundThread { try { - val isXml = if (path.endsWith("txt")) { - // Need to read the first line to determine if it is xml - val tempStream = getInputStreamForPath(path) - tempStream.bufferedReader().use { - it.readLine().startsWith(" + try { + if (message.backupType == BackupType.SMS && config.importSms) { + messageWriter.writeSmsMessage(message as SmsBackup) + messagesImported++ + } else if (message.backupType == BackupType.MMS && config.importMms) { + messageWriter.writeMmsMessage(message as MmsBackup) + messagesImported++ + } + } catch (e: Exception) { + activity.showErrorToast(e) + messagesFailed++ } - } else { - path.endsWith("xml") } - - val inputStream = getInputStreamForPath(path) - - if (isXml) { - inputStream.importXml() - } else { - inputStream.importJson() - } - + refreshMessages() } catch (e: Exception) { - context.showErrorToast(e) - messagesFailed++ + activity.showErrorToast(e) } callback.invoke( when { - messagesImported == 0 && messagesFailed == 0 -> IMPORT_NOTHING_NEW - messagesFailed > 0 && messagesImported > 0 -> IMPORT_PARTIAL - messagesFailed > 0 -> IMPORT_FAIL - else -> IMPORT_OK + messagesImported == 0 && messagesFailed == 0 -> ImportResult.IMPORT_NOTHING_NEW + messagesFailed > 0 && messagesImported > 0 -> ImportResult.IMPORT_PARTIAL + messagesFailed > 0 -> ImportResult.IMPORT_FAIL + else -> ImportResult.IMPORT_OK } ) } } - private fun getInputStreamForPath(path: String): InputStream { - return if (path.contains("/")) { - File(path).inputStream() - } else { - context.assets.open(path) - } - } - - private fun InputStream.importJson() { - bufferedReader().use { reader -> - val jsonReader = gson.newJsonReader(reader) - val smsMessageType = object : TypeToken() {}.type - val mmsMessageType = object : TypeToken() {}.type - - jsonReader.beginArray() - while (jsonReader.hasNext()) { - jsonReader.beginObject() - while (jsonReader.hasNext()) { - val nextToken = jsonReader.peek() - if (nextToken.ordinal == JsonToken.NAME.ordinal) { - val msgType = jsonReader.nextName() - - if ((!msgType.equals("sms") && !msgType.equals("mms")) || - (msgType.equals("sms") && !config.importSms) || - (msgType.equals("mms") && !config.importMms) - ) { - jsonReader.skipValue() - continue - } - - jsonReader.beginArray() - while (jsonReader.hasNext()) { - try { - if (msgType.equals("sms")) { - val message = gson.fromJson(jsonReader, smsMessageType) - messageWriter.writeSmsMessage(message) - } else { - val message = gson.fromJson(jsonReader, mmsMessageType) - messageWriter.writeMmsMessage(message) - } - - messagesImported++ - } catch (e: Exception) { - context.showErrorToast(e) - messagesFailed++ - - } - } - jsonReader.endArray() - } else { - jsonReader.skipValue() - } - } - - jsonReader.endObject() - refreshMessages() - } - - jsonReader.endArray() - } - } - private fun InputStream.importXml() { - bufferedReader().use { reader -> - val xmlParser = Xml.newPullParser().apply { - setInput(reader) - } - - xmlParser.nextTag() - xmlParser.require(XmlPullParser.START_TAG, null, "smses") - - var depth = 1 - while (depth != 0) { - when (xmlParser.next()) { - XmlPullParser.END_TAG -> depth-- - XmlPullParser.START_TAG -> depth++ + try { + bufferedReader().use { reader -> + val xmlParser = Xml.newPullParser().apply { + setInput(reader) } - if (xmlParser.eventType != XmlPullParser.START_TAG) { - continue - } + xmlParser.nextTag() + xmlParser.require(XmlPullParser.START_TAG, null, "smses") - try { - if (xmlParser.name == "sms") { - if (config.importSms) { - val message = xmlParser.readSms() - messageWriter.writeSmsMessage(message) - messagesImported++ + var depth = 1 + while (depth != 0) { + when (xmlParser.next()) { + XmlPullParser.END_TAG -> depth-- + XmlPullParser.START_TAG -> depth++ + } + + if (xmlParser.eventType != XmlPullParser.START_TAG) { + continue + } + + try { + if (xmlParser.name == "sms") { + if (config.importSms) { + val message = xmlParser.readSms() + messageWriter.writeSmsMessage(message) + messagesImported++ + } else { + xmlParser.skip() + } } else { xmlParser.skip() } - } else { - xmlParser.skip() + } catch (e: Exception) { + activity.showErrorToast(e) + messagesFailed++ } - } catch (e: Exception) { - context.showErrorToast(e) - messagesFailed++ } + refreshMessages() } - - refreshMessages() + when { + messagesFailed > 0 && messagesImported > 0 -> activity.toast(R.string.importing_some_entries_failed) + messagesFailed > 0 -> activity.toast(R.string.importing_failed) + else -> activity.toast(R.string.importing_successful) + } + } catch (_: Exception) { + activity.toast(R.string.invalid_file_format) } } @@ -200,4 +173,27 @@ class MessagesImporter(private val context: Context) { } } } + + private fun getInputStreamFromUri(uri: Uri): InputStream? { + return try { + activity.contentResolver.openInputStream(uri) + } catch (e: Exception) { + null + } + } + + private fun isFileXml(uri: Uri): Boolean { + val inputStream = getInputStreamFromUri(uri) + return inputStream?.bufferedReader()?.use { reader -> + reader.readLine()?.startsWith(" Unit) { + + fun getMessagesToExport( + getSms: Boolean, getMms: Boolean, callback: (messages: List) -> Unit + ) { + val conversationIds = context.getConversationIds() + var smsMessages = listOf() + var mmsMessages = listOf() + + if (getSms) { + smsMessages = getSmsMessages(conversationIds) + } + if (getMms) { + mmsMessages = getMmsMessages(conversationIds) + } + callback(smsMessages + mmsMessages) + } + + private fun getSmsMessages(threadIds: List): List { val projection = arrayOf( Sms.SUBSCRIPTION_ID, Sms.ADDRESS, @@ -33,25 +48,28 @@ class MessagesReader(private val context: Context) { ) val selection = "${Sms.THREAD_ID} = ?" - val selectionArgs = arrayOf(threadId.toString()) - context.queryCursor(Sms.CONTENT_URI, projection, selection, selectionArgs) { cursor -> - val subscriptionId = cursor.getLongValue(Sms.SUBSCRIPTION_ID) - val address = cursor.getStringValue(Sms.ADDRESS) - val body = cursor.getStringValueOrNull(Sms.BODY) - val date = cursor.getLongValue(Sms.DATE) - val dateSent = cursor.getLongValue(Sms.DATE_SENT) - val locked = cursor.getIntValue(Sms.LOCKED) - val protocol = cursor.getStringValueOrNull(Sms.PROTOCOL) - val read = cursor.getIntValue(Sms.READ) - val status = cursor.getIntValue(Sms.STATUS) - val type = cursor.getIntValue(Sms.TYPE) - val serviceCenter = cursor.getStringValueOrNull(Sms.SERVICE_CENTER) - block(SmsBackup(subscriptionId, address, body, date, dateSent, locked, protocol, read, status, type, serviceCenter)) + val smsList = mutableListOf() + + threadIds.map { it.toString() }.forEach { threadId -> + context.queryCursor(Sms.CONTENT_URI, projection, selection, arrayOf(threadId)) { cursor -> + val subscriptionId = cursor.getLongValue(Sms.SUBSCRIPTION_ID) + val address = cursor.getStringValue(Sms.ADDRESS) + val body = cursor.getStringValueOrNull(Sms.BODY) + val date = cursor.getLongValue(Sms.DATE) + val dateSent = cursor.getLongValue(Sms.DATE_SENT) + val locked = cursor.getIntValue(Sms.DATE_SENT) + val protocol = cursor.getStringValueOrNull(Sms.PROTOCOL) + val read = cursor.getIntValue(Sms.READ) + val status = cursor.getIntValue(Sms.STATUS) + val type = cursor.getIntValue(Sms.TYPE) + val serviceCenter = cursor.getStringValueOrNull(Sms.SERVICE_CENTER) + smsList.add(SmsBackup(subscriptionId, address, body, date, dateSent, locked, protocol, read, status, type, serviceCenter)) + } } + return smsList } - // all mms from simple sms are non-text messages - fun forEachMms(threadId: Long, includeTextOnlyAttachment: Boolean = false, block: (MmsBackup) -> Unit) { + private fun getMmsMessages(threadIds: List, includeTextOnlyAttachment: Boolean = false): List { val projection = arrayOf( Mms._ID, Mms.CREATOR, @@ -71,65 +89,67 @@ class MessagesReader(private val context: Context) { Mms.SUBSCRIPTION_ID, Mms.TRANSACTION_ID ) - val selection = if (includeTextOnlyAttachment) { "${Mms.THREAD_ID} = ? AND ${Mms.TEXT_ONLY} = ?" } else { "${Mms.THREAD_ID} = ?" } + val mmsList = mutableListOf() - val selectionArgs = if (includeTextOnlyAttachment) { - arrayOf(threadId.toString(), "1") - } else { - arrayOf(threadId.toString()) - } + threadIds.map { it.toString() }.forEach { threadId -> + val selectionArgs = if (includeTextOnlyAttachment) { + arrayOf(threadId, "1") + } else { + arrayOf(threadId) + } + context.queryCursor(Mms.CONTENT_URI, projection, selection, selectionArgs) { cursor -> + val mmsId = cursor.getLongValue(Mms._ID) + val creator = cursor.getStringValueOrNull(Mms.CREATOR) + val contentType = cursor.getStringValueOrNull(Mms.CONTENT_TYPE) + val deliveryReport = cursor.getIntValue(Mms.DELIVERY_REPORT) + val date = cursor.getLongValue(Mms.DATE) + val dateSent = cursor.getLongValue(Mms.DATE_SENT) + val locked = cursor.getIntValue(Mms.LOCKED) + val messageType = cursor.getIntValue(Mms.MESSAGE_TYPE) + val messageBox = cursor.getIntValue(Mms.MESSAGE_BOX) + val read = cursor.getIntValue(Mms.READ) + val readReport = cursor.getIntValue(Mms.READ_REPORT) + val seen = cursor.getIntValue(Mms.SEEN) + val textOnly = cursor.getIntValue(Mms.TEXT_ONLY) + val status = cursor.getStringValueOrNull(Mms.STATUS) + val subject = cursor.getStringValueOrNull(Mms.SUBJECT) + val subjectCharSet = cursor.getStringValueOrNull(Mms.SUBJECT_CHARSET) + val subscriptionId = cursor.getLongValue(Mms.SUBSCRIPTION_ID) + val transactionId = cursor.getStringValueOrNull(Mms.TRANSACTION_ID) - context.queryCursor(Mms.CONTENT_URI, projection, selection, selectionArgs) { cursor -> - val mmsId = cursor.getLongValue(Mms._ID) - val creator = cursor.getStringValueOrNull(Mms.CREATOR) - val contentType = cursor.getStringValueOrNull(Mms.CONTENT_TYPE) - val deliveryReport = cursor.getIntValue(Mms.DELIVERY_REPORT) - val date = cursor.getLongValue(Mms.DATE) - val dateSent = cursor.getLongValue(Mms.DATE_SENT) - val locked = cursor.getIntValue(Mms.LOCKED) - val messageType = cursor.getIntValue(Mms.MESSAGE_TYPE) - val messageBox = cursor.getIntValue(Mms.MESSAGE_BOX) - val read = cursor.getIntValue(Mms.READ) - val readReport = cursor.getIntValue(Mms.READ_REPORT) - val seen = cursor.getIntValue(Mms.SEEN) - val textOnly = cursor.getIntValue(Mms.TEXT_ONLY) - val status = cursor.getStringValueOrNull(Mms.STATUS) - val subject = cursor.getStringValueOrNull(Mms.SUBJECT) - val subjectCharSet = cursor.getStringValueOrNull(Mms.SUBJECT_CHARSET) - val subscriptionId = cursor.getLongValue(Mms.SUBSCRIPTION_ID) - val transactionId = cursor.getStringValueOrNull(Mms.TRANSACTION_ID) - - val parts = getParts(mmsId) - val addresses = getMmsAddresses(mmsId) - block( - MmsBackup( - creator, - contentType, - deliveryReport, - date, - dateSent, - locked, - messageType, - messageBox, - read, - readReport, - seen, - textOnly, - status, - subject, - subjectCharSet, - subscriptionId, - transactionId, - addresses, - parts + val parts = getParts(mmsId) + val addresses = getMmsAddresses(mmsId) + mmsList.add( + MmsBackup( + creator, + contentType, + deliveryReport, + date, + dateSent, + locked, + messageType, + messageBox, + read, + readReport, + seen, + textOnly, + status, + subject, + subjectCharSet, + subscriptionId, + transactionId, + addresses, + parts + ) ) - ) + } } + return mmsList } @SuppressLint("NewApi") diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/BackupType.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/BackupType.kt new file mode 100644 index 00000000..c6132aee --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/BackupType.kt @@ -0,0 +1,13 @@ +package com.simplemobiletools.smsmessenger.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class BackupType { + @SerialName("sms") + SMS, + + @SerialName("mms") + MMS, +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ImportResult.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ImportResult.kt new file mode 100644 index 00000000..c4151d15 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/ImportResult.kt @@ -0,0 +1,5 @@ +package com.simplemobiletools.smsmessenger.models + +enum class ImportResult { + IMPORT_FAIL, IMPORT_OK, IMPORT_PARTIAL, IMPORT_NOTHING_NEW +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MessagesBackup.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MessagesBackup.kt new file mode 100644 index 00000000..a01137c9 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MessagesBackup.kt @@ -0,0 +1,24 @@ +package com.simplemobiletools.smsmessenger.models + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.* + +@Serializable(with = BackupSerializer::class) +sealed class MessagesBackup() { + @SerialName("backupType") + abstract val backupType: BackupType +} + +object BackupSerializer : + JsonContentPolymorphicSerializer(MessagesBackup::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + return when (element.jsonObject["backupType"]?.jsonPrimitive?.content) { + "sms" -> SmsBackup.serializer() + "mms" -> MmsBackup.serializer() + else -> throw SerializationException("ERROR: No Serializer found. Serialization failed.") + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsAddress.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsAddress.kt index 07148905..821047cc 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsAddress.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsAddress.kt @@ -4,7 +4,9 @@ import android.content.ContentValues import android.provider.Telephony import androidx.core.content.contentValuesOf import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +@Serializable data class MmsAddress( @SerializedName("address") val address: String, diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsBackup.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsBackup.kt index e8957d93..3d0c9d25 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsBackup.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsBackup.kt @@ -4,7 +4,9 @@ import android.content.ContentValues import android.provider.Telephony import androidx.core.content.contentValuesOf import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +@Serializable data class MmsBackup( @SerializedName("creator") val creator: String?, @@ -44,7 +46,9 @@ data class MmsBackup( val addresses: List, @SerializedName("parts") val parts: List, -) { + + override val backupType: BackupType = BackupType.MMS, +): MessagesBackup() { fun toContentValues(): ContentValues { return contentValuesOf( diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsPart.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsPart.kt index ac5d53d1..8edde56e 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsPart.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/MmsPart.kt @@ -4,7 +4,9 @@ import android.content.ContentValues import android.provider.Telephony import androidx.core.content.contentValuesOf import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +@Serializable data class MmsPart( @SerializedName("cd") val contentDisposition: String?, diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/SmsBackup.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/SmsBackup.kt index a6daa883..ff3c9a39 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/SmsBackup.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/SmsBackup.kt @@ -5,7 +5,9 @@ import android.content.ContentValues import android.provider.Telephony import androidx.core.content.contentValuesOf import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +@Serializable data class SmsBackup( @SerializedName("sub_id") val subscriptionId: Long, @@ -28,8 +30,10 @@ data class SmsBackup( @SerializedName("type") val type: Int, @SerializedName("service_center") - val serviceCenter: String? -) { + val serviceCenter: String?, + + override val backupType: BackupType = BackupType.SMS, + ): MessagesBackup() { fun toContentValues(): ContentValues { return contentValuesOf( diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 7f7f562e..8d733d2c 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -382,6 +382,47 @@ android:text="@string/password_protect_whole_app" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_export_messages.xml b/app/src/main/res/layout/dialog_export_messages.xml index 7fb85583..55747ed5 100644 --- a/app/src/main/res/layout/dialog_export_messages.xml +++ b/app/src/main/res/layout/dialog_export_messages.xml @@ -14,21 +14,6 @@ android:paddingTop="@dimen/activity_margin" android:paddingEnd="@dimen/activity_margin"> - - - - - - - -