mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-04 15:35:38 +01:00
Write plaintext export to directory instead of zip, add notification content intent.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user