From 7f32115afe15a8ec0acccef0fa508bb6e5ad9bab Mon Sep 17 00:00:00 2001 From: Paul Akhamiogu Date: Sun, 12 Sep 2021 00:43:00 +0100 Subject: [PATCH] back up messages --- .../smsmessenger/activities/MainActivity.kt | 12 +- .../smsmessenger/extensions/Context.kt | 16 +- .../smsmessenger/extensions/Cursor.kt | 21 +++ .../smsmessenger/extensions/Gson.kt | 99 ++++++++++++ .../smsmessenger/helpers/JsonObjectWriter.kt | 44 ++++++ .../smsmessenger/helpers/MessagesExporter.kt | 84 ++++++++++ .../smsmessenger/helpers/MessagesReader.kt | 143 ++++++++++++++++++ 7 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Cursor.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Gson.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/JsonObjectWriter.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesReader.kt 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 1e0fff09..32f171f8 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/MainActivity.kt @@ -24,6 +24,7 @@ import com.simplemobiletools.smsmessenger.dialogs.ExportMessagesDialog import com.simplemobiletools.smsmessenger.dialogs.ImportMessagesDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.EXPORT_MIME_TYPE +import com.simplemobiletools.smsmessenger.helpers.MessagesExporter import com.simplemobiletools.smsmessenger.helpers.THREAD_ID import com.simplemobiletools.smsmessenger.helpers.THREAD_TITLE import com.simplemobiletools.smsmessenger.models.Conversation @@ -45,6 +46,7 @@ class MainActivity : SimpleActivity() { private var storedTextColor = 0 private var storedFontSize = 0 private var bus: EventBus? = null + private val smsExporter by lazy { MessagesExporter(this) } @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { @@ -361,7 +363,15 @@ class MainActivity : SimpleActivity() { private fun exportMessagesTo(outputStream: OutputStream?) { ensureBackgroundThread { - + toast(R.string.exporting) + smsExporter.exportMessages(outputStream){ + toast( + when (it) { + MessagesExporter.ExportResult.EXPORT_OK -> R.string.exporting_successful + else -> R.string.exporting_failed + } + ) + } } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt index fbe15066..10c8889e 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt @@ -35,9 +35,9 @@ import com.simplemobiletools.smsmessenger.interfaces.MessagesDao import com.simplemobiletools.smsmessenger.models.* import com.simplemobiletools.smsmessenger.receivers.DirectReplyReceiver import com.simplemobiletools.smsmessenger.receivers.MarkAsReadReceiver -import me.leolin.shortcutbadger.ShortcutBadger import java.util.* import kotlin.collections.ArrayList +import me.leolin.shortcutbadger.ShortcutBadger val Context.config: Config get() = Config.newInstance(applicationContext) @@ -251,6 +251,20 @@ fun Context.getConversations(threadId: Long? = null, privateContacts: ArrayList< return conversations } +fun Context.getConversationIds(): List { + val uri = Uri.parse("${Threads.CONTENT_URI}?simple=true") + val projection = arrayOf(Threads._ID) + val selection = "${Threads.MESSAGE_COUNT} > ?" + val selectionArgs = arrayOf("0") + val sortOrder = "${Threads.DATE} DESC" + val conversationIds = mutableListOf() + queryCursor(uri, projection, selection, selectionArgs, sortOrder, true) { cursor -> + val id = cursor.getLongValue(Threads._ID) + conversationIds.add(id) + } + return conversationIds +} + // based on https://stackoverflow.com/a/6446831/1967672 @SuppressLint("NewApi") fun Context.getMmsAttachment(id: Long): MessageAttachment { diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Cursor.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Cursor.kt new file mode 100644 index 00000000..8baefa61 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Cursor.kt @@ -0,0 +1,21 @@ +package com.simplemobiletools.smsmessenger.extensions + +import android.database.Cursor +import com.google.gson.JsonNull +import com.google.gson.JsonObject + +fun Cursor. + rowsToJson(): JsonObject { + val obj = JsonObject() + for (i in 0 until columnCount) { + val key = getColumnName(i) + + when (getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> obj.addProperty(key, getLong(i)) + Cursor.FIELD_TYPE_FLOAT -> obj.addProperty(key, getFloat(i)) + Cursor.FIELD_TYPE_STRING -> obj.addProperty(key, getString(i)) + Cursor.FIELD_TYPE_NULL -> obj.add(key, JsonNull.INSTANCE) + } + } + return obj +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Gson.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Gson.kt new file mode 100644 index 00000000..a3e28622 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Gson.kt @@ -0,0 +1,99 @@ +package com.simplemobiletools.smsmessenger.extensions + +import com.google.gson.* +import java.math.BigDecimal +import java.math.BigInteger + + +val JsonElement.optString: String? + get() = safeConversion { asString } + +val JsonElement.optLong: Long? + get() = safeConversion { asLong } + +val JsonElement.optBoolean: Boolean? + get() = safeConversion { asBoolean } + +val JsonElement.optFloat: Float? + get() = safeConversion { asFloat } + +val JsonElement.optDouble: Double? + get() = safeConversion { asDouble } + +val JsonElement.optJsonObject: JsonObject? + get() = safeConversion { asJsonObject } + +val JsonElement.optJsonArray: JsonArray? + get() = safeConversion { asJsonArray } + +val JsonElement.optJsonPrimitive: JsonPrimitive? + get() = safeConversion { asJsonPrimitive } + +val JsonElement.optInt: Int? + get() = safeConversion { asInt } + +val JsonElement.optBigDecimal: BigDecimal? + get() = safeConversion { asBigDecimal } + +val JsonElement.optBigInteger: BigInteger? + get() = safeConversion { asBigInteger } + +val JsonElement.optByte: Byte? + get() = safeConversion { asByte } + +val JsonElement.optShort: Short? + get() = safeConversion { asShort } + +val JsonElement.optJsonNull: JsonNull? + get() = safeConversion { asJsonNull } + +val JsonElement.optCharacter: Char? + get() = safeConversion { asCharacter } + +private fun JsonElement.safeConversion(converter: () -> T?): T? { + + return try { + converter() + } catch (e: Exception) { + null + } +} + +fun JsonObject.optGet(key: String): JsonElement? = get(key) + +fun JsonObject.optGetJsonArray(key: String): JsonArray? = getAsJsonArray(key) + +fun JsonObject.optGetJsonObject(key: String): JsonObject? = getAsJsonObject(key) + +fun JsonObject.optGetJsonPrimitive(key: String): JsonPrimitive? = getAsJsonPrimitive(key) + +fun JsonObject.optString(key: String) = optGet(key)?.asString + +fun JsonObject.optLong(key: String) = optGet(key)?.asLong + +fun JsonObject.optBoolean(key: String) = optGet(key)?.asBoolean + +fun JsonObject.optFloat(key: String) = optGet(key)?.asFloat + +fun JsonObject.optDouble(key: String) = optGet(key)?.asDouble + +fun JsonObject.optJsonObject(key: String) = optGet(key)?.asJsonObject + +fun JsonObject.optJsonArray(key: String) = optGet(key)?.asJsonArray + +fun JsonObject.optJsonPrimitive(key: String) = optGet(key)?.asJsonPrimitive + +fun JsonObject.optInt(key: String) = optGet(key)?.asInt + +fun JsonObject.optBigDecimal(key: String) = optGet(key)?.asBigDecimal + +fun JsonObject.optBigInteger(key: String) = optGet(key)?.asBigInteger + +fun JsonObject.optByte(key: String) = optGet(key)?.asByte + +fun JsonObject.optShort(key: String) = optGet(key)?.asShort + +fun JsonObject.optJsonNull(key: String) = optGet(key)?.asJsonNull + +fun JsonObject.optCharacter(key: String) = optGet(key)?.asCharacter + diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/JsonObjectWriter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/JsonObjectWriter.kt new file mode 100644 index 00000000..37f6a0d8 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/JsonObjectWriter.kt @@ -0,0 +1,44 @@ +package com.simplemobiletools.smsmessenger.helpers + +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.stream.JsonWriter + +class JsonObjectWriter(private val writer: JsonWriter) { + + fun dump(obj: JsonObject) { + writer.beginObject() + for (key in obj.keySet()) { + writer.name(key) + val keyObj = obj.get(key) + dump(keyObj) + } + writer.endObject() + } + + private fun dump(arr: JsonArray) { + writer.beginArray() + for (i in 0 until arr.size()) { + dump(arr.get(i)) + } + writer.endArray() + } + + private fun dump(obj: Any) { + when (obj) { + is JsonNull -> writer.nullValue() + is JsonPrimitive -> { + when{ + obj.isString -> writer.value(obj.asString) + obj.isBoolean -> writer.value(obj.asNumber) + obj.isNumber -> writer.value(obj.asBoolean) + obj.isNumber -> writer.value(obj.asBoolean) + } + } + is JsonArray -> dump(obj) + is JsonObject -> dump(obj) + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt new file mode 100644 index 00000000..0942d95d --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesExporter.kt @@ -0,0 +1,84 @@ +package com.simplemobiletools.smsmessenger.helpers + +import android.content.Context +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) + + fun exportMessages( + outputStream: OutputStream?, + callback: (result: ExportResult) -> Unit, + ) { + ensureBackgroundThread { + if (outputStream == null) { + callback.invoke(ExportResult.EXPORT_FAIL) + return@ensureBackgroundThread + } + + /* + * We should have json in this format + * { + * "threadId" : { + * "threadId": "" + * "sms": [{ smses }], + * "mms": [{ mmses }] + * } + * } + * + * */ + val writer = JsonWriter(outputStream.bufferedWriter()) + writer.use { + try { + var written = 0 + writer.beginObject() + val conversationIds = context.getConversationIds() + for(threadId in conversationIds){ + writer.name(threadId.toString()) + + writer.beginObject() + writer.name("threadId") + writer.value(threadId) + if(config.exportSms){ + writer.name("sms") + writer.beginArray() + //write all sms + messageReader.forEachSms(threadId){ + JsonObjectWriter(writer).dump(it) + written++ + } + writer.endArray() + } + + if(config.exportMms){ + writer.name("mms") + writer.beginArray() + //write all mms + messageReader.forEachMms(threadId){ + JsonObjectWriter(writer).dump(it) + written++ + } + + writer.endArray() + } + + writer.endObject() + } + writer.endObject() + callback.invoke(ExportResult.EXPORT_OK) + } catch (e: Exception) { + callback.invoke(ExportResult.EXPORT_FAIL) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesReader.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesReader.kt new file mode 100644 index 00000000..d4118234 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/MessagesReader.kt @@ -0,0 +1,143 @@ +package com.simplemobiletools.smsmessenger.helpers + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.provider.Telephony +import android.util.Base64 +import android.util.Log +import com.google.android.mms.pdu_alt.PduHeaders +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.simplemobiletools.commons.extensions.getIntValue +import com.simplemobiletools.commons.extensions.getStringValue +import com.simplemobiletools.commons.extensions.queryCursor +import com.simplemobiletools.commons.helpers.isQPlus +import com.simplemobiletools.smsmessenger.extensions.optLong +import com.simplemobiletools.smsmessenger.extensions.optString +import com.simplemobiletools.smsmessenger.extensions.rowsToJson +import java.io.IOException +import java.io.InputStream + +class MessagesReader(private val context: Context) { + companion object { + private const val TAG = "MessagesReader" + private const val MMS_CONTENT = "mms_content" + } + fun forEachSms(threadId: Long, block: (JsonObject) -> Unit) { + forEachThreadMessage(Telephony.Sms.CONTENT_URI, threadId, block) + } + + fun forEachMms(threadId: Long, includeAttachment: Boolean = true, block: (JsonObject) -> Unit) { + forEachThreadMessage(Telephony.Mms.CONTENT_URI, threadId) { obj -> + if (includeAttachment) { + obj.add("parts", getParts(obj.getAsJsonPrimitive("_id").asLong)) + } + obj.add(Telephony.CanonicalAddressesColumns.ADDRESS, getMMSAddresses(obj.getAsJsonPrimitive("_id").asLong)) + block(obj) + } + } + + private fun forEachThreadMessage(contentUri: Uri, threadId: Long, block: (JsonObject) -> Unit) { + val selection = "${Telephony.Sms.THREAD_ID} = ?" + val selectionArgs = arrayOf(threadId.toString()) + context.queryCursor(contentUri, null, selection, selectionArgs) { cursor -> + val json = cursor.rowsToJson() + forceMillisDate(json, "date") + forceMillisDate(json, "date_sent") + block(json) + } + } + + private fun forceMillisDate(message: JsonObject, field: String) { + /* sometimes the sms are in millis and the mms in secs... */ + if (message.get(field).isJsonPrimitive) { + val value = message.get(field).optLong + if (value != null && value != 0L && value < 500000000000L) { // 500000000000 = Tuesday, 5 November 1985 00:53:20 GMT + message.addProperty(field, value * 1000) + } + } + } + + @SuppressLint("NewApi") + private fun getParts(mmsId: Long): JsonArray { + val jsonArray = JsonArray() + val uri = if (isQPlus()) { + Telephony.Mms.Part.CONTENT_URI + } else { + Uri.parse("content://mms/part") + } + + val selection = "${Telephony.Mms.Part.MSG_ID}=$mmsId" + context.queryCursor(uri, emptyArray(), selection) { cursor -> + val part = cursor.rowsToJson() + + val hasTextValue = (part.has(Telephony.Mms.Part.TEXT) && !part.get(Telephony.Mms.Part.TEXT).optString.isNullOrEmpty()) + + when { + hasTextValue -> { + part.addProperty(MMS_CONTENT, "") + } + + part.get(Telephony.Mms.Part.CONTENT_TYPE).optString?.startsWith("text/") == true -> { + part.addProperty(MMS_CONTENT, usePart(part.get(Telephony.Mms.Part._ID).asLong) { stream -> + stream.readBytes().toString(Charsets.UTF_8) + }) + } + else -> { + part.addProperty(MMS_CONTENT, usePart(part.get(Telephony.Mms.Part._ID).asLong) { stream -> + Base64.encodeToString(stream.readBytes(), Base64.DEFAULT) + }) + } + } + jsonArray.add(part) + } + + return jsonArray + } + + @SuppressLint("NewApi") + private fun usePart(partId: Long, block: (InputStream) -> String): String { + val partUri = if (isQPlus()) { + Telephony.Mms.Part.CONTENT_URI.buildUpon().appendPath(partId.toString()).build() + } else { + Uri.parse("content://mms/part/$partId") + } + try { + val stream = context.contentResolver.openInputStream(partUri) + if (stream == null) { + val msg = "failed opening stream for mms part $partUri" + Log.e(TAG, msg) + return "" + } + stream.use { + return block(stream) + } + } catch (e: IOException) { + val msg = "failed to read MMS part on $partUri" + Log.e(TAG, msg, e) + return "" + } + } + + @SuppressLint("NewApi") + private fun getMMSAddresses(messageId: Long): JsonArray { + val jsonArray = JsonArray() + val addressUri = if (isQPlus()) { + Telephony.Mms.Addr.getAddrUriForMessage(messageId.toString()) + } else { + Uri.parse("content://mms/$messageId/addr") + } + + val projection = arrayOf(Telephony.Mms.Addr.ADDRESS, Telephony.Mms.Addr.TYPE) + val selection = "${Telephony.Mms.Addr.MSG_ID}=$messageId" + + context.queryCursor(addressUri, projection, selection) { cursor -> + when (cursor.getIntValue(Telephony.Mms.Addr.TYPE)) { + PduHeaders.FROM, PduHeaders.TO, PduHeaders.CC, PduHeaders.BCC -> jsonArray.add(cursor.getStringValue(Telephony.Mms.Addr.ADDRESS)) + } + } + + return jsonArray + } +}