Skip to content

Commit cffa02a

Browse files
Merge pull request #1128 from torlando-tech/fix/migration-export-oom-v2
fix(migration): chunked 0x03 GCM format to fix export OOM
2 parents a3392a3 + d14dc83 commit cffa02a

7 files changed

Lines changed: 1003 additions & 220 deletions

File tree

app/src/main/java/network/columba/app/migration/MigrationCrypto.kt

Lines changed: 494 additions & 34 deletions
Large diffs are not rendered by default.

app/src/main/java/network/columba/app/migration/MigrationData.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package network.columba.app.migration
22

33
import network.columba.app.data.db.entity.CustomThemeEntity
44
import kotlinx.serialization.Serializable
5+
import java.io.File
56

67
/**
78
* Migration bundle containing all exportable app data.
@@ -430,12 +431,13 @@ data class MigrationPreview(
430431
)
431432

432433
/**
433-
* Result of previewing a migration file, including the decrypted ZIP bytes
434-
* so they can be reused during import without redundant decryption.
434+
* Result of previewing a migration file, including the local copy of the
435+
* bundle so the import can re-stream it without redundant key derivation
436+
* or re-copying from the content resolver.
435437
*/
436438
class PreviewWithData(
437439
val preview: MigrationPreview,
438-
val zipBytes: ByteArray,
440+
val file: File,
439441
)
440442

441443
/**

app/src/main/java/network/columba/app/migration/MigrationImporter.kt

Lines changed: 143 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
1010
import kotlinx.coroutines.flow.first
1111
import kotlinx.coroutines.withContext
1212
import kotlinx.serialization.json.Json
13+
import kotlinx.serialization.json.decodeFromStream
1314
import network.columba.app.data.crypto.IdentityKeyEncryptor
1415
import network.columba.app.data.crypto.WrongPasswordException
1516
import network.columba.app.data.database.InterfaceDatabase
@@ -56,6 +57,23 @@ class MigrationImporter
5657
private const val TAG = "MigrationImporter"
5758
private const val MANIFEST_FILENAME = "manifest.json"
5859
private const val ATTACHMENTS_PREFIX = "attachments/"
60+
/**
61+
* Staged import bundle files (local plaintext ZIPs) live here
62+
* while an import is in progress; [importData] removes the staged
63+
* file in a finally block.
64+
*/
65+
const val IMPORT_CACHE_DIR = "migration_import"
66+
}
67+
68+
/**
69+
* Delete staged import bundles left behind by a cancelled preview or a
70+
* crash. Safe: the directory is written only by this importer during
71+
* its import/preview flow.
72+
*/
73+
fun cleanupStagedImports() {
74+
File(context.cacheDir, IMPORT_CACHE_DIR).listFiles()?.forEach { file ->
75+
if (file.isFile) file.delete()
76+
}
5977
}
6078

6179
private val json =
@@ -91,13 +109,25 @@ class MigrationImporter
91109
}
92110
}
93111

112+
/**
113+
* Preview a migration file before importing.
114+
*
115+
* The bundle is copied to the cache directory (and decrypted there if
116+
* needed) so the manifest can be parsed in a streaming pass and the
117+
* import can reuse the same local file without re-reading through the
118+
* content resolver or holding the bundle in memory.
119+
*
120+
* Ownership of the staged file transfers to the caller (the
121+
* ViewModel), which deletes it when the import completes, the state
122+
* resets, or the ViewModel is cleared.
123+
*/
94124
suspend fun previewMigration(
95125
uri: Uri,
96126
password: String? = null,
97127
): Result<PreviewWithData> =
98128
withContext(Dispatchers.IO) {
99129
try {
100-
val (bundle, zipBytes) =
130+
val (bundle, localFile) =
101131
readMigrationBundle(uri, password)
102132
?: return@withContext Result.failure(
103133
Exception("Failed to read migration file"),
@@ -120,7 +150,7 @@ class MigrationImporter
120150
callHistoryCount = bundle.callHistory.size,
121151
identityNames = bundle.identities.map { it.displayName },
122152
),
123-
zipBytes = zipBytes,
153+
file = localFile,
124154
),
125155
)
126156
} catch (e: Exception) {
@@ -129,15 +159,6 @@ class MigrationImporter
129159
}
130160
}
131161

132-
/**
133-
* Check if an import file requires a password.
134-
*/
135-
suspend fun requiresPassword(uri: Uri): Boolean =
136-
withContext(Dispatchers.IO) {
137-
val (bundle, _) = readMigrationBundle(uri) ?: return@withContext false
138-
bundle.keysEncrypted
139-
}
140-
141162
/**
142163
* Import data from a migration bundle file.
143164
*
@@ -149,57 +170,77 @@ class MigrationImporter
149170
suspend fun importData(
150171
uri: Uri,
151172
password: String? = null,
152-
cachedZipBytes: ByteArray? = null,
173+
cachedZipFile: File? = null,
153174
onProgress: (Float) -> Unit = {},
154175
importPassword: CharArray? = null,
155176
): ImportResult =
156177
withContext(Dispatchers.IO) {
178+
Log.i(TAG, "Starting migration import...")
179+
onProgress(0.05f)
180+
181+
// Stage the bundle as a local plaintext ZIP: reuse the file
182+
// prepared by the preview when available (it is already
183+
// decrypted), otherwise copy + decrypt from the URI. The
184+
// manifest is parsed in a single streaming pass either way.
185+
val (bundle, stagedFile, importerOwnedFile) =
186+
if (cachedZipFile != null && cachedZipFile.exists()) {
187+
// Reuse the preview's local bundle (already a plaintext
188+
// ZIP). The caller still owns this file, so do not
189+
// delete it here.
190+
val parsed = streamBundleFromZipFile(cachedZipFile)
191+
?: return@withContext ImportResult.Error(
192+
"Failed to read migration file",
193+
)
194+
Triple(parsed.first, parsed.second, false)
195+
} else {
196+
val parsed = readMigrationBundle(uri, password)
197+
?: return@withContext ImportResult.Error(
198+
"Failed to read migration file",
199+
)
200+
Triple(parsed.first, parsed.second, true)
201+
}
202+
157203
try {
158-
Log.i(TAG, "Starting migration import...")
159-
onProgress(0.05f)
160-
161-
val (bundle, zipBytes) =
162-
if (cachedZipBytes != null) {
163-
// Reuse decrypted bytes from preview to avoid redundant PBKDF2 + decryption
164-
val manifestJson =
165-
extractManifestFromZip(java.io.ByteArrayInputStream(cachedZipBytes))
166-
val parsed =
167-
manifestJson?.let { json.decodeFromString<MigrationBundle>(it) }
168-
?: return@withContext ImportResult.Error(
169-
"Failed to read migration file",
170-
)
171-
parsed to cachedZipBytes
172-
} else {
173-
readMigrationBundle(uri, password)
174-
?: return@withContext ImportResult.Error(
175-
"Failed to read migration file",
176-
)
177-
}
204+
importDataInternal(bundle, stagedFile, onProgress, importPassword)
205+
} finally {
206+
// Only remove files this importer created; the caller
207+
// (ViewModel) owns and re-cleans the preview's cache file.
208+
if (importerOwnedFile) stagedFile.delete()
209+
}
210+
}
178211

212+
@Suppress("LongMethod", "ComplexMethod", "ReturnCount")
213+
private suspend fun importDataInternal(
214+
bundle: MigrationBundle,
215+
stagedFile: File,
216+
onProgress: (Float) -> Unit,
217+
importPassword: CharArray?,
218+
): ImportResult {
219+
return try {
179220
if (bundle.version > MigrationBundle.CURRENT_VERSION) {
180-
return@withContext ImportResult.Error(
221+
return ImportResult.Error(
181222
"Migration file is from a newer version (${bundle.version}). " +
182223
"Please update the app first.",
183224
)
184225
}
185226

186227
// Check minimum supported version for backwards compatibility
187228
if (bundle.version < MigrationBundle.MINIMUM_VERSION) {
188-
return@withContext ImportResult.Error(
229+
return ImportResult.Error(
189230
"Migration file is from an old version (${bundle.version}). " +
190231
"Minimum supported version is ${MigrationBundle.MINIMUM_VERSION}.",
191232
)
192233
}
193234

194235
if (bundle.version < 8 && bundle.callHistoryDeletions.isNotEmpty()) {
195-
return@withContext ImportResult.Error(
236+
return ImportResult.Error(
196237
"Call-history deletion authority requires migration format version 8.",
197238
)
198239
}
199240

200241
// Check if password is required but not provided
201242
if (bundle.keysEncrypted && importPassword == null) {
202-
return@withContext ImportResult.Error(
243+
return ImportResult.Error(
203244
"This export file is password-protected. Please provide the password.",
204245
)
205246
}
@@ -218,7 +259,7 @@ class MigrationImporter
218259
val interfacesImported = importInterfaces(bundle.interfaces)
219260
onProgress(0.86f)
220261

221-
if (bundle.attachmentManifest.isNotEmpty()) importAttachments(zipBytes)
262+
if (bundle.attachmentManifest.isNotEmpty()) importAttachments(stagedFile)
222263
onProgress(0.90f)
223264

224265
importRatchets(bundle.ratchetFiles)
@@ -675,52 +716,93 @@ class MigrationImporter
675716
)
676717

677718
/**
678-
* Read and parse the MigrationBundle from a ZIP file.
719+
* Read the migration bundle from a URI, preparing a local cache file
720+
* for the rest of the import flow.
721+
*
722+
* The file is copied to the cache directory via [File.copyTo] (no
723+
* full in-memory read), encrypted files are decrypted in-place with a
724+
* streaming chunked pass, and the manifest is parsed with a streaming
725+
* JSON decoder (no full JSON String). Returns null on unreadable input;
726+
* password errors propagate to the caller.
679727
*/
680728
@Suppress("ThrowsCount")
681729
private fun readMigrationBundle(
682730
uri: Uri,
683731
password: String? = null,
684-
): Pair<MigrationBundle, ByteArray>? {
732+
): Pair<MigrationBundle, File>? {
733+
var localFile: File? = null
685734
return try {
686735
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
687-
inputStream.use { stream ->
688-
val rawBytes = stream.readBytes()
689-
val zipBytes =
690-
if (MigrationCrypto.isEncrypted(rawBytes)) {
691-
if (password == null) {
692-
throw PasswordRequiredException("This export file is encrypted")
736+
val cacheDir = File(context.cacheDir, IMPORT_CACHE_DIR).also { it.mkdirs() }
737+
val stagedFile = File(cacheDir, "columba_import_${System.currentTimeMillis()}.columba")
738+
localFile = stagedFile
739+
try {
740+
inputStream.use { stream -> stream.copyTo(stagedFile.outputStream()) }
741+
} catch (e: Exception) {
742+
stagedFile.delete()
743+
throw e
744+
}
745+
if (MigrationCrypto.isEncrypted(
746+
stagedFile.inputStream().use { stream ->
747+
val header = ByteArray(2)
748+
stream.read(header).let { bytesRead ->
749+
if (bytesRead < 1) ByteArray(0) else header
693750
}
694-
MigrationCrypto.decrypt(rawBytes, password)
695-
} else {
696-
rawBytes
697-
}
698-
val manifestJson = extractManifestFromZip(java.io.ByteArrayInputStream(zipBytes))
699-
manifestJson?.let { json.decodeFromString<MigrationBundle>(it) to zipBytes }
751+
},
752+
)
753+
) {
754+
if (password == null) {
755+
stagedFile.delete()
756+
throw PasswordRequiredException("This export file is encrypted")
757+
}
758+
MigrationCrypto.decryptFile(stagedFile, password)
759+
}
760+
streamBundleFromZipFile(stagedFile) ?: run {
761+
stagedFile.delete()
762+
null
700763
}
701764
} catch (e: network.columba.app.migration.WrongPasswordException) {
765+
// Thrown by MigrationCrypto (GCM auth tag mismatch). The staged
766+
// file is a full copy of the (encrypted) bundle, so remove it
767+
// rather than leaking it on every wrong-password attempt.
768+
localFile?.delete()
702769
Log.e(TAG, "Wrong password for encrypted export", e)
703770
throw e
704771
} catch (e: WrongPasswordException) {
772+
// data.crypto variant (identity-key decryption).
773+
localFile?.delete()
705774
Log.e(TAG, "Wrong password for encrypted export", e)
706775
throw e
707776
} catch (e: PasswordRequiredException) {
777+
localFile?.delete()
708778
Log.e(TAG, "Password required for encrypted export", e)
709779
throw e
710780
} catch (e: Exception) {
781+
// Decryption and manifest-decoding failures otherwise leave the
782+
// staged file (a full bundle copy, or a plaintext ZIP after a
783+
// successful decrypt) behind. Remove it on every failure path
784+
// that does not transfer ownership to a successful result.
785+
localFile?.delete()
711786
Log.e(TAG, "Failed to read migration bundle", e)
712787
null
713788
}
714789
}
715790

716-
private fun extractManifestFromZip(inputStream: java.io.InputStream): String? {
717-
ZipInputStream(inputStream).use { zipIn ->
718-
var entry = zipIn.nextEntry
719-
while (entry != null) {
720-
if (entry.name == MANIFEST_FILENAME) {
721-
return zipIn.bufferedReader().readText()
791+
/**
792+
* Parse the manifest with a streaming JSON decoder and return the
793+
* bundle together with the local ZIP file (reused for attachments).
794+
*/
795+
private fun streamBundleFromZipFile(localFile: File): Pair<MigrationBundle, File>? {
796+
localFile.inputStream().use { stream ->
797+
ZipInputStream(stream).use { zipIn ->
798+
var entry = zipIn.nextEntry
799+
while (entry != null) {
800+
if (entry.name == MANIFEST_FILENAME) {
801+
val bundle = json.decodeFromStream<MigrationBundle>(zipIn)
802+
return bundle to localFile
803+
}
804+
entry = zipIn.nextEntry
722805
}
723-
entry = zipIn.nextEntry
724806
}
725807
}
726808
return null
@@ -876,14 +958,14 @@ class MigrationImporter
876958
}
877959

878960
/**
879-
* Import attachments from the ZIP file.
961+
* Import attachments from the staged ZIP file.
880962
*/
881-
private fun importAttachments(zipBytes: ByteArray): Int {
963+
private fun importAttachments(zipFile: File): Int {
882964
val attachmentsDir = File(context.filesDir, "attachments")
883965
attachmentsDir.mkdirs()
884966

885967
return try {
886-
extractAttachmentsFromZip(java.io.ByteArrayInputStream(zipBytes), attachmentsDir)
968+
zipFile.inputStream().use { stream -> extractAttachmentsFromZip(stream, attachmentsDir) }
887969
} catch (e: Exception) {
888970
Log.e(TAG, "Failed to import attachments", e)
889971
0

0 commit comments

Comments
 (0)