diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt index 50bf61f0e1..8b09f7d072 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt @@ -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 typealias RestoreResult = org.signal.core.util.Result @@ -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() + val prefixDirs = HashMap() 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index eb241ea0b6..a6db86afed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt index a9fa5dbbbd..f592578f1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt @@ -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())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.kt index 54ca719bca..b2f8b72b23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.kt @@ -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) ) } } diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 8d2713d4df..3f18aafb47 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -818,6 +818,16 @@ app:popUpTo="@id/app_settings" app:popUpToInclusive="true" /> + +