Skip to content

Commit 1bf61bb

Browse files
authored
Merge pull request #738 from Esri/Caleb/Backport-Fix-Crashes
[Fix] Backport: Download Offline Resources bugs
2 parents d61a574 + 667783b commit 1bf61bb

File tree

2 files changed

+100
-58
lines changed

2 files changed

+100
-58
lines changed

Shared/Supporting Files/Models/OnDemandResource.swift

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ final class OnDemandResource {
3333
}
3434

3535
/// The progress of the on-demand resource request.
36-
var progress: Progress { request.progress }
36+
let progress = Progress(totalUnitCount: 100)
3737

3838
/// A Boolean value indicating whether a resource request can be initiated.
3939
var isDownloadable: Bool {
@@ -47,20 +47,30 @@ final class OnDemandResource {
4747
private(set) var error: (any Error)?
4848

4949
/// The on-demand resource request.
50-
private let request: NSBundleResourceRequest
50+
private var request: NSBundleResourceRequest
51+
52+
/// A task for monitoring `request.progress` to update `self.progress`.
53+
///
54+
/// This is needed because passing `request.progress` to a `ProgressView` can cause race condition crashes.
55+
@ObservationIgnored private var progressTask: Task<Void, Never>?
5156

5257
/// Initializes a request with a set of Resource Tags.
5358
init(tags: Set<String>) async {
54-
request = NSBundleResourceRequest(tags: tags)
59+
let request = NSBundleResourceRequest(tags: tags)
5560
request.loadingPriority = NSBundleResourceRequestLoadingPriorityUrgent
61+
self.request = request
5662

5763
let isResourceAvailable = await request.conditionallyBeginAccessingResources()
5864
requestState = isResourceAvailable ? .downloaded : .notStarted
5965
}
6066

67+
deinit {
68+
progressTask?.cancel()
69+
}
70+
6171
/// Cancels the on-demand resource request.
6272
func cancel() {
63-
progress.cancel()
73+
progressTask?.cancel()
6474
request.endAccessingResources()
6575
requestState = .cancelled
6676
}
@@ -69,8 +79,38 @@ final class OnDemandResource {
6979
func download() async {
7080
guard isDownloadable else { return }
7181

72-
requestState = .inProgress
82+
// Recreates the request if a download has been attempted before so
83+
// beginAccessingResources() can be called again without crashing.
84+
if requestState == .cancelled || requestState == .error {
85+
request = NSBundleResourceRequest(tags: request.tags)
86+
request.loadingPriority = NSBundleResourceRequestLoadingPriorityUrgent
87+
88+
error = nil
89+
}
90+
91+
// Monitors `request.progress` to update `self.progress`.
92+
progressTask = Task { @MainActor [weak self] in
93+
guard let self else { return }
94+
95+
// A stream is used here because `progress.publisher` doesn't always produce values.
96+
let stream = AsyncStream { continuation in
97+
let observation = request.progress
98+
.observe(\.fractionCompleted, options: [.initial, .new]) { _, change in
99+
guard let newValue = change.newValue else { return }
100+
continuation.yield(newValue)
101+
}
102+
continuation.onTermination = { _ in
103+
observation.invalidate()
104+
}
105+
}
106+
107+
for await fractionCompleted in stream {
108+
self.progress.completedUnitCount = Int64(fractionCompleted * 100)
109+
}
110+
}
111+
73112
do {
113+
requestState = .inProgress
74114
try await request.beginAccessingResources()
75115
requestState = .downloaded
76116
} catch {
@@ -81,6 +121,8 @@ final class OnDemandResource {
81121
cancel()
82122
}
83123
}
124+
125+
progressTask?.cancel()
84126
}
85127
}
86128

Shared/Supporting Files/Views/DownloadOfflineResourcesView.swift

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,70 +16,58 @@ import SwiftUI
1616

1717
/// A view with controls for downloading the app's on-demand resources.
1818
struct DownloadOfflineResourcesView: View {
19-
/// The samples that require offline resources.
20-
let samples = SamplesApp.samples.filter(\.hasDependencies)
21-
2219
/// The action to dismiss the view.
2320
@Environment(\.dismiss) private var dismiss
2421

22+
/// The progress of a download-all operation.
23+
@State private var downloadAllProgress: Progress?
24+
2525
/// The view models for the on-demand resource requests.
2626
@State private var onDemandResources: [String: OnDemandResource] = [:]
2727

28-
/// The current state of the "download all on-demand resources" request.
29-
@State private var downloadAllRequestState: OnDemandResource.RequestState = .notStarted
28+
/// A Boolean value indicating whether all of the `onDemandResources` have successfully downloaded.
29+
private var allResourcesAreDownloaded: Bool { uniqueRequestStates == [.downloaded] }
3030

31-
/// A Boolean value indicating whether confirm cancel alert is showing.
32-
@State private var confirmCancelAlertIsShowing = false
31+
/// A Boolean value indicating whether there is an on going download-all operation.
32+
private var isDownloadingAll: Bool { downloadAllProgress != nil }
3333

34-
/// A Boolean value indicating whether all of the `onDemandResources` have successfully downloaded.
35-
private var allResourcesAreDownloaded: Bool {
36-
guard !onDemandResources.isEmpty else { return false }
37-
return onDemandResources.values
38-
.allSatisfy { $0.requestState == .downloaded }
39-
}
34+
/// A Boolean value indicating whether there is an in progress download.
35+
private var isDownloadingResource: Bool { uniqueRequestStates.contains(.inProgress) }
4036

41-
/// Returns the on-demand resource for the given sample.
42-
func resource(for sample: Sample) -> OnDemandResource? {
43-
return onDemandResources[sample.name]
37+
/// The distinct request states of all on-demand resources.
38+
private var uniqueRequestStates: Set<OnDemandResource.RequestState> {
39+
onDemandResources.values.reduce(into: Set()) { result, resource in
40+
result.insert(resource.requestState)
41+
}
4442
}
4543

4644
var body: some View {
4745
NavigationStack {
4846
Form {
4947
Section {
5048
Button {
51-
downloadAllRequestState = .inProgress
49+
downloadAllProgress = Progress()
5250
} label: {
5351
Label {
5452
Text("Download All")
5553
} icon: {
56-
switch downloadAllRequestState {
57-
case .inProgress:
58-
ProgressView()
59-
case .downloaded:
54+
if let downloadAllProgress {
55+
ProgressView(downloadAllProgress)
56+
.progressViewStyle(GaugeProgressViewStyle())
57+
} else if allResourcesAreDownloaded {
6058
Image(systemName: "checkmark.circle")
6159
.foregroundStyle(.secondary)
62-
default:
60+
} else {
6361
Image(systemName: "arrow.down.circle")
6462
}
6563
}
6664
.frame(maxWidth: .infinity)
6765
}
68-
.disabled(onDemandResources.isEmpty || downloadAllRequestState != .notStarted || allResourcesAreDownloaded)
69-
.task(id: downloadAllRequestState) {
70-
guard downloadAllRequestState == .inProgress else { return }
71-
await downloadAll()
72-
}
73-
.onChange(of: allResourcesAreDownloaded) {
74-
guard allResourcesAreDownloaded else { return }
75-
downloadAllRequestState = .downloaded
76-
}
66+
.disabled(onDemandResources.isEmpty || isDownloadingAll || allResourcesAreDownloaded)
7767
}
7868
Section {
79-
List(samples, id: \.name) { sample in
80-
if let resource = resource(for: sample) {
81-
DownloadOnDemandResourceView(name: sample.name, resource: resource)
82-
}
69+
List(onDemandResources.sorted(by: { $0.key < $1.key }), id: \.key) { name, resource in
70+
DownloadOnDemandResourceView(name: name, resource: resource)
8371
}
8472
} footer: {
8573
Text("**Note**: The system may purge downloads at any time.")
@@ -89,29 +77,24 @@ struct DownloadOfflineResourcesView: View {
8977
.navigationBarTitleDisplayMode(.inline)
9078
.toolbar {
9179
ToolbarItem(placement: .confirmationAction) {
92-
Button("Done") {
93-
if onDemandResources.values.allSatisfy({ $0.requestState != .inProgress }) {
94-
dismiss()
95-
} else {
96-
confirmCancelAlertIsShowing = true
97-
}
98-
}
99-
.alert("Cancel downloads?", isPresented: $confirmCancelAlertIsShowing) {
100-
Button("Don't Cancel", role: .cancel, action: {})
101-
80+
if isDownloadingResource {
10281
Button("Cancel") {
10382
for resource in onDemandResources.values where resource.requestState == .inProgress {
10483
resource.cancel()
10584
}
10685
dismiss()
10786
}
87+
} else {
88+
Button("Done") {
89+
dismiss()
90+
}
10891
}
10992
}
11093
}
11194
.task {
11295
guard onDemandResources.isEmpty else { return }
11396
onDemandResources = await withTaskGroup { group in
114-
for sample in samples {
97+
for sample in SamplesApp.samples where sample.hasDependencies {
11598
group.addTask {
11699
let resource = await OnDemandResource(tags: sample.odrTags)
117100
return (sample.name, resource)
@@ -124,22 +107,30 @@ struct DownloadOfflineResourcesView: View {
124107
return resources
125108
}
126109
}
110+
.task(id: isDownloadingAll) {
111+
guard isDownloadingAll else { return }
112+
await downloadAll()
113+
}
114+
.onChange(of: isDownloadingResource) {
115+
guard isDownloadingAll, !isDownloadingResource else { return }
116+
downloadAllProgress = nil
117+
}
127118
}
128119
}
129120

130121
/// Downloads all of the on-demand resources that haven't started a request yet.
131122
/// - Note: The system may purge the resources at any time after the request object is deallocated.
132123
private func downloadAll() async {
133124
await withTaskGroup { group in
134-
for resource in onDemandResources.values where resource.isDownloadable {
135-
group.addTask(operation: resource.download)
125+
for resource in onDemandResources.values {
126+
if resource.isDownloadable {
127+
group.addTask(operation: resource.download)
128+
downloadAllProgress?.addChildUnits(resource.progress)
129+
} else if resource.requestState == .inProgress {
130+
downloadAllProgress?.addChildUnits(resource.progress)
131+
}
136132
}
137133
}
138-
139-
// Automatically dismisses the view if all of the resources have downloaded successfully.
140-
if allResourcesAreDownloaded {
141-
dismiss()
142-
}
143134
}
144135
}
145136

@@ -205,3 +196,12 @@ private struct GaugeProgressViewStyle: ProgressViewStyle {
205196
}
206197
}
207198
}
199+
200+
private extension Progress {
201+
/// Adds a process object and its unit count as a suboperation of a progress tree.
202+
/// - Parameter progress: The progress instance to add to the progress tree.
203+
func addChildUnits(_ progress: Progress) {
204+
addChild(progress, withPendingUnitCount: progress.totalUnitCount)
205+
totalUnitCount += progress.totalUnitCount
206+
}
207+
}

0 commit comments

Comments
 (0)