Skip to content

Commit fb3b01e

Browse files
[PM-21125] Stream send updates when viewing a send (#1620)
1 parent e028efd commit fb3b01e

17 files changed

+229
-12
lines changed

BitwardenShared/Core/Tools/Repositories/SendRepository.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ public protocol SendRepository: AnyObject {
8787
///
8888
func sendListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>>
8989

90+
/// A publisher for a send.
91+
///
92+
/// - Parameter id: The ID of the send that is being subscribed to.
93+
/// - Returns: A publisher for a send with a specified identifier.
94+
///
95+
func sendPublisher(id: String) async throws -> AsyncThrowingPublisher<AnyPublisher<SendView?, Error>>
96+
9097
/// A publisher for all the sends in the user's account.
9198
///
9299
/// - Parameter: The `SendType` to use to filter the sends.
@@ -289,6 +296,16 @@ class DefaultSendRepository: SendRepository {
289296
.values
290297
}
291298

299+
func sendPublisher(id: String) async throws -> AsyncThrowingPublisher<AnyPublisher<SendView?, Error>> {
300+
try await sendService.sendPublisher(id: id)
301+
.asyncTryMap { send in
302+
guard let send else { return nil }
303+
return try await self.clientService.sends().decrypt(send: send)
304+
}
305+
.eraseToAnyPublisher()
306+
.values
307+
}
308+
292309
// MARK: Private Methods
293310

294311
/// Returns a list of the sections in the vault list from a sync response.

BitwardenShared/Core/Tools/Repositories/SendRepositoryTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,49 @@ class SendRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
345345
}
346346
}
347347

348+
/// `sendListPublisher()` returns a publisher for a single send.
349+
func test_sendPublisher() async throws {
350+
let send1 = Send.fixture(name: "Initial")
351+
sendService.sendSubject.send(send1)
352+
353+
var iterator = try await subject.sendPublisher(id: "1").makeAsyncIterator()
354+
355+
var sendView = try await iterator.next()
356+
XCTAssertEqual(sendView, SendView(send: send1))
357+
XCTAssertEqual(clientService.mockSends.decryptedSends, [send1])
358+
359+
let send2 = Send.fixture(name: "Updated")
360+
sendService.sendSubject.send(send2)
361+
sendView = try await iterator.next()
362+
XCTAssertEqual(sendView, SendView(send: send2))
363+
XCTAssertEqual(clientService.mockSends.decryptedSends, [send1, send2])
364+
}
365+
366+
/// `sendListPublisher()` throws an error if underlying publisher returns an error.
367+
func test_sendPublisher_error() async throws {
368+
sendService.sendSubject.send(completion: .failure(BitwardenTestError.example))
369+
370+
var iterator = try await subject.sendPublisher(id: "1").makeAsyncIterator()
371+
await assertAsyncThrows(error: BitwardenTestError.example) {
372+
_ = try await iterator.next()
373+
}
374+
}
375+
376+
/// `sendListPublisher()` passes along a `nil` send.
377+
func test_sendPublisher_nilSend() async throws {
378+
sendService.sendSubject.send(nil)
379+
380+
var iterator = try await subject.sendPublisher(id: "1").makeAsyncIterator()
381+
382+
var sendView = try await iterator.next()
383+
XCTAssertEqual(sendView, Optional(Optional(nil)))
384+
385+
let send = Send.fixture()
386+
sendService.sendSubject.send(send)
387+
sendView = try await iterator.next()
388+
XCTAssertEqual(sendView, SendView(send: send))
389+
}
390+
348391
/// `shareURL()` successfully generates a share url for the send view.
349392
func test_shareURL() async throws {
350393
let sendView = SendView.fixture(accessId: "ACCESS_ID", key: "KEY")

BitwardenShared/Core/Tools/Repositories/TestHelpers/MockSendRepository.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class MockSendRepository: SendRepository {
2424

2525
var sendListSubject = CurrentValueSubject<[SendListSection], Error>([])
2626

27+
var sendSubject = CurrentValueSubject<SendView?, Error>(nil)
28+
2729
var sendTypeListPublisherType: BitwardenShared.SendType?
2830
var sendTypeListSubject = CurrentValueSubject<[SendListItem], Error>([])
2931

@@ -104,6 +106,12 @@ class MockSendRepository: SendRepository {
104106
.values
105107
}
106108

109+
func sendPublisher(id: String) async throws -> AsyncThrowingPublisher<AnyPublisher<SendView?, Error>> {
110+
sendSubject
111+
.eraseToAnyPublisher()
112+
.values
113+
}
114+
107115
func sendTypeListPublisher(
108116
type: BitwardenShared.SendType
109117
) async throws -> AsyncThrowingPublisher<AnyPublisher<[SendListItem], Error>> {

BitwardenShared/Core/Tools/Services/SendService.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ protocol SendService {
7070

7171
// MARK: Publishers
7272

73+
/// A publisher for a `Send`.
74+
///
75+
/// - Parameter id: The ID of the `Send` to publish updates for.
76+
/// - Returns: A publisher containing the encrypted send.
77+
///
78+
func sendPublisher(id: String) async throws -> AnyPublisher<Send?, Error>
79+
7380
/// A publisher for the list of sends.
7481
///
7582
/// - Returns: The list of encrypted sends.
@@ -206,8 +213,13 @@ extension DefaultSendService {
206213
try await sendDataStore.replaceSends(sends.map(Send.init), userId: userId)
207214
}
208215

216+
func sendPublisher(id: String) async throws -> AnyPublisher<Send?, Error> {
217+
let userId = try await stateService.getActiveAccountId()
218+
return sendDataStore.sendPublisher(id: id, userId: userId)
219+
}
220+
209221
func sendsPublisher() async throws -> AnyPublisher<[Send], Error> {
210222
let userId = try await stateService.getActiveAccountId()
211-
return sendDataStore.sendPublisher(userId: userId)
223+
return sendDataStore.sendsPublisher(userId: userId)
212224
}
213225
}

BitwardenShared/Core/Tools/Services/SendServiceTests.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,29 @@ class SendServiceTests: BitwardenTestCase {
347347
XCTAssertEqual(sendDataStore.replaceSendsUserId, "1")
348348
}
349349

350+
/// `sendPublisher()` throws an error if one occurs.
351+
func test_sendPublisher_error() async {
352+
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
353+
_ = try await subject.sendPublisher(id: "1")
354+
}
355+
}
356+
357+
/// `sendPublisher()` returns a publisher for a send.
358+
func test_sendPublisher_withValues() async throws {
359+
stateService.activeAccount = .fixtureAccountLogin()
360+
sendDataStore.sendSubject.send(.fixture())
361+
362+
var iterator = try await subject.sendPublisher(id: "1").values.makeAsyncIterator()
363+
let publisherValue = try await iterator.next()
364+
365+
try XCTAssertEqual(XCTUnwrap(publisherValue), .fixture())
366+
}
367+
350368
/// `sendsPublisher()` returns a publisher for the list of sections and items that are
351369
/// displayed in the sends tab.
352370
func test_sendsPublisher_withValues() async throws {
353371
stateService.activeAccount = .fixtureAccountLogin()
354-
sendDataStore.sendSubject.send([.fixture()])
372+
sendDataStore.sendsSubject.send([.fixture()])
355373

356374
var iterator = try await subject.sendsPublisher().values.makeAsyncIterator()
357375
let publisherValue = try await iterator.next()

BitwardenShared/Core/Tools/Services/Stores/SendDataStore.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,21 @@ protocol SendDataStore: AnyObject {
3030
///
3131
func fetchSend(id: String, userId: String) async throws -> Send?
3232

33+
/// A publisher for a single `Send` for a user.
34+
///
35+
/// - Parameters:
36+
/// - id: The ID of the `Send` to publish updates for.
37+
/// - userId: The user ID of the user to associated with the object to fetch.
38+
/// - Returns: A publisher for the user's send.
39+
///
40+
func sendPublisher(id: String, userId: String) -> AnyPublisher<Send?, Error>
41+
3342
/// A publisher for a user's send objects.
3443
///
3544
/// - Parameter userId: The user ID of the user to associated with the objects to fetch.
3645
/// - Returns: A publisher for the user's sends.
3746
///
38-
func sendPublisher(userId: String) -> AnyPublisher<[Send], Error>
47+
func sendsPublisher(userId: String) -> AnyPublisher<[Send], Error>
3948

4049
/// Replaces a list of `Send` objects for a user.
4150
///
@@ -74,7 +83,20 @@ extension DataStore: SendDataStore {
7483
.first
7584
}
7685

77-
func sendPublisher(userId: String) -> AnyPublisher<[Send], Error> {
86+
func sendPublisher(id: String, userId: String) -> AnyPublisher<Send?, Error> {
87+
let fetchRequest = SendData.fetchByIdRequest(id: id, userId: userId)
88+
fetchRequest.fetchLimit = 1
89+
// A sort descriptor is needed by `NSFetchedResultsController`.
90+
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \SendData.id, ascending: true)]
91+
return FetchedResultsPublisher(
92+
context: persistentContainer.viewContext,
93+
request: fetchRequest
94+
)
95+
.tryMap { try $0.first.map(Send.init) }
96+
.eraseToAnyPublisher()
97+
}
98+
99+
func sendsPublisher(userId: String) -> AnyPublisher<[Send], Error> {
78100
let fetchRequest = SendData.fetchByUserIdRequest(userId: userId)
79101
// A sort descriptor is needed by `NSFetchedResultsController`.
80102
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \SendData.id, ascending: true)]

BitwardenShared/Core/Tools/Services/Stores/SendDataStoreTests.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,29 @@ class SendDataStoreTests: BitwardenTestCase {
5555
)
5656
}
5757

58-
/// `sendPublisher(userId:)` returns a publisher for a user's send objects.
58+
/// `sendPublisher(userId:)` returns a publisher for a single send.
5959
func test_sendPublisher() async throws {
60+
var publishedValues = [Send?]()
61+
let publisher = subject.sendPublisher(id: "1", userId: "1")
62+
.sink(
63+
receiveCompletion: { _ in },
64+
receiveValue: { value in
65+
publishedValues.append(value)
66+
}
67+
)
68+
defer { publisher.cancel() }
69+
70+
try await subject.replaceSends(sends, userId: "1")
71+
72+
waitFor { publishedValues.count == 2 }
73+
XCTAssertNil(publishedValues[0])
74+
XCTAssertEqual(publishedValues[1], Send.fixture(id: "1", name: "SEND1"))
75+
}
76+
77+
/// `sendsPublisher(userId:)` returns a publisher for a user's send objects.
78+
func test_sendsPublisher() async throws {
6079
var publishedValues = [[Send]]()
61-
let publisher = subject.sendPublisher(userId: "1")
80+
let publisher = subject.sendsPublisher(userId: "1")
6281
.sink(
6382
receiveCompletion: { _ in },
6483
receiveValue: { values in

BitwardenShared/Core/Tools/Services/Stores/TestHelpers/MockSendDataStore.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ class MockSendDataStore: SendDataStore {
1313
var fetchSendUserId: String?
1414
var fetchSendResult: Result<Send?, Error> = .success(nil)
1515

16-
var sendSubject = CurrentValueSubject<[Send], Error>([])
16+
var sendSubject = CurrentValueSubject<Send?, Error>(nil)
17+
18+
var sendsSubject = CurrentValueSubject<[Send], Error>([])
1719

1820
var replaceSendsValue: [Send]?
1921
var replaceSendsUserId: String?
@@ -36,10 +38,14 @@ class MockSendDataStore: SendDataStore {
3638
return try fetchSendResult.get()
3739
}
3840

39-
func sendPublisher(userId: String) -> AnyPublisher<[Send], Error> {
41+
func sendPublisher(id: String, userId: String) -> AnyPublisher<Send?, Error> {
4042
sendSubject.eraseToAnyPublisher()
4143
}
4244

45+
func sendsPublisher(userId: String) -> AnyPublisher<[Send], Error> {
46+
sendsSubject.eraseToAnyPublisher()
47+
}
48+
4349
func replaceSends(_ sends: [Send], userId: String) async throws {
4450
replaceSendsValue = sends
4551
replaceSendsUserId = userId

BitwardenShared/Core/Tools/Services/TestHelpers/MockSendService.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class MockSendService: SendService {
3737
var syncSendWithServerId: String?
3838
var syncSendWithServerResult: Result<Void, Error> = .success(())
3939

40+
var sendSubject = CurrentValueSubject<Send?, Error>(nil)
41+
4042
var sendsSubject = CurrentValueSubject<[Send], Error>([])
4143

4244
// MARK: Methods
@@ -87,6 +89,10 @@ class MockSendService: SendService {
8789
return try syncSendWithServerResult.get()
8890
}
8991

92+
func sendPublisher(id: String) async throws -> AnyPublisher<Send?, Error> {
93+
sendSubject.eraseToAnyPublisher()
94+
}
95+
9096
func sendsPublisher() async throws -> AnyPublisher<[Send], Error> {
9197
sendsSubject.eraseToAnyPublisher()
9298
}

BitwardenShared/UI/Tools/PreviewContent/SendView+Fixtures.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Foundation
55
#if DEBUG
66
extension SendView {
77
static func fixture(
8-
id: String = "id",
8+
id: String? = "id",
99
accessId: String = "accessId",
1010
name: String = "name",
1111
notes: String? = nil,

BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import BitwardenSdk
22
import SwiftUI
33

4+
// swiftlint:disable file_length
5+
46
// MARK: - MainSendListView
57

68
/// The main content of the `SendListView`. Broken out into it's own view so that the

BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessor.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import BitwardenKit
22
@preconcurrency import BitwardenSdk
33
import Foundation
44

5-
// swiftlint:disable file_length
6-
75
// MARK: - AddEditSendItemProcessor
86

97
/// The processor used to manage state and handle actions for the add/edit send item screen.

BitwardenShared/UI/Tools/Send/SendItem/ViewSendItem/ViewSendItemEffect.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ enum ViewSendItemEffect: Equatable {
88

99
/// Any initial data for the view should be loaded.
1010
case loadData
11+
12+
/// Stream the details of the send.
13+
case streamSend
1114
}

BitwardenShared/UI/Tools/Send/SendItem/ViewSendItem/ViewSendItemProcessor.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class ViewSendItemProcessor: StateProcessor<ViewSendItemState, ViewSendItemActio
4646
})
4747
case .loadData:
4848
await loadData()
49+
case .streamSend:
50+
await streamSend()
4951
}
5052
}
5153

@@ -101,4 +103,21 @@ class ViewSendItemProcessor: StateProcessor<ViewSendItemState, ViewSendItemActio
101103
await coordinator.showErrorAlert(error: error)
102104
}
103105
}
106+
107+
/// Streams the details of the send, so the view updates if the send changes.
108+
///
109+
private func streamSend() async {
110+
do {
111+
guard let sendId = state.sendView.id else {
112+
throw BitwardenError.dataError("View Send: send ID is nil, can't stream updates to send")
113+
}
114+
for try await sendView in try await services.sendRepository.sendPublisher(id: sendId) {
115+
guard let sendView else { return }
116+
state.sendView = sendView
117+
}
118+
} catch {
119+
services.errorReporter.log(error: error)
120+
await coordinator.showErrorAlert(error: error)
121+
}
122+
}
104123
}

0 commit comments

Comments
 (0)