Skip to content

Commit a505f66

Browse files
committed
[messages] Switch from JSON to ZIP / NDJSON format
This is a major rewrite of the message import / export code, that switches the format from a single (standard) JSON file, with embedded Base64 encoded MMS binary data, to a ZIP file containing a Newline-delimited JSON (NDJSON) file ('messasges.ndjson'), containing message metadata and text data, and a 'data' directory, containing the untouched binary files stored natively by Android. There are a number of advantages, as well as some disadvantages, to the new format: Advantages: ----------- Separating (encoded) binary data from text data and metadata results in much cleaner text, which can be much more comfortably browsed by humans. The ZIP file format is much more flexibile than the monolithic JSON file format. E.g., additional information about the exporting system and app and statistics about the export run can be easily included in another file within the ZIP archive without substantially modifying the existing export flow (this is not yet implemented, but will likely be in the future.) Using ZIP files automatically provides compression, although the reduction in file size will depend on how much of the exported data is compressible text (i.e., metadata and text data), as opposed to binary data, which will generally be already compressed and not able to be compressed much further. Not including the binary data in the (ND)JSON eliminates the need to read entire binary files into RAM at one time, resulting in much more efficient RAM usage. This fixes #84, which was the initial impetus for the format change. NDJSON allows the reading of message records one at a time, eliminating the need to use JSON streaming (see #6), resulting in much simpler and cleaner code. Disadvantages: -------------- The ZIP file format add code complexity. NDJSON is less common then standard JSON. NDJSON is less easily humanly-readable than the pretty-printed JSON previously used (since NDJSON records cannot contain newlines), although this can be easily mitigated by simply running 'jq < messages.ndjson' to pretty-print the NDJSON. Additional Changes: ------------------- An additional change in this commit is the prefixing of a double underscore to all (ND)JSON attributes added by the app (e.g., '__display_name', '__parts'), in order to clearly indicate that these have been added by the app and are not the names of columns in the Android message database tables. Bugs: ----- The current implementation of the new format works, although import performance is unacceptably poor for large message collections. This is apparently a consequence of the use of the InputStream paradigm (required by Android's Storage Access framework) to access the ZIP file, which allows only sequential access, not random access, and so accessing each binary data file requires a sequential read from the beginning of the ZIP file. This should be fixed in a subsequent commit. Closes: #6, #84
1 parent 55e1f3f commit a505f66

File tree

5 files changed

+462
-383
lines changed

5 files changed

+462
-383
lines changed

app/src/main/java/com/github/tmo1/sms_ie/ExportWorker.kt

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/*
2-
* SMS Import / Export: a simple Android app for importing and exporting SMS messages from and to JSON files.
3-
* Copyright (c) 2021-2022 Thomas More
2+
* SMS Import / Export: a simple Android app for importing and exporting SMS and MMS messages,
3+
* call logs, and contacts, from and to JSON / NDJSON files.
4+
*
5+
* Copyright (c) 2021-2023 Thomas More
46
*
57
* This file is part of SMS Import / Export.
68
*
@@ -15,7 +17,8 @@
1517
* GNU General Public License for more details.
1618
*
1719
* You should have received a copy of the GNU General Public License
18-
* along with SMS Import / Export. If not, see <https://www.gnu.org/licenses/>.
20+
* along with SMS Import / Export. If not, see <https://www.gnu.org/licenses/>
21+
*
1922
*/
2023

