First draft of CLVBackupProgressView

This commit is contained in:
Sasha Weiss 2026-02-17 09:49:33 -08:00 committed by GitHub
parent b7ea9e5dcc
commit 696d33c750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 802 additions and 114 deletions

View File

@ -1770,7 +1770,7 @@
B99CD9452DB066740035C77B /* AttachmentSaving.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954A5AC2D7BA7DA00F61C36 /* AttachmentSaving.swift */; };
B9A0807A2B07D76A000FDB5B /* HomeTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A080792B07D76A000FDB5B /* HomeTabViewController.swift */; };
B9A47ACE2D36DA6B0024DD9C /* circular_indeterminate.json in Resources */ = {isa = PBXBuildFile; fileRef = B9A47ACD2D36DA6B0024DD9C /* circular_indeterminate.json */; };
B9A53B912CF507FB0000578B /* BackupProgressModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B902CF507FB0000578B /* BackupProgressModal.swift */; };
B9A53B912CF507FB0000578B /* BackupRestoreProgressModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B902CF507FB0000578B /* BackupRestoreProgressModal.swift */; };
B9A53B932CF7928A0000578B /* SheetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B922CF7928A0000578B /* SheetPreviewViewController.swift */; };
B9A53B952CF799590000578B /* LinkOrSyncPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B942CF799590000578B /* LinkOrSyncPickerSheet.swift */; };
B9A53B992D0250FC0000578B /* EditCallLinkNameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B982D0250FC0000578B /* EditCallLinkNameViewController.swift */; };
@ -2685,6 +2685,7 @@
D953427B2D8B71190087AD93 /* recipient_self_08.binproto in Resources */ = {isa = PBXBuildFile; fileRef = D95342422D8B71190087AD93 /* recipient_self_08.binproto */; };
D953427C2D8B71190087AD93 /* recipient_self_08.txtproto in Resources */ = {isa = PBXBuildFile; fileRef = D95342432D8B71190087AD93 /* recipient_self_08.txtproto */; };
D953427D2D8B71190087AD93 /* recipient_groups_11.txtproto in Resources */ = {isa = PBXBuildFile; fileRef = D95342312D8B71190087AD93 /* recipient_groups_11.txtproto */; };
D9538E412F3FF7FC002C887E /* UITableView+RowHeights.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9538E402F3FF7F3002C887E /* UITableView+RowHeights.swift */; };
D954A5AE2D7BA9B200F61C36 /* AttachmentSharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762EBBCF2A2FB759002FD28F /* AttachmentSharing.swift */; };
D95787772C6D2A080051AC74 /* TSInfoMessage+GroupUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95787762C6D2A080051AC74 /* TSInfoMessage+GroupUpdates.swift */; };
D95787792C6D2ADE0051AC74 /* TSInfoMessage+Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95787782C6D2ADE0051AC74 /* TSInfoMessage+Payments.swift */; };
@ -3086,6 +3087,8 @@
D9CAFAE62A538CA200B32BDE /* UsernameLinkQRCodeContentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CAFAE52A538CA200B32BDE /* UsernameLinkQRCodeContentController.swift */; };
D9CAFAEA2A53CB1F00B32BDE /* UsernameLinkTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CAFAE92A53CB1F00B32BDE /* UsernameLinkTooltipView.swift */; };
D9CD40622A155C4800545803 /* TSInfoMessage+PersistableGroupUpdateItemTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CD40612A155C4800545803 /* TSInfoMessage+PersistableGroupUpdateItemTest.swift */; };
D9CFB0102F1FF87F00DFB14A /* ChatListViewController+BackupProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CFB00F2F1FF87800DFB14A /* ChatListViewController+BackupProgressView.swift */; };
D9CFB0142F20466300DFB14A /* ArcView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CFB0132F20466100DFB14A /* ArcView.swift */; };
D9D1A6EB2DD69D0800050A85 /* DonationReceiptCredentialRedemptionJobFinderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D1A6EA2DD69CFE00050A85 /* DonationReceiptCredentialRedemptionJobFinderTest.swift */; };
D9D3216A2A8AC9B0004FC110 /* OutgoingGroupCallUpdateMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D321692A8AC9B0004FC110 /* OutgoingGroupCallUpdateMessageTest.swift */; };
D9D5018A2F2B16820068EEA5 /* KeyTransparencyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D501892F2B16820068EEA5 /* KeyTransparencyManager.swift */; };
@ -5933,7 +5936,7 @@
B99CD9432DA9AB530035C77B /* TypedItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedItemProvider.swift; sourceTree = "<group>"; };
B9A080792B07D76A000FDB5B /* HomeTabViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabViewController.swift; sourceTree = "<group>"; };
B9A47ACD2D36DA6B0024DD9C /* circular_indeterminate.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = circular_indeterminate.json; sourceTree = "<group>"; };
B9A53B902CF507FB0000578B /* BackupProgressModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupProgressModal.swift; sourceTree = "<group>"; };
B9A53B902CF507FB0000578B /* BackupRestoreProgressModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupRestoreProgressModal.swift; sourceTree = "<group>"; };
B9A53B922CF7928A0000578B /* SheetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetPreviewViewController.swift; sourceTree = "<group>"; };
B9A53B942CF799590000578B /* LinkOrSyncPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkOrSyncPickerSheet.swift; sourceTree = "<group>"; };
B9A53B982D0250FC0000578B /* EditCallLinkNameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCallLinkNameViewController.swift; sourceTree = "<group>"; };
@ -6855,6 +6858,7 @@
D95342472D8B71190087AD93 /* recipient_self_10.txtproto */ = {isa = PBXFileReference; lastKnownFileType = text; path = recipient_self_10.txtproto; sourceTree = "<group>"; };
D95342482D8B71190087AD93 /* recipient_self_11.binproto */ = {isa = PBXFileReference; lastKnownFileType = file; path = recipient_self_11.binproto; sourceTree = "<group>"; };
D95342492D8B71190087AD93 /* recipient_self_11.txtproto */ = {isa = PBXFileReference; lastKnownFileType = text; path = recipient_self_11.txtproto; sourceTree = "<group>"; };
D9538E402F3FF7F3002C887E /* UITableView+RowHeights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+RowHeights.swift"; sourceTree = "<group>"; };
D954A5AC2D7BA7DA00F61C36 /* AttachmentSaving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentSaving.swift; sourceTree = "<group>"; };
D95777B92B46411300CFE3AE /* GroupCallPeekClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallPeekClient.swift; sourceTree = "<group>"; };
D95787762C6D2A080051AC74 /* TSInfoMessage+GroupUpdates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+GroupUpdates.swift"; sourceTree = "<group>"; };
@ -7263,6 +7267,8 @@
D9CAFAE52A538CA200B32BDE /* UsernameLinkQRCodeContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameLinkQRCodeContentController.swift; sourceTree = "<group>"; };
D9CAFAE92A53CB1F00B32BDE /* UsernameLinkTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameLinkTooltipView.swift; sourceTree = "<group>"; };
D9CD40612A155C4800545803 /* TSInfoMessage+PersistableGroupUpdateItemTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+PersistableGroupUpdateItemTest.swift"; sourceTree = "<group>"; };
D9CFB00F2F1FF87800DFB14A /* ChatListViewController+BackupProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+BackupProgressView.swift"; sourceTree = "<group>"; };
D9CFB0132F20466100DFB14A /* ArcView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArcView.swift; sourceTree = "<group>"; };
D9D1A6EA2DD69CFE00050A85 /* DonationReceiptCredentialRedemptionJobFinderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationReceiptCredentialRedemptionJobFinderTest.swift; sourceTree = "<group>"; };
D9D321692A8AC9B0004FC110 /* OutgoingGroupCallUpdateMessageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingGroupCallUpdateMessageTest.swift; sourceTree = "<group>"; };
D9D3217B2A8FEA9B004FC110 /* Groups.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = Groups.proto; sourceTree = "<group>"; };
@ -8852,6 +8858,7 @@
766CE0D92A32E52300AD609D /* UIStackView+SignalUI.swift */,
F9C45D9329CB93E200B2CD2D /* UIStackView+SignalUITest.swift */,
500FB6172915B86D00257951 /* UITableView+ReusableCell.swift */,
D9538E402F3FF7F3002C887E /* UITableView+RowHeights.swift */,
766CE0D72A32968600AD609D /* UIView+AutoLayout.swift */,
3402A9E5271D97090084CBAE /* UIView+SignalUI.swift */,
762A41682A37D71600057955 /* UIViewController+SignalUI.swift */,
@ -10932,6 +10939,7 @@
children = (
B97802FF2DD65AFD00E9FC82 /* SwiftUI */,
32E958A925C12B3800BF12AD /* AnimatedProgressView.swift */,
D9CFB0132F20466100DFB14A /* ArcView.swift */,
4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */,
14E4A33F278EE999008408FD /* BlurredToolbarContainer.swift */,
32A9E22524C11B3F00C43518 /* EmojiMoodPickerView.swift */,
@ -11068,6 +11076,7 @@
50101FB12B083C8100C648E4 /* ChatListSettingsButtonState.swift */,
34E95C26269F6095004807EC /* ChatListViewController+Actions.swift */,
66F98DE92DC5314E009F1A86 /* ChatListViewController+BackupDownloadProgressView.swift */,
D9CFB00F2F1FF87800DFB14A /* ChatListViewController+BackupProgressView.swift */,
76847C932A13416A008E2EAB /* ChatListViewController+Camera.swift */,
34E95C28269F6109004807EC /* ChatListViewController+Helpers.swift */,
34E95C2C26A0673F004807EC /* ChatListViewController+Loading.swift */,
@ -11185,7 +11194,7 @@
887B6DCD25F6C44800E677D4 /* Linked Devices */ = {
isa = PBXGroup;
children = (
B9A53B902CF507FB0000578B /* BackupProgressModal.swift */,
B9A53B902CF507FB0000578B /* BackupRestoreProgressModal.swift */,
505C2ED32997015800C23FB2 /* LinkDeviceViewController.swift */,
B99287FA2CF0FE8D000D62C4 /* LinkedDevicesEducationSheet.swift */,
B9E322E82CD191CF006DAF3B /* LinkedDevicesView.swift */,
@ -17540,6 +17549,7 @@
762A416B2A38397500057955 /* UIKit+Text.swift in Sources */,
766CE0DA2A32E52300AD609D /* UIStackView+SignalUI.swift in Sources */,
500FB6182915B86D00257951 /* UITableView+ReusableCell.swift in Sources */,
D9538E412F3FF7FC002C887E /* UITableView+RowHeights.swift in Sources */,
766CE0D82A32968600AD609D /* UIView+AutoLayout.swift in Sources */,
3402A9E8271D97090084CBAE /* UIView+SignalUI.swift in Sources */,
764FE03F2A2EC2E2004D2804 /* UIViewController+Permissions.swift in Sources */,
@ -17617,6 +17627,7 @@
F9BC0A2527FB8E730085B23D /* AppSettingsViewsUtil.swift in Sources */,
1489ED0227A3D70200C7043A /* ArchivedConversationsCell.swift in Sources */,
C1661A1A2C3D939300AB887F /* ArchivedPaymentHistoryItem.swift in Sources */,
D9CFB0142F20466300DFB14A /* ArcView.swift in Sources */,
F9B3A92D293553930071EB95 /* ASWebAuthenticationSession+Util.swift in Sources */,
661AEE482C2088FD0046B1D8 /* AttachmentDownloadRetryRunner.swift in Sources */,
88A9729222FA5D4B004B4FBF /* AttachmentFormatPickerView.swift in Sources */,
@ -17653,10 +17664,10 @@
D999345A2DE97BBC002C9196 /* BackupOnboardingCoordinator.swift in Sources */,
D9DE34FD2DEE7765005099D7 /* BackupOnboardingIntroViewController.swift in Sources */,
D98CA2AD2DF14A890060370E /* BackupOnboardingKeyIntroViewController.swift in Sources */,
B9A53B912CF507FB0000578B /* BackupProgressModal.swift in Sources */,
D98CA2B32DF245140060370E /* BackupRecordKeyViewController.swift in Sources */,
04E66D422DFF3A4B0059DBAC /* BackupRecoveryKeyReminderCoordinator.swift in Sources */,
50438A8E2ECBBDF600FCB28F /* BackupRefreshManager.swift in Sources */,
B9A53B912CF507FB0000578B /* BackupRestoreProgressModal.swift in Sources */,
0480F0002E57C51A006CBB29 /* BackupsEnabledNotificationMegaphone.swift in Sources */,
D951F5312D9B236700C5EBF3 /* BackupSettingsViewController.swift in Sources */,
D92CB5562F030F8300537EBE /* BackupSubscriptionAlreadyRedeemedSheet.swift in Sources */,
@ -17750,6 +17761,7 @@
50101FB22B083C8100C648E4 /* ChatListSettingsButtonState.swift in Sources */,
34E95C27269F6096004807EC /* ChatListViewController+Actions.swift in Sources */,
66F98DEA2DC53155009F1A86 /* ChatListViewController+BackupDownloadProgressView.swift in Sources */,
D9CFB0102F1FF87F00DFB14A /* ChatListViewController+BackupProgressView.swift in Sources */,
76847C942A13416A008E2EAB /* ChatListViewController+Camera.swift in Sources */,
34E95C29269F6109004807EC /* ChatListViewController+Helpers.swift in Sources */,
34E95C2D26A06740004807EC /* ChatListViewController+Loading.swift in Sources */,

View File

@ -223,7 +223,7 @@ class BackupSettingsViewController:
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupExportJobRunner.updates(),
label: "BackupExportJob",
label: "BackupSettings.BackupExportJob",
) { [weak self] exportJobUpdate in
guard let self else { return false }
@ -258,7 +258,7 @@ class BackupSettingsViewController:
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupAttachmentDownloadTracker.updates(),
label: "Downloads",
label: "BackupSettings.BackupDownloads",
) { [weak self] downloadTrackerUpdate in
guard let self else { return false }
@ -295,7 +295,7 @@ class BackupSettingsViewController:
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupAttachmentUploadTracker.updates(),
label: "Uploads",
label: "BackupSettings.BackupUploads",
) { [weak self] uploadUpdate in
guard let self else { return false }
viewModel.latestBackupAttachmentUploadUpdate = uploadUpdate

View File

@ -11,7 +11,7 @@ class RegistrationLoadingViewController: OWSViewController, OWSNavigationChildCo
case generic
case submittingPhoneNumber(e164: String)
case submittingVerificationCode
case restoringBackup(BackupProgressModal)
case restoringBackup(BackupRestoreProgressModal)
}
init(mode: RegistrationLoadingMode) {

View File

@ -92,7 +92,7 @@ public class RegistrationNavigationController: OWSNavigationController {
Task { @MainActor [self] in
let step = await step.awaitable()
if let progressModal = self.presentedViewController as? BackupProgressModal {
if let progressModal = self.presentedViewController as? BackupRestoreProgressModal {
Logger.info("Dismissing progress view")
await progressModal.completeAndDismiss()
}
@ -737,7 +737,7 @@ extension RegistrationNavigationController: RegistrationRestoreFromBackupConfirm
func restoreFromBackupConfirmed() {
Task { @MainActor in
let progressModal = BackupProgressModal(style: .backupRestore)
let progressModal = BackupRestoreProgressModal(style: .backupRestore)
let (progress, stream) = await OWSSequentialProgress<BackupRestoreProgressPhase>.createSink()
Task { @MainActor in
for await progress in stream {

View File

@ -10,7 +10,7 @@ import SwiftUI
// MARK: View Model
class BackupProgressViewModel: ObservableObject {
class BackupRestoreProgressViewModel: ObservableObject {
@Published var didTapCancel: Bool = false
@Published var taskProgress: Float = 0
@ -128,19 +128,19 @@ class BackupProgressViewModel: ObservableObject {
// MARK: Hosting Controller
class BackupProgressModal: HostingController<BackupProgressView>, LinkAndSyncProgressUI {
class BackupRestoreProgressModal: HostingController<BackupRestoreProgressView>, LinkAndSyncProgressUI {
var shouldSuppressNotifications: Bool { true }
let viewModel = BackupProgressViewModel()
let viewModel = BackupRestoreProgressViewModel()
var backupTask: Task<Void, Never>? {
get { viewModel.backupTask }
set { viewModel.backupTask = newValue }
}
init(style: BackupProgressView.Style) {
super.init(wrappedView: BackupProgressView(
init(style: BackupRestoreProgressView.Style) {
super.init(wrappedView: BackupRestoreProgressView(
style: style,
viewModel: viewModel,
))
@ -191,7 +191,7 @@ class BackupProgressModal: HostingController<BackupProgressView>, LinkAndSyncPro
// MARK: SwiftUI View
struct BackupProgressView: View {
struct BackupRestoreProgressView: View {
@Environment(\.appearanceTransitionState) private var appearanceTransitionState
enum Style {
@ -200,7 +200,7 @@ struct BackupProgressView: View {
}
fileprivate var style: Style
@ObservedObject fileprivate var viewModel: BackupProgressViewModel
@ObservedObject fileprivate var viewModel: BackupRestoreProgressViewModel
@State private var indeterminateProgressIsPlaying = false
private var loopMode: LottieLoopMode {
@ -430,7 +430,7 @@ func simulateProgress(for source: OWSProgressSource) async throws {
@MainActor
@available(iOS 17, *)
private func setupDemoProgressBackupRestore(
modal: BackupProgressModal,
modal: BackupRestoreProgressModal,
instantComplete: Bool,
) async throws {
let progress = await OWSSequentialProgress<BackupRestoreProgressPhase>.createSink { progress in
@ -475,7 +475,7 @@ private func setupDemoProgressBackupRestore(
@MainActor
@available(iOS 17, *)
private func setupDemoProgress(
modal: BackupProgressModal,
modal: BackupRestoreProgressModal,
slowLinking: Bool,
) async throws {
let progress = await OWSSequentialProgress<PrimaryLinkNSyncProgressPhase>.createSink { progress in
@ -527,7 +527,7 @@ private func setupDemoProgress(
@MainActor
@available(iOS 17, *)
func demoTask(
modal: BackupProgressModal,
modal: BackupRestoreProgressModal,
slowLinking: Bool,
) -> Task<Void, Never> {
Task {
@ -548,7 +548,7 @@ func demoTask(
@available(iOS 17, *)
#Preview("Slow linking") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = BackupProgressModal(style: .linkAndSync)
let modal = BackupRestoreProgressModal(style: .linkAndSync)
modal.backupTask = demoTask(modal: modal, slowLinking: true)
return modal
}
@ -557,7 +557,7 @@ func demoTask(
@available(iOS 17, *)
#Preview("Fast linking") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = BackupProgressModal(style: .linkAndSync)
let modal = BackupRestoreProgressModal(style: .linkAndSync)
modal.backupTask = demoTask(modal: modal, slowLinking: false)
return modal
}
@ -566,7 +566,7 @@ func demoTask(
@available(iOS 17, *)
#Preview("Backup restore") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = BackupProgressModal(style: .backupRestore)
let modal = BackupRestoreProgressModal(style: .backupRestore)
modal.backupTask = Task {
try? await setupDemoProgressBackupRestore(modal: modal, instantComplete: false)
}
@ -577,7 +577,7 @@ func demoTask(
@available(iOS 17, *)
#Preview("Backup restore - instant complete") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = BackupProgressModal(style: .backupRestore)
let modal = BackupRestoreProgressModal(style: .backupRestore)
modal.backupTask = Task {
try? await setupDemoProgressBackupRestore(modal: modal, instantComplete: true)
}

View File

@ -267,7 +267,7 @@ extension LinkedDevicesViewModel: LinkDeviceViewControllerDelegate {
}
// Don't wait for the view pop to start the linking process
let linkAndSyncProgressModal = BackupProgressModal(style: .linkAndSync)
let linkAndSyncProgressModal = BackupRestoreProgressModal(style: .linkAndSync)
linkDeviceViewController.popToLinkedDeviceList { [weak self] in
self?.present.send(.activityIndicator(linkAndSyncProgressModal))
}

View File

@ -76,15 +76,19 @@ struct CLVRenderState {
case .reminders where hasVisibleReminders,
.backupDownloadProgressView where shouldBackupDownloadProgressViewBeVisible,
.backupProgressView where shouldBackupProgressViewBeVisible,
.archiveButton where hasArchivedThreadsRow:
return Section(type: sectionType)
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton:
return nil
case .inboxFilterFooter:
guard let inboxFilterSection else { return nil }
return Section(type: sectionType, value: inboxFilterSection)
case .reminders, .backupDownloadProgressView, .archiveButton:
return nil
}
}
@ -108,11 +112,19 @@ struct CLVRenderState {
viewInfo.shouldBackupDownloadProgressViewBeVisible
}
var shouldBackupProgressViewBeVisible: Bool {
viewInfo.shouldBackupProgressViewBeVisible
}
// MARK: UITableViewDataSource
func numberOfRows(in section: Section) -> Int {
switch section.type {
case .reminders, .backupDownloadProgressView, .archiveButton, .inboxFilterFooter:
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton,
.inboxFilterFooter:
return 1
case .pinned:
return pinnedThreadUniqueIds.count
@ -130,7 +142,12 @@ struct CLVRenderState {
let oldValue = renderState.items(in: section) ?? []
return items.difference(from: oldValue)
case .pinned, .unpinned, .reminders, .backupDownloadProgressView, .archiveButton:
case .pinned,
.unpinned,
.reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton:
return nil
}
}
@ -144,7 +161,12 @@ struct CLVRenderState {
return nil
}
case .pinned, .unpinned, .reminders, .backupDownloadProgressView, .archiveButton:
case .pinned,
.unpinned,
.reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton:
owsFailDebug("Section diffing not yet supported in section '\(section.type)'")
return nil
}

View File

@ -14,6 +14,7 @@ public enum ChatListMode: Int, CaseIterable {
public enum ChatListSectionType: String, CaseIterable {
case reminders
case backupDownloadProgressView
case backupProgressView
case pinned
case unpinned
case archiveButton
@ -185,7 +186,11 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
}
let conversationIndexPaths = visibleIndexPaths.compactMap { indexPath -> IndexPath? in
switch renderState.sections[indexPath.section].type {
case .reminders, .backupDownloadProgressView, .archiveButton, .inboxFilterFooter:
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton,
.inboxFilterFooter:
return nil
case .pinned, .unpinned:
return indexPath
@ -324,7 +329,7 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
case .reminders, .inboxFilterFooter:
return nil
case .backupDownloadProgressView, .archiveButton:
case .backupDownloadProgressView, .backupProgressView, .archiveButton:
return indexPath
case .pinned, .unpinned:
@ -373,6 +378,10 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
tableView.deselectRow(at: indexPath, animated: false)
viewController.handleBackupDownloadProgressViewTapped()
case .backupProgressView:
tableView.deselectRow(at: indexPath, animated: false)
viewController.handleBackupProgressViewTapped()
case .pinned, .unpinned:
guard let threadUniqueId = renderState.threadUniqueId(forIndexPath: indexPath) else {
owsFailDebug("Missing thread.")
@ -536,7 +545,7 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
switch renderState.sections[indexPath.section].type {
case .reminders, .archiveButton, .inboxFilterFooter:
case .reminders, .archiveButton, .inboxFilterFooter, .backupProgressView:
return UITableView.automaticDimension
case .backupDownloadProgressView:
guard let viewState = viewController?.viewState else {
@ -562,6 +571,8 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
cell = viewController.viewState.reminderViews.reminderViewCell
case .backupDownloadProgressView:
cell = viewController.viewState.backupDownloadProgressView.backupDownloadProgressViewCell
case .backupProgressView:
cell = viewController.viewState.backupProgressView.backupProgressViewCell
case .pinned, .unpinned:
cell = buildConversationCell(tableView: tableView, indexPath: indexPath)
case .archiveButton:
@ -650,7 +661,11 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
switch renderState.sections[indexPath.section].type {
case .reminders, .backupDownloadProgressView, .archiveButton, .inboxFilterFooter:
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton,
.inboxFilterFooter:
return nil
case .pinned, .unpinned:
@ -675,7 +690,11 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
switch renderState.sections[indexPath.section].type {
case .reminders, .backupDownloadProgressView, .archiveButton, .inboxFilterFooter:
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton,
.inboxFilterFooter:
return false
case .pinned, .unpinned:
return true
@ -684,7 +703,11 @@ class CLVTableDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
switch renderState.sections[indexPath.section].type {
case .reminders, .backupDownloadProgressView, .archiveButton, .inboxFilterFooter:
case .reminders,
.backupDownloadProgressView,
.backupProgressView,
.archiveButton,
.inboxFilterFooter:
return nil
case .pinned, .unpinned:

View File

@ -14,6 +14,7 @@ struct CLVViewInfo: Equatable {
let isMultiselectActive: Bool
let hasVisibleReminders: Bool
let shouldBackupDownloadProgressViewBeVisible: Bool
let shouldBackupProgressViewBeVisible: Bool
let lastSelectedThreadId: String?
let requiredVisibleThreadIds: Set<String>
@ -30,6 +31,7 @@ struct CLVViewInfo: Equatable {
isMultiselectActive: false,
hasVisibleReminders: false,
shouldBackupDownloadProgressViewBeVisible: false,
shouldBackupProgressViewBeVisible: false,
lastSelectedThreadId: nil,
requiredVisibleThreadIds: [],
)
@ -42,6 +44,7 @@ struct CLVViewInfo: Equatable {
lastSelectedThreadId: String?,
hasVisibleReminders: Bool,
shouldBackupDownloadProgressViewBeVisible: Bool,
shouldBackupProgressViewBeVisible: Bool,
transaction: DBReadTransaction,
) -> CLVViewInfo {
do {
@ -61,6 +64,7 @@ struct CLVViewInfo: Equatable {
isMultiselectActive: isMultiselectActive,
hasVisibleReminders: hasVisibleReminders,
shouldBackupDownloadProgressViewBeVisible: shouldBackupDownloadProgressViewBeVisible,
shouldBackupProgressViewBeVisible: shouldBackupProgressViewBeVisible,
lastSelectedThreadId: lastSelectedThreadId,
requiredVisibleThreadIds: requiredThreadIds,
)

View File

@ -25,6 +25,7 @@ class CLVViewState {
let containerView: ChatListContainerView
let reminderViews: CLVReminderViews
let backupDownloadProgressView: CLVBackupDownloadProgressView
let backupProgressView: CLVBackupProgressView
let settingsButtonCreator: ChatListSettingsButtonState
let proxyButtonCreator: ChatListProxyButtonCreator
@ -131,6 +132,7 @@ class CLVViewState {
self.containerView = ChatListContainerView(tableView: tableDataSource.tableView, searchBar: searchController.searchBar)
self.reminderViews = CLVReminderViews()
self.backupDownloadProgressView = CLVBackupDownloadProgressView()
self.backupProgressView = CLVBackupProgressView()
self.settingsButtonCreator = ChatListSettingsButtonState()
self.proxyButtonCreator = ChatListProxyButtonCreator(chatConnectionManager: DependenciesBridge.shared.chatConnectionManager)
}

View File

@ -66,7 +66,7 @@ public class CLVBackupDownloadProgressView {
// MARK: -
@MainActor
func trackDownloads() {
func startTracking() {
Task { [weak self, backupAttachmentDownloadTracker] in
for await downloadUpdate in backupAttachmentDownloadTracker.updates() {
guard let self else { return }
@ -167,7 +167,7 @@ public class CLVBackupDownloadProgressView {
}
case .restoring, .wifiNotReachable, .paused, .outOfDiskSpace:
if state.deviceSleepBlock == nil {
let deviceSleepBlock = DeviceSleepBlockObject(blockReason: "BackupAttachmentDownloadProgressView")
let deviceSleepBlock = DeviceSleepBlockObject(blockReason: "CLVBackupDownloadProgressView")
state.deviceSleepBlock = deviceSleepBlock
deviceSleepManager.addBlock(blockObject: deviceSleepBlock)
}
@ -1042,68 +1042,6 @@ private class BackupAttachmentDownloadProgressView: UIView {
)
static var resumeButtonFont: UIFont { .dynamicTypeSubheadlineClamped.bold() }
}
// MARK: ArcView
private class ArcView: UIView {
var percentComplete: Float = 0 {
didSet {
setNeedsDisplay()
}
}
init() {
super.init(frame: .zero)
self.isOpaque = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("Unimplemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: rect.midX, y: rect.midY)
let lineWidth: CGFloat = 3
let radius = min(rect.width, rect.height) / 2 - lineWidth / 2
context.setStrokeColor(UIColor.Signal.secondaryLabel.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.addArc(
center: center,
radius: radius,
startAngle: 0,
endAngle: 2 * .pi,
clockwise: false,
)
context.strokePath()
let startAngle: CGFloat = -.pi / 2
let endAngle = 2 * .pi * CGFloat(percentComplete)
context.setStrokeColor(UIColor.Signal.ultramarine.cgColor)
context.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle + startAngle,
clockwise: false,
)
context.strokePath()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setNeedsDisplay()
}
}
}
// MARK: -

View File

@ -0,0 +1,586 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import PureLayout
import SignalServiceKit
import UIKit
class CLVBackupProgressView {
private struct State {
var updateStreamTasks: [Task<Void, Never>] = []
var isVisible: Bool = false
var deviceSleepBlock: DeviceSleepBlockObject?
var lastUploadTrackerUpdate: BackupAttachmentUploadTracker.UploadUpdate?
var lastExportJobUpdate: BackupExportJobRunnerUpdate?
}
private let backupAttachmentUploadTracker: BackupAttachmentUploadTracker
private let backupExportJobRunner: BackupExportJobRunner
private let db: DB
private let deviceSleepManager: DeviceSleepManager
weak var chatListViewController: ChatListViewController?
let backupProgressViewCell: UITableViewCell
private let backupProgressView: BackupProgressView
private let state: AtomicValue<State>
init() {
self.backupAttachmentUploadTracker = AppEnvironment.shared.backupAttachmentUploadTracker
self.backupExportJobRunner = DependenciesBridge.shared.backupExportJobRunner
self.db = DependenciesBridge.shared.db
self.deviceSleepManager = DependenciesBridge.shared.deviceSleepManager.owsFailUnwrap("Missing DeviceSleepManager!")
self.backupProgressViewCell = UITableViewCell()
self.backupProgressViewCell.backgroundColor = .Signal.background
self.backupProgressView = BackupProgressView(viewState: nil)
self.state = AtomicValue(State(), lock: .init())
self.backupProgressViewCell.contentView.addSubview(self.backupProgressView)
self.backupProgressView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(hMargin: 12, vMargin: 12))
}
// MARK: -
var shouldBeVisible: Bool {
guard BuildFlags.Backups.chatListProgress else {
return false
}
return backupProgressView.viewState != nil
}
// MARK: -
@MainActor
func willAppear() {
state.update { _state in
_state.isVisible = true
manageDeviceSleepBlock(state: &_state)
}
}
@MainActor
func didDisapper() {
state.update { _state in
_state.isVisible = false
manageDeviceSleepBlock(state: &_state)
}
}
// MARK: -
func startTracking() {
state.update { _state in
guard _state.updateStreamTasks.isEmpty else { return }
_state.updateStreamTasks = _startTracking()
}
}
private func _startTracking() -> [Task<Void, Never>] {
return [
Task { @MainActor [weak self, backupAttachmentUploadTracker] in
for await uploadTrackerUpdate in backupAttachmentUploadTracker.updates() {
guard let self else { return }
state.update { _state in
_state.lastUploadTrackerUpdate = uploadTrackerUpdate
self.setViewStateForState(state: &_state)
}
}
},
Task { @MainActor [weak self, backupExportJobRunner] in
for await exportJobUpdate in backupExportJobRunner.updates() {
guard let self else { return }
state.update { _state in
_state.lastExportJobUpdate = exportJobUpdate
self.setViewStateForState(state: &_state)
}
}
},
]
}
// MARK: -
private let chatListReloadQueue = SerialTaskQueue()
@MainActor
private func setViewStateForState(state: inout State) {
let oldViewState = backupProgressView.viewState
let newViewState = viewStateForState(state: state)
chatListReloadQueue.enqueue { @MainActor [self] in
if oldViewState != newViewState {
backupProgressView.viewState = newViewState
}
if (oldViewState == nil) != (newViewState == nil) {
// We're hiding/showing the view: reload the chat list.
chatListViewController?.loadCoordinator.loadIfNecessary()
} else if oldViewState?.id != newViewState?.id {
// Our height may change when we change view states, so tell the
// table view to recompute.
chatListViewController?.tableView.recomputeRowHeights()
}
}
manageDeviceSleepBlock(state: &state)
}
private func viewStateForState(state: State) -> BackupProgressView.ViewState? {
switch state.lastExportJobUpdate {
case .progress(let sequentialProgress):
switch sequentialProgress.currentStep {
case .backupFileExport, .backupFileUpload:
let percentExportCompleted = sequentialProgress.progress(for: .backupFileExport)?.percentComplete ?? 0
let percentUploadCompleted = sequentialProgress.progress(for: .backupFileUpload)?.percentComplete ?? 0
let percentComplete = (0.95 * percentExportCompleted) + (0.05 * percentUploadCompleted)
return .backupFilePreparation(percentComplete: percentComplete)
case .attachmentUpload, .attachmentProcessing:
break
}
case nil, .completion:
break
}
if let lastUploadTrackerUpdate = state.lastUploadTrackerUpdate {
switch lastUploadTrackerUpdate.state {
case .running:
return .attachmentUploadRunning(
bytesUploaded: lastUploadTrackerUpdate.bytesUploaded,
totalBytesToUpload: lastUploadTrackerUpdate.totalBytesToUpload,
)
case .pausedLowBattery:
return .attachmentUploadPausedLowBattery
case .pausedLowPowerMode:
return .attachmentUploadPausedLowPowerMode
case .pausedNeedsWifi:
return .attachmentUploadPausedNoWifi
case .pausedNeedsInternet:
return .attachmentUploadPausedNoInternet
}
}
return nil
}
@MainActor
private func manageDeviceSleepBlock(state: inout State) {
var shouldBlockDeviceSleep = switch backupProgressView.viewState {
case .backupFilePreparation: true
case .attachmentUploadRunning: true
case .attachmentUploadPausedNoWifi: false
case .attachmentUploadPausedNoInternet: false
case .attachmentUploadPausedLowBattery: false
case .attachmentUploadPausedLowPowerMode: false
case .complete: false
case .failed: false
case nil: false
}
shouldBlockDeviceSleep = shouldBlockDeviceSleep && state.isVisible
if
shouldBlockDeviceSleep,
state.deviceSleepBlock == nil
{
let deviceSleepBlock = DeviceSleepBlockObject(blockReason: "CLVBackupProgressView")
deviceSleepManager.addBlock(blockObject: deviceSleepBlock)
state.deviceSleepBlock = deviceSleepBlock
} else if
!shouldBlockDeviceSleep,
let deviceSleepBlock = state.deviceSleepBlock.take()
{
deviceSleepManager.removeBlock(blockObject: deviceSleepBlock)
}
}
}
// MARK: -
private class BackupProgressView: UIView {
enum ViewState: Equatable, Identifiable {
case backupFilePreparation(percentComplete: Float)
case attachmentUploadRunning(bytesUploaded: UInt64, totalBytesToUpload: UInt64)
case attachmentUploadPausedNoWifi
case attachmentUploadPausedNoInternet
case attachmentUploadPausedLowBattery
case attachmentUploadPausedLowPowerMode
case complete
case failed
var id: String {
return switch self {
case .backupFilePreparation: "backupFilePreparation"
case .attachmentUploadRunning: "attachmentUploadRunning"
case .attachmentUploadPausedNoWifi: "attachmentUploadPausedNoWifi"
case .attachmentUploadPausedNoInternet: "attachmentUploadPausedNoInternet"
case .attachmentUploadPausedLowBattery: "attachmentUploadPausedLowBattery"
case .attachmentUploadPausedLowPowerMode: "attachmentUploadPausedLowPowerMode"
case .complete: "complete"
case .failed: "failed"
}
}
}
var viewState: ViewState? {
didSet {
configureSubviewsForCurrentState()
}
}
// MARK: -
private static func configure(label: UILabel, color: UIColor) {
label.translatesAutoresizingMaskIntoConstraints = false
label.adjustsFontForContentSizeCategory = true
label.font = .dynamicTypeSubheadline
label.textColor = color
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
}
private let leadingAccessoryImageView = UIImageView()
private let labelStackView = UIStackView()
private let titleLabel = UILabel()
private let progressLabel = UILabel()
/// A container for the various trailingAccessory views we might display. A
/// stack view so we can use `isHidden = true` to make subviews take up zero
/// space.
private let trailingAccessoryContainerView = UIStackView()
private let trailingAccessorySpacerView = UIView()
private let trailingAccessoryRunningArcView = ArcView()
private let trailingAccessoryPausedWifiResumeButton = UIButton()
private let trailingAccessoryPausedNoInternetLabel = UILabel()
private let trailingAccessoryPausedLowBatteryLabel = UILabel()
private let trailingAccessoryPausedLowPowerModeLabel = UILabel()
private let trailingAccessoryCompleteDismissButton = UIButton()
private let trailingAccessoryFailedDetailsButton = UIButton()
init(viewState: ViewState?) {
self.viewState = viewState
super.init(frame: .zero)
backgroundColor = .Signal.quaternaryFill
layer.cornerRadius = 24
layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 12)
addSubview(leadingAccessoryImageView)
leadingAccessoryImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(labelStackView)
labelStackView.translatesAutoresizingMaskIntoConstraints = false
labelStackView.axis = .vertical
labelStackView.spacing = 4
labelStackView.addArrangedSubview(titleLabel)
Self.configure(label: titleLabel, color: .Signal.label)
labelStackView.addArrangedSubview(progressLabel)
Self.configure(label: progressLabel, color: .Signal.secondaryLabel)
addSubview(trailingAccessoryContainerView)
trailingAccessoryContainerView.alignment = .trailing
trailingAccessoryContainerView.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryContainerView.addArrangedSubview(trailingAccessorySpacerView)
trailingAccessorySpacerView.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryRunningArcView)
trailingAccessoryRunningArcView.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryPausedWifiResumeButton)
trailingAccessoryPausedWifiResumeButton.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryPausedWifiResumeButton.setTitle(
"Resume",
for: .normal,
)
trailingAccessoryPausedWifiResumeButton.setTitleColor(.Signal.label, for: .normal)
trailingAccessoryPausedWifiResumeButton.titleLabel?.font = .dynamicTypeSubheadline.semibold()
trailingAccessoryPausedWifiResumeButton.titleLabel?.adjustsFontForContentSizeCategory = true
trailingAccessoryPausedWifiResumeButton.addAction(
UIAction { [weak self] _ in
self?.didTapPausedWifiResumeButton()
},
for: .touchUpInside,
)
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryCompleteDismissButton)
trailingAccessoryCompleteDismissButton.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryCompleteDismissButton.setImage(.x, animated: false)
trailingAccessoryCompleteDismissButton.tintColor = .Signal.secondaryLabel
trailingAccessoryCompleteDismissButton.addAction(
UIAction { [weak self] _ in
self?.didTapDismissButton()
},
for: .touchUpInside,
)
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryPausedNoInternetLabel)
Self.configure(label: trailingAccessoryPausedNoInternetLabel, color: .Signal.secondaryLabel)
trailingAccessoryPausedNoInternetLabel.text = "No Internet…"
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryPausedLowBatteryLabel)
Self.configure(label: trailingAccessoryPausedLowBatteryLabel, color: .Signal.secondaryLabel)
trailingAccessoryPausedLowBatteryLabel.text = "Low Battery…"
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryPausedLowPowerModeLabel)
Self.configure(label: trailingAccessoryPausedLowPowerModeLabel, color: .Signal.secondaryLabel)
trailingAccessoryPausedLowPowerModeLabel.text = "Low Power Mode…"
trailingAccessoryContainerView.addArrangedSubview(trailingAccessoryFailedDetailsButton)
trailingAccessoryFailedDetailsButton.translatesAutoresizingMaskIntoConstraints = false
trailingAccessoryFailedDetailsButton.setTitle(
"See Details",
for: .normal,
)
trailingAccessoryFailedDetailsButton.setTitleColor(.Signal.label, for: .normal)
trailingAccessoryFailedDetailsButton.titleLabel?.font = .dynamicTypeSubheadline.semibold()
trailingAccessoryFailedDetailsButton.titleLabel?.adjustsFontForContentSizeCategory = true
trailingAccessoryFailedDetailsButton.addAction(
UIAction { [weak self] _ in
self?.didTapFailedDetailsButton()
},
for: .touchUpInside,
)
initializeConstraints()
configureSubviewsForCurrentState()
}
required init?(coder: NSCoder) {
owsFail("Not implemented!")
}
// MARK: -
private func configureSubviewsForCurrentState() {
// Leading accessory
switch viewState {
case nil,
.backupFilePreparation,
.attachmentUploadRunning,
.attachmentUploadPausedNoWifi,
.attachmentUploadPausedNoInternet,
.attachmentUploadPausedLowBattery,
.attachmentUploadPausedLowPowerMode:
leadingAccessoryImageView.image = .backup
leadingAccessoryImageView.tintColor = .Signal.label
case .complete:
leadingAccessoryImageView.image = .checkCircle
leadingAccessoryImageView.tintColor = .Signal.ultramarine
case .failed:
leadingAccessoryImageView.image = .errorCircle
leadingAccessoryImageView.tintColor = .Signal.orange
}
// Labels
let titleLabelText: String
var progressLabelText: String?
switch viewState {
case .backupFilePreparation(let percentComplete):
titleLabelText = "Preparing backup"
progressLabelText = percentComplete.formatted(.owsPercent())
case .attachmentUploadRunning(let bytesUploaded, let totalBytesToUpload):
titleLabelText = "Uploading backup"
progressLabelText = String(
format: "%1$@ of %2$@",
bytesUploaded.formatted(.owsByteCount()),
totalBytesToUpload.formatted(.owsByteCount()),
)
case .attachmentUploadPausedNoWifi:
titleLabelText = "Waiting for Wi-Fi"
case .attachmentUploadPausedNoInternet:
titleLabelText = "Backup paused"
case .attachmentUploadPausedLowBattery:
titleLabelText = "Backup paused"
case .attachmentUploadPausedLowPowerMode:
titleLabelText = "Backup paused"
case .complete:
titleLabelText = "Backup complete"
case .failed:
titleLabelText = "Backup failed"
case nil:
titleLabelText = ""
}
titleLabel.text = titleLabelText
if let progressLabelText {
progressLabel.text = progressLabelText
progressLabel.isHidden = false
} else {
progressLabel.isHidden = true
}
// Trailing accessory
let trailingAccessoryView: UIView?
switch viewState {
case .backupFilePreparation(let percentComplete):
trailingAccessoryRunningArcView.percentComplete = percentComplete
trailingAccessoryView = trailingAccessoryRunningArcView
case .attachmentUploadRunning(let bytesUploaded, let totalBytesToUpload):
trailingAccessoryRunningArcView.percentComplete = Float(bytesUploaded) / Float(totalBytesToUpload)
trailingAccessoryView = trailingAccessoryRunningArcView
case .attachmentUploadPausedNoWifi:
trailingAccessoryView = trailingAccessoryPausedWifiResumeButton
case .attachmentUploadPausedNoInternet:
trailingAccessoryView = trailingAccessoryPausedNoInternetLabel
case .attachmentUploadPausedLowBattery:
trailingAccessoryView = trailingAccessoryPausedLowBatteryLabel
case .attachmentUploadPausedLowPowerMode:
trailingAccessoryView = trailingAccessoryPausedLowPowerModeLabel
case .complete:
trailingAccessoryView = trailingAccessoryCompleteDismissButton
case .failed:
trailingAccessoryView = trailingAccessoryFailedDetailsButton
case nil:
trailingAccessoryView = nil
}
// Hide all but at most one trailingAccessory view.
for view in trailingAccessoryContainerView.arrangedSubviews {
if view == trailingAccessoryView || view == trailingAccessorySpacerView {
view.isHidden = false
} else {
view.isHidden = true
}
}
}
// MARK: -
private func initializeConstraints() {
NSLayoutConstraint.activate([
leadingAccessoryImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
leadingAccessoryImageView.centerYAnchor.constraint(equalTo: labelStackView.centerYAnchor),
leadingAccessoryImageView.heightAnchor.constraint(equalToConstant: 24),
leadingAccessoryImageView.widthAnchor.constraint(equalToConstant: 24),
labelStackView.leadingAnchor.constraint(equalTo: leadingAccessoryImageView.trailingAnchor, constant: 12),
labelStackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
labelStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
labelStackView.trailingAnchor.constraint(equalTo: trailingAccessoryContainerView.leadingAnchor, constant: -12),
trailingAccessoryContainerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
trailingAccessoryContainerView.centerYAnchor.constraint(equalTo: labelStackView.centerYAnchor),
trailingAccessoryRunningArcView.heightAnchor.constraint(equalToConstant: 24),
trailingAccessoryRunningArcView.widthAnchor.constraint(equalToConstant: 24),
trailingAccessoryCompleteDismissButton.heightAnchor.constraint(equalToConstant: 24),
trailingAccessoryCompleteDismissButton.widthAnchor.constraint(equalToConstant: 24),
])
}
// MARK: -
private func didTapDismissButton() {
// TODO: Implement
print(#function)
}
private func didTapPausedWifiResumeButton() {
// TODO: Implement
print(#function)
}
private func didTapFailedDetailsButton() {
// TODO: Implement
print(#function)
}
}
// MARK: -
extension ChatListViewController {
func handleBackupProgressViewTapped() {
// TODO: Implement
print(#function)
}
}
// MARK: -
#if DEBUG
private class BackupProgressViewPreviewViewController: UIViewController {
private let state: BackupProgressView.ViewState?
init(state: BackupProgressView.ViewState?) {
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
let progressView = BackupProgressView(viewState: state)
view.addSubview(progressView)
progressView.autoPinEdgesToSuperviewSafeArea(
with: UIEdgeInsets(margin: 16),
excludingEdge: .bottom,
)
}
}
@available(iOS 17, *)
#Preview("Preparing") {
return BackupProgressViewPreviewViewController(state: .backupFilePreparation(percentComplete: 0.33))
}
@available(iOS 17, *)
#Preview("Running") {
return BackupProgressViewPreviewViewController(state: .attachmentUploadRunning(
bytesUploaded: 1_000_000_000,
totalBytesToUpload: 2_400_000_000,
))
}
@available(iOS 17, *)
#Preview("Paused: WiFi") {
return BackupProgressViewPreviewViewController(state: .attachmentUploadPausedNoWifi)
}
@available(iOS 17, *)
#Preview("Paused: Internet") {
return BackupProgressViewPreviewViewController(state: .attachmentUploadPausedNoInternet)
}
@available(iOS 17, *)
#Preview("Paused: Battery") {
return BackupProgressViewPreviewViewController(state: .attachmentUploadPausedLowBattery)
}
@available(iOS 17, *)
#Preview("Paused: Low Power Mode") {
return BackupProgressViewPreviewViewController(state: .attachmentUploadPausedLowPowerMode)
}
@available(iOS 17, *)
#Preview("Complete") {
return BackupProgressViewPreviewViewController(state: .complete)
}
@available(iOS 17, *)
#Preview("Failed") {
return BackupProgressViewPreviewViewController(state: .failed)
}
@available(iOS 17, *)
#Preview("Nil") {
return BackupProgressViewPreviewViewController(state: nil)
}
#endif

View File

@ -286,6 +286,7 @@ public class CLVLoadCoordinator {
lastSelectedThreadId: String?,
hasVisibleReminders: Bool,
shouldBackupDownloadProgressViewBeVisible: Bool,
shouldBackupProgressViewBeVisible: Bool,
lastViewInfo: CLVViewInfo,
transaction: DBReadTransaction,
) -> CLVLoadInfo {
@ -298,6 +299,7 @@ public class CLVLoadCoordinator {
lastSelectedThreadId: lastSelectedThreadId,
hasVisibleReminders: hasVisibleReminders,
shouldBackupDownloadProgressViewBeVisible: shouldBackupDownloadProgressViewBeVisible,
shouldBackupProgressViewBeVisible: shouldBackupProgressViewBeVisible,
transaction: transaction,
)
@ -388,9 +390,6 @@ public class CLVLoadCoordinator {
// Copy the "current" load info, reset "next" load info.
let hasVisibleReminders = viewController.viewState.reminderViews.hasVisibleReminders
let shouldBackupDownloadProgressViewBeVisible = viewController.viewState.backupDownloadProgressView.shouldBeVisible
let loadResult: CLVLoadResult = SSKEnvironment.shared.databaseStorageRef.read { transaction in
// Decide what kind of load we prefer.
let loadInfo = loadInfoBuilder.build(
@ -399,8 +398,9 @@ public class CLVLoadCoordinator {
inboxFilter: viewController.viewState.inboxFilter,
isMultiselectActive: viewController.viewState.multiSelectState.isActive,
lastSelectedThreadId: viewController.viewState.lastSelectedThreadId,
hasVisibleReminders: hasVisibleReminders,
shouldBackupDownloadProgressViewBeVisible: shouldBackupDownloadProgressViewBeVisible,
hasVisibleReminders: viewController.viewState.reminderViews.hasVisibleReminders,
shouldBackupDownloadProgressViewBeVisible: viewController.viewState.backupDownloadProgressView.shouldBeVisible,
shouldBackupProgressViewBeVisible: viewController.viewState.backupProgressView.shouldBeVisible,
lastViewInfo: viewController.renderState.viewInfo,
transaction: transaction,
)

View File

@ -246,10 +246,6 @@ extension ChatListViewController {
}
}
public func updateDownloadProgressView() {
viewState.backupDownloadProgressView.trackDownloads()
}
// MARK: -
public func updateBackupFailureAlertsWithSneakyTransaction() {

View File

@ -24,6 +24,7 @@ public class ChatListViewController: OWSViewController, HomeTabViewController {
loadCoordinator.viewController = self
viewState.reminderViews.chatListViewController = self
viewState.backupDownloadProgressView.chatListViewController = self
viewState.backupProgressView.chatListViewController = self
viewState.settingsButtonCreator.delegate = self
viewState.proxyButtonCreator.delegate = self
viewState.configure()
@ -89,6 +90,10 @@ public class ChatListViewController: OWSViewController, HomeTabViewController {
viewState.searchController.searchResultsUpdater = self
searchResultsController.delegate = self
// Backups
viewState.backupDownloadProgressView.startTracking()
viewState.backupProgressView.startTracking()
updateBarButtonItems()
updateArchiveReminderView()
updateRegistrationReminderView()
@ -96,7 +101,6 @@ public class ChatListViewController: OWSViewController, HomeTabViewController {
updateExpirationReminderView()
updatePaymentReminderView()
updateUsernameReminderView()
updateDownloadProgressView()
updateTableViewPaddingIfNeeded()
observeNotifications()
}
@ -167,6 +171,7 @@ public class ChatListViewController: OWSViewController, HomeTabViewController {
viewState.searchResultsController.viewWillAppear(animated)
viewState.backupDownloadProgressView.willAppear()
viewState.backupProgressView.willAppear()
updateUnreadPaymentNotificationsCountWithSneakyTransaction()
@ -282,6 +287,7 @@ public class ChatListViewController: OWSViewController, HomeTabViewController {
searchResultsController.viewDidDisappear(animated)
viewState.backupDownloadProgressView.didDisappear()
viewState.backupProgressView.didDisapper()
}
override public func viewIsAppearing(_ animated: Bool) {

View File

@ -60,8 +60,7 @@ class NameCollisionResolutionViewController: OWSTableViewController2 {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in
// Force tableview to recalculate self-sized cell height
self.tableView.beginUpdates()
self.tableView.endUpdates()
self.tableView.recomputeRowHeights()
}
}

View File

@ -0,0 +1,67 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import UIKit
/// A torus, whose ring is filled up to `percentComplete` with blue. Useful as a
/// square-aspect-ratio progress indicator.
class ArcView: UIView {
var percentComplete: Float = 0 {
didSet {
setNeedsDisplay()
}
}
init() {
super.init(frame: .zero)
self.isOpaque = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("Unimplemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: rect.midX, y: rect.midY)
let lineWidth: CGFloat = 3
let radius = min(rect.width, rect.height) / 2 - lineWidth / 2
context.setStrokeColor(UIColor.Signal.tertiaryLabel.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.addArc(
center: center,
radius: radius,
startAngle: 0,
endAngle: 2 * .pi,
clockwise: false,
)
context.strokePath()
let startAngle: CGFloat = -.pi / 2
let endAngle = 2 * .pi * CGFloat(percentComplete)
context.setStrokeColor(UIColor.Signal.ultramarine.cgColor)
context.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle + startAngle,
clockwise: false,
)
context.strokePath()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setNeedsDisplay()
}
}

View File

@ -51,6 +51,8 @@ public enum BuildFlags {
public static let useLowerDefaultListMediaRefreshInterval = build <= .beta
public static let performListMediaIntegrityChecks = build <= .beta
public static let chatListProgress = build <= .dev
}
public static let callQualitySurvey = true

View File

@ -7,10 +7,25 @@ extension Optional {
public func mapAsync<T>(_ fn: (Wrapped) async throws -> T) async rethrows -> T? {
switch self {
case .none:
case nil:
return nil
case .some(let v):
return try await fn(v)
}
}
public func owsFailUnwrap(
_ message: String,
logger: PrefixedLogger = .empty(),
file: String = #file,
function: String = #function,
line: Int = #line,
) -> Wrapped {
switch self {
case nil:
owsFail(message, logger: logger, file: file, function: function, line: line)
case .some(let value):
return value
}
}
}

View File

@ -0,0 +1,16 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
extension UITableView {
/// Force the table view to recompute the height of its rows.
///
/// Useful for tables with rows that use AutoLayout and need to tell their
/// owning table that their height may have changed, for example due to the
/// contents of the cell having changed.
public func recomputeRowHeights() {
beginUpdates()
endUpdates()
}
}