@@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
1010import kotlinx.coroutines.flow.first
1111import kotlinx.coroutines.withContext
1212import kotlinx.serialization.json.Json
13+ import kotlinx.serialization.json.decodeFromStream
1314import network.columba.app.data.crypto.IdentityKeyEncryptor
1415import network.columba.app.data.crypto.WrongPasswordException
1516import 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