2124
package com.github.tmo1.sms_ie
@@ -53,7 +56,7 @@ class ExportWorker(appContext: Context, workerParams: WorkerParameters) :
5356
CoroutineScope(Dispatchers.IO).launch {
5457
if (prefs.getBoolean("export_messages", true)) {
5558
val file =
56-
documentTree?.createFile("application/json", "messages$dateInString.json")
59+
documentTree?.createFile("application/zip", "messages$dateInString.zip")
5760
val fileUri = file?.uri
5861
if (fileUri != null) {
5962
Log.v(LOG_TAG, "Beginning messages export ...")
@@ -174,10 +177,11 @@ fun deleteOldExports(
174177
val newFilename = newExport?.name.toString()
175178
val files = documentTree.listFiles()
176179
var total = 0
180+
val extension = if (prefix == "messages") "zip" else "json"
177181
files.forEach {
178182
val name = it.name
179183
if (name != null && name != newFilename && name.startsWith(prefix) && name.endsWith(
180-
".json"
184+
".$extension"
181185
)
182186
) {
183187
it.delete()
@@ -186,7 +190,7 @@ fun deleteOldExports(
186190
}
187191
if (prefs.getBoolean("remove_datestamps_from_filenames", false)
188192
) {
189-
newExport?.renameTo("$prefix.json")
193+
newExport?.renameTo("$prefix.$extension")
190194
}
191195
Log.v(LOG_TAG, "$total exports deleted")
192196
}

app/src/main/java/com/github/tmo1/sms_ie/ImportExport.kt

+30-47
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,9 @@ import kotlinx.coroutines.withContext
4343

4444
fun checkReadSMSContactsPermissions(appContext: Context): Boolean {
4545
if (ContextCompat.checkSelfPermission(
46-
appContext,
47-
Manifest.permission.READ_SMS
48-
) == PackageManager.PERMISSION_GRANTED
49-
&& ContextCompat.checkSelfPermission(
50-
appContext,
51-
Manifest.permission.READ_CONTACTS
46+
appContext, Manifest.permission.READ_SMS
47+
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
48+
appContext, Manifest.permission.READ_CONTACTS
5249
) == PackageManager.PERMISSION_GRANTED
5350
) return true
5451
/*else {
@@ -63,12 +60,9 @@ fun checkReadSMSContactsPermissions(appContext: Context): Boolean {
6360

6461
fun checkReadCallLogsContactsPermissions(appContext: Context): Boolean {
6562
if (ContextCompat.checkSelfPermission(
66-
appContext,
67-
Manifest.permission.READ_CALL_LOG
68-
) == PackageManager.PERMISSION_GRANTED
69-
&& ContextCompat.checkSelfPermission(
70-
appContext,
71-
Manifest.permission.READ_CONTACTS
63+
appContext, Manifest.permission.READ_CALL_LOG
64+
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
65+
appContext, Manifest.permission.READ_CONTACTS
7266
) == PackageManager.PERMISSION_GRANTED
7367
) return true
7468
/*else {
@@ -83,66 +77,51 @@ fun checkReadCallLogsContactsPermissions(appContext: Context): Boolean {
8377

8478
fun checkReadWriteCallLogPermissions(appContext: Context): Boolean {
8579
return ContextCompat.checkSelfPermission(
86-
appContext,
87-
Manifest.permission.WRITE_CALL_LOG
88-
) == PackageManager.PERMISSION_GRANTED
89-
&& ContextCompat.checkSelfPermission(
90-
appContext,
91-
Manifest.permission.READ_CALL_LOG
80+
appContext, Manifest.permission.WRITE_CALL_LOG
81+
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
82+
appContext, Manifest.permission.READ_CALL_LOG
9283
) == PackageManager.PERMISSION_GRANTED
9384
}
9485

9586
fun checkReadContactsPermission(appContext: Context): Boolean {
9687
return ContextCompat.checkSelfPermission(
97-
appContext,
98-
Manifest.permission.READ_CONTACTS
88+
appContext, Manifest.permission.READ_CONTACTS
9989
) == PackageManager.PERMISSION_GRANTED
10090
}
10191

10292
fun checkWriteContactsPermission(appContext: Context): Boolean {
10393
return ContextCompat.checkSelfPermission(
104-
appContext,
105-
Manifest.permission.WRITE_CONTACTS
94+
appContext, Manifest.permission.WRITE_CONTACTS
10695
) == PackageManager.PERMISSION_GRANTED
10796
}
10897

10998
fun lookupDisplayName(
110-
appContext: Context,
111-
displayNames: MutableMap<String, String?>,
112-
address: String
99+
appContext: Context, displayNames: MutableMap<String, String?>, address: String
113100
): String? {
114101
// look up display name by phone number
115102
if (address == "") return null
116103
if (displayNames[address] != null) return displayNames[address]
117104
val displayName: String?
118105
val uri = Uri.withAppendedPath(
119-
ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
120-
Uri.encode(address)
106+
ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)
121107
)
122108
val nameCursor = appContext.contentResolver.query(
123-
uri,
124-
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
125-
null,
126-
null,
127-
null
109+
uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null
128110
)
129111
nameCursor.use {
130-
displayName = if (it != null && it.moveToFirst())
131-
it.getString(
132-
it.getColumnIndexOrThrow(
133-
ContactsContract.PhoneLookup.DISPLAY_NAME
134-
)
112+
displayName = if (it != null && it.moveToFirst()) it.getString(
113+
it.getColumnIndexOrThrow(
114+
ContactsContract.PhoneLookup.DISPLAY_NAME
135115
)
116+
)
136117
else null
137118
}
138119
displayNames[address] = displayName
139120
return displayName
140121
}
141122

142123
suspend fun wipeSmsAndMmsMessages(
143-
appContext: Context,
144-
statusReportText: TextView,
145-
progressBar: ProgressBar
124+
appContext: Context, statusReportText: TextView, progressBar: ProgressBar
146125
) {
147126
val prefs = PreferenceManager.getDefaultSharedPreferences(appContext)
148127
withContext(Dispatchers.IO) {
@@ -194,14 +173,18 @@ suspend fun incrementProgress(progressBar: ProgressBar?) {
194173
}
195174

196175
// From: https://stackoverflow.com/a/18143773
197-
suspend fun displayError(appContext: Context, e: Exception, title: String, message: String) {
198-
e.printStackTrace()
176+
suspend fun displayError(appContext: Context, e: Exception?, title: String, message: String) {
177+
val messageExpanded = if (e != null) {
178+
e.printStackTrace()
179+
"$message:\n\n\"$e\"\n\nSee logcat for more information."
180+
} else {
181+
message
182+
}
199183
val errorBox = AlertDialog.Builder(appContext)
200-
errorBox.setTitle(title)
201-
.setMessage("$message:\n\n\"$e\"\n\nSee logcat for more information.")
202-
.setCancelable(false)
203-
.setNeutralButton("Okay", null)
184+
errorBox.setTitle(title).setMessage(messageExpanded)
185+
//errorBox.setTitle(title).setMessage("$message:\n\n\"$e\"\n\nSee logcat for more information.")
186+
.setCancelable(false).setNeutralButton("Okay", null)
204187
withContext(Dispatchers.Main) {
205188
errorBox.show()
206189
}
207-
}
190+
}

0 commit comments

Comments
 (0)