Skip to content
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

Open
wants to merge 19 commits into
base: develop
Choose a base branch
from

Conversation

jullianm
Copy link
Contributor

@jullianm jullianm commented Dec 13, 2024

TaskWPB-10219 [iOS] Pull pending events when receiving an APNS push

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:

  1. Receives a push notification in NotificationService
  2. Pull pending events from the server in NotificationSession
  3. Generate new notification content based on these events (will be tackled in WPB-11175)
  4. Show the notification content to the user

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 service

Checklist

  • Title contains a reference JIRA issue number like [WPB-XXX].
  • Description is filled and free of optional paragraphs.
  • Adds/updates automated tests.

UI accessibility checklist

If your PR includes UI changes, please utilize this checklist:

  • Make sure you use the API for UI elements that support large fonts.
  • All colors are taken from WireDesign.ColorTheme or constructed using WireDesign.BaseColorPalette.
  • New UI elements have Accessibility strings for VoiceOver.

@echoes-hq echoes-hq bot added echoes: technical-roadmap Work contributing to the Technical Roadmap, to improve our velocity or reduce the technical debt. echoes/initiative: ios-sync-refactoring labels Dec 13, 2024
Copy link
Contributor

github-actions bot commented Dec 13, 2024

Test Results

    7 files      8 suites   5m 49s ⏱️
4 734 tests 4 706 ✅ 28 💤 0 ❌
4 744 runs  4 716 ✅ 28 💤 0 ❌

Results for commit 29d5784.

♻️ This comment has been updated with latest results.

@datadog-wireapp
Copy link

datadog-wireapp bot commented Dec 13, 2024

Datadog Report

Branch report: refactor/nse-pull-pending-events
Commit report: 263bb85
Test service: wire-ios-mono

✅ 0 Failed, 4496 Passed, 28 Skipped, 2m 14.98s Total Time

@jullianm jullianm requested a review from caldrian December 13, 2024 12:52
}
}

final class Injector {
Copy link
Contributor Author

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.

Copy link
Collaborator

@netbe netbe left a 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.

Comment on lines +71 to +83
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)
}
Copy link
Collaborator

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?

Copy link
Contributor Author

@jullianm jullianm Jan 3, 2025

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.

}

// Then, the instance is resolved
let _: UpdateEventsRepositoryProtocol = Injector.resolve()
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

entry: ServiceEntryProtocol,
invoker: (Factory) -> Any
) -> Service? {
let resolvedInstance = invoker(entry.factory as! Factory)
Copy link
Collaborator

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?

Copy link
Contributor Author

@jullianm jullianm Jan 3, 2025

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.

Copy link
Collaborator

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()
Copy link
Collaborator

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

Copy link
Contributor Author

@jullianm jullianm Jan 6, 2025

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

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good

Copy link
Collaborator

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 {
Copy link
Collaborator

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?

Copy link
Contributor Author

@jullianm jullianm Jan 6, 2025

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

Copy link
Collaborator

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
Copy link
Collaborator

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?

Copy link
Contributor Author

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

@jullianm jullianm requested review from johnxnguyen and netbe January 6, 2025 16:17
@jullianm jullianm changed the title refactor: NSE pull pending events - WPB-10219 refactor: implement new NSE mechanism and pull pending events - WPB-10219 Jan 13, 2025
self.pushChannel = pushChannel
self.cookieStorage = cookieStorage

registerNotificationServiceDependencies()
Copy link
Collaborator

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.

Comment on lines +46 to +51
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
}
Copy link
Collaborator

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

Comment on lines +81 to +82
logger.warn("legacy service extension will expire")
finishWithEmptyNotification()
Copy link
Collaborator

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?

Comment on lines +88 to +89
let userLocalStore: UserLocalStoreProtocol = Injector.resolve()
let selfUserInfo = await userLocalStore.selfUserInfo()
Copy link
Collaborator

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) {
Copy link
Collaborator

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.

Comment on lines +54 to +67
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
)
Copy link
Collaborator

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.

Comment on lines +69 to +71
try await notificationSession?.processPushNotification(
eventID: notification.eventID
)
Copy link
Collaborator

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?

Comment on lines +63 to +65
if lastEventId == nil {
updateEventsRepository.storeLastEventEnvelopeID(newEventID)
}
Copy link
Collaborator

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.

Comment on lines +44 to +49
self.subscription = updateEventsRepository.observePendingEvents()
.collect() // Collects all the events batches.
.map { $0.flatMap { $0 } }
.map(generateNotificationContent)
.sink(receiveValue: onNotificationContent)
}
Copy link
Collaborator

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
echoes/initiative: ios-sync-refactoring echoes: technical-roadmap Work contributing to the Technical Roadmap, to improve our velocity or reduce the technical debt.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants