-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor: implement new NSE mechanism and pull pending events - WPB-10219 #2287
base: develop
Are you sure you want to change the base?
Conversation
Test Results 7 files 8 suites 5m 49s ⏱️ Results for commit 29d5784. ♻️ This comment has been updated with latest results. |
Datadog ReportBranch report: ✅ 0 Failed, 4496 Passed, 28 Skipped, 2m 14.98s Total Time |
} | ||
} | ||
|
||
final class Injector { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will probably evolve to a more stable solution (e.g Needle) at some point in the refactoring process.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added some comments before approving.
static func register<Service, Arg1, Arg2, Arg3, Arg4, Arg5>( | ||
_ serviceType: Service.Type, | ||
factory: @escaping (Arg1, Arg2, Arg3, Arg4, Arg5) -> Service | ||
) { | ||
_register(serviceType, factory: factory) | ||
} | ||
|
||
static func register<Service, Arg1, Arg2, Arg3, Arg4, Arg5, Arg6>( | ||
_ serviceType: Service.Type, | ||
factory: @escaping (Arg1, Arg2, Arg3, Arg4, Arg5, Arg6) -> Service | ||
) { | ||
_register(serviceType, factory: factory) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: in what case do we need multiple arguments?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When we want to resolve a given dependency with a set of arguments that we only have access to at some point in the app lifecycle. We don't access the concrete type directly, we'll just provide the arguments it needs using the (public) protocol it conforms to.
WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift
Outdated
Show resolved
Hide resolved
WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift
Outdated
Show resolved
Hide resolved
WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift
Show resolved
Hide resolved
WireDomain/Sources/WireDomain/NotificationService/NotificationSession.swift
Outdated
Show resolved
Hide resolved
} | ||
|
||
// Then, the instance is resolved | ||
let _: UpdateEventsRepositoryProtocol = Injector.resolve() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don't assert anything here, or am I missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no because if the instance cannot be resolved, it's a fatal error. The intent here was to not allow any chances of trying to resolve a dependency that has not been already registered (that's why we don't use a throwing function), I think this mistake should be caught early on at development stage and fixed right away.
WireDomain/Tests/WireDomainTests/Repositories/Mock/MockUpdateEventsRepository.swift
Show resolved
Hide resolved
entry: ServiceEntryProtocol, | ||
invoker: (Factory) -> Any | ||
) -> Service? { | ||
let resolvedInstance = invoker(entry.factory as! Factory) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we need to cast here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
because the underlying type of entry.factory is Any so we need to force cast to deal with a same expected generic type (Factory
). Also, at this point the force cast is without any risks because we know we're dealing with a matching entry in the services
dict.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok I feel like a tuple call will help me better understand it, I wonder if we can avoid casting using generic
self.pushChannel = pushChannel | ||
self.cookieStorage = cookieStorage | ||
|
||
registerNotificationServiceDependencies() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So just for my understanding, when and where would we initialize the Assembly.
One thing to have in mind there can be multiple process of NSE or iOS can reuse the same process to process different notifications payload
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what I had in mind is that the assembly is the entry point of WireDomain so when the app starts, we use that entry point to setup all of our dependencies, we also take that occasion to register (using the lightweight DI mechanism Injector
) some of the dependencies the notification service needs so we don't have to initialize them again every time we receive a new notification.
For instance, the NotificationSession
is initialized everytime we receive a new notification, given some of these dependencies were already registered before, we can just resolve them and only pass variable userID which comes from the current notification payload :
let updateEventsRepository = UpdateEventsRepository(
userID: userID,
selfClientID: selfClientID,
// these were already initialized, resolving them
updateEventsAPI: Injector.resolve(),
pushChannel: Injector.resolve(),
updateEventDecryptor: Injector.resolve(),
updateEventsLocalStore: Injector.resolve()
)
also mentioning @johnxnguyen because it is related
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sounds good
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@netbe, you, and I are all facing the question of how to set up the dependency graph, and we all have various solutions. I propose we meet to find a common solution.
} | ||
} | ||
|
||
enum Injector { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like introducing a dependency injection system is out of the scope of this task, or is there a specific reason to include it here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was out of the scope but I also felt like we kind of needed to introduce such mechanism to easily resolve the dependencies required by the NotificationSession object and avoid initializing the same objects over and over again.
For instance, to initialize an UpdateEventsLocalStore
I would need a context, where I would find this context ? This is the kind of question (and work) the notification service (I think) doesn't need to do since we have the entry point that setup of all of WireDomain dependencies and basically does that work already.
So I figured: let's introduce a lightweight DI mechanism and use our entry point to register the dependencies we need so we don't have to do that work over and over again in the NotificationService.
also mentioning @netbe because it is related to the same topic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jullianm Ok, I think it would be better to create a dedicated PR for the Injector and to present to the rest of the team before going further with this DI mechnism.
) { | ||
ongoingTask?.cancel() | ||
let cookieStorage: ZMPersistentCookieStorage = Injector.resolve() | ||
let isAuthenticated = cookieStorage.isAuthenticated |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the notification could be for one of user accounts, shouldn't this check happen inside the notification session for that account?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, you're right thank you. fixed in 9378165
…Service.swift Co-authored-by: François Benaiteau <[email protected]>
…Service.swift Co-authored-by: François Benaiteau <[email protected]>
…Service.swift Co-authored-by: François Benaiteau <[email protected]>
…Session.swift Co-authored-by: François Benaiteau <[email protected]>
self.pushChannel = pushChannel | ||
self.cookieStorage = cookieStorage | ||
|
||
registerNotificationServiceDependencies() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@netbe, you, and I are all facing the question of how to set up the dependency graph, and we all have various solutions. I propose we meet to find a common solution.
guard let innerData = data[Key.data.rawValue] as? [AnyHashable: Any], | ||
let eventIDString = innerData[Key.id.rawValue] as? String, | ||
let eventID = UUID(uuidString: eventIDString) | ||
else { | ||
throw Failure.missingEventID | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: be consistent with previous guard
logger.warn("legacy service extension will expire") | ||
finishWithEmptyNotification() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to call super? Also, should we cancel the ongoing task?
let userLocalStore: UserLocalStoreProtocol = Injector.resolve() | ||
let selfUserInfo = await userLocalStore.selfUserInfo() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would move this after we know the user is authenticated.
terminate() | ||
} | ||
|
||
private func logError(_ error: any Error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps we can add errorDescription
to all the errors and just log them inline.
onGoingTask?.cancel() | ||
self.contentHandler = contentHandler | ||
|
||
onGoingTask = Task { | ||
do { | ||
let notificationUserInfo = request.content.userInfo | ||
|
||
let notification = try NotificationPayload( | ||
userInfo: notificationUserInfo | ||
) | ||
|
||
notificationSession = try await createNotificationSession( | ||
userID: notification.userID | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We must be careful here to handle interleaved pushes for different accounts. For example, suppose the same service is invoked:
- received push for user A, start a task
- received push for user B, ... what do we do now?
Probably, for each user, we only want to have a single ongoing task at a time, and if a new push comes in, we cancel the existing task and start a new one. But since we may receive pushes for different users, we would need to have a cache of notification sessions.
Which leads me to think that perhaps the session should keep track of its task management.
try await notificationSession?.processPushNotification( | ||
eventID: notification.eventID | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering if we need to retain the content handler. Do you think it could be passed to the session and the session takes care of invoking it?
if lastEventId == nil { | ||
updateEventsRepository.storeLastEventEnvelopeID(newEventID) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there's no last event id then it means we need to perform a slow sync, so I would throw an error here. In this case, we actually have no use for the eventID
argument.
self.subscription = updateEventsRepository.observePendingEvents() | ||
.collect() // Collects all the events batches. | ||
.map { $0.flatMap { $0 } } | ||
.map(generateNotificationContent) | ||
.sink(receiveValue: onNotificationContent) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's your reasoning for having a separate publisher to get the events? In my opinion, the benefit of receiving the events directly when calling pullPendingEvents
is a linear flow of control (call a method, get a result). Here we are mandating thress separate steps in different places (setup observation, call method, teardown observation).
Key points
This PR introduces notification service extension related components to receive push notifications, pull the pending events from the server and generate new content based on these events, specifically:
NotificationService
NotificationSession
This PR also introduces a lightweight dependency injection mechanism which automatically resolves some already initialized components required by the
NotificationService
.Testing
NotificationSession
callback is properly triggered when observing the pending events.Injector
properly resolves a registered serviceChecklist
[WPB-XXX]
.UI accessibility checklist
If your PR includes UI changes, please utilize this checklist: