Write plaintext export to directory instead of zip, add notification content intent.

This commit is contained in:
Alex Hart
2026-04-02 12:15:14 -03:00
committed by GitHub
parent eb2dfb3fb6
commit ed4944f806
5 changed files with 90 additions and 52 deletions
@@ -5,7 +5,9 @@
package org.thoughtcrime.securesms.backup.v2.local
import android.content.ContentResolver
import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile
import okio.ByteString.Companion.toByteString
import org.signal.archive.local.ArchivedFilesWriter
import org.signal.archive.local.proto.FilesFrame
@@ -35,8 +37,6 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Collections
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
typealias ArchiveResult = org.signal.core.util.Result<LocalArchiver.ArchiveSuccess, LocalArchiver.ArchiveFailure>
typealias RestoreResult = org.signal.core.util.Result<LocalArchiver.RestoreSuccess, LocalArchiver.RestoreFailure>
@@ -145,36 +145,44 @@ object LocalArchiver {
}
/**
* Export a plaintext archive to the provided [zipOutputStream].
* Export a plaintext archive to the provided [directory].
*/
fun exportPlaintext(
zipOutputStream: ZipOutputStream,
directory: DocumentFile,
contentResolver: ContentResolver,
includeMedia: Boolean,
stopwatch: Stopwatch,
cancellationSignal: () -> Boolean = { false }
): ArchiveResult {
try {
zipOutputStream.putNextEntry(ZipEntry("metadata.json"))
zipOutputStream.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
zipOutputStream.closeEntry()
val metadataFile = directory.createFile("application/octet-stream", "metadata.json")
?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
contentResolver.openOutputStream(metadataFile.uri)?.use { out ->
out.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
} ?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
stopwatch.split("metadata")
zipOutputStream.putNextEntry(ZipEntry("main.jsonl"))
val mainFile = directory.createFile("application/octet-stream", "main.jsonl")
?: return ArchiveResult.failure(ArchiveFailure.MainStream)
val progressListener = LocalPlaintextExportProgressListener()
val attachments = BackupRepository.exportForLocalPlaintextArchive(
outputStream = zipOutputStream,
progressEmitter = progressListener,
cancellationSignal = cancellationSignal,
includeMedia = includeMedia
)
zipOutputStream.closeEntry()
val attachments = contentResolver.openOutputStream(mainFile.uri)?.use { mainStream ->
BackupRepository.exportForLocalPlaintextArchive(
outputStream = mainStream,
progressEmitter = progressListener,
cancellationSignal = cancellationSignal,
includeMedia = includeMedia
)
} ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
stopwatch.split("frames")
if (includeMedia) {
val filesDir = directory.createDirectory("files")
?: return ArchiveResult.failure(ArchiveFailure.FilesStream)
val total = attachments.size.toLong()
var completed = 0L
progressListener.onAttachment(0, total)
val writtenEntries = HashSet<String>()
val prefixDirs = HashMap<String, DocumentFile>()
for (attachment in attachments) {
if (cancellationSignal()) break
val mediaName = MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key)
@@ -185,13 +193,21 @@ object LocalArchiver {
?.let { ".$it" }
?: ""
val prefix = mediaName.name.substring(0..1)
val entryName = "files/$prefix/${mediaName.name}$ext"
val entryName = "$prefix/${mediaName.name}$ext"
if (!writtenEntries.add(entryName)) continue
zipOutputStream.putNextEntry(ZipEntry(entryName))
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
StreamUtil.copy(input, zipOutputStream, false, false)
val prefixDir = prefixDirs[prefix]
?: filesDir.createDirectory(prefix)?.also { prefixDirs[prefix] = it }
?: run {
Log.w(TAG, "Unable to create prefix directory $prefix, skipping attachment ${attachment.attachmentId}")
progressListener.onAttachment(++completed, total)
continue
}
val mediaFile = prefixDir.createFile("application/octet-stream", "${mediaName.name}$ext") ?: continue
contentResolver.openOutputStream(mediaFile.uri)?.use { out ->
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
StreamUtil.copy(input, out, false, false)
}
}
zipOutputStream.closeEntry()
} catch (e: IOException) {
Log.w(TAG, "Unable to export attachment ${attachment.attachmentId}, skipping", e)
}
@@ -82,6 +82,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
appSettingsRoute.threadIds
)
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
@@ -214,6 +215,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmOverloads
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
@JvmStatic
fun chats(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatsRoute.Chats)
@JvmStatic
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.jobs
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CoroutineScope
@@ -7,11 +9,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.signal.core.util.PendingIntentFlags.immutable
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -24,7 +28,6 @@ import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipOutputStream
class LocalPlaintextArchiveJob internal constructor(
private val destinationUri: String,
@@ -41,7 +44,7 @@ class LocalPlaintextArchiveJob internal constructor(
private const val KEY_INCLUDE_MEDIA = "include_media"
}
private var zipFile: DocumentFile? = null
private var exportDir: DocumentFile? = null
override fun serialize(): ByteArray? {
return JsonJobData.Builder()
@@ -57,13 +60,21 @@ class LocalPlaintextArchiveJob internal constructor(
override fun run(): Result {
Log.i(TAG, "Executing plaintext archive job...")
val contentIntent = PendingIntent.getActivity(
context,
0,
AppSettingsActivity.chats(context).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
immutable()
)
var notification: NotificationController? = null
try {
notification = GenericForegroundService.startForegroundTask(
context,
context.getString(R.string.LocalBackupJob_creating_signal_backup),
NotificationChannels.getInstance().BACKUPS,
R.drawable.ic_signal_backup
R.drawable.ic_signal_backup,
contentIntent
)
} catch (e: UnableToStartException) {
Log.w(TAG, "Unable to start foreground service, continuing without service")
@@ -84,24 +95,17 @@ class LocalPlaintextArchiveJob internal constructor(
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(Date())
val fileName = "signal-export-$timestamp"
zipFile = root.createFile("application/zip", fileName)
val zipFile = this.zipFile ?: run {
Log.w(TAG, "Unable to create zip file")
exportDir = root.createDirectory(fileName)
val exportDir = this.exportDir ?: run {
Log.w(TAG, "Unable to create export directory")
return Result.failure()
}
stopwatch.split("create-file")
stopwatch.split("create-dir")
try {
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
val outputStream = context.contentResolver.openOutputStream(zipFile.uri)
if (outputStream == null) {
Log.w(TAG, "Unable to open output stream for zip file")
zipFile.delete()
return Result.failure()
}
val progressScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
progressScope.launch {
LocalExportProgress.plaintextProgress.collect { progress ->
@@ -110,18 +114,16 @@ class LocalPlaintextArchiveJob internal constructor(
}
try {
ZipOutputStream(outputStream).use { zipOutputStream ->
val result = LocalArchiver.exportPlaintext(zipOutputStream, includeMedia, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Plaintext archive finished with result: $result")
if (isCanceled) {
zipFile.delete()
setProgress(LocalBackupCreationProgress(canceled = LocalBackupCreationProgress.Canceled()), notification)
return Result.failure()
} else if (result !is org.signal.core.util.Result.Success) {
zipFile.delete()
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
return Result.failure()
}
val result = LocalArchiver.exportPlaintext(exportDir, context.contentResolver, includeMedia, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Plaintext archive finished with result: $result")
if (isCanceled) {
exportDir.delete()
setProgress(LocalBackupCreationProgress(canceled = LocalBackupCreationProgress.Canceled()), notification)
return Result.failure()
} else if (result !is org.signal.core.util.Result.Success) {
exportDir.delete()
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
return Result.failure()
}
} finally {
progressScope.cancel()
@@ -132,7 +134,7 @@ class LocalPlaintextArchiveJob internal constructor(
} catch (e: IOException) {
Log.w(TAG, "Error during plaintext archive!", e)
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
zipFile.delete()
exportDir.delete()
throw e
}
@@ -145,7 +147,7 @@ class LocalPlaintextArchiveJob internal constructor(
}
override fun onFailure() {
zipFile?.delete()
exportDir?.delete()
val current = LocalExportProgress.plaintextProgress.value
if (current.canceled == null && current.failed == null) {
LocalExportProgress.setPlaintextProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()))
@@ -11,6 +11,7 @@ import android.os.IBinder
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.IntentCompat
import org.signal.core.util.PendingIntentFlags.mutable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
@@ -42,6 +43,7 @@ class GenericForegroundService : Service() {
private const val EXTRA_PROGRESS = "extra_progress"
private const val EXTRA_PROGRESS_MAX = "extra_progress_max"
private const val EXTRA_PROGRESS_INDETERMINATE = "extra_progress_indeterminate"
private const val EXTRA_CONTENT_INTENT = "extra_content_intent"
private const val ACTION_START = "start"
private const val ACTION_STOP = "stop"
@@ -78,7 +80,8 @@ class GenericForegroundService : Service() {
context: Context,
task: String,
channelId: String = DEFAULT_ENTRY.channelId,
@DrawableRes iconRes: Int = DEFAULT_ENTRY.iconRes
@DrawableRes iconRes: Int = DEFAULT_ENTRY.iconRes,
contentIntent: PendingIntent? = null
): NotificationController {
val id = NEXT_ID.getAndIncrement()
Log.i(TAG, "[startForegroundTask] Task: $task, ID: $id")
@@ -89,6 +92,7 @@ class GenericForegroundService : Service() {
putExtra(EXTRA_CHANNEL_ID, channelId)
putExtra(EXTRA_ICON_RES, iconRes)
putExtra(EXTRA_ID, id)
if (contentIntent != null) putExtra(EXTRA_CONTENT_INTENT, contentIntent)
}
ForegroundServiceUtil.start(context, intent)
@@ -237,7 +241,7 @@ class GenericForegroundService : Service() {
.setSmallIcon(active.iconRes)
.setContentTitle(active.title)
.setProgress(active.progressMax, active.progress, active.indeterminate)
.setContentIntent(PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), mutable()))
.setContentIntent(active.contentIntent ?: PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), mutable()))
.setVibrate(longArrayOf(0))
.build()
)
@@ -281,7 +285,8 @@ class GenericForegroundService : Service() {
val id: Int,
val progressMax: Int,
val progress: Int,
val indeterminate: Boolean
val indeterminate: Boolean,
val contentIntent: PendingIntent? = null
) {
override fun toString(): String {
return "ChannelId: $channelId, ID: $id, Progress: $progress/$progressMax ${if (indeterminate) "indeterminate" else "determinate"}"
@@ -296,7 +301,8 @@ class GenericForegroundService : Service() {
id = intent.getIntExtra(EXTRA_ID, DEFAULT_ENTRY.id),
progressMax = intent.getIntExtra(EXTRA_PROGRESS_MAX, DEFAULT_ENTRY.progressMax),
progress = intent.getIntExtra(EXTRA_PROGRESS, DEFAULT_ENTRY.progress),
indeterminate = intent.getBooleanExtra(EXTRA_PROGRESS_INDETERMINATE, DEFAULT_ENTRY.indeterminate)
indeterminate = intent.getBooleanExtra(EXTRA_PROGRESS_INDETERMINATE, DEFAULT_ENTRY.indeterminate),
contentIntent = IntentCompat.getParcelableExtra(intent, EXTRA_CONTENT_INTENT, PendingIntent::class.java)
)
}
}