Add additional debug info for the backups alpha.

This commit is contained in:
Greyson Parrelli
2025-07-03 10:09:03 -04:00
committed by Alex Hart
parent 869b5aa3d5
commit dc8e93a9d3
13 changed files with 252 additions and 38 deletions

View File

@@ -29,12 +29,14 @@ import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.decodeOrNull
import org.signal.core.util.forceForeignKeyConstraintsEnabled
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.getAllIndexDefinitions
import org.signal.core.util.getAllTableDefinitions
import org.signal.core.util.getAllTriggerDefinitions
import org.signal.core.util.getForeignKeyViolations
import org.signal.core.util.isNotEmpty
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.requireIntOrNull
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.backup.v2.processor.ChatItemArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.NotificationProfileProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.StickerArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.BackupImportReader
@@ -104,6 +107,7 @@ import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredential
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
@@ -147,7 +151,10 @@ import java.io.OutputStream
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -798,7 +805,8 @@ object BackupRepository {
version = VERSION,
backupTimeMs = exportState.backupTime,
mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey.value.toByteString(),
firstAppVersion = SignalStore.backup.firstAppVersion
firstAppVersion = SignalStore.backup.firstAppVersion,
debugInfo = buildDebugInfo()
)
)
frameCount++
@@ -1211,6 +1219,7 @@ object BackupRepository {
stopwatch.split("group-jobs")
SignalStore.backup.firstAppVersion = header.firstAppVersion
SignalStore.internal.importedBackupDebugInfo = header.debugInfo.let { BackupDebugInfo.ADAPTER.decodeOrNull(it.toByteArray()) }
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
stopwatch.stop(TAG)
@@ -1846,6 +1855,38 @@ object BackupRepository {
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
if (!RemoteConfig.internalUser) {
return ByteString.EMPTY
}
var debuglogUrl: String? = null
if (SignalStore.internal.includeDebuglogInBackup) {
Log.i(TAG, "User has debuglog inclusion enabled. Generating a debuglog.")
val latch = CountDownLatch(1)
SubmitDebugLogRepository().buildAndSubmitLog { url ->
debuglogUrl = url.getOrNull()
latch.countDown()
}
try {
val success = latch.await(10, TimeUnit.SECONDS)
if (!success) {
Log.w(TAG, "Timed out waiting for debuglog!")
}
} catch (e: Exception) {
Log.w(TAG, "Hit an error while generating the debuglog!")
}
}
return BackupDebugInfo(
debuglogUrl = debuglogUrl ?: "",
attachmentDetails = SignalDatabase.attachments.debugAttachmentStatsForBackupProto(),
usingPaidTier = SignalStore.backup.backupTier == MessageBackupTier.PAID
).encodeByteString()
}
interface ExportProgressListener {
fun onAccount()
fun onRecipient()

View File

@@ -171,7 +171,7 @@ private fun BackupsSettingsContent(
is BackupState.ActiveFree, is BackupState.ActivePaid, is BackupState.Canceled -> {
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
backupState = backupsSettingsState.backupState as BackupState.WithTypeAndRenewalTime,
onBackupsRowClick = onBackupsRowClick,
lastBackupAt = backupsSettingsState.lastBackupAt
)
@@ -524,7 +524,8 @@ private fun BackupsSettingsContentPreview() {
),
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
)
),
lastBackupAt = 0.seconds
)
)
}
@@ -536,7 +537,8 @@ private fun BackupsSettingsContentNotAvailablePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.NotAvailable
backupState = BackupState.NotAvailable,
lastBackupAt = 0.seconds
)
)
}
@@ -550,7 +552,8 @@ private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.None,
showBackupTierInternalOverride = true,
backupTierInternalOverride = null
backupTierInternalOverride = null,
lastBackupAt = 0.seconds
)
)
}

View File

@@ -279,6 +279,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.manageStorage(requireActivity()))
}
override fun onIncludeDebuglogClick(newState: Boolean) {
viewModel.setIncludeDebuglog(newState)
}
}
private fun displayBackupKey() {
@@ -368,6 +372,7 @@ private interface ContentCallbacks {
fun onDisplayProgressDialog() = Unit
fun onDisplayDownloadingBackupDialog() = Unit
fun onManageStorageClick() = Unit
fun onIncludeDebuglogClick(newState: Boolean) = Unit
object Empty : ContentCallbacks
}
@@ -512,6 +517,7 @@ private fun RemoteBackupsSettingsContent(
canBackUpUsingCellular = state.canBackUpUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
canBackUpNow = !state.isOutOfStorageSpace,
includeDebuglog = state.includeDebuglog,
contentCallbacks = contentCallbacks
)
} else {
@@ -815,6 +821,7 @@ private fun LazyListScope.appendBackupDetailsItems(
canBackUpUsingCellular: Boolean,
canRestoreUsingCellular: Boolean,
canBackUpNow: Boolean,
includeDebuglog: Boolean?,
contentCallbacks: ContentCallbacks
) {
item {
@@ -843,6 +850,12 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
if (includeDebuglog != null) {
item {
IncludeDebuglogRow(includeDebuglog) { contentCallbacks.onIncludeDebuglogClick(it) }
}
}
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None || backupProgress.state == ArchiveUploadProgressState.State.UserCanceled) {
item {
LastBackupRow(
@@ -1421,6 +1434,19 @@ private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState
return stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
}
@Composable
private fun IncludeDebuglogRow(
enabled: Boolean,
onToggle: (Boolean) -> Unit
) {
Rows.ToggleRow(
checked = enabled,
text = "[INTERNAL ONLY] Include debuglog?",
label = "If enabled, we will capture a debuglog and include it in the backup file.",
onCheckChanged = onToggle
)
}
@Composable
private fun LastBackupRow(
lastBackupTimestamp: Long,
@@ -1748,6 +1774,36 @@ private fun RemoteBackupsSettingsContentPreview() {
}
}
@SignalPreview
@Composable
private fun RemoteBackupsSettingsInternalUserContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
),
hasRedemptionError = false,
isOutOfStorageSpace = false,
includeDebuglog = true
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup),
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@SignalPreview
@Composable
private fun RedemptionErrorAlertPreview() {

View File

@@ -9,6 +9,9 @@ import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
/**
* @param includeDebuglog The state for whether or not we should include a debuglog in the backup. If `null`, hide the setting.
*/
data class RemoteBackupsSettingsState(
val tier: MessageBackupTier? = null,
val backupsEnabled: Boolean,
@@ -23,7 +26,8 @@ data class RemoteBackupsSettingsState(
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE
val snackbar: Snackbar = Snackbar.NONE,
val includeDebuglog: Boolean? = null
) {
enum class Dialog {

View File

@@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.service.MessageBackupListener
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import kotlin.time.Duration.Companion.seconds
@@ -66,7 +67,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser }
)
)
@@ -214,6 +216,11 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
ArchiveUploadProgress.cancel()
}
fun setIncludeDebuglog(includeDebuglog: Boolean) {
SignalStore.internal.includeDebuglogInBackup = includeDebuglog
_state.update { it.copy(includeDebuglog = includeDebuglog) }
}
private suspend fun refreshState(lastPurchase: InAppPaymentTable.InAppPayment?) {
try {
Log.i(TAG, "Performing a state refresh.")

View File

@@ -70,16 +70,12 @@ import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.WallpaperAttachment
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.COPY_PENDING
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.FINISHED
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.NONE
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.UPLOAD_IN_PROGRESS
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_END
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.PREUPLOAD_MESSAGE_ID
@@ -926,7 +922,7 @@ class AttachmentTable(
fun deleteAttachments(toDelete: List<SyncAttachmentId>): List<SyncMessageId> {
val unhandled = mutableListOf<SyncMessageId>()
for (syncAttachmentId in toDelete) {
val messageId = SignalDatabase.messages.getMessageIdOrNull(syncAttachmentId.syncMessageId)
val messageId = messages.getMessageIdOrNull(syncAttachmentId.syncMessageId)
if (messageId != null) {
val attachments = readableDatabase
.select(ID, ATTACHMENT_UUID, REMOTE_DIGEST, DATA_HASH_END)
@@ -949,7 +945,7 @@ class AttachmentTable(
val attachmentToDelete = (byUuid ?: byDigest ?: byPlaintext)?.id
if (attachmentToDelete != null) {
if (attachments.size == 1) {
SignalDatabase.messages.deleteMessage(messageId)
messages.deleteMessage(messageId)
} else {
deleteAttachment(attachmentToDelete)
}
@@ -2463,7 +2459,7 @@ class AttachmentTable(
transferProgress = cursor.requireInt(TRANSFER_STATE),
size = cursor.requireLong(DATA_SIZE),
fileName = cursor.requireString(FILE_NAME),
cdn = cursor.requireObject(CDN_NUMBER, Cdn.Serializer),
cdn = cursor.requireObject(CDN_NUMBER, Cdn),
location = cursor.requireString(REMOTE_LOCATION),
key = cursor.requireString(REMOTE_KEY),
digest = cursor.requireBlob(REMOTE_DIGEST),
@@ -2621,6 +2617,27 @@ class AttachmentTable(
)
}
fun debugAttachmentStatsForBackupProto(): BackupDebugInfo.AttachmentDetails {
val archiveStateCounts = ArchiveTransferState
.entries.associateWith {
readableDatabase
.count()
.from(TABLE_NAME)
.where("$ARCHIVE_TRANSFER_STATE = ${it.value} AND $DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL")
.run()
.readToSingleLong(-1L)
}
return BackupDebugInfo.AttachmentDetails(
notStartedCount = archiveStateCounts[ArchiveTransferState.NONE]?.toInt() ?: 0,
uploadInProgressCount = archiveStateCounts[ArchiveTransferState.UPLOAD_IN_PROGRESS]?.toInt() ?: 0,
copyPendingCount = archiveStateCounts[ArchiveTransferState.COPY_PENDING]?.toInt() ?: 0,
finishedCount = archiveStateCounts[ArchiveTransferState.FINISHED]?.toInt() ?: 0,
permanentFailureCount = archiveStateCounts[ArchiveTransferState.PERMANENT_FAILURE]?.toInt() ?: 0,
temporaryFailureCount = archiveStateCounts[ArchiveTransferState.TEMPORARY_FAILURE]?.toInt() ?: 0
)
}
class DataFileWriteResult(
val file: File,
val length: Long,

View File

@@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.InvalidMacException
import org.signal.libsignal.protocol.InvalidMessageException
@@ -302,22 +303,32 @@ class RestoreAttachmentJob private constructor(
Log.w(TAG, e.message)
markFailed(attachmentId)
} catch (e: NonSuccessfulResponseCodeException) {
if (SignalStore.backup.backsUpMedia) {
if (e.code == 404 && !forceTransitTier && attachment.remoteLocation?.isNotBlank() == true) {
Log.i(TAG, "[$attachmentId] Failed to download attachment from archive! Should only happen for recent attachments in a narrow window. Retrying download from transit CDN.")
if (RemoteConfig.internalUser) {
postFailedToDownloadFromArchiveNotification()
}
when (e.code) {
404 -> {
if (forceTransitTier) {
Log.w(TAG, "[$attachmentId] Completely failed to restore an attachment! Failed downloading from both the archive and transit CDN.")
maybePostFailedToDownloadFromArchiveAndTransitNotification()
} else if (SignalStore.backup.backsUpMedia && attachment.remoteLocation.isNotNullOrBlank()) {
Log.w(TAG, "[$attachmentId] Failed to download attachment from the archive CDN! Retrying download from transit CDN.")
maybePostFailedToDownloadFromArchiveNotification()
retrieveAttachment(messageId, attachmentId, attachment, true)
return
} else if (e.code == 401 && useArchiveCdn) {
SignalStore.backup.mediaCredentials.cdnReadCredentials = null
SignalStore.backup.cachedMediaCdnPath = null
throw RetryLaterException(e)
} else if (e.code == 404 && attachment.remoteLocation?.isNotBlank() == true) {
Log.i(TAG, "Failed to download attachment from transit tier. Scheduling retry.")
throw e
return retrieveAttachment(messageId, attachmentId, attachment, forceTransitTier = true)
} else if (SignalStore.backup.backsUpMedia) {
Log.w(TAG, "[$attachmentId] Completely failed to restore an attachment! Failed to download from archive CDN, and there's not transit CDN info.")
maybePostFailedToDownloadFromArchiveAndTransitNotification()
} else if (attachment.remoteLocation.isNotNullOrBlank()) {
Log.w(TAG, "[$attachmentId] Failed to restore an attachment for a free tier user. Likely just older than 45 days.")
}
}
401 -> {
if (useArchiveCdn) {
Log.w(TAG, "[$attachmentId] Had a credential issue when downloading an attachment. Clearing credentials and retrying.")
SignalStore.backup.mediaCredentials.cdnReadCredentials = null
SignalStore.backup.cachedMediaCdnPath = null
throw RetryLaterException(e)
} else {
Log.w(TAG, "[$attachmentId] Unexpected 401 response for transit CDN restore.")
}
}
}
@@ -348,10 +359,29 @@ class RestoreAttachmentJob private constructor(
SignalDatabase.attachments.setRestoreTransferState(attachmentId, AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)
}
private fun postFailedToDownloadFromArchiveNotification() {
private fun maybePostFailedToDownloadFromArchiveNotification() {
if (!RemoteConfig.internalUser || !SignalStore.backup.backsUpMedia) {
return
}
val notification: Notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("[Internal-only] Failed to download attachment from archive!")
.setContentTitle("[Internal-only] Failed to restore attachment from Archive CDN!")
.setContentText("Tap to send a debug log")
.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, SubmitDebugLogActivity::class.java), PendingIntentFlags.mutable()))
.build()
NotificationManagerCompat.from(context).notify(NotificationIds.INTERNAL_ERROR, notification)
}
private fun maybePostFailedToDownloadFromArchiveAndTransitNotification() {
if (!RemoteConfig.internalUser || !SignalStore.backup.backsUpMedia) {
return
}
val notification: Notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("[Internal-only] Completely failed to restore attachment!")
.setContentText("Tap to send a debug log")
.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, SubmitDebugLogActivity::class.java), PendingIntentFlags.mutable()))
.build()

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.keyvalue
import org.signal.ringrtc.CallManager.DataMode
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
import org.thoughtcrime.securesms.util.Environment.Calling.defaultSfuUrl
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -34,6 +35,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val LARGE_SCREEN_UI: String = "internal.large.screen.ui"
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -181,6 +184,12 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
var showArchiveStateHint by booleanValue(SHOW_ARCHIVE_STATE_HINT, false).defaultForExternalUsers()
/** Whether or not we should include a debuglog in the backup debug info when generating a backup. */
var includeDebuglogInBackup by booleanValue(INCLUDE_DEBUGLOG_IN_BACKUP, true).falseForExternalUsers()
/** Any [BackupDebugInfo] that was imported during the last backup restore, if any. */
var importedBackupDebugInfo: BackupDebugInfo? by protoValue(IMPORTED_BACKUP_DEBUG_INFO, BackupDebugInfo.ADAPTER).defaultForExternalUsers()
private fun <T> SignalStoreValueDelegate<T>.defaultForExternalUsers(): SignalStoreValueDelegate<T> {
return this.withPrecondition { RemoteConfig.internalUser }
}

View File

@@ -55,7 +55,23 @@ class LogSectionRemoteBackups : LogSection {
output.append("IAP error type (or null): ${inAppPayment.data.error?.type}\n")
output.append("IAP cancellation reason (or null): ${inAppPayment.data.cancellation?.reason}\n")
} else {
output.append("No in-app payment data available.")
output.append("No in-app payment data available.\n")
}
output.append("\n -- Imported DebugInfo\n")
if (SignalStore.internal.importedBackupDebugInfo != null) {
val info = SignalStore.internal.importedBackupDebugInfo!!
output.append("Debuglog : ${info.debuglogUrl}\n")
output.append("Using Paid Tier : ${info.usingPaidTier}\n")
output.append("Attachment Details:\n")
output.append(" NONE : ${info.attachmentDetails?.notStartedCount ?: "N/A"}\n")
output.append(" UPLOAD_IN_PROGRESS: ${info.attachmentDetails?.uploadInProgressCount ?: "N/A"}\n")
output.append(" COPY_PENDING : ${info.attachmentDetails?.copyPendingCount ?: "N/A"}\n")
output.append(" FINISHED : ${info.attachmentDetails?.finishedCount ?: "N/A"}\n")
output.append(" PERMANENT_FAILURE : ${info.attachmentDetails?.permanentFailureCount ?: "N/A"}\n")
output.append(" TEMPORARY_FAILURE : ${info.attachmentDetails?.temporaryFailureCount ?: "N/A"}\n")
} else {
output.append("None\n")
}
return